diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 4a5cb2e..c6e2919 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -5255,6 +5255,36 @@ export function mergeHermesCronConfig(config = {}, form = {}) { return next } +export function buildHermesSessionsMaintenanceConfigValues(config = {}) { + const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} + const sessions = root.sessions && typeof root.sessions === 'object' && !Array.isArray(root.sessions) + ? root.sessions + : {} + return { + sessionsAutoPrune: readHermesBool(sessions.auto_prune, false), + sessionsRetentionDays: parseHermesInteger(sessions.retention_days, 'sessions.retention_days', 90, 1, 36500, false), + sessionsVacuumAfterPrune: readHermesBool(sessions.vacuum_after_prune, true), + sessionsMinIntervalHours: parseHermesInteger(sessions.min_interval_hours, 'sessions.min_interval_hours', 24, 0, 87600, false), + sessionsWriteJsonSnapshots: readHermesBool(sessions.write_json_snapshots, false), + } +} + +export function mergeHermesSessionsMaintenanceConfig(config = {}, form = {}) { + const next = mergeConfigsPreservingFields({}, config && typeof config === 'object' && !Array.isArray(config) ? config : {}) + const currentValues = buildHermesSessionsMaintenanceConfigValues(next) + const sessions = next.sessions && typeof next.sessions === 'object' && !Array.isArray(next.sessions) + ? mergeConfigsPreservingFields(next.sessions, {}) + : {} + + sessions.auto_prune = formHermesBool(form, 'sessionsAutoPrune', currentValues.sessionsAutoPrune) + sessions.retention_days = parseHermesInteger(Object.hasOwn(form, 'sessionsRetentionDays') ? form.sessionsRetentionDays : currentValues.sessionsRetentionDays, 'sessions.retention_days', 90, 1, 36500, true) + sessions.vacuum_after_prune = formHermesBool(form, 'sessionsVacuumAfterPrune', currentValues.sessionsVacuumAfterPrune) + sessions.min_interval_hours = parseHermesInteger(Object.hasOwn(form, 'sessionsMinIntervalHours') ? form.sessionsMinIntervalHours : currentValues.sessionsMinIntervalHours, 'sessions.min_interval_hours', 24, 0, 87600, true) + sessions.write_json_snapshots = formHermesBool(form, 'sessionsWriteJsonSnapshots', currentValues.sessionsWriteJsonSnapshots) + next.sessions = sessions + return next +} + export function buildHermesLoggingConfigValues(config = {}) { const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} const logging = root.logging && typeof root.logging === 'object' && !Array.isArray(root.logging) @@ -12436,6 +12466,27 @@ const handlers = { } }, + hermes_sessions_maintenance_config_read() { + const { configPath, exists, config } = readHermesConfigYamlObject() + return { + exists, + configPath, + values: buildHermesSessionsMaintenanceConfigValues(config), + } + }, + + hermes_sessions_maintenance_config_save({ form } = {}) { + const { configPath, config } = readHermesConfigYamlObject() + const next = mergeHermesSessionsMaintenanceConfig(config, form || {}) + const backup = writeHermesConfigYamlObject(configPath, next) + return { + ok: true, + configPath, + backup, + values: buildHermesSessionsMaintenanceConfigValues(next), + } + }, + hermes_logging_config_read() { const { configPath, exists, config } = readHermesConfigYamlObject() return { diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index 08dc26f..75cf8d9 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -7730,6 +7730,102 @@ fn merge_hermes_cron_config(config: &mut serde_yaml::Value, form: &Value) -> Res Ok(()) } +fn build_hermes_sessions_maintenance_config_values(config: &serde_yaml::Value) -> Value { + let root = config.as_mapping(); + let sessions = root.and_then(|map| yaml_get_mapping(map, "sessions")); + let sessions_auto_prune = sessions + .and_then(|map| yaml_bool_field(map, "auto_prune")) + .unwrap_or(false); + let sessions_retention_days = sessions + .map(|map| bounded_hermes_i64(yaml_i64_field(map, "retention_days"), 90, 1, 36500)) + .unwrap_or(90); + let sessions_vacuum_after_prune = sessions + .and_then(|map| yaml_bool_field(map, "vacuum_after_prune")) + .unwrap_or(true); + let sessions_min_interval_hours = sessions + .map(|map| bounded_hermes_i64(yaml_i64_field(map, "min_interval_hours"), 24, 0, 87600)) + .unwrap_or(24); + let sessions_write_json_snapshots = sessions + .and_then(|map| yaml_bool_field(map, "write_json_snapshots")) + .unwrap_or(false); + + serde_json::json!({ + "sessionsAutoPrune": sessions_auto_prune, + "sessionsRetentionDays": sessions_retention_days, + "sessionsVacuumAfterPrune": sessions_vacuum_after_prune, + "sessionsMinIntervalHours": sessions_min_interval_hours, + "sessionsWriteJsonSnapshots": sessions_write_json_snapshots, + }) +} + +fn merge_hermes_sessions_maintenance_config( + config: &mut serde_yaml::Value, + form: &Value, +) -> Result<(), String> { + let current = build_hermes_sessions_maintenance_config_values(config); + let sessions_retention_days = validate_hermes_i64( + if form.get("sessionsRetentionDays").is_some() { + form_i64(form, "sessionsRetentionDays") + } else { + Some(current["sessionsRetentionDays"].as_i64().unwrap_or(90)) + }, + "sessions.retention_days", + 90, + 1, + 36500, + )?; + let sessions_min_interval_hours = validate_hermes_i64( + if form.get("sessionsMinIntervalHours").is_some() { + form_i64(form, "sessionsMinIntervalHours") + } else { + Some(current["sessionsMinIntervalHours"].as_i64().unwrap_or(24)) + }, + "sessions.min_interval_hours", + 24, + 0, + 87600, + )?; + + let root = ensure_yaml_object(config)?; + let sessions = yaml_child_object(root, "sessions")?; + sessions.insert( + yaml_key("auto_prune"), + serde_yaml::Value::Bool( + form_bool(form, "sessionsAutoPrune") + .unwrap_or_else(|| current["sessionsAutoPrune"].as_bool().unwrap_or(false)), + ), + ); + sessions.insert( + yaml_key("retention_days"), + serde_yaml::Value::Number(sessions_retention_days.into()), + ); + sessions.insert( + yaml_key("vacuum_after_prune"), + serde_yaml::Value::Bool( + form_bool(form, "sessionsVacuumAfterPrune").unwrap_or_else(|| { + current["sessionsVacuumAfterPrune"] + .as_bool() + .unwrap_or(true) + }), + ), + ); + sessions.insert( + yaml_key("min_interval_hours"), + serde_yaml::Value::Number(sessions_min_interval_hours.into()), + ); + sessions.insert( + yaml_key("write_json_snapshots"), + serde_yaml::Value::Bool( + form_bool(form, "sessionsWriteJsonSnapshots").unwrap_or_else(|| { + current["sessionsWriteJsonSnapshots"] + .as_bool() + .unwrap_or(false) + }), + ), + ); + Ok(()) +} + fn build_hermes_logging_config_values(config: &serde_yaml::Value) -> Value { let root = config.as_mapping(); let logging = root.and_then(|map| yaml_get_mapping(map, "logging")); @@ -10272,6 +10368,30 @@ pub fn hermes_cron_config_save(form: Value) -> Result { })) } +#[tauri::command] +pub fn hermes_sessions_maintenance_config_read() -> Result { + let (config_path, exists, config) = read_hermes_channel_yaml_config()?; + ensure_yaml_object(&mut config.clone())?; + Ok(serde_json::json!({ + "exists": exists, + "configPath": config_path.to_string_lossy(), + "values": build_hermes_sessions_maintenance_config_values(&config), + })) +} + +#[tauri::command] +pub fn hermes_sessions_maintenance_config_save(form: Value) -> Result { + let (config_path, _exists, mut config) = read_hermes_channel_yaml_config()?; + merge_hermes_sessions_maintenance_config(&mut config, &form)?; + let backup = write_hermes_yaml_config(&config_path, &config)?; + Ok(serde_json::json!({ + "ok": true, + "configPath": config_path.to_string_lossy(), + "backup": backup, + "values": build_hermes_sessions_maintenance_config_values(&config), + })) +} + #[tauri::command] pub fn hermes_logging_config_read() -> Result { let (config_path, exists, config) = read_hermes_channel_yaml_config()?; @@ -17160,6 +17280,121 @@ cron: } } +#[cfg(test)] +mod hermes_sessions_maintenance_config_tests { + use super::{ + build_hermes_sessions_maintenance_config_values, merge_hermes_sessions_maintenance_config, + }; + use serde_json::json; + + #[test] + fn sessions_maintenance_values_have_upstream_defaults() { + let config: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let values = build_hermes_sessions_maintenance_config_values(&config); + assert_eq!(values["sessionsAutoPrune"], false); + assert_eq!(values["sessionsRetentionDays"], 90); + assert_eq!(values["sessionsVacuumAfterPrune"], true); + assert_eq!(values["sessionsMinIntervalHours"], 24); + assert_eq!(values["sessionsWriteJsonSnapshots"], false); + } + + #[test] + fn sessions_maintenance_values_read_yaml_fields() { + let config: serde_yaml::Value = serde_yaml::from_str( + r#" +sessions: + auto_prune: true + retention_days: 14 + vacuum_after_prune: false + min_interval_hours: 6 + write_json_snapshots: true +"#, + ) + .unwrap(); + let values = build_hermes_sessions_maintenance_config_values(&config); + assert_eq!(values["sessionsAutoPrune"], true); + assert_eq!(values["sessionsRetentionDays"], 14); + assert_eq!(values["sessionsVacuumAfterPrune"], false); + assert_eq!(values["sessionsMinIntervalHours"], 6); + assert_eq!(values["sessionsWriteJsonSnapshots"], true); + } + + #[test] + fn merge_sessions_maintenance_config_preserves_unknown_fields() { + let mut config: serde_yaml::Value = serde_yaml::from_str( + r#" +sessions: + auto_prune: false + custom_flag: keep-sessions +model: + provider: anthropic +streaming: + enabled: true +"#, + ) + .unwrap(); + + merge_hermes_sessions_maintenance_config( + &mut config, + &json!({ + "sessionsAutoPrune": true, + "sessionsRetentionDays": "30", + "sessionsVacuumAfterPrune": false, + "sessionsMinIntervalHours": "12", + "sessionsWriteJsonSnapshots": true, + }), + ) + .unwrap(); + + assert_eq!(config["model"]["provider"].as_str(), Some("anthropic")); + assert_eq!(config["streaming"]["enabled"].as_bool(), Some(true)); + assert_eq!(config["sessions"]["auto_prune"].as_bool(), Some(true)); + assert_eq!(config["sessions"]["retention_days"].as_i64(), Some(30)); + assert_eq!( + config["sessions"]["vacuum_after_prune"].as_bool(), + Some(false) + ); + assert_eq!(config["sessions"]["min_interval_hours"].as_i64(), Some(12)); + assert_eq!( + config["sessions"]["write_json_snapshots"].as_bool(), + Some(true) + ); + assert_eq!( + config["sessions"]["custom_flag"].as_str(), + Some("keep-sessions") + ); + } + + #[test] + fn merge_sessions_maintenance_config_rejects_invalid_values() { + let mut config = serde_yaml::Value::Mapping(serde_yaml::Mapping::new()); + let err = merge_hermes_sessions_maintenance_config( + &mut config, + &json!({ "sessionsRetentionDays": 0 }), + ) + .unwrap_err(); + assert!(err.contains("sessions.retention_days")); + let err = merge_hermes_sessions_maintenance_config( + &mut config, + &json!({ "sessionsRetentionDays": 36501 }), + ) + .unwrap_err(); + assert!(err.contains("sessions.retention_days")); + let err = merge_hermes_sessions_maintenance_config( + &mut config, + &json!({ "sessionsMinIntervalHours": -1 }), + ) + .unwrap_err(); + assert!(err.contains("sessions.min_interval_hours")); + let err = merge_hermes_sessions_maintenance_config( + &mut config, + &json!({ "sessionsMinIntervalHours": 87601 }), + ) + .unwrap_err(); + assert!(err.contains("sessions.min_interval_hours")); + } +} + #[cfg(test)] mod hermes_logging_config_tests { use super::{build_hermes_logging_config_values, merge_hermes_logging_config}; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6c3c350..dfa8f90 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -315,6 +315,8 @@ pub fn run() { hermes::hermes_checkpoints_config_save, hermes::hermes_cron_config_read, hermes::hermes_cron_config_save, + hermes::hermes_sessions_maintenance_config_read, + hermes::hermes_sessions_maintenance_config_save, hermes::hermes_logging_config_read, hermes::hermes_logging_config_save, hermes::hermes_approvals_config_read, diff --git a/src/engines/hermes/pages/config.js b/src/engines/hermes/pages/config.js index f68cd54..3fc1fe8 100644 --- a/src/engines/hermes/pages/config.js +++ b/src/engines/hermes/pages/config.js @@ -15,6 +15,14 @@ const SESSION_RUNTIME_DEFAULTS = { worktreeEnabled: false, } +const SESSIONS_MAINTENANCE_DEFAULTS = { + sessionsAutoPrune: false, + sessionsRetentionDays: 90, + sessionsVacuumAfterPrune: true, + sessionsMinIntervalHours: 24, + sessionsWriteJsonSnapshots: false, +} + const COMPRESSION_DEFAULTS = { enabled: true, threshold: 0.5, @@ -345,9 +353,10 @@ export function render() { const el = document.createElement('div') el.className = 'page' el.dataset.engine = 'hermes' - let yaml = '' - let runtimeValues = { ...SESSION_RUNTIME_DEFAULTS } - let compressionValues = { ...COMPRESSION_DEFAULTS } + let yaml = '' + let runtimeValues = { ...SESSION_RUNTIME_DEFAULTS } + let sessionsMaintenanceValues = { ...SESSIONS_MAINTENANCE_DEFAULTS } + let compressionValues = { ...COMPRESSION_DEFAULTS } let promptCachingValues = { ...PROMPT_CACHING_DEFAULTS } let openrouterCacheValues = { ...OPENROUTER_CACHE_DEFAULTS } let providerRoutingValues = { ...PROVIDER_ROUTING_DEFAULTS } @@ -381,9 +390,10 @@ export function render() { let browserValues = { ...BROWSER_DEFAULTS } let sttValues = { ...STT_DEFAULTS } let terminalValues = { ...TERMINAL_DEFAULTS } - let loading = true - let runtimeLoading = true - let compressionLoading = true + let loading = true + let runtimeLoading = true + let sessionsMaintenanceLoading = true + let compressionLoading = true let promptCachingLoading = true let openrouterCacheLoading = true let providerRoutingLoading = true @@ -417,9 +427,10 @@ export function render() { let browserLoading = true let sttLoading = true let terminalLoading = true - let saving = false - let runtimeSaving = false - let compressionSaving = false + let saving = false + let runtimeSaving = false + let sessionsMaintenanceSaving = false + let compressionSaving = false let promptCachingSaving = false let openrouterCacheSaving = false let providerRoutingSaving = false @@ -453,9 +464,10 @@ export function render() { let browserSaving = false let sttSaving = false let terminalSaving = false - let error = null - let runtimeError = null - let compressionError = null + let error = null + let runtimeError = null + let sessionsMaintenanceError = null + let compressionError = null let promptCachingError = null let openrouterCacheError = null let providerRoutingError = null @@ -499,7 +511,7 @@ export function render() { } function isBusy() { - return loading || runtimeLoading || compressionLoading || promptCachingLoading || openrouterCacheLoading || providerRoutingLoading || auxiliaryLoading || toolGuardrailsLoading || memoryLoading || skillsLoading || curatorLoading || quickCommandsLoading || modelLoading || modelAliasesLoading || hooksLoading || providerOverridesLoading || mcpServersLoading || agentToolsetsLoading || platformToolsetsLoading || agentRuntimeLoading || unauthorizedDmLoading || securityLoading || displayLoading || humanDelayLoading || kanbanLoading || streamingLoading || executionLimitsLoading || ioSafetyLoading || checkpointsLoading || cronLoading || loggingLoading || approvalsLoading || privacyLoading || browserLoading || sttLoading || terminalLoading || saving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || curatorSaving || quickCommandsSaving || modelSaving || modelAliasesSaving || hooksSaving || providerOverridesSaving || mcpServersSaving || agentToolsetsSaving || platformToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || securitySaving || displaySaving || humanDelaySaving || kanbanSaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || privacySaving || browserSaving || sttSaving || terminalSaving + return loading || runtimeLoading || sessionsMaintenanceLoading || compressionLoading || promptCachingLoading || openrouterCacheLoading || providerRoutingLoading || auxiliaryLoading || toolGuardrailsLoading || memoryLoading || skillsLoading || curatorLoading || quickCommandsLoading || modelLoading || modelAliasesLoading || hooksLoading || providerOverridesLoading || mcpServersLoading || agentToolsetsLoading || platformToolsetsLoading || agentRuntimeLoading || unauthorizedDmLoading || securityLoading || displayLoading || humanDelayLoading || kanbanLoading || streamingLoading || executionLimitsLoading || ioSafetyLoading || checkpointsLoading || cronLoading || loggingLoading || approvalsLoading || privacyLoading || browserLoading || sttLoading || terminalLoading || saving || runtimeSaving || sessionsMaintenanceSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || curatorSaving || quickCommandsSaving || modelSaving || modelAliasesSaving || hooksSaving || providerOverridesSaving || mcpServersSaving || agentToolsetsSaving || platformToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || securitySaving || displaySaving || humanDelaySaving || kanbanSaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || privacySaving || browserSaving || sttSaving || terminalSaving } function option(labelKey, value, selected) { @@ -516,7 +528,7 @@ export function render() { } function renderRuntimePanel() { - const disabled = loading || saving || runtimeLoading || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || providerOverridesSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || runtimeLoading || runtimeSaving || sessionsMaintenanceSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || providerOverridesSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving return `
@@ -567,6 +579,52 @@ export function render() { ` } + function renderSessionsMaintenancePanel() { + const disabled = loading || saving || sessionsMaintenanceLoading || sessionsMaintenanceSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || providerOverridesSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving + return ` +
+
+
+
${t('engine.hermesSessionsMaintenanceTitle')}
+
${t('engine.hermesSessionsMaintenanceDesc')}
+
+
+ ${sessionsMaintenanceSaving ? t('engine.hermesConfigStatusSaving') : sessionsMaintenanceLoading ? t('engine.hermesConfigStatusLoading') : t('engine.hermesSessionsMaintenanceStatusReady')} + +
+
+
+ ${renderError(sessionsMaintenanceError)} +
+ + + +
+
+ + +
+
${t('engine.hermesSessionsMaintenanceFootnote')}
+
+
+ ` + } + function renderCompressionPanel() { const disabled = loading || saving || compressionLoading || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || runtimeSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || providerOverridesSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving return ` @@ -2291,6 +2349,7 @@ export function render() {
${renderRuntimePanel()} + ${renderSessionsMaintenancePanel()} ${renderTerminalPanel()} ${renderStreamingPanel()} ${renderExecutionLimitsPanel()} @@ -2345,6 +2404,7 @@ export function render() { el.querySelector('#hm-config-reload')?.addEventListener('click', load) el.querySelector('#hm-config-save')?.addEventListener('click', save) el.querySelector('#hm-runtime-save')?.addEventListener('click', saveRuntime) + el.querySelector('#hm-sessions-maintenance-save')?.addEventListener('click', saveSessionsMaintenance) el.querySelector('#hm-compression-save')?.addEventListener('click', saveCompression) el.querySelector('#hm-prompt-caching-save')?.addEventListener('click', savePromptCaching) el.querySelector('#hm-openrouter-cache-save')?.addEventListener('click', saveOpenrouterCache) @@ -2391,6 +2451,11 @@ export function render() { runtimeValues = { ...SESSION_RUNTIME_DEFAULTS, ...(data?.values || {}) } } + async function loadSessionsMaintenance() { + const data = await api.hermesSessionsMaintenanceConfigRead() + sessionsMaintenanceValues = { ...SESSIONS_MAINTENANCE_DEFAULTS, ...(data?.values || {}) } + } + async function loadCompression() { const data = await api.hermesCompressionConfigRead() compressionValues = { ...COMPRESSION_DEFAULTS, ...(data?.values || {}) } @@ -2564,6 +2629,7 @@ export function render() { async function load() { loading = true runtimeLoading = true + sessionsMaintenanceLoading = true compressionLoading = true promptCachingLoading = true openrouterCacheLoading = true @@ -2600,6 +2666,7 @@ export function render() { terminalLoading = true error = null runtimeError = null + sessionsMaintenanceError = null compressionError = null promptCachingError = null openrouterCacheError = null @@ -2649,6 +2716,14 @@ export function render() { runtimeLoading = false draw() } + try { + await loadSessionsMaintenance() + } catch (err) { + sessionsMaintenanceError = humanizeError(err, t('engine.hermesSessionsMaintenanceLoadFailed') || 'Load session maintenance config failed') + } finally { + sessionsMaintenanceLoading = false + draw() + } try { await loadCompression() } catch (err) { @@ -2945,6 +3020,9 @@ export function render() { try { await loadRuntime() } catch {} + try { + await loadSessionsMaintenance() + } catch {} try { await loadCompression() } catch {} @@ -3080,6 +3158,35 @@ export function render() { } } + async function saveSessionsMaintenance() { + const form = { + sessionsAutoPrune: !!el.querySelector('#hm-sessions-auto-prune')?.checked, + sessionsRetentionDays: el.querySelector('#hm-sessions-retention-days')?.value || '90', + sessionsVacuumAfterPrune: !!el.querySelector('#hm-sessions-vacuum-after-prune')?.checked, + sessionsMinIntervalHours: el.querySelector('#hm-sessions-min-interval-hours')?.value || '24', + sessionsWriteJsonSnapshots: !!el.querySelector('#hm-sessions-write-json-snapshots')?.checked, + } + sessionsMaintenanceSaving = true + sessionsMaintenanceError = null + draw() + try { + const result = await api.hermesSessionsMaintenanceConfigSave(form) + sessionsMaintenanceValues = { ...SESSIONS_MAINTENANCE_DEFAULTS, ...(result?.values || form) } + await refreshRawAfterStructuredSave() + const backup = result?.backup || '' + toast({ + message: t('engine.hermesSessionsMaintenanceSaveSuccess'), + hint: backup ? t('engine.hermesConfigBackupHint', { path: backup }) : '', + }, 'success') + } catch (err) { + sessionsMaintenanceError = humanizeError(err, t('engine.hermesSessionsMaintenanceSaveFailed') || 'Save session maintenance config failed') + toast(sessionsMaintenanceError, 'error') + } finally { + sessionsMaintenanceSaving = false + draw() + } + } + async function saveCompression() { const form = { enabled: !!el.querySelector('#hm-compression-enabled')?.checked, diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index 4e8809e..4791dbc 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -567,6 +567,8 @@ export const api = { hermesCheckpointsConfigSave: (form) => invoke('hermes_checkpoints_config_save', { form }), hermesCronConfigRead: () => invoke('hermes_cron_config_read'), hermesCronConfigSave: (form) => invoke('hermes_cron_config_save', { form }), + hermesSessionsMaintenanceConfigRead: () => invoke('hermes_sessions_maintenance_config_read'), + hermesSessionsMaintenanceConfigSave: (form) => invoke('hermes_sessions_maintenance_config_save', { form }), hermesLoggingConfigRead: () => invoke('hermes_logging_config_read'), hermesLoggingConfigSave: (form) => invoke('hermes_logging_config_save', { form }), hermesApprovalsConfigRead: () => invoke('hermes_approvals_config_read'), diff --git a/src/locales/modules/engine.js b/src/locales/modules/engine.js index 720bd8c..fe98d93 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -499,6 +499,19 @@ export default { hermesThreadSessionsPerUser: _('线程也按成员隔离', 'Isolate thread sessions per user', '討論串也依成員隔離'), hermesWorktreeEnabled: _('CLI 会话默认使用 Git worktree 隔离', 'Use Git worktree isolation for CLI sessions by default', 'CLI 會話預設使用 Git worktree 隔離'), hermesSessionRuntimeFootnote: _('推荐保持群聊隔离开启;多人或多 Agent 同仓库长跑时,可开启 worktree 隔离来减少文件冲突。', 'Keeping group isolation on is recommended. For multi-user or multi-agent long runs in the same repository, enable worktree isolation to reduce file conflicts.', '建議保持群聊隔離開啟;多人或多 Agent 同倉庫長跑時,可啟用 worktree 隔離以減少檔案衝突。'), + hermesSessionsMaintenanceTitle: _('会话库维护', 'Session store maintenance', '會話庫維護'), + hermesSessionsMaintenanceDesc: _('控制 state.db 自动清理、VACUUM 和旧 JSON 快照写入,避免无人值守长跑后会话库与磁盘占用持续膨胀。', 'Control state.db auto-pruning, VACUUM, and legacy JSON snapshot writing so unattended long runs do not keep growing the session store and disk usage.', '控制 state.db 自動清理、VACUUM 和舊 JSON 快照寫入,避免無人值守長跑後會話庫與磁碟占用持續膨脹。'), + hermesSessionsMaintenanceStatusReady: _('结构化配置', 'structured settings', '結構化設定'), + hermesSessionsMaintenanceSave: _('保存会话维护配置', 'Save session maintenance', '儲存會話維護設定'), + hermesSessionsMaintenanceSaveSuccess: _('会话库维护配置已保存,建议重启 Hermes Gateway 生效', 'Session store maintenance settings saved. Restart Hermes Gateway to take effect.', '會話庫維護設定已儲存,建議重啟 Hermes Gateway 生效'), + hermesSessionsMaintenanceLoadFailed: _('加载会话库维护配置失败', 'Load session store maintenance failed', '載入會話庫維護設定失敗'), + hermesSessionsMaintenanceSaveFailed: _('保存会话库维护配置失败', 'Save session store maintenance failed', '儲存會話庫維護設定失敗'), + hermesSessionsMaintenanceAutoPrune: _('自动清理已结束会话', 'Auto-prune ended sessions', '自動清理已結束會話'), + hermesSessionsMaintenanceVacuumAfterPrune: _('清理后执行 SQLite VACUUM', 'Run SQLite VACUUM after pruning', '清理後執行 SQLite VACUUM'), + hermesSessionsMaintenanceWriteJsonSnapshots: _('继续写入旧版 JSON 快照', 'Keep writing legacy JSON snapshots', '繼續寫入舊版 JSON 快照'), + hermesSessionsMaintenanceRetentionDays: _('已结束会话保留天数', 'Ended-session retention days', '已結束會話保留天數'), + hermesSessionsMaintenanceMinIntervalHours: _('自动维护最小间隔小时', 'Minimum maintenance interval hours', '自動維護最小間隔小時'), + hermesSessionsMaintenanceFootnote: _('这里写入 sessions.*。自动清理只触碰已结束会话,活跃会话由 Hermes 保留;VACUUM 会在删除后回收磁盘但可能短暂阻塞写入。旧版 JSON 快照默认关闭,只有外部工具直接读取 ~/.hermes/sessions/session_*.json 时才建议开启。', 'This writes sessions.*. Auto-prune only touches ended sessions; active sessions are preserved by Hermes. VACUUM reclaims disk after deletes but may briefly block writes. Legacy JSON snapshots are off by default; enable them only if an external tool reads ~/.hermes/sessions/session_*.json directly.', '這裡寫入 sessions.*。自動清理只觸碰已結束會話,活躍會話由 Hermes 保留;VACUUM 會在刪除後回收磁碟但可能短暫阻塞寫入。舊版 JSON 快照預設關閉,只有外部工具直接讀取 ~/.hermes/sessions/session_*.json 時才建議啟用。'), hermesTerminalConfigTitle: _('终端执行', 'Terminal execution', '終端執行'), hermesTerminalConfigDesc: _('控制 Hermes 工具命令的执行环境、工作目录、超时和容器资源,避免长任务卡死或沙箱范围误配。', 'Control command execution backend, working directory, timeouts, and container resources to avoid stuck runs or sandbox misconfiguration.', '控制 Hermes 工具命令的執行環境、工作目錄、逾時和容器資源,避免長任務卡死或沙箱範圍誤配。'), hermesTerminalConfigStatusReady: _('结构化配置', 'structured settings', '結構化設定'), diff --git a/tests/hermes-config-page-ui.test.js b/tests/hermes-config-page-ui.test.js index f626dd5..ec12261 100644 --- a/tests/hermes-config-page-ui.test.js +++ b/tests/hermes-config-page-ui.test.js @@ -23,6 +23,19 @@ test('Hermes 配置页会暴露会话安全结构化配置字段', () => { } }) +test('Hermes 配置页会暴露会话维护结构化配置字段', () => { + for (const id of [ + 'hm-sessions-maintenance-save', + 'hm-sessions-auto-prune', + 'hm-sessions-retention-days', + 'hm-sessions-vacuum-after-prune', + 'hm-sessions-min-interval-hours', + 'hm-sessions-write-json-snapshots', + ]) { + assert.match(source, new RegExp(`id="${id}"`), `缺少 ${id}`) + } +}) + test('Hermes 配置页会暴露工具循环防护结构化配置字段', () => { for (const id of [ 'hm-tool-guardrails-save', diff --git a/tests/hermes-sessions-maintenance-config.test.js b/tests/hermes-sessions-maintenance-config.test.js new file mode 100644 index 0000000..354bddb --- /dev/null +++ b/tests/hermes-sessions-maintenance-config.test.js @@ -0,0 +1,82 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + buildHermesSessionsMaintenanceConfigValues, + mergeHermesSessionsMaintenanceConfig, +} from '../scripts/dev-api.js' + +test('Hermes 会话维护配置读取会提供上游默认值', () => { + const values = buildHermesSessionsMaintenanceConfigValues({}) + + assert.deepEqual(values, { + sessionsAutoPrune: false, + sessionsRetentionDays: 90, + sessionsVacuumAfterPrune: true, + sessionsMinIntervalHours: 24, + sessionsWriteJsonSnapshots: false, + }) +}) + +test('Hermes 会话维护配置读取会回显 YAML 字段', () => { + const values = buildHermesSessionsMaintenanceConfigValues({ + sessions: { + auto_prune: true, + retention_days: 14, + vacuum_after_prune: false, + min_interval_hours: 6, + write_json_snapshots: true, + }, + }) + + assert.equal(values.sessionsAutoPrune, true) + assert.equal(values.sessionsRetentionDays, 14) + assert.equal(values.sessionsVacuumAfterPrune, false) + assert.equal(values.sessionsMinIntervalHours, 6) + assert.equal(values.sessionsWriteJsonSnapshots, true) +}) + +test('Hermes 会话维护配置保存会保留未知字段并写入 sessions', () => { + const next = mergeHermesSessionsMaintenanceConfig({ + sessions: { + auto_prune: false, + custom_flag: 'keep-sessions', + }, + model: { provider: 'anthropic' }, + streaming: { enabled: true }, + }, { + sessionsAutoPrune: true, + sessionsRetentionDays: '30', + sessionsVacuumAfterPrune: false, + sessionsMinIntervalHours: '12', + sessionsWriteJsonSnapshots: true, + }) + + assert.deepEqual(next.model, { provider: 'anthropic' }) + assert.deepEqual(next.streaming, { enabled: true }) + assert.equal(next.sessions.auto_prune, true) + assert.equal(next.sessions.retention_days, 30) + assert.equal(next.sessions.vacuum_after_prune, false) + assert.equal(next.sessions.min_interval_hours, 12) + assert.equal(next.sessions.write_json_snapshots, true) + assert.equal(next.sessions.custom_flag, 'keep-sessions') +}) + +test('Hermes 会话维护配置保存会拒绝越界值', () => { + assert.throws( + () => mergeHermesSessionsMaintenanceConfig({}, { sessionsRetentionDays: '0' }), + /sessions\.retention_days/, + ) + assert.throws( + () => mergeHermesSessionsMaintenanceConfig({}, { sessionsRetentionDays: '36501' }), + /sessions\.retention_days/, + ) + assert.throws( + () => mergeHermesSessionsMaintenanceConfig({}, { sessionsMinIntervalHours: '-1' }), + /sessions\.min_interval_hours/, + ) + assert.throws( + () => mergeHermesSessionsMaintenanceConfig({}, { sessionsMinIntervalHours: '87601' }), + /sessions\.min_interval_hours/, + ) +})