diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml index 341b8abc3..9fac47c89 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml @@ -84,6 +84,90 @@ Foreground="{ThemeResource TextFillColorSecondaryBrush}" VerticalAlignment="Center"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml.cs index 0a6f4234d..231fc2f23 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml.cs @@ -36,6 +36,7 @@ public sealed partial class ConfigPage : Page private ConfigEditorSnapshot _serverSnapshot = ConfigEditorSnapshot.Empty; private ConfigEditorSnapshot _editSnapshot = ConfigEditorSnapshot.Empty; private IOperatorGatewayClient? _permissionClient; + private IOperatorGatewayClient? _configClient; private string _selectedPath = ""; private string _searchText = ""; @@ -47,6 +48,7 @@ public sealed partial class ConfigPage : Page private bool _jsonPreviewCollapsedByUser; private bool _refreshConfigAfterReconnect; private bool _refreshConfigWhenGatewayAvailable; + private bool _syncingMaintenanceControls = true; private ContentDialog? _reconnectDialog; private TextBlock? _reconnectDialogMessage; private GridLength _jsonPreviewExpandedWidth = new(360); @@ -64,6 +66,7 @@ public sealed partial class ConfigPage : Page public ConfigPage() { InitializeComponent(); + _syncingMaintenanceControls = false; NavigationCacheMode = NavigationCacheMode.Required; Unloaded += (_, _) => { @@ -109,7 +112,9 @@ public void Initialize() _appState = CurrentApp.AppState!; _appState.PropertyChanged += OnAppStateChanged; - SubscribePermissionClient(CurrentApp.GatewayClient); + var currentClient = CurrentApp.GatewayClient; + ResetForGatewayClientChange(currentClient); + SubscribePermissionClient(currentClient); Logger.Info("[ConfigPage] Initialize"); if (CompleteReconnectIfReady()) return; @@ -147,6 +152,7 @@ public void UpdateConfig(JsonElement config) _serverSnapshot = ConfigEditorModel.CaptureSnapshot(configSnapshot); if (_pendingChanges.Count == 0) _editSnapshot = _serverSnapshot; + SyncMaintenanceControlsFromDraft(); if (configSnapshot.TryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String) @@ -214,7 +220,10 @@ private void OnAppStateChanged(object? sender, PropertyChangedEventArgs e) if (_appState!.ConfigSchema.HasValue) UpdateConfigSchema(_appState.ConfigSchema.Value); break; case nameof(AppState.Status): - SubscribePermissionClient(CurrentApp.GatewayClient); + { + var currentClient = CurrentApp.GatewayClient; + ResetForGatewayClientChange(currentClient); + SubscribePermissionClient(currentClient); UpdateConnectionBanner(); UpdatePermissionBanner(); var configPermissionState = GetConfigPermissionState(); @@ -234,6 +243,7 @@ configPermissionState is (ConfigPermissionState.ReadOnly or ConfigPermissionStat } UpdateMetaAndButtons(); break; + } } } @@ -472,6 +482,14 @@ private void ApplyEditorChanges(string sectionPath, SchemaConfigChangedEventArgs _validationErrors[fullPath] = error; } + if (sectionPath.StartsWith("session.maintenance", StringComparison.Ordinal) || + args.Changes.Keys.Any(path => + (string.IsNullOrEmpty(sectionPath) ? path : $"{sectionPath}.{path}") + .StartsWith("session.maintenance", StringComparison.Ordinal))) + { + SyncMaintenanceControlsFromDraft(); + } + RenderTree(); UpdateSelectedJsonPreviewForCurrentSelection(); UpdateMetaAndButtons(); @@ -665,6 +683,7 @@ private void OnDiscardChanges(object sender, RoutedEventArgs e) _pendingChanges.Clear(); _validationErrors.Clear(); _editSnapshot = ConfigEditorSnapshot.Empty; + SyncMaintenanceControlsFromDraft(); ShowStatus( L("ConfigPage_StatusChangesDiscardedTitle"), L("ConfigPage_StatusChangesDiscardedMessage"), @@ -679,6 +698,12 @@ private void OnResetSection(object sender, RoutedEventArgs e) { RemoveSectionEntries(_selectedPath, _pendingChanges); RemoveSectionEntries(_selectedPath, _validationErrors); + if (string.IsNullOrEmpty(_selectedPath) || + _selectedPath.StartsWith("session.maintenance", StringComparison.Ordinal) || + "session.maintenance".StartsWith(_selectedPath + ".", StringComparison.Ordinal)) + { + SyncMaintenanceControlsFromDraft(); + } var sectionLabel = string.IsNullOrEmpty(_selectedPath) ? L("ConfigPage_FullConfig") : _selectedPath; ShowStatus( L("ConfigPage_StatusSectionResetTitle"), @@ -894,12 +919,183 @@ private void UpdateMetaAndButtons() SaveStatusIcon.Glyph = BuildSaveStatusGlyph(permissionState, dirtyCount, invalidCount); AccessSummaryText.Text = BuildAccessSummaryText(permissionState, dirtyCount, invalidCount); SetDetailEditingEnabled(!editingLocked); + SetMaintenanceEditingEnabled(!editingLocked && _serverSnapshot.HasRoot); ResetSectionButton.IsEnabled = !editingLocked; SaveButton.IsEnabled = !_saving && !_loading && canWriteConfig && dirtyCount > 0 && invalidCount == 0; DiscardButton.IsEnabled = !editingLocked && dirtyCount > 0; } + private void SyncMaintenanceControlsFromDraft() + { + if (!_serverSnapshot.HasRoot || MaintenanceModeComboBox is null) + return; + + var root = _pendingChanges.Count == 0 + ? _serverSnapshot.Root + : ConfigEditorModel.ApplyChanges(_serverSnapshot.Root, _pendingChanges); + var settings = SessionMaintenanceConfigModel.Read(root); + _syncingMaintenanceControls = true; + try + { + MaintenanceModeComboBox.SelectedIndex = settings.Mode == "warn" ? 1 : 0; + PruneAfterTextBox.Text = settings.PruneAfter; + MaxEntriesNumberBox.Value = settings.MaxEntries; + KeepResetArchivesToggle.IsOn = settings.KeepResetArchives; + ResetArchiveRetentionTextBox.Text = settings.ResetArchiveRetention; + LimitDiskUsageToggle.IsOn = settings.LimitDiskUsage; + MaxDiskBytesTextBox.Text = settings.MaxDiskBytes; + HighWaterBytesTextBox.Text = settings.HighWaterBytes == "0" ? "" : settings.HighWaterBytes; + } + finally + { + _syncingMaintenanceControls = false; + } + SetMaintenanceEditingEnabled(!IsConfigEditingLocked(GetConfigPermissionState())); + } + + private void ResetForGatewayClientChange(IOperatorGatewayClient? client) + { + if (ReferenceEquals(_configClient, client)) + return; + + _configClient = client; + _pendingChanges.Clear(); + _validationErrors.Clear(); + _serverSnapshot = ConfigEditorSnapshot.Empty; + _editSnapshot = ConfigEditorSnapshot.Empty; + _lastConfig = null; + _lastSchema = null; + _initialized = false; + _syncingMaintenanceControls = true; + try + { + var defaults = SessionMaintenanceSettings.Defaults; + MaintenanceModeComboBox.SelectedIndex = 0; + PruneAfterTextBox.Text = defaults.PruneAfter; + MaxEntriesNumberBox.Value = defaults.MaxEntries; + KeepResetArchivesToggle.IsOn = defaults.KeepResetArchives; + ResetArchiveRetentionTextBox.Text = defaults.ResetArchiveRetention; + LimitDiskUsageToggle.IsOn = defaults.LimitDiskUsage; + MaxDiskBytesTextBox.Text = defaults.MaxDiskBytes; + HighWaterBytesTextBox.Text = defaults.HighWaterBytes; + } + finally + { + _syncingMaintenanceControls = false; + } + } + + private SessionMaintenanceSettings CaptureMaintenanceSettings() + { + var mode = (MaintenanceModeComboBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "enforce"; + var maxEntriesValue = MaxEntriesNumberBox.Value; + var maxEntries = double.IsFinite(maxEntriesValue) && + maxEntriesValue >= 1 && + maxEntriesValue <= int.MaxValue && + maxEntriesValue == Math.Truncate(maxEntriesValue) + ? Convert.ToInt64(maxEntriesValue) + : 0; + return new SessionMaintenanceSettings( + mode, + PruneAfterTextBox.Text, + maxEntries, + KeepResetArchivesToggle.IsOn, + ResetArchiveRetentionTextBox.Text, + LimitDiskUsageToggle.IsOn, + MaxDiskBytesTextBox.Text, + HighWaterBytesTextBox.Text); + } + + private void OnMaintenanceValueChanged(object sender, RoutedEventArgs e) + { + if (!_syncingMaintenanceControls) + ApplyMaintenanceDraft(); + } + + private void OnMaintenanceNumberChanged(NumberBox sender, NumberBoxValueChangedEventArgs args) + { + if (!_syncingMaintenanceControls) + ApplyMaintenanceDraft(); + } + + private void OnMaintenanceOptionToggled(object sender, RoutedEventArgs e) + { + if (_syncingMaintenanceControls) + return; + ApplyMaintenanceDraft(); + SetMaintenanceEditingEnabled(!IsConfigEditingLocked(GetConfigPermissionState())); + } + + private void ApplyMaintenanceDraft() + { + if (IsConfigEditingLocked(GetConfigPermissionState()) || !_serverSnapshot.HasRoot) + return; + if (_pendingChanges.Count == 0) + _editSnapshot = _serverSnapshot; + + const string section = "session.maintenance"; + RemoveSectionEntries(section, _pendingChanges); + RemoveSectionEntries(section, _validationErrors); + + var settings = CaptureMaintenanceSettings(); + _pendingChanges[$"{section}.mode"] = settings.Mode; + _pendingChanges[$"{section}.pruneAfter"] = settings.PruneAfter.Trim(); + _pendingChanges[$"{section}.maxEntries"] = settings.MaxEntries; + _pendingChanges[$"{section}.resetArchiveRetention"] = settings.KeepResetArchives + ? false + : settings.ResetArchiveRetention.Trim(); + _pendingChanges[$"{section}.maxDiskBytes"] = settings.LimitDiskUsage + ? settings.MaxDiskBytes.Trim() + : false; + _pendingChanges[$"{section}.highWaterBytes"] = string.IsNullOrWhiteSpace(settings.HighWaterBytes) + ? 0L + : settings.HighWaterBytes.Trim(); + + foreach (var (field, _) in SessionMaintenanceConfigModel.Validate(settings)) + _validationErrors[$"{section}.{MaintenanceFieldPath(field)}"] = MaintenanceValidationMessage(field); + + RenderTree(); + UpdateSelectedJsonPreviewForCurrentSelection(); + UpdateMetaAndButtons(); + } + + private void SetMaintenanceEditingEnabled(bool isEnabled) + { + if (MaintenanceModeComboBox is null) + return; + MaintenanceModeComboBox.IsEnabled = isEnabled; + PruneAfterTextBox.IsEnabled = isEnabled; + MaxEntriesNumberBox.IsEnabled = isEnabled; + KeepResetArchivesToggle.IsEnabled = isEnabled; + ResetArchiveRetentionTextBox.IsEnabled = isEnabled && !KeepResetArchivesToggle.IsOn; + LimitDiskUsageToggle.IsEnabled = isEnabled; + MaxDiskBytesTextBox.IsEnabled = isEnabled && LimitDiskUsageToggle.IsOn; + HighWaterBytesTextBox.IsEnabled = isEnabled && LimitDiskUsageToggle.IsOn; + } + + private static string MaintenanceFieldPath(string field) => field switch + { + "Mode" => "mode", + "Prune after" => "pruneAfter", + "Maximum entries" => "maxEntries", + "Reset archive retention" => "resetArchiveRetention", + "Maximum disk usage" => "maxDiskBytes", + "High-water target" => "highWaterBytes", + _ => field, + }; + + private static string MaintenanceValidationMessage(string field) => field switch + { + "Mode" => L("ConfigPage_MaintenanceModeError"), + "Prune after" => L("ConfigPage_MaintenancePruneAfterError"), + "Maximum entries" => L("ConfigPage_MaintenanceMaxEntriesError"), + "Reset archive retention" => L("ConfigPage_MaintenanceArchiveRetentionError"), + "Maximum disk usage" => L("ConfigPage_MaintenanceMaxDiskError"), + "High-water target" => L("ConfigPage_MaintenanceHighWaterError"), + _ => field, + }; + private bool IsConfigEditingLocked(ConfigPermissionState permissionState) => _saving || _refreshConfigAfterReconnect || diff --git a/src/OpenClaw.Tray.WinUI/Services/SessionMaintenanceConfigModel.cs b/src/OpenClaw.Tray.WinUI/Services/SessionMaintenanceConfigModel.cs new file mode 100644 index 000000000..99356b47d --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/SessionMaintenanceConfigModel.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace OpenClawTray.Services; + +internal sealed record SessionMaintenanceSettings( + string Mode, + string PruneAfter, + long MaxEntries, + bool KeepResetArchives, + string ResetArchiveRetention, + bool LimitDiskUsage, + string MaxDiskBytes, + string HighWaterBytes) +{ + public static SessionMaintenanceSettings Defaults { get; } = new( + "enforce", + "30d", + 500, + true, + "", + true, + "10gb", + ""); +} + +internal static partial class SessionMaintenanceConfigModel +{ + private static readonly Regex DurationPattern = CreateDurationPattern(); + private static readonly Regex CompositeDurationTokenPattern = CreateCompositeDurationTokenPattern(); + private static readonly Regex ByteSizePattern = CreateByteSizePattern(); + + public static SessionMaintenanceSettings Read(JsonElement configResponse) + { + var snapshot = ConfigEditorModel.CaptureSnapshot(configResponse); + if (!snapshot.HasRoot || + !snapshot.Root.TryGetProperty("session", out var session) || + session.ValueKind != JsonValueKind.Object || + !session.TryGetProperty("maintenance", out var maintenance) || + maintenance.ValueKind != JsonValueKind.Object) + { + return SessionMaintenanceSettings.Defaults; + } + + var defaults = SessionMaintenanceSettings.Defaults; + var mode = ReadString(maintenance, "mode") ?? defaults.Mode; + var pruneAfter = ReadScalar(maintenance, "pruneAfter") ?? defaults.PruneAfter; + var maxEntries = ReadInt64(maintenance, "maxEntries") ?? defaults.MaxEntries; + + var keepResetArchives = true; + var resetArchiveRetention = ""; + if (maintenance.TryGetProperty("resetArchiveRetention", out var resetValue) && + resetValue.ValueKind is not (JsonValueKind.False or JsonValueKind.Null)) + { + keepResetArchives = false; + resetArchiveRetention = ScalarText(resetValue); + } + + var limitDiskUsage = true; + var maxDiskBytes = defaults.MaxDiskBytes; + if (maintenance.TryGetProperty("maxDiskBytes", out var maxDiskValue)) + { + if (maxDiskValue.ValueKind == JsonValueKind.False) + { + limitDiskUsage = false; + maxDiskBytes = ""; + } + else if (maxDiskValue.ValueKind is not JsonValueKind.Null) + { + maxDiskBytes = ScalarText(maxDiskValue); + } + } + + var highWaterBytes = ReadScalar(maintenance, "highWaterBytes") ?? ""; + return new SessionMaintenanceSettings( + mode, + pruneAfter, + maxEntries, + keepResetArchives, + resetArchiveRetention, + limitDiskUsage, + maxDiskBytes, + highWaterBytes); + } + + public static IReadOnlyDictionary Validate(SessionMaintenanceSettings settings) + { + var errors = new Dictionary(StringComparer.Ordinal); + if (settings.Mode is not ("enforce" or "warn")) + errors["Mode"] = "Choose Enforce or Warn only."; + if (!IsPositiveDuration(settings.PruneAfter)) + errors["Prune after"] = "Use a positive duration such as 30d, 12h, or 500ms."; + if (settings.MaxEntries < 1) + errors["Maximum entries"] = "Enter an integer of at least 1."; + if (!settings.KeepResetArchives && !IsPositiveDuration(settings.ResetArchiveRetention)) + errors["Reset archive retention"] = "Use a positive duration such as 14d."; + if (settings.LimitDiskUsage && !IsByteSize(settings.MaxDiskBytes, allowZero: false)) + errors["Maximum disk usage"] = "Use a positive size such as 10gb or 500mb."; + if (!string.IsNullOrWhiteSpace(settings.HighWaterBytes) && + !IsByteSize(settings.HighWaterBytes, allowZero: true)) + { + errors["High-water target"] = "Use a non-negative size such as 8gb or leave it blank for 80%."; + } + return errors; + } + + private static string? ReadString(JsonElement parent, string name) => + parent.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + + private static string? ReadScalar(JsonElement parent, string name) => + parent.TryGetProperty(name, out var value) && + value.ValueKind is JsonValueKind.String or JsonValueKind.Number + ? ScalarText(value) + : null; + + private static long? ReadInt64(JsonElement parent, string name) => + parent.TryGetProperty(name, out var value) && value.TryGetInt64(out var result) + ? result + : null; + + private static string ScalarText(JsonElement value) => value.ValueKind == JsonValueKind.String + ? value.GetString() ?? "" + : value.GetRawText(); + + private static bool IsPositiveDuration(string value) + { + var trimmed = value.Trim(); + var match = DurationPattern.Match(trimmed); + if (match.Success) + { + return decimal.TryParse(match.Groups[1].Value, NumberStyles.Number, CultureInfo.InvariantCulture, out var amount) && + amount > 0; + } + + decimal total = 0; + var consumed = 0; + foreach (Match token in CompositeDurationTokenPattern.Matches(trimmed)) + { + if (token.Index != consumed || + !decimal.TryParse(token.Groups[1].Value, NumberStyles.Number, CultureInfo.InvariantCulture, out var amount)) + { + return false; + } + total += amount; + consumed += token.Length; + } + return consumed == trimmed.Length && consumed > 0 && total > 0; + } + + private static bool IsByteSize(string value, bool allowZero) + { + var match = ByteSizePattern.Match(value.Trim()); + return match.Success && + decimal.TryParse(match.Groups[1].Value, NumberStyles.Number, CultureInfo.InvariantCulture, out var amount) && + (allowZero ? amount >= 0 : amount > 0); + } + + [GeneratedRegex(@"^([0-9]+(?:\.[0-9]+)?)(ms|s|m|h|d)?$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex CreateDurationPattern(); + + [GeneratedRegex(@"([0-9]+(?:\.[0-9]+)?)(ms|s|m|h|d)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex CreateCompositeDurationTokenPattern(); + + [GeneratedRegex(@"^([0-9]+(?:\.[0-9]+)?)(b|k|kb|m|mb|g|gb|t|tb)?$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex CreateByteSizePattern(); +} diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index 02c292072..372799bf0 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -4734,6 +4734,28 @@ Commands are blocked while sandboxing is unavailable because strict fallback blo 64 MiB ++ Session maintenance + Control automatic session retention and disk cleanup on this gateway. Changes use the Save changes button below. + Mode + Enforce cleanup + Warn only + Prune inactive sessions after + 30d + Maximum session entries + Reset archive retention + 14d + Maximum disk usage + 10gb + Cleanup target + 80% of maximum (default) + Disable age-based archive expiry + Limit session disk usage ++ Mode: choose Enforce or Warn only. + Prune after: use a positive duration such as 30d, 12h, or 1h30m. + Maximum entries: enter a whole number of at least 1. + Reset archive retention: use a positive duration such as 14d. + Maximum disk usage: use a positive size such as 10gb or 500mb. + Cleanup target: use a non-negative size such as 8gb or leave it blank for 80%. Sessions diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index 60e556866..cb250660e 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -4687,6 +4687,28 @@ Les commandes sont bloquées tant que le sandboxing est indisponible, car le blo 64 MiB ++ Session maintenance + Control automatic session retention and disk cleanup on this gateway. Changes use the Save changes button below. + Mode + Enforce cleanup + Warn only + Prune inactive sessions after + 30d + Maximum session entries + Reset archive retention + 14d + Maximum disk usage + 10gb + Cleanup target + 80% of maximum (default) + Disable age-based archive expiry + Limit session disk usage ++ Mode: choose Enforce or Warn only. + Prune after: use a positive duration such as 30d, 12h, or 1h30m. + Maximum entries: enter a whole number of at least 1. + Reset archive retention: use a positive duration such as 14d. + Maximum disk usage: use a positive size such as 10gb or 500mb. + Cleanup target: use a non-negative size such as 8gb or leave it blank for 80%. Mes sessions diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index cbb9ea5f2..02ccd7109 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -4688,6 +4688,28 @@ Opdrachten worden geblokkeerd zolang sandboxing niet beschikbaar is, omdat strik 64 MiB ++ Session maintenance + Control automatic session retention and disk cleanup on this gateway. Changes use the Save changes button below. + Mode + Enforce cleanup + Warn only + Prune inactive sessions after + 30d + Maximum session entries + Reset archive retention + 14d + Maximum disk usage + 10gb + Cleanup target + 80% of maximum (default) + Disable age-based archive expiry + Limit session disk usage ++ Mode: choose Enforce or Warn only. + Prune after: use a positive duration such as 30d, 12h, or 1h30m. + Maximum entries: enter a whole number of at least 1. + Reset archive retention: use a positive duration such as 14d. + Maximum disk usage: use a positive size such as 10gb or 500mb. + Cleanup target: use a non-negative size such as 8gb or leave it blank for 80%. Sessies diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index bf824cb7a..a7b010373 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -4687,6 +4687,28 @@ 64 MiB ++ Session maintenance + Control automatic session retention and disk cleanup on this gateway. Changes use the Save changes button below. + Mode + Enforce cleanup + Warn only + Prune inactive sessions after + 30d + Maximum session entries + Reset archive retention + 14d + Maximum disk usage + 10gb + Cleanup target + 80% of maximum (default) + Disable age-based archive expiry + Limit session disk usage ++ Mode: choose Enforce or Warn only. + Prune after: use a positive duration such as 30d, 12h, or 1h30m. + Maximum entries: enter a whole number of at least 1. + Reset archive retention: use a positive duration such as 14d. + Maximum disk usage: use a positive size such as 10gb or 500mb. + Cleanup target: use a non-negative size such as 8gb or leave it blank for 80%. 会话 diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index 58acfbc95..e6b4225e3 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -4687,6 +4687,28 @@ 64 MiB ++ Session maintenance + Control automatic session retention and disk cleanup on this gateway. Changes use the Save changes button below. + Mode + Enforce cleanup + Warn only + Prune inactive sessions after + 30d + Maximum session entries + Reset archive retention + 14d + Maximum disk usage + 10gb + Cleanup target + 80% of maximum (default) + Disable age-based archive expiry + Limit session disk usage ++ Mode: choose Enforce or Warn only. + Prune after: use a positive duration such as 30d, 12h, or 1h30m. + Maximum entries: enter a whole number of at least 1. + Reset archive retention: use a positive duration such as 14d. + Maximum disk usage: use a positive size such as 10gb or 500mb. + Cleanup target: use a non-negative size such as 8gb or leave it blank for 80%. 工作階段 diff --git a/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs b/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs index 0cdec5a5f..cc22bea93 100644 --- a/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs +++ b/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs @@ -204,6 +204,30 @@ public class LocalizationValidationTests "ConfigPage_ReconnectDialogBody", "ConfigPage_ReconnectDialogTitle", "ConfigPage_ReconnectDialogWaiting", + // Gateway session-maintenance controls are seeded English-only across + // all locales until the next translation pass. + "ConfigPage_Maintenance.Header", + "ConfigPage_MaintenanceDescription.Text", + "ConfigPage_MaintenanceMode.Header", + "ConfigPage_MaintenanceEnforce.Content", + "ConfigPage_MaintenanceWarn.Content", + "ConfigPage_MaintenancePruneAfter.Header", + "ConfigPage_MaintenancePruneAfter.PlaceholderText", + "ConfigPage_MaintenanceMaxEntries.Header", + "ConfigPage_MaintenanceArchiveRetention.Header", + "ConfigPage_MaintenanceArchiveRetention.PlaceholderText", + "ConfigPage_MaintenanceMaxDisk.Header", + "ConfigPage_MaintenanceMaxDisk.PlaceholderText", + "ConfigPage_MaintenanceHighWater.Header", + "ConfigPage_MaintenanceHighWater.PlaceholderText", + "ConfigPage_MaintenanceDisableArchiveExpiry.Header", + "ConfigPage_MaintenanceLimitDisk.Header", + "ConfigPage_MaintenanceModeError", + "ConfigPage_MaintenancePruneAfterError", + "ConfigPage_MaintenanceMaxEntriesError", + "ConfigPage_MaintenanceArchiveRetentionError", + "ConfigPage_MaintenanceMaxDiskError", + "ConfigPage_MaintenanceHighWaterError", "CronPage_AmericaChicago.Content", "CronPage_AmericaDenver.Content", "CronPage_AmericaLosAngeles.Content", diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index ac1e0df6e..43c3857dc 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -65,6 +65,7 @@ + diff --git a/tests/OpenClaw.Tray.Tests/SessionMaintenanceConfigModelTests.cs b/tests/OpenClaw.Tray.Tests/SessionMaintenanceConfigModelTests.cs new file mode 100644 index 000000000..ded745a75 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/SessionMaintenanceConfigModelTests.cs @@ -0,0 +1,67 @@ +using System.Text.Json; +using OpenClawTray.Services; + +namespace OpenClaw.Tray.Tests; + +public sealed class SessionMaintenanceConfigModelTests +{ + [Fact] + public void Read_UsesGatewayValuesAndDefaultsForOmittedFields() + { + using var document = JsonDocument.Parse(""" + { + "baseHash": "abc", + "parsed": { + "session": { + "maintenance": { + "mode": "warn", + "maxEntries": 125, + "resetArchiveRetention": "14d", + "maxDiskBytes": false + } + } + } + } + """); + + var settings = SessionMaintenanceConfigModel.Read(document.RootElement); + + Assert.Equal("warn", settings.Mode); + Assert.Equal("30d", settings.PruneAfter); + Assert.Equal(125, settings.MaxEntries); + Assert.False(settings.KeepResetArchives); + Assert.Equal("14d", settings.ResetArchiveRetention); + Assert.False(settings.LimitDiskUsage); + Assert.Equal("", settings.HighWaterBytes); + } + + [Fact] + public void Validate_RejectsInvalidDurationsCountsAndSizes() + { + var settings = new SessionMaintenanceSettings( + "other", "never", 0, false, "0d", true, "lots", "-1gb"); + + var errors = SessionMaintenanceConfigModel.Validate(settings); + + Assert.Equal(6, errors.Count); + Assert.Contains("Mode", errors.Keys); + Assert.Contains("Prune after", errors.Keys); + Assert.Contains("Maximum entries", errors.Keys); + Assert.Contains("Reset archive retention", errors.Keys); + Assert.Contains("Maximum disk usage", errors.Keys); + Assert.Contains("High-water target", errors.Keys); + } + + [Fact] + public void Validate_MatchesGatewayDurationAndByteGrammar() + { + var valid = new SessionMaintenanceSettings( + "enforce", "1h30m", 500, false, "2m500ms", true, "10g", "512mb"); + var invalidSpacing = valid with { PruneAfter = "30 d", MaxDiskBytes = "10 gb" }; + + Assert.Empty(SessionMaintenanceConfigModel.Validate(valid)); + var errors = SessionMaintenanceConfigModel.Validate(invalidSpacing); + Assert.Contains("Prune after", errors.Keys); + Assert.Contains("Maximum disk usage", errors.Keys); + } +} diff --git a/tests/OpenClaw.Tray.Tests/SessionMaintenanceUiContractTests.cs b/tests/OpenClaw.Tray.Tests/SessionMaintenanceUiContractTests.cs new file mode 100644 index 000000000..d0b49a64a --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/SessionMaintenanceUiContractTests.cs @@ -0,0 +1,54 @@ +namespace OpenClaw.Tray.Tests; + +public sealed class SessionMaintenanceUiContractTests +{ + [Fact] + public void ConfigPage_ExposesSessionMaintenanceEditor() + { + var xaml = Read("src", "OpenClaw.Tray.WinUI", "Pages", "ConfigPage.xaml"); + + Assert.Contains("ConfigPageSessionMaintenance", xaml, StringComparison.Ordinal); + Assert.Contains("MaintenanceModeComboBox", xaml, StringComparison.Ordinal); + Assert.Contains("PruneAfterTextBox", xaml, StringComparison.Ordinal); + Assert.Contains("MaxEntriesNumberBox", xaml, StringComparison.Ordinal); + Assert.Contains("ResetArchiveRetentionTextBox", xaml, StringComparison.Ordinal); + Assert.Contains("MaxDiskBytesTextBox", xaml, StringComparison.Ordinal); + Assert.Contains("HighWaterBytesTextBox", xaml, StringComparison.Ordinal); + Assert.Contains("Changes use the Save changes button below", xaml, StringComparison.Ordinal); + } + + [Fact] + public void ConfigPage_StagesMaintenanceInExistingOptimisticConfigPatch() + { + var source = Read("src", "OpenClaw.Tray.WinUI", "Pages", "ConfigPage.xaml.cs"); + + Assert.Contains("ConfigEditorModel.CaptureSnapshot", source, StringComparison.Ordinal); + Assert.Contains("ApplyMaintenanceDraft", source, StringComparison.Ordinal); + Assert.Contains("_pendingChanges[$\"{section}.maxEntries\"]", source, StringComparison.Ordinal); + Assert.Contains("PatchConfigDetailedAsync(updated, saveBase.BaseHash)", source, StringComparison.Ordinal); + Assert.Contains("LooksLikeStaleBaseHash", source, StringComparison.Ordinal); + Assert.Contains("OperatorScopeHelper.CanWriteConfig", source, StringComparison.Ordinal); + } + + private static string Read(params string[] parts) + { + var path = Path.Combine(new[] { RepoRoot() }.Concat(parts).ToArray()); + return File.ReadAllText(path); + } + + private static string RepoRoot() + { + var env = Environment.GetEnvironmentVariable("OPENCLAW_REPO_ROOT"); + if (!string.IsNullOrWhiteSpace(env) && Directory.Exists(env)) + return env; + + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null && + (!File.Exists(Path.Combine(directory.FullName, "openclaw-windows-node.slnx")) || + !Directory.Exists(Path.Combine(directory.FullName, "src")))) + { + directory = directory.Parent; + } + return directory?.FullName ?? throw new DirectoryNotFoundException("Repository root not found."); + } +}