From 92d64efd8e501d1b1b05a9915e78cdabbc146172 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Mon, 25 May 2026 03:01:14 +0800 Subject: [PATCH] feat(hermes): add cron output settings --- scripts/dev-api.js | 46 ++++++++ src-tauri/src/commands/hermes.rs | 160 ++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 2 + src/engines/hermes/pages/config.js | 131 +++++++++++++++++++---- src/lib/tauri-api.js | 2 + src/locales/modules/engine.js | 10 ++ tests/hermes-config-page-ui.test.js | 13 ++- tests/hermes-cron-config.test.js | 69 ++++++++++++ 8 files changed, 411 insertions(+), 22 deletions(-) create mode 100644 tests/hermes-cron-config.test.js diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 0ede237..2ae4166 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -4066,6 +4066,31 @@ export function mergeHermesCheckpointsConfig(config = {}, form = {}) { return next } +export function buildHermesCronConfigValues(config = {}) { + const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} + const cron = root.cron && typeof root.cron === 'object' && !Array.isArray(root.cron) + ? root.cron + : {} + return { + cronWrapResponse: readHermesBool(cron.wrap_response, true), + cronMaxParallelJobs: parseHermesInteger(cron.max_parallel_jobs, 'cron.max_parallel_jobs', 0, 0, 10000, false), + } +} + +export function mergeHermesCronConfig(config = {}, form = {}) { + const next = mergeConfigsPreservingFields({}, config && typeof config === 'object' && !Array.isArray(config) ? config : {}) + const currentValues = buildHermesCronConfigValues(next) + const cron = next.cron && typeof next.cron === 'object' && !Array.isArray(next.cron) + ? mergeConfigsPreservingFields(next.cron, {}) + : {} + + cron.wrap_response = formHermesBool(form, 'cronWrapResponse', currentValues.cronWrapResponse) + const maxParallelJobs = parseHermesInteger(Object.hasOwn(form, 'cronMaxParallelJobs') ? form.cronMaxParallelJobs : currentValues.cronMaxParallelJobs, 'cron.max_parallel_jobs', 0, 0, 10000, true) + cron.max_parallel_jobs = maxParallelJobs === 0 ? null : maxParallelJobs + next.cron = cron + return next +} + export function buildHermesApprovalsConfigValues(config = {}) { const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} const approvals = root.approvals && typeof root.approvals === 'object' && !Array.isArray(root.approvals) @@ -10809,6 +10834,27 @@ const handlers = { } }, + hermes_cron_config_read() { + const { configPath, exists, config } = readHermesConfigYamlObject() + return { + exists, + configPath, + values: buildHermesCronConfigValues(config), + } + }, + + hermes_cron_config_save({ form } = {}) { + const { configPath, config } = readHermesConfigYamlObject() + const next = mergeHermesCronConfig(config, form || {}) + const backup = writeHermesConfigYamlObject(configPath, next) + return { + ok: true, + configPath, + backup, + values: buildHermesCronConfigValues(next), + } + }, + hermes_approvals_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 35fdba7..261e8a8 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -5088,6 +5088,55 @@ fn merge_hermes_checkpoints_config( Ok(()) } +fn build_hermes_cron_config_values(config: &serde_yaml::Value) -> Value { + let root = config.as_mapping(); + let cron = root.and_then(|map| yaml_get_mapping(map, "cron")); + let cron_wrap_response = cron + .and_then(|map| yaml_bool_field(map, "wrap_response")) + .unwrap_or(true); + let cron_max_parallel_jobs = cron + .map(|map| bounded_hermes_i64(yaml_i64_field(map, "max_parallel_jobs"), 0, 0, 10000)) + .unwrap_or(0); + + serde_json::json!({ + "cronWrapResponse": cron_wrap_response, + "cronMaxParallelJobs": cron_max_parallel_jobs, + }) +} + +fn merge_hermes_cron_config(config: &mut serde_yaml::Value, form: &Value) -> Result<(), String> { + let current = build_hermes_cron_config_values(config); + let cron_wrap_response = form_bool(form, "cronWrapResponse") + .unwrap_or_else(|| current["cronWrapResponse"].as_bool().unwrap_or(true)); + let cron_max_parallel_jobs = validate_hermes_i64( + if form.get("cronMaxParallelJobs").is_some() { + form_i64(form, "cronMaxParallelJobs") + } else { + Some(current["cronMaxParallelJobs"].as_i64().unwrap_or(0)) + }, + "cron.max_parallel_jobs", + 0, + 0, + 10000, + )?; + + let root = ensure_yaml_object(config)?; + let cron = yaml_child_object(root, "cron")?; + cron.insert( + yaml_key("wrap_response"), + serde_yaml::Value::Bool(cron_wrap_response), + ); + cron.insert( + yaml_key("max_parallel_jobs"), + if cron_max_parallel_jobs == 0 { + serde_yaml::Value::Null + } else { + serde_yaml::Value::Number(cron_max_parallel_jobs.into()) + }, + ); + Ok(()) +} + fn build_hermes_approvals_config_values(config: &serde_yaml::Value) -> Value { let root = config.as_mapping(); let approvals = root.and_then(|map| yaml_get_mapping(map, "approvals")); @@ -6813,6 +6862,30 @@ pub fn hermes_checkpoints_config_save(form: Value) -> Result { })) } +#[tauri::command] +pub fn hermes_cron_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_cron_config_values(&config), + })) +} + +#[tauri::command] +pub fn hermes_cron_config_save(form: Value) -> Result { + let (config_path, _exists, mut config) = read_hermes_channel_yaml_config()?; + merge_hermes_cron_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_cron_config_values(&config), + })) +} + #[tauri::command] pub fn hermes_approvals_config_read() -> Result { let (config_path, exists, config) = read_hermes_channel_yaml_config()?; @@ -12784,6 +12857,93 @@ streaming: } } +#[cfg(test)] +mod hermes_cron_config_tests { + use super::{build_hermes_cron_config_values, merge_hermes_cron_config}; + use serde_json::json; + + #[test] + fn cron_values_have_upstream_defaults() { + let config: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let values = build_hermes_cron_config_values(&config); + assert_eq!(values["cronWrapResponse"], true); + assert_eq!(values["cronMaxParallelJobs"], 0); + } + + #[test] + fn cron_values_read_yaml_fields() { + let config: serde_yaml::Value = serde_yaml::from_str( + r#" +cron: + wrap_response: false + max_parallel_jobs: 4 +"#, + ) + .unwrap(); + let values = build_hermes_cron_config_values(&config); + assert_eq!(values["cronWrapResponse"], false); + assert_eq!(values["cronMaxParallelJobs"], 4); + } + + #[test] + fn merge_cron_config_preserves_unknown_fields() { + let mut config: serde_yaml::Value = serde_yaml::from_str( + r#" +cron: + wrap_response: true + custom_flag: keep-cron +approvals: + cron_mode: deny +streaming: + enabled: true +"#, + ) + .unwrap(); + + merge_hermes_cron_config( + &mut config, + &json!({ + "cronWrapResponse": false, + "cronMaxParallelJobs": "3", + }), + ) + .unwrap(); + + assert_eq!(config["approvals"]["cron_mode"].as_str(), Some("deny")); + assert_eq!(config["streaming"]["enabled"].as_bool(), Some(true)); + assert_eq!(config["cron"]["wrap_response"].as_bool(), Some(false)); + assert_eq!(config["cron"]["max_parallel_jobs"].as_i64(), Some(3)); + assert_eq!(config["cron"]["custom_flag"].as_str(), Some("keep-cron")); + } + + #[test] + fn merge_cron_config_writes_unbounded_null_and_rejects_invalid_values() { + let mut config: serde_yaml::Value = serde_yaml::from_str( + r#" +cron: + max_parallel_jobs: 8 +"#, + ) + .unwrap(); + + merge_hermes_cron_config( + &mut config, + &json!({ + "cronMaxParallelJobs": "0", + }), + ) + .unwrap(); + assert_eq!(config["cron"]["max_parallel_jobs"], serde_yaml::Value::Null); + + let err = merge_hermes_cron_config(&mut config, &json!({ "cronMaxParallelJobs": -1 })) + .unwrap_err(); + assert!(err.contains("cron.max_parallel_jobs")); + let err = merge_hermes_cron_config(&mut config, &json!({ "cronMaxParallelJobs": 10001 })) + .unwrap_err(); + assert!(err.contains("cron.max_parallel_jobs")); + } +} + #[cfg(test)] mod hermes_approvals_config_tests { use super::{build_hermes_approvals_config_values, merge_hermes_approvals_config}; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 79aa518..49fa98d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -289,6 +289,8 @@ pub fn run() { hermes::hermes_io_safety_config_save, hermes::hermes_checkpoints_config_read, hermes::hermes_checkpoints_config_save, + hermes::hermes_cron_config_read, + hermes::hermes_cron_config_save, hermes::hermes_approvals_config_read, hermes::hermes_approvals_config_save, hermes::hermes_privacy_config_read, diff --git a/src/engines/hermes/pages/config.js b/src/engines/hermes/pages/config.js index a6d9026..343a2f2 100644 --- a/src/engines/hermes/pages/config.js +++ b/src/engines/hermes/pages/config.js @@ -136,6 +136,11 @@ const CHECKPOINTS_DEFAULTS = { checkpointMinIntervalHours: 24, } +const CRON_DEFAULTS = { + cronWrapResponse: true, + cronMaxParallelJobs: 0, +} + const APPROVALS_DEFAULTS = { approvalMode: 'manual', approvalTimeout: 60, @@ -203,6 +208,7 @@ export function render() { let executionLimitsValues = { ...EXECUTION_LIMITS_DEFAULTS } let ioSafetyValues = { ...IO_SAFETY_DEFAULTS } let checkpointsValues = { ...CHECKPOINTS_DEFAULTS } + let cronValues = { ...CRON_DEFAULTS } let approvalsValues = { ...APPROVALS_DEFAULTS } let privacyValues = { ...PRIVACY_DEFAULTS } let browserValues = { ...BROWSER_DEFAULTS } @@ -224,6 +230,7 @@ export function render() { let executionLimitsLoading = true let ioSafetyLoading = true let checkpointsLoading = true + let cronLoading = true let approvalsLoading = true let privacyLoading = true let browserLoading = true @@ -245,6 +252,7 @@ export function render() { let executionLimitsSaving = false let ioSafetySaving = false let checkpointsSaving = false + let cronSaving = false let approvalsSaving = false let privacySaving = false let browserSaving = false @@ -266,6 +274,7 @@ export function render() { let executionLimitsError = null let ioSafetyError = null let checkpointsError = null + let cronError = null let approvalsError = null let privacyError = null let browserError = null @@ -280,7 +289,7 @@ export function render() { } function isBusy() { - return loading || runtimeLoading || compressionLoading || toolGuardrailsLoading || memoryLoading || skillsLoading || quickCommandsLoading || agentToolsetsLoading || agentRuntimeLoading || unauthorizedDmLoading || securityLoading || displayLoading || humanDelayLoading || streamingLoading || executionLimitsLoading || ioSafetyLoading || checkpointsLoading || approvalsLoading || privacyLoading || browserLoading || terminalLoading || saving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || securitySaving || displaySaving || humanDelaySaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving || approvalsSaving || privacySaving || browserSaving || terminalSaving + return loading || runtimeLoading || compressionLoading || toolGuardrailsLoading || memoryLoading || skillsLoading || quickCommandsLoading || agentToolsetsLoading || agentRuntimeLoading || unauthorizedDmLoading || securityLoading || displayLoading || humanDelayLoading || streamingLoading || executionLimitsLoading || ioSafetyLoading || checkpointsLoading || cronLoading || approvalsLoading || privacyLoading || browserLoading || terminalLoading || saving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || securitySaving || displaySaving || humanDelaySaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving || cronSaving || approvalsSaving || privacySaving || browserSaving || terminalSaving } function option(labelKey, value, selected) { @@ -297,7 +306,7 @@ export function render() { } function renderRuntimePanel() { - const disabled = loading || saving || runtimeLoading || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || runtimeLoading || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || approvalsSaving || terminalSaving return `
@@ -345,7 +354,7 @@ export function render() { } function renderCompressionPanel() { - const disabled = loading || saving || compressionLoading || compressionSaving || runtimeSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || compressionLoading || compressionSaving || runtimeSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || approvalsSaving || terminalSaving return `
@@ -395,7 +404,7 @@ export function render() { } function renderToolGuardrailsPanel() { - const disabled = loading || saving || toolGuardrailsLoading || toolGuardrailsSaving || runtimeSaving || compressionSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || toolGuardrailsLoading || toolGuardrailsSaving || runtimeSaving || compressionSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || approvalsSaving || terminalSaving return `
@@ -457,7 +466,7 @@ export function render() { } function renderMemoryPanel() { - const disabled = loading || saving || memoryLoading || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || memoryLoading || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || approvalsSaving || terminalSaving return `
@@ -507,7 +516,7 @@ export function render() { } function renderSkillsConfigPanel() { - const disabled = loading || saving || skillsLoading || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || streamingSaving || executionLimitsSaving || checkpointsSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || skillsLoading || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || approvalsSaving || terminalSaving return `
@@ -539,7 +548,7 @@ export function render() { } function renderQuickCommandsConfigPanel() { - const disabled = loading || saving || quickCommandsLoading || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || quickCommandsLoading || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || approvalsSaving || terminalSaving return `
@@ -565,7 +574,7 @@ export function render() { } function renderAgentToolsetsConfigPanel() { - const disabled = loading || saving || agentToolsetsLoading || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || agentToolsetsLoading || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || approvalsSaving || terminalSaving return `
@@ -591,7 +600,7 @@ export function render() { } function renderAgentRuntimeConfigPanel() { - const disabled = loading || saving || agentRuntimeLoading || agentRuntimeSaving || agentToolsetsSaving || unauthorizedDmSaving || securitySaving || displaySaving || humanDelaySaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving || approvalsSaving || privacySaving || browserSaving || terminalSaving + const disabled = loading || saving || agentRuntimeLoading || agentRuntimeSaving || agentToolsetsSaving || unauthorizedDmSaving || securitySaving || displaySaving || humanDelaySaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving || cronSaving || approvalsSaving || privacySaving || browserSaving || terminalSaving return `
@@ -653,7 +662,7 @@ export function render() { } function renderUnauthorizedDmConfigPanel() { - const disabled = loading || saving || unauthorizedDmLoading || unauthorizedDmSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || securitySaving || streamingSaving || executionLimitsSaving || checkpointsSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || unauthorizedDmLoading || unauthorizedDmSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || securitySaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || approvalsSaving || terminalSaving return `
@@ -683,7 +692,7 @@ export function render() { } function renderSecurityConfigPanel() { - const disabled = loading || saving || securityLoading || securitySaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || securityLoading || securitySaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || approvalsSaving || terminalSaving return `
@@ -725,7 +734,7 @@ export function render() { } function renderDisplayConfigPanel() { - const disabled = loading || saving || displayLoading || displaySaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || securitySaving || humanDelaySaving || streamingSaving || executionLimitsSaving || checkpointsSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || displayLoading || displaySaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || securitySaving || humanDelaySaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || approvalsSaving || terminalSaving return `
@@ -789,7 +798,7 @@ export function render() { } function renderHumanDelayConfigPanel() { - const disabled = loading || saving || humanDelayLoading || humanDelaySaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || securitySaving || streamingSaving || executionLimitsSaving || checkpointsSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || humanDelayLoading || humanDelaySaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || securitySaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || approvalsSaving || terminalSaving return `
@@ -827,7 +836,7 @@ export function render() { } function renderStreamingPanel() { - const disabled = loading || saving || streamingLoading || streamingSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || securitySaving || executionLimitsSaving || checkpointsSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || streamingLoading || streamingSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || securitySaving || executionLimitsSaving || checkpointsSaving || cronSaving || approvalsSaving || terminalSaving return `
@@ -879,7 +888,7 @@ export function render() { } function renderExecutionLimitsPanel() { - const disabled = loading || saving || executionLimitsLoading || executionLimitsSaving || terminalSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || checkpointsSaving || approvalsSaving + const disabled = loading || saving || executionLimitsLoading || executionLimitsSaving || terminalSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || checkpointsSaving || cronSaving || approvalsSaving return `
@@ -951,7 +960,7 @@ export function render() { } function renderIoSafetyPanel() { - const disabled = loading || saving || ioSafetyLoading || ioSafetySaving || checkpointsSaving || approvalsSaving || terminalSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving + const disabled = loading || saving || ioSafetyLoading || ioSafetySaving || checkpointsSaving || cronSaving || approvalsSaving || terminalSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving return `
@@ -991,7 +1000,7 @@ export function render() { } function renderCheckpointsPanel() { - const disabled = loading || saving || checkpointsLoading || checkpointsSaving || ioSafetySaving || approvalsSaving || privacySaving || browserSaving || terminalSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving + const disabled = loading || saving || checkpointsLoading || checkpointsSaving || ioSafetySaving || cronSaving || approvalsSaving || privacySaving || browserSaving || terminalSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving return `
@@ -1048,8 +1057,42 @@ export function render() { ` } + function renderCronPanel() { + const disabled = loading || saving || cronLoading || cronSaving || checkpointsSaving || approvalsSaving || privacySaving || browserSaving || terminalSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || ioSafetySaving + return ` +
+
+
+
${t('engine.hermesCronConfigTitle')}
+
${t('engine.hermesCronConfigDesc')}
+
+
+ ${cronSaving ? t('engine.hermesConfigStatusSaving') : cronLoading ? t('engine.hermesConfigStatusLoading') : t('engine.hermesCronConfigStatusReady')} + +
+
+
+ ${renderError(cronError)} +
+ +
+
+ +
+
${t('engine.hermesCronConfigFootnote')}
+
+
+ ` + } + function renderApprovalsPanel() { - const disabled = loading || saving || approvalsLoading || approvalsSaving || checkpointsSaving || privacySaving || browserSaving || terminalSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || ioSafetySaving + const disabled = loading || saving || approvalsLoading || approvalsSaving || checkpointsSaving || cronSaving || privacySaving || browserSaving || terminalSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || ioSafetySaving return `
@@ -1099,7 +1142,7 @@ export function render() { } function renderPrivacyPanel() { - const disabled = loading || saving || privacyLoading || privacySaving || approvalsSaving || browserSaving || terminalSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving + const disabled = loading || saving || privacyLoading || privacySaving || approvalsSaving || cronSaving || browserSaving || terminalSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving return `
@@ -1127,7 +1170,7 @@ export function render() { } function renderBrowserPanel() { - const disabled = loading || saving || browserLoading || browserSaving || approvalsSaving || privacySaving || terminalSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving + const disabled = loading || saving || browserLoading || browserSaving || approvalsSaving || cronSaving || privacySaving || terminalSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving return `
@@ -1171,7 +1214,7 @@ export function render() { } function renderTerminalPanel() { - const disabled = loading || saving || terminalLoading || terminalSaving || approvalsSaving || browserSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving + const disabled = loading || saving || terminalLoading || terminalSaving || approvalsSaving || cronSaving || browserSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving return `
@@ -1261,6 +1304,7 @@ export function render() { ${renderExecutionLimitsPanel()} ${renderIoSafetyPanel()} ${renderCheckpointsPanel()} + ${renderCronPanel()} ${renderApprovalsPanel()} ${renderPrivacyPanel()} ${renderBrowserPanel()} @@ -1310,6 +1354,7 @@ export function render() { el.querySelector('#hm-execution-limits-save')?.addEventListener('click', saveExecutionLimits) el.querySelector('#hm-io-safety-save')?.addEventListener('click', saveIoSafety) el.querySelector('#hm-checkpoints-save')?.addEventListener('click', saveCheckpoints) + el.querySelector('#hm-cron-save')?.addEventListener('click', saveCronConfig) el.querySelector('#hm-approvals-save')?.addEventListener('click', saveApprovalsConfig) el.querySelector('#hm-privacy-save')?.addEventListener('click', savePrivacyConfig) el.querySelector('#hm-browser-save')?.addEventListener('click', saveBrowserConfig) @@ -1401,6 +1446,11 @@ export function render() { checkpointsValues = { ...CHECKPOINTS_DEFAULTS, ...(data?.values || {}) } } + async function loadCronConfig() { + const data = await api.hermesCronConfigRead() + cronValues = { ...CRON_DEFAULTS, ...(data?.values || {}) } + } + async function loadApprovalsConfig() { const data = await api.hermesApprovalsConfigRead() approvalsValues = { ...APPROVALS_DEFAULTS, ...(data?.values || {}) } @@ -1439,6 +1489,7 @@ export function render() { executionLimitsLoading = true ioSafetyLoading = true checkpointsLoading = true + cronLoading = true approvalsLoading = true privacyLoading = true browserLoading = true @@ -1460,6 +1511,7 @@ export function render() { executionLimitsError = null ioSafetyError = null checkpointsError = null + cronError = null approvalsError = null privacyError = null browserError = null @@ -1528,6 +1580,14 @@ export function render() { checkpointsLoading = false draw() } + try { + await loadCronConfig() + } catch (err) { + cronError = humanizeError(err, t('engine.hermesCronConfigLoadFailed') || 'Load cron config failed') + } finally { + cronLoading = false + draw() + } try { await loadApprovalsConfig() } catch (err) { @@ -1701,6 +1761,9 @@ export function render() { try { await loadCheckpoints() } catch {} + try { + await loadCronConfig() + } catch {} try { await loadApprovalsConfig() } catch {} @@ -2188,6 +2251,32 @@ export function render() { } } + async function saveCronConfig() { + const form = { + cronWrapResponse: !!el.querySelector('#hm-cron-wrap-response')?.checked, + cronMaxParallelJobs: el.querySelector('#hm-cron-max-parallel-jobs')?.value || '0', + } + cronSaving = true + cronError = null + draw() + try { + const result = await api.hermesCronConfigSave(form) + cronValues = { ...CRON_DEFAULTS, ...(result?.values || form) } + await refreshRawAfterStructuredSave() + const backup = result?.backup || '' + toast({ + message: t('engine.hermesCronConfigSaveSuccess'), + hint: backup ? t('engine.hermesConfigBackupHint', { path: backup }) : '', + }, 'success') + } catch (err) { + cronError = humanizeError(err, t('engine.hermesCronConfigSaveFailed') || 'Save cron config failed') + toast(cronError, 'error') + } finally { + cronSaving = false + draw() + } + } + async function saveApprovalsConfig() { const form = { approvalMode: el.querySelector('#hm-approval-mode')?.value || 'manual', diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index 0ec7026..3f399f3 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -541,6 +541,8 @@ export const api = { hermesIoSafetyConfigSave: (form) => invoke('hermes_io_safety_config_save', { form }), hermesCheckpointsConfigRead: () => invoke('hermes_checkpoints_config_read'), hermesCheckpointsConfigSave: (form) => invoke('hermes_checkpoints_config_save', { form }), + hermesCronConfigRead: () => invoke('hermes_cron_config_read'), + hermesCronConfigSave: (form) => invoke('hermes_cron_config_save', { form }), hermesApprovalsConfigRead: () => invoke('hermes_approvals_config_read'), hermesApprovalsConfigSave: (form) => invoke('hermes_approvals_config_save', { form }), hermesPrivacyConfigRead: () => invoke('hermes_privacy_config_read'), diff --git a/src/locales/modules/engine.js b/src/locales/modules/engine.js index 5fcab39..a580401 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -592,6 +592,16 @@ export default { hermesCheckpointsConfigRetentionDays: _('保留天数', 'Retention days', '保留天數'), hermesCheckpointsConfigMinIntervalHours: _('自动清理最小间隔小时', 'Minimum auto-prune interval hours', '自動清理最小間隔小時'), hermesCheckpointsConfigFootnote: _('这里写入 checkpoints.*。容量上限或单文件上限设为 0 表示关闭对应限制;其他 checkpoints 高级字段会保留在 raw YAML 中。', 'This writes checkpoints.*. Set total size cap or single-file cap to 0 to disable that limit. Other advanced checkpoint fields stay in raw YAML.', '這裡寫入 checkpoints.*。容量上限或單檔上限設為 0 表示關閉對應限制;其他 checkpoints 進階欄位會保留在 raw YAML 中。'), + hermesCronConfigTitle: _('定时任务输出', 'Cron job output', '定時任務輸出'), + hermesCronConfigDesc: _('控制定时任务结果是否包装任务名和提示,并限制同一轮最多并发任务数,降低无人值守长跑时的噪音和资源冲高。', 'Control whether scheduled job results are wrapped with task context, and cap jobs running in parallel per tick to reduce unattended-run noise and resource spikes.', '控制定時任務結果是否包裝任務名稱和提示,並限制同一輪最多並發任務數,降低無人值守長跑時的噪音和資源衝高。'), + hermesCronConfigStatusReady: _('结构化配置', 'structured settings', '結構化設定'), + hermesCronConfigSave: _('保存定时任务配置', 'Save cron settings', '儲存定時任務設定'), + hermesCronConfigSaveSuccess: _('定时任务输出配置已保存,建议重启 Hermes Gateway 生效', 'Cron job output settings saved. Restart Hermes Gateway to take effect.', '定時任務輸出設定已儲存,建議重啟 Hermes Gateway 生效'), + hermesCronConfigLoadFailed: _('加载定时任务输出配置失败', 'Load cron job output settings failed', '載入定時任務輸出設定失敗'), + hermesCronConfigSaveFailed: _('保存定时任务输出配置失败', 'Save cron job output settings failed', '儲存定時任務輸出設定失敗'), + hermesCronConfigWrapResponse: _('结果回传时附加任务名和系统提示', 'Add job name and system notice when delivering results', '結果回傳時附加任務名稱和系統提示'), + hermesCronConfigMaxParallelJobs: _('每轮最多并发任务数', 'Max parallel jobs per tick', '每輪最多並發任務數'), + hermesCronConfigFootnote: _('这里写入 cron.wrap_response 与 cron.max_parallel_jobs。并发数设为 0 表示不额外限制,由 Hermes 按上游默认语义写入 null;其他 cron 高级字段会保留在 raw YAML 中。', 'This writes cron.wrap_response and cron.max_parallel_jobs. Set parallel jobs to 0 for no extra cap; Hermes writes null to match upstream semantics. Other advanced cron fields stay in raw YAML.', '這裡寫入 cron.wrap_response 與 cron.max_parallel_jobs。並發數設為 0 表示不額外限制,由 Hermes 依上游預設語義寫入 null;其他 cron 進階欄位會保留在 raw YAML 中。'), hermesApprovalsConfigTitle: _('审批安全', 'Approval safety', '審批安全'), hermesApprovalsConfigDesc: _('控制危险命令、Cron 任务和破坏性 slash 命令的审批策略,避免无人值守长跑任务误放行高风险操作。', 'Control approval policy for dangerous commands, cron jobs, and destructive slash commands so unattended long runs do not approve risky operations by mistake.', '控制危險命令、Cron 任務和破壞性 slash 命令的審批策略,避免無人值守長跑任務誤放行高風險操作。'), hermesApprovalsConfigStatusReady: _('结构化配置', 'structured settings', '結構化設定'), diff --git a/tests/hermes-config-page-ui.test.js b/tests/hermes-config-page-ui.test.js index f1b246c..797458d 100644 --- a/tests/hermes-config-page-ui.test.js +++ b/tests/hermes-config-page-ui.test.js @@ -205,6 +205,16 @@ test('Hermes 配置页会暴露审批安全结构化配置字段', () => { } }) +test('Hermes 配置页会暴露定时任务结构化配置字段', () => { + for (const id of [ + 'hm-cron-save', + 'hm-cron-wrap-response', + 'hm-cron-max-parallel-jobs', + ]) { + assert.match(source, new RegExp(`id="${id}"`), `缺少 ${id}`) + } +}) + test('Hermes 配置页会暴露隐私脱敏结构化配置字段', () => { for (const id of [ 'hm-privacy-save', @@ -266,7 +276,8 @@ test('Hermes 配置页新增结构化配置不会暴露翻译 key', () => { key.includes('BrowserConfig') || key.includes('TerminalConfig') || key.includes('CheckpointsConfig') || - key.includes('ApprovalsConfig') + key.includes('ApprovalsConfig') || + key.includes('CronConfig') ))) assert.ok(keys.size > 0, '应能提取新增结构化配置用到的 engine 翻译 key') diff --git a/tests/hermes-cron-config.test.js b/tests/hermes-cron-config.test.js new file mode 100644 index 0000000..7ede249 --- /dev/null +++ b/tests/hermes-cron-config.test.js @@ -0,0 +1,69 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + buildHermesCronConfigValues, + mergeHermesCronConfig, +} from '../scripts/dev-api.js' + +test('Hermes 定时任务配置读取会提供上游默认值', () => { + const values = buildHermesCronConfigValues({}) + + assert.deepEqual(values, { + cronWrapResponse: true, + cronMaxParallelJobs: 0, + }) +}) + +test('Hermes 定时任务配置读取会回显 YAML 字段', () => { + const values = buildHermesCronConfigValues({ + cron: { + wrap_response: false, + max_parallel_jobs: 4, + }, + }) + + assert.equal(values.cronWrapResponse, false) + assert.equal(values.cronMaxParallelJobs, 4) +}) + +test('Hermes 定时任务配置保存会保留未知字段并写入 cron', () => { + const next = mergeHermesCronConfig({ + cron: { + wrap_response: true, + custom_flag: 'keep-cron', + }, + approvals: { cron_mode: 'deny' }, + streaming: { enabled: true }, + }, { + cronWrapResponse: false, + cronMaxParallelJobs: '3', + }) + + assert.deepEqual(next.approvals, { cron_mode: 'deny' }) + assert.deepEqual(next.streaming, { enabled: true }) + assert.equal(next.cron.wrap_response, false) + assert.equal(next.cron.max_parallel_jobs, 3) + assert.equal(next.cron.custom_flag, 'keep-cron') +}) + +test('Hermes 定时任务配置保存 0 会写回不限制并拒绝越界值', () => { + const next = mergeHermesCronConfig({ + cron: { + max_parallel_jobs: 8, + }, + }, { + cronMaxParallelJobs: '0', + }) + + assert.equal(next.cron.max_parallel_jobs, null) + + assert.throws( + () => mergeHermesCronConfig({}, { cronMaxParallelJobs: '-1' }), + /cron\.max_parallel_jobs/, + ) + assert.throws( + () => mergeHermesCronConfig({}, { cronMaxParallelJobs: '10001' }), + /cron\.max_parallel_jobs/, + ) +})