From 8703dffc5b632324fd41467a787f9a8fc5be700c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Wed, 27 May 2026 01:14:10 +0800 Subject: [PATCH] feat(hermes): add kanban profile routing controls --- scripts/dev-api.js | 33 ++++++++++++ src-tauri/src/commands/hermes.rs | 82 +++++++++++++++++++++++++++++ src/engines/hermes/pages/config.js | 12 +++++ src/locales/modules/engine.js | 5 +- tests/hermes-config-page-ui.test.js | 2 + tests/hermes-kanban-config.test.js | 35 ++++++++++++ 6 files changed, 168 insertions(+), 1 deletion(-) diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 6e02ae0..f68384e 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -3425,6 +3425,15 @@ function formHermesBool(form, key, fallback) { return readHermesBool(form?.[key], fallback) } +function normalizeHermesKanbanOptionalString(value, key, strict = false) { + if (value === undefined || value === null) return '' + if (typeof value !== 'string') { + if (strict) throw new Error(`${key} must be a string`) + return '' + } + return value.trim() +} + function normalizeHermesStreamingTransport(value, strict = false) { const transport = String(value ?? '').trim().toLowerCase() || 'edit' if (HERMES_STREAMING_TRANSPORTS.has(transport)) return transport @@ -3816,6 +3825,16 @@ export function buildHermesKanbanConfigValues(config = {}) { 100, false, ), + orchestratorProfile: normalizeHermesKanbanOptionalString( + kanban.orchestrator_profile, + 'kanban.orchestrator_profile', + false, + ), + defaultAssignee: normalizeHermesKanbanOptionalString( + kanban.default_assignee, + 'kanban.default_assignee', + false, + ), dispatchStaleTimeoutSeconds: parseHermesInteger( kanban.dispatch_stale_timeout_seconds, 'kanban.dispatch_stale_timeout_seconds', @@ -3896,6 +3915,20 @@ export function mergeHermesKanbanConfig(config = {}, form = {}) { 100, true, ) + const orchestratorProfile = normalizeHermesKanbanOptionalString( + Object.hasOwn(form, 'orchestratorProfile') ? form.orchestratorProfile : currentValues.orchestratorProfile, + 'kanban.orchestrator_profile', + true, + ) + if (orchestratorProfile) kanban.orchestrator_profile = orchestratorProfile + else delete kanban.orchestrator_profile + const defaultAssignee = normalizeHermesKanbanOptionalString( + Object.hasOwn(form, 'defaultAssignee') ? form.defaultAssignee : currentValues.defaultAssignee, + 'kanban.default_assignee', + true, + ) + if (defaultAssignee) kanban.default_assignee = defaultAssignee + else delete kanban.default_assignee kanban.dispatch_stale_timeout_seconds = parseHermesInteger( Object.hasOwn(form, 'dispatchStaleTimeoutSeconds') ? form.dispatchStaleTimeoutSeconds : currentValues.dispatchStaleTimeoutSeconds, 'kanban.dispatch_stale_timeout_seconds', diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index 264f07c..fc5ec0a 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -6605,6 +6605,12 @@ fn build_hermes_kanban_config_values(config: &serde_yaml::Value) -> Value { 100, )) .unwrap_or(1), + "orchestratorProfile": kanban + .and_then(|map| yaml_string_field(map, "orchestrator_profile")) + .unwrap_or_default(), + "defaultAssignee": kanban + .and_then(|map| yaml_string_field(map, "default_assignee")) + .unwrap_or_default(), "dispatchStaleTimeoutSeconds": kanban .map(|map| bounded_hermes_i64( yaml_i64_field(map, "dispatch_stale_timeout_seconds"), @@ -6674,6 +6680,30 @@ fn merge_hermes_kanban_config(config: &mut serde_yaml::Value, form: &Value) -> R 0, 100, )?; + let orchestrator_profile = if form.get("orchestratorProfile").is_some() { + form_string(form, "orchestratorProfile") + .ok_or_else(|| "kanban.orchestrator_profile must be a string".to_string())? + .trim() + .to_string() + } else { + current["orchestratorProfile"] + .as_str() + .unwrap_or_default() + .trim() + .to_string() + }; + let default_assignee = if form.get("defaultAssignee").is_some() { + form_string(form, "defaultAssignee") + .ok_or_else(|| "kanban.default_assignee must be a string".to_string())? + .trim() + .to_string() + } else { + current["defaultAssignee"] + .as_str() + .unwrap_or_default() + .trim() + .to_string() + }; let stale_timeout = validate_hermes_i64( form_i64(form, "dispatchStaleTimeoutSeconds") .or_else(|| current["dispatchStaleTimeoutSeconds"].as_i64()), @@ -6728,6 +6758,8 @@ fn merge_hermes_kanban_config(config: &mut serde_yaml::Value, form: &Value) -> R yaml_key("worker_log_backup_count"), serde_yaml::Value::Number(serde_yaml::Number::from(worker_log_backup_count)), ); + set_optional_yaml_string(kanban, "orchestrator_profile", orchestrator_profile); + set_optional_yaml_string(kanban, "default_assignee", default_assignee); kanban.insert( yaml_key("dispatch_stale_timeout_seconds"), serde_yaml::Value::Number(serde_yaml::Number::from(stale_timeout)), @@ -19500,6 +19532,8 @@ mod hermes_kanban_config_tests { assert_eq!(values["autoDecomposePerTick"], 3); assert_eq!(values["workerLogRotateBytes"], 2097152); assert_eq!(values["workerLogBackupCount"], 1); + assert_eq!(values["orchestratorProfile"], ""); + assert_eq!(values["defaultAssignee"], ""); assert_eq!(values["dispatchStaleTimeoutSeconds"], 14400); } @@ -19517,6 +19551,8 @@ kanban: auto_decompose_per_tick: "7" worker_log_rotate_bytes: "4194304" worker_log_backup_count: "3" + orchestrator_profile: triage + default_assignee: builder dispatch_stale_timeout_seconds: "7200" "#, ) @@ -19531,6 +19567,8 @@ kanban: assert_eq!(values["autoDecomposePerTick"], 7); assert_eq!(values["workerLogRotateBytes"], 4194304); assert_eq!(values["workerLogBackupCount"], 3); + assert_eq!(values["orchestratorProfile"], "triage"); + assert_eq!(values["defaultAssignee"], "builder"); assert_eq!(values["dispatchStaleTimeoutSeconds"], 7200); } @@ -19563,6 +19601,8 @@ memory: "autoDecomposePerTick": 2, "workerLogRotateBytes": 1048576, "workerLogBackupCount": 0, + "orchestratorProfile": "triage", + "defaultAssignee": "builder", "dispatchStaleTimeoutSeconds": 0, }), ) @@ -19595,12 +19635,46 @@ memory: config["kanban"]["worker_log_backup_count"].as_i64(), Some(0) ); + assert_eq!( + config["kanban"]["orchestrator_profile"].as_str(), + Some("triage") + ); + assert_eq!( + config["kanban"]["default_assignee"].as_str(), + Some("builder") + ); assert_eq!( config["kanban"]["dispatch_stale_timeout_seconds"].as_i64(), Some(0) ); } + #[test] + fn merge_kanban_config_removes_optional_profile_routes() { + let mut config: serde_yaml::Value = serde_yaml::from_str( + r#" +kanban: + orchestrator_profile: triage + default_assignee: builder + custom_flag: keep-me +"#, + ) + .unwrap(); + + merge_hermes_kanban_config( + &mut config, + &json!({ + "orchestratorProfile": " ", + "defaultAssignee": "", + }), + ) + .unwrap(); + + assert_eq!(config["kanban"]["custom_flag"].as_str(), Some("keep-me")); + assert!(config["kanban"].get("orchestrator_profile").is_none()); + assert!(config["kanban"].get("default_assignee").is_none()); + } + #[test] fn merge_kanban_config_removes_optional_concurrency_limits() { let mut config: serde_yaml::Value = serde_yaml::from_str( @@ -19657,6 +19731,14 @@ kanban: .unwrap_err(); assert!(err.contains("kanban.worker_log_backup_count")); + let err = merge_hermes_kanban_config(&mut config, &json!({ "orchestratorProfile": 123 })) + .unwrap_err(); + assert!(err.contains("kanban.orchestrator_profile")); + + let err = merge_hermes_kanban_config(&mut config, &json!({ "defaultAssignee": false })) + .unwrap_err(); + assert!(err.contains("kanban.default_assignee")); + let err = merge_hermes_kanban_config(&mut config, &json!({ "dispatchStaleTimeoutSeconds": -1 })) .unwrap_err(); diff --git a/src/engines/hermes/pages/config.js b/src/engines/hermes/pages/config.js index 8b955e9..ede400e 100644 --- a/src/engines/hermes/pages/config.js +++ b/src/engines/hermes/pages/config.js @@ -183,6 +183,8 @@ const KANBAN_DEFAULTS = { autoDecomposePerTick: 3, workerLogRotateBytes: 2097152, workerLogBackupCount: 1, + orchestratorProfile: '', + defaultAssignee: '', dispatchStaleTimeoutSeconds: 14400, } @@ -1535,6 +1537,14 @@ export function render() { ${t('engine.hermesKanbanConfigWorkerLogBackupCount')} + +