diff --git a/scripts/dev-api.js b/scripts/dev-api.js index c6e2919..1b1e963 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -5285,6 +5285,30 @@ export function mergeHermesSessionsMaintenanceConfig(config = {}, form = {}) { return next } +export function buildHermesUpdatesConfigValues(config = {}) { + const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} + const updates = root.updates && typeof root.updates === 'object' && !Array.isArray(root.updates) + ? root.updates + : {} + return { + updatesPreUpdateBackup: readHermesBool(updates.pre_update_backup, false), + updatesBackupKeep: parseHermesInteger(updates.backup_keep, 'updates.backup_keep', 5, 1, 1000, false), + } +} + +export function mergeHermesUpdatesConfig(config = {}, form = {}) { + const next = mergeConfigsPreservingFields({}, config && typeof config === 'object' && !Array.isArray(config) ? config : {}) + const currentValues = buildHermesUpdatesConfigValues(next) + const updates = next.updates && typeof next.updates === 'object' && !Array.isArray(next.updates) + ? mergeConfigsPreservingFields(next.updates, {}) + : {} + + updates.pre_update_backup = formHermesBool(form, 'updatesPreUpdateBackup', currentValues.updatesPreUpdateBackup) + updates.backup_keep = parseHermesInteger(Object.hasOwn(form, 'updatesBackupKeep') ? form.updatesBackupKeep : currentValues.updatesBackupKeep, 'updates.backup_keep', 5, 1, 1000, true) + next.updates = updates + 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) @@ -12487,6 +12511,27 @@ const handlers = { } }, + hermes_updates_config_read() { + const { configPath, exists, config } = readHermesConfigYamlObject() + return { + exists, + configPath, + values: buildHermesUpdatesConfigValues(config), + } + }, + + hermes_updates_config_save({ form } = {}) { + const { configPath, config } = readHermesConfigYamlObject() + const next = mergeHermesUpdatesConfig(config, form || {}) + const backup = writeHermesConfigYamlObject(configPath, next) + return { + ok: true, + configPath, + backup, + values: buildHermesUpdatesConfigValues(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 75cf8d9..5a61c0f 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -7826,6 +7826,51 @@ fn merge_hermes_sessions_maintenance_config( Ok(()) } +fn build_hermes_updates_config_values(config: &serde_yaml::Value) -> Value { + let root = config.as_mapping(); + let updates = root.and_then(|map| yaml_get_mapping(map, "updates")); + let updates_pre_update_backup = updates + .and_then(|map| yaml_bool_field(map, "pre_update_backup")) + .unwrap_or(false); + let updates_backup_keep = updates + .map(|map| bounded_hermes_i64(yaml_i64_field(map, "backup_keep"), 5, 1, 1000)) + .unwrap_or(5); + + serde_json::json!({ + "updatesPreUpdateBackup": updates_pre_update_backup, + "updatesBackupKeep": updates_backup_keep, + }) +} + +fn merge_hermes_updates_config(config: &mut serde_yaml::Value, form: &Value) -> Result<(), String> { + let current = build_hermes_updates_config_values(config); + let updates_pre_update_backup = form_bool(form, "updatesPreUpdateBackup") + .unwrap_or_else(|| current["updatesPreUpdateBackup"].as_bool().unwrap_or(false)); + let updates_backup_keep = validate_hermes_i64( + if form.get("updatesBackupKeep").is_some() { + form_i64(form, "updatesBackupKeep") + } else { + Some(current["updatesBackupKeep"].as_i64().unwrap_or(5)) + }, + "updates.backup_keep", + 5, + 1, + 1000, + )?; + + let root = ensure_yaml_object(config)?; + let updates = yaml_child_object(root, "updates")?; + updates.insert( + yaml_key("pre_update_backup"), + serde_yaml::Value::Bool(updates_pre_update_backup), + ); + updates.insert( + yaml_key("backup_keep"), + serde_yaml::Value::Number(updates_backup_keep.into()), + ); + 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")); @@ -10392,6 +10437,30 @@ pub fn hermes_sessions_maintenance_config_save(form: Value) -> Result 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_updates_config_values(&config), + })) +} + +#[tauri::command] +pub fn hermes_updates_config_save(form: Value) -> Result { + let (config_path, _exists, mut config) = read_hermes_channel_yaml_config()?; + merge_hermes_updates_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_updates_config_values(&config), + })) +} + #[tauri::command] pub fn hermes_logging_config_read() -> Result { let (config_path, exists, config) = read_hermes_channel_yaml_config()?; @@ -17395,6 +17464,80 @@ streaming: } } +#[cfg(test)] +mod hermes_updates_config_tests { + use super::{build_hermes_updates_config_values, merge_hermes_updates_config}; + use serde_json::json; + + #[test] + fn updates_values_have_upstream_defaults() { + let config: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let values = build_hermes_updates_config_values(&config); + assert_eq!(values["updatesPreUpdateBackup"], false); + assert_eq!(values["updatesBackupKeep"], 5); + } + + #[test] + fn updates_values_read_yaml_fields() { + let config: serde_yaml::Value = serde_yaml::from_str( + r#" +updates: + pre_update_backup: true + backup_keep: 9 +"#, + ) + .unwrap(); + let values = build_hermes_updates_config_values(&config); + assert_eq!(values["updatesPreUpdateBackup"], true); + assert_eq!(values["updatesBackupKeep"], 9); + } + + #[test] + fn merge_updates_config_preserves_unknown_fields() { + let mut config: serde_yaml::Value = serde_yaml::from_str( + r#" +updates: + pre_update_backup: false + custom_flag: keep-updates +sessions: + auto_prune: true +model: + provider: anthropic +"#, + ) + .unwrap(); + + merge_hermes_updates_config( + &mut config, + &json!({ + "updatesPreUpdateBackup": true, + "updatesBackupKeep": "7", + }), + ) + .unwrap(); + + assert_eq!(config["sessions"]["auto_prune"].as_bool(), Some(true)); + assert_eq!(config["model"]["provider"].as_str(), Some("anthropic")); + assert_eq!(config["updates"]["pre_update_backup"].as_bool(), Some(true)); + assert_eq!(config["updates"]["backup_keep"].as_i64(), Some(7)); + assert_eq!( + config["updates"]["custom_flag"].as_str(), + Some("keep-updates") + ); + } + + #[test] + fn merge_updates_config_rejects_invalid_backup_keep() { + let mut config = serde_yaml::Value::Mapping(serde_yaml::Mapping::new()); + let err = merge_hermes_updates_config(&mut config, &json!({ "updatesBackupKeep": 0 })) + .unwrap_err(); + assert!(err.contains("updates.backup_keep")); + let err = merge_hermes_updates_config(&mut config, &json!({ "updatesBackupKeep": 1001 })) + .unwrap_err(); + assert!(err.contains("updates.backup_keep")); + } +} + #[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 dfa8f90..602f2c9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -317,6 +317,8 @@ pub fn run() { hermes::hermes_cron_config_save, hermes::hermes_sessions_maintenance_config_read, hermes::hermes_sessions_maintenance_config_save, + hermes::hermes_updates_config_read, + hermes::hermes_updates_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 3fc1fe8..5339beb 100644 --- a/src/engines/hermes/pages/config.js +++ b/src/engines/hermes/pages/config.js @@ -23,6 +23,11 @@ const SESSIONS_MAINTENANCE_DEFAULTS = { sessionsWriteJsonSnapshots: false, } +const UPDATES_DEFAULTS = { + updatesPreUpdateBackup: false, + updatesBackupKeep: 5, +} + const COMPRESSION_DEFAULTS = { enabled: true, threshold: 0.5, @@ -356,6 +361,7 @@ export function render() { let yaml = '' let runtimeValues = { ...SESSION_RUNTIME_DEFAULTS } let sessionsMaintenanceValues = { ...SESSIONS_MAINTENANCE_DEFAULTS } + let updatesValues = { ...UPDATES_DEFAULTS } let compressionValues = { ...COMPRESSION_DEFAULTS } let promptCachingValues = { ...PROMPT_CACHING_DEFAULTS } let openrouterCacheValues = { ...OPENROUTER_CACHE_DEFAULTS } @@ -393,6 +399,7 @@ export function render() { let loading = true let runtimeLoading = true let sessionsMaintenanceLoading = true + let updatesLoading = true let compressionLoading = true let promptCachingLoading = true let openrouterCacheLoading = true @@ -430,6 +437,7 @@ export function render() { let saving = false let runtimeSaving = false let sessionsMaintenanceSaving = false + let updatesSaving = false let compressionSaving = false let promptCachingSaving = false let openrouterCacheSaving = false @@ -467,6 +475,7 @@ export function render() { let error = null let runtimeError = null let sessionsMaintenanceError = null + let updatesError = null let compressionError = null let promptCachingError = null let openrouterCacheError = null @@ -511,7 +520,7 @@ export function render() { } function isBusy() { - 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 + return loading || runtimeLoading || sessionsMaintenanceLoading || updatesLoading || 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 || updatesSaving || 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) { @@ -528,7 +537,7 @@ export function render() { } function renderRuntimePanel() { - 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 + const disabled = loading || saving || runtimeLoading || runtimeSaving || sessionsMaintenanceSaving || updatesSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || providerOverridesSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving return `
@@ -580,7 +589,7 @@ 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 + const disabled = loading || saving || sessionsMaintenanceLoading || sessionsMaintenanceSaving || runtimeSaving || updatesSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || providerOverridesSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving return `
@@ -625,8 +634,42 @@ export function render() { ` } + function renderUpdatesPanel() { + const disabled = loading || saving || updatesLoading || updatesSaving || runtimeSaving || sessionsMaintenanceSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || providerOverridesSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving + return ` +
+
+
+
${t('engine.hermesUpdatesConfigTitle')}
+
${t('engine.hermesUpdatesConfigDesc')}
+
+
+ ${updatesSaving ? t('engine.hermesConfigStatusSaving') : updatesLoading ? t('engine.hermesConfigStatusLoading') : t('engine.hermesUpdatesConfigStatusReady')} + +
+
+
+ ${renderError(updatesError)} +
+ +
+
+ +
+
${t('engine.hermesUpdatesConfigFootnote')}
+
+
+ ` + } + 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 + const disabled = loading || saving || compressionLoading || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || runtimeSaving || updatesSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || providerOverridesSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving return `
@@ -2350,6 +2393,7 @@ export function render() { ${renderRuntimePanel()} ${renderSessionsMaintenancePanel()} + ${renderUpdatesPanel()} ${renderTerminalPanel()} ${renderStreamingPanel()} ${renderExecutionLimitsPanel()} @@ -2405,6 +2449,7 @@ export function render() { 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-updates-save')?.addEventListener('click', saveUpdatesConfig) 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) @@ -2456,6 +2501,11 @@ export function render() { sessionsMaintenanceValues = { ...SESSIONS_MAINTENANCE_DEFAULTS, ...(data?.values || {}) } } + async function loadUpdatesConfig() { + const data = await api.hermesUpdatesConfigRead() + updatesValues = { ...UPDATES_DEFAULTS, ...(data?.values || {}) } + } + async function loadCompression() { const data = await api.hermesCompressionConfigRead() compressionValues = { ...COMPRESSION_DEFAULTS, ...(data?.values || {}) } @@ -2630,6 +2680,7 @@ export function render() { loading = true runtimeLoading = true sessionsMaintenanceLoading = true + updatesLoading = true compressionLoading = true promptCachingLoading = true openrouterCacheLoading = true @@ -2667,6 +2718,7 @@ export function render() { error = null runtimeError = null sessionsMaintenanceError = null + updatesError = null compressionError = null promptCachingError = null openrouterCacheError = null @@ -2724,6 +2776,14 @@ export function render() { sessionsMaintenanceLoading = false draw() } + try { + await loadUpdatesConfig() + } catch (err) { + updatesError = humanizeError(err, t('engine.hermesUpdatesConfigLoadFailed') || 'Load updates config failed') + } finally { + updatesLoading = false + draw() + } try { await loadCompression() } catch (err) { @@ -3023,6 +3083,9 @@ export function render() { try { await loadSessionsMaintenance() } catch {} + try { + await loadUpdatesConfig() + } catch {} try { await loadCompression() } catch {} @@ -3187,6 +3250,32 @@ export function render() { } } + async function saveUpdatesConfig() { + const form = { + updatesPreUpdateBackup: !!el.querySelector('#hm-updates-pre-update-backup')?.checked, + updatesBackupKeep: el.querySelector('#hm-updates-backup-keep')?.value || '5', + } + updatesSaving = true + updatesError = null + draw() + try { + const result = await api.hermesUpdatesConfigSave(form) + updatesValues = { ...UPDATES_DEFAULTS, ...(result?.values || form) } + await refreshRawAfterStructuredSave() + const backup = result?.backup || '' + toast({ + message: t('engine.hermesUpdatesConfigSaveSuccess'), + hint: backup ? t('engine.hermesConfigBackupHint', { path: backup }) : '', + }, 'success') + } catch (err) { + updatesError = humanizeError(err, t('engine.hermesUpdatesConfigSaveFailed') || 'Save updates config failed') + toast(updatesError, 'error') + } finally { + updatesSaving = 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 4791dbc..6a32337 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -569,6 +569,8 @@ export const api = { hermesCronConfigSave: (form) => invoke('hermes_cron_config_save', { form }), hermesSessionsMaintenanceConfigRead: () => invoke('hermes_sessions_maintenance_config_read'), hermesSessionsMaintenanceConfigSave: (form) => invoke('hermes_sessions_maintenance_config_save', { form }), + hermesUpdatesConfigRead: () => invoke('hermes_updates_config_read'), + hermesUpdatesConfigSave: (form) => invoke('hermes_updates_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 fe98d93..693f55d 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -512,6 +512,16 @@ export default { 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 時才建議啟用。'), + hermesUpdatesConfigTitle: _('升级前备份', 'Pre-update backup', '升級前備份'), + hermesUpdatesConfigDesc: _('控制 hermes update 前是否创建完整备份,并限制升级前备份保留数量,降低自动更新失败后的恢复成本。', 'Control whether hermes update creates a full backup first, and cap how many pre-update backups are retained to reduce recovery cost after failed updates.', '控制 hermes update 前是否建立完整備份,並限制升級前備份保留數量,降低自動更新失敗後的復原成本。'), + hermesUpdatesConfigStatusReady: _('结构化配置', 'structured settings', '結構化設定'), + hermesUpdatesConfigSave: _('保存升级备份配置', 'Save update backups', '儲存升級備份設定'), + hermesUpdatesConfigSaveSuccess: _('升级前备份配置已保存,建议重启 Hermes Gateway 生效', 'Pre-update backup settings saved. Restart Hermes Gateway to take effect.', '升級前備份設定已儲存,建議重啟 Hermes Gateway 生效'), + hermesUpdatesConfigLoadFailed: _('加载升级前备份配置失败', 'Load pre-update backup settings failed', '載入升級前備份設定失敗'), + hermesUpdatesConfigSaveFailed: _('保存升级前备份配置失败', 'Save pre-update backup settings failed', '儲存升級前備份設定失敗'), + hermesUpdatesConfigPreUpdateBackup: _('执行 hermes update 前创建完整备份', 'Create a full backup before hermes update', '執行 hermes update 前建立完整備份'), + hermesUpdatesConfigBackupKeep: _('保留升级前备份数量', 'Pre-update backups to keep', '保留升級前備份數量'), + hermesUpdatesConfigFootnote: _('这里写入 updates.*。备份会落在 /backups/,大目录可能让每次更新慢几分钟;保留数量最少为 1,如需关闭升级前备份请关闭开关,而不是填 0。其他 updates 高级字段会保留在 raw YAML 中。', 'This writes updates.*. Backups are stored under /backups/ and large homes may add minutes to each update. Keep count must be at least 1; turn the switch off to disable pre-update backups instead of entering 0. Other advanced updates fields stay in raw YAML.', '這裡寫入 updates.*。備份會落在 /backups/,大目錄可能讓每次更新慢幾分鐘;保留數量最少為 1,如需關閉升級前備份請關閉開關,而不是填 0。其他 updates 進階欄位會保留在 raw YAML 中。'), 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 ec12261..d5e6157 100644 --- a/tests/hermes-config-page-ui.test.js +++ b/tests/hermes-config-page-ui.test.js @@ -36,6 +36,16 @@ test('Hermes 配置页会暴露会话维护结构化配置字段', () => { } }) +test('Hermes 配置页会暴露更新备份结构化配置字段', () => { + for (const id of [ + 'hm-updates-save', + 'hm-updates-pre-update-backup', + 'hm-updates-backup-keep', + ]) { + assert.match(source, new RegExp(`id="${id}"`), `缺少 ${id}`) + } +}) + test('Hermes 配置页会暴露工具循环防护结构化配置字段', () => { for (const id of [ 'hm-tool-guardrails-save', @@ -514,6 +524,7 @@ test('Hermes 配置页新增结构化配置不会暴露翻译 key', () => { key.includes('SttConfig') || key.includes('KanbanConfig') || key.includes('CheckpointsConfig') || + key.includes('UpdatesConfig') || key.includes('ApprovalsConfig') || key.includes('CronConfig') || key.includes('LoggingConfig') || diff --git a/tests/hermes-updates-config.test.js b/tests/hermes-updates-config.test.js new file mode 100644 index 0000000..db717f1 --- /dev/null +++ b/tests/hermes-updates-config.test.js @@ -0,0 +1,59 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + buildHermesUpdatesConfigValues, + mergeHermesUpdatesConfig, +} from '../scripts/dev-api.js' + +test('Hermes 更新配置读取会提供上游默认值', () => { + const values = buildHermesUpdatesConfigValues({}) + + assert.deepEqual(values, { + updatesPreUpdateBackup: false, + updatesBackupKeep: 5, + }) +}) + +test('Hermes 更新配置读取会回显 YAML 字段', () => { + const values = buildHermesUpdatesConfigValues({ + updates: { + pre_update_backup: true, + backup_keep: 9, + }, + }) + + assert.equal(values.updatesPreUpdateBackup, true) + assert.equal(values.updatesBackupKeep, 9) +}) + +test('Hermes 更新配置保存会保留未知字段并写入 updates', () => { + const next = mergeHermesUpdatesConfig({ + updates: { + pre_update_backup: false, + custom_flag: 'keep-updates', + }, + sessions: { auto_prune: true }, + model: { provider: 'anthropic' }, + }, { + updatesPreUpdateBackup: true, + updatesBackupKeep: '7', + }) + + assert.deepEqual(next.sessions, { auto_prune: true }) + assert.deepEqual(next.model, { provider: 'anthropic' }) + assert.equal(next.updates.pre_update_backup, true) + assert.equal(next.updates.backup_keep, 7) + assert.equal(next.updates.custom_flag, 'keep-updates') +}) + +test('Hermes 更新配置保存会拒绝非法保留数量', () => { + assert.throws( + () => mergeHermesUpdatesConfig({}, { updatesBackupKeep: '0' }), + /updates\.backup_keep/, + ) + assert.throws( + () => mergeHermesUpdatesConfig({}, { updatesBackupKeep: '1001' }), + /updates\.backup_keep/, + ) +})