From 0e41b8333b8189b607685157b93c9a518a26f63b Mon Sep 17 00:00:00 2001 From: root Date: Sat, 7 Mar 2026 22:46:37 +0800 Subject: [PATCH 1/6] feat(workflow): implement multi-agent foundation baseline --- CHANGELOG.md | 18 + Cargo.lock | 28 +- crates/openfang-api/src/channel_bridge.rs | 4 +- crates/openfang-api/src/middleware.rs | 1 + crates/openfang-api/src/routes.rs | 762 +++- crates/openfang-api/src/server.rs | 42 +- crates/openfang-api/src/types.rs | 2 + crates/openfang-api/src/ws.rs | 27 +- .../tests/api_integration_test.rs | 236 ++ crates/openfang-channels/src/bridge.rs | 4 +- crates/openfang-channels/src/discord.rs | 29 +- crates/openfang-channels/src/email.rs | 10 +- crates/openfang-channels/src/telegram.rs | 12 +- crates/openfang-channels/src/whatsapp.rs | 8 +- crates/openfang-cli/src/bundled_agents.rs | 130 +- crates/openfang-cli/src/main.rs | 61 +- crates/openfang-cli/src/tui/chat_runner.rs | 26 +- crates/openfang-cli/src/tui/mod.rs | 22 +- crates/openfang-cli/src/tui/screens/agents.rs | 5 +- crates/openfang-cli/src/tui/screens/audit.rs | 5 +- crates/openfang-cli/src/tui/screens/chat.rs | 15 +- crates/openfang-cli/src/tui/screens/comms.rs | 31 +- .../openfang-cli/src/tui/screens/dashboard.rs | 5 +- .../src/tui/screens/init_wizard.rs | 4 +- crates/openfang-cli/src/tui/screens/logs.rs | 5 +- crates/openfang-cli/src/tui/screens/memory.rs | 5 +- crates/openfang-cli/src/tui/screens/mod.rs | 2 +- crates/openfang-cli/src/tui/screens/peers.rs | 5 +- .../openfang-cli/src/tui/screens/sessions.rs | 5 +- .../openfang-cli/src/tui/screens/settings.rs | 5 +- crates/openfang-cli/src/tui/screens/skills.rs | 5 +- .../openfang-cli/src/tui/screens/templates.rs | 5 +- .../openfang-cli/src/tui/screens/triggers.rs | 5 +- crates/openfang-cli/src/tui/screens/usage.rs | 5 +- .../openfang-cli/src/tui/screens/workflows.rs | 5 +- crates/openfang-extensions/src/oauth.rs | 4 +- crates/openfang-hands/src/registry.rs | 3 +- crates/openfang-kernel/src/config_reload.rs | 5 +- crates/openfang-kernel/src/cron.rs | 3 +- crates/openfang-kernel/src/kernel.rs | 599 ++- crates/openfang-kernel/src/scheduler.rs | 3 +- .../openfang-kernel/src/whatsapp_gateway.rs | 19 +- crates/openfang-kernel/src/workflow.rs | 3582 ++++++++++++++++- .../tests/session_resume_integration_test.rs | 254 ++ .../tests/workflow_integration_test.rs | 227 +- crates/openfang-memory/src/structured.rs | 13 +- crates/openfang-migrate/src/openclaw.rs | 679 +++- crates/openfang-runtime/src/agent_loop.rs | 18 +- crates/openfang-runtime/src/browser.rs | 39 +- crates/openfang-runtime/src/compactor.rs | 12 +- crates/openfang-runtime/src/context_budget.rs | 12 +- .../openfang-runtime/src/context_overflow.rs | 6 +- crates/openfang-runtime/src/copilot_oauth.rs | 10 +- .../src/drivers/claude_code.rs | 23 +- crates/openfang-runtime/src/drivers/gemini.rs | 5 +- crates/openfang-runtime/src/drivers/mod.rs | 10 +- crates/openfang-runtime/src/drivers/openai.rs | 16 +- crates/openfang-runtime/src/lib.rs | 2 +- crates/openfang-runtime/src/mcp.rs | 4 +- crates/openfang-runtime/src/model_catalog.rs | 8 +- .../openfang-runtime/src/process_manager.rs | 17 + crates/openfang-runtime/src/str_utils.rs | 2 +- crates/openfang-runtime/src/tool_runner.rs | 546 ++- crates/openfang-runtime/src/web_content.rs | 5 +- crates/openfang-runtime/src/web_fetch.rs | 4 +- crates/openfang-types/src/approval.rs | 3 +- crates/openfang-types/src/lib.rs | 1 + crates/openfang-types/src/task_state.rs | 182 + docs/multi-agent-foundation.md | 132 + 69 files changed, 7243 insertions(+), 744 deletions(-) create mode 100644 crates/openfang-kernel/tests/session_resume_integration_test.rs create mode 100644 crates/openfang-types/src/task_state.rs create mode 100644 docs/multi-agent-foundation.md diff --git a/CHANGELOG.md b/CHANGELOG.md index d9475b629..399f1cbaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ All notable changes to OpenFang will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Multi-Agent Foundation Release Candidate +- Finalized the multi-agent foundation release candidate handoff for `feat/multiagent-foundation-v1`, covering declarative workflow orchestration, durable workflow recovery, review/retry controls, audit/metrics hooks, session isolation, shadow routing, rollback controls, and OpenClaw migration compatibility. +- Confirmed the upstream delivery plan is split into six reviewable slices with rollback notes and focused gates recorded in `.codex-tasks/openfang-multiagent-foundation/PROGRESS.md`. +- Recorded the operator acceptance outcome: the two-user shadow drill passed with stable user-specific routing, no cross-session context bleed, successful shadow comparison capture, and rollback checklist execution inside the five-minute rollback window. + +### Handoff Checklist +- [x] Operator guide is published in `docs/multi-agent-foundation.md` with shadow-run, rollout, and rollback instructions. +- [x] Upstream PR slicing plan and submission checklist are captured in `.codex-tasks/openfang-multiagent-foundation/PROGRESS.md`. +- [x] Two-user acceptance drill passed via focused gates for route specificity, session isolation, shadow comparison, and rollback controls. +- [x] Release handoff notes are consolidated in `CHANGELOG.md` so the final row is documentation-only and does not introduce unrelated runtime refactors. + +### Known Risks +- Multi-session isolation spans kernel, runtime, API, and workspace persistence; keep rollout gradual and monitor for session-scoped memory regressions when traffic increases. +- Shadow and rollback controls are operator-facing guardrails; keep `stable_path` anchored to the last production route until each upstream slice is reviewed and landed. +- OpenClaw migration compatibility covers historical identity/provider/bindings variants, so representative production exports should still be revalidated before promotion. + ## [0.1.0] - 2026-02-24 ### Added diff --git a/Cargo.lock b/Cargo.lock index ec1a7cb05..32b35b542 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3866,7 +3866,7 @@ dependencies = [ [[package]] name = "openfang-api" -version = "0.3.16" +version = "0.3.17" dependencies = [ "async-trait", "axum", @@ -3903,7 +3903,7 @@ dependencies = [ [[package]] name = "openfang-channels" -version = "0.3.16" +version = "0.3.17" dependencies = [ "async-trait", "axum", @@ -3934,7 +3934,7 @@ dependencies = [ [[package]] name = "openfang-cli" -version = "0.3.16" +version = "0.3.17" dependencies = [ "clap", "clap_complete", @@ -3961,7 +3961,7 @@ dependencies = [ [[package]] name = "openfang-desktop" -version = "0.3.16" +version = "0.3.17" dependencies = [ "axum", "open", @@ -3987,7 +3987,7 @@ dependencies = [ [[package]] name = "openfang-extensions" -version = "0.3.16" +version = "0.3.17" dependencies = [ "aes-gcm", "argon2", @@ -4015,7 +4015,7 @@ dependencies = [ [[package]] name = "openfang-hands" -version = "0.3.16" +version = "0.3.17" dependencies = [ "chrono", "dashmap", @@ -4032,7 +4032,7 @@ dependencies = [ [[package]] name = "openfang-kernel" -version = "0.3.16" +version = "0.3.17" dependencies = [ "async-trait", "chrono", @@ -4068,7 +4068,7 @@ dependencies = [ [[package]] name = "openfang-memory" -version = "0.3.16" +version = "0.3.17" dependencies = [ "async-trait", "chrono", @@ -4087,7 +4087,7 @@ dependencies = [ [[package]] name = "openfang-migrate" -version = "0.3.16" +version = "0.3.17" dependencies = [ "chrono", "dirs 6.0.0", @@ -4106,7 +4106,7 @@ dependencies = [ [[package]] name = "openfang-runtime" -version = "0.3.16" +version = "0.3.17" dependencies = [ "anyhow", "async-trait", @@ -4138,7 +4138,7 @@ dependencies = [ [[package]] name = "openfang-skills" -version = "0.3.16" +version = "0.3.17" dependencies = [ "chrono", "hex", @@ -4161,7 +4161,7 @@ dependencies = [ [[package]] name = "openfang-types" -version = "0.3.16" +version = "0.3.17" dependencies = [ "async-trait", "chrono", @@ -4180,7 +4180,7 @@ dependencies = [ [[package]] name = "openfang-wire" -version = "0.3.16" +version = "0.3.17" dependencies = [ "async-trait", "chrono", @@ -8802,7 +8802,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] name = "xtask" -version = "0.3.16" +version = "0.3.17" [[package]] name = "yoke" diff --git a/crates/openfang-api/src/channel_bridge.rs b/crates/openfang-api/src/channel_bridge.rs index 9285d0608..ef3ed76d1 100644 --- a/crates/openfang-api/src/channel_bridge.rs +++ b/crates/openfang-api/src/channel_bridge.rs @@ -1065,7 +1065,9 @@ pub async fn start_channel_bridge_with_config( // WhatsApp — supports Cloud API mode (access token) or Web/QR mode (gateway URL) if let Some(ref wa_config) = config.whatsapp { let cloud_token = read_token(&wa_config.access_token_env, "WhatsApp"); - let gateway_url = std::env::var(&wa_config.gateway_url_env).ok().filter(|u| !u.is_empty()); + let gateway_url = std::env::var(&wa_config.gateway_url_env) + .ok() + .filter(|u| !u.is_empty()); if cloud_token.is_some() || gateway_url.is_some() { let token = cloud_token.unwrap_or_default(); diff --git a/crates/openfang-api/src/middleware.rs b/crates/openfang-api/src/middleware.rs index b2785da92..9fc3b5b8b 100644 --- a/crates/openfang-api/src/middleware.rs +++ b/crates/openfang-api/src/middleware.rs @@ -106,6 +106,7 @@ pub async fn auth( || path == "/api/integrations/available" || path == "/api/integrations/health" || path == "/api/workflows" + || path == "/api/workflows/metrics" || path == "/api/logs/stream" || path.starts_with("/api/cron/") || path.starts_with("/api/providers/github-copilot/oauth/") diff --git a/crates/openfang-api/src/routes.rs b/crates/openfang-api/src/routes.rs index ac886fafe..7896f6a03 100644 --- a/crates/openfang-api/src/routes.rs +++ b/crates/openfang-api/src/routes.rs @@ -8,12 +8,13 @@ use axum::Json; use dashmap::DashMap; use openfang_kernel::triggers::{TriggerId, TriggerPattern}; use openfang_kernel::workflow::{ - ErrorMode, StepAgent, StepMode, Workflow, WorkflowId, WorkflowStep, + ErrorMode, StepAgent, StepMode, Workflow, WorkflowId, WorkflowRunId, WorkflowShadowComparison, + WorkflowStep, WorkflowTrafficPath, }; use openfang_kernel::OpenFangKernel; use openfang_runtime::kernel_handle::KernelHandle; use openfang_runtime::tool_runner::builtin_tool_definitions; -use openfang_types::agent::{AgentId, AgentIdentity, AgentManifest}; +use openfang_types::agent::{AgentId, AgentIdentity, AgentManifest, SessionId}; use std::collections::HashMap; use std::sync::{Arc, LazyLock}; use std::time::Instant; @@ -126,15 +127,13 @@ pub async fn list_agents(State(state): State>) -> impl IntoRespons .into_iter() .map(|e| { // Resolve "default" provider/model to actual kernel defaults - let provider = if e.manifest.model.provider.is_empty() - || e.manifest.model.provider == "default" - { - dm.provider.as_str() - } else { - e.manifest.model.provider.as_str() - }; - let model = if e.manifest.model.model.is_empty() - || e.manifest.model.model == "default" + let provider = + if e.manifest.model.provider.is_empty() || e.manifest.model.provider == "default" { + dm.provider.as_str() + } else { + e.manifest.model.provider.as_str() + }; + let model = if e.manifest.model.model.is_empty() || e.manifest.model.model == "default" { dm.model.as_str() } else { @@ -243,6 +242,7 @@ pub fn resolve_attachments( pub fn inject_attachments_into_session( kernel: &OpenFangKernel, agent_id: AgentId, + session_id: Option, image_blocks: Vec, ) { use openfang_types::message::{Message, MessageContent, Role}; @@ -252,10 +252,12 @@ pub fn inject_attachments_into_session( None => return, }; - let mut session = match kernel.memory.get_session(entry.session_id) { + let resolved_session_id = session_id.unwrap_or(entry.session_id); + + let mut session = match kernel.memory.get_session(resolved_session_id) { Ok(Some(s)) => s, _ => openfang_memory::session::Session { - id: entry.session_id, + id: resolved_session_id, agent_id, messages: Vec::new(), context_window_tokens: 0, @@ -273,6 +275,21 @@ pub fn inject_attachments_into_session( } } +fn parse_requested_session_id( + session_id: Option<&str>, +) -> Result, (StatusCode, Json)> { + match session_id { + Some(session_id) => match session_id.parse::() { + Ok(parsed) => Ok(Some(SessionId(parsed))), + Err(_) => Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid session ID"})), + )), + }, + None => Ok(None), + } +} + /// POST /api/agents/:id/message — Send a message to an agent. pub async fn send_message( State(state): State>, @@ -306,18 +323,33 @@ pub async fn send_message( ); } + let requested_session_id = match parse_requested_session_id(req.session_id.as_deref()) { + Ok(session_id) => session_id, + Err(response) => return response, + }; + // Resolve file attachments into image content blocks if !req.attachments.is_empty() { let image_blocks = resolve_attachments(&req.attachments); if !image_blocks.is_empty() { - inject_attachments_into_session(&state.kernel, agent_id, image_blocks); + inject_attachments_into_session( + &state.kernel, + agent_id, + requested_session_id, + image_blocks, + ); } } let kernel_handle: Arc = state.kernel.clone() as Arc; match state .kernel - .send_message_with_handle(agent_id, &req.message, Some(kernel_handle)) + .send_message_with_handle_in_session( + agent_id, + &req.message, + Some(kernel_handle), + requested_session_id, + ) .await { Ok(result) => { @@ -687,16 +719,75 @@ pub async fn run_workflow( }); let input = req["input"].as_str().unwrap_or("").to_string(); - - match state.kernel.run_workflow(workflow_id, input).await { - Ok((run_id, output)) => ( - StatusCode::OK, + let shadow_request = req.get("shadow"); + let shadow_enabled = shadow_request + .and_then(|shadow| shadow.get("enabled").and_then(|value| value.as_bool())) + .unwrap_or_else(|| shadow_request.is_some()); + let production_output = shadow_request + .and_then(|shadow| shadow.get("production_output")) + .and_then(|value| value.as_str()) + .map(str::to_string); + + if shadow_enabled && production_output.is_none() { + return ( + StatusCode::BAD_REQUEST, Json(serde_json::json!({ - "run_id": run_id.to_string(), - "output": output, - "status": "completed", + "error": "Shadow run requires shadow.production_output" })), + ); + } + + let run_result: Result< + ( + WorkflowRunId, + Option, + Option, ), + _, + > = if let Some(production_output) = production_output { + state + .kernel + .run_workflow_shadow(workflow_id, input, production_output) + .await + .map(|(run_id, comparison)| (run_id, None, Some(comparison))) + } else { + state + .kernel + .run_workflow(workflow_id, input) + .await + .map(|(run_id, output)| (run_id, Some(output), None)) + }; + + match run_result { + Ok((run_id, output, shadow_comparison)) => { + let trace_id = state + .kernel + .workflows + .get_run(run_id) + .await + .map(|run| run.trace_id) + .unwrap_or_default(); + let response = if let Some(shadow) = shadow_comparison { + let production_output = shadow.production_output.clone(); + let shadow_output = shadow.shadow_output.clone(); + serde_json::json!({ + "run_id": run_id.to_string(), + "trace_id": trace_id, + "output": production_output, + "shadow_output": shadow_output, + "shadow": shadow, + "status": "shadow_completed", + }) + } else { + serde_json::json!({ + "run_id": run_id.to_string(), + "trace_id": trace_id, + "output": output.unwrap_or_default(), + "status": "completed", + }) + }; + (StatusCode::OK, Json(response)) + } Err(e) => { tracing::warn!("Workflow run failed for {id}: {e}"); ( @@ -707,6 +798,151 @@ pub async fn run_workflow( } } +/// GET /api/workflows/:id/rollout — Inspect rollout + rollback controls for a workflow. +pub async fn get_workflow_rollout( + State(state): State>, + Path(id): Path, +) -> impl IntoResponse { + let workflow_id = WorkflowId(match id.parse() { + Ok(u) => u, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid workflow ID"})), + ); + } + }); + + match state.kernel.workflows.get_rollout_state(workflow_id).await { + Some(rollout) => ( + StatusCode::OK, + Json(serde_json::to_value(rollout).unwrap_or_default()), + ), + None => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Workflow not found"})), + ), + } +} + +/// PUT /api/workflows/:id/rollout — Update authoritative path / stable path / shadow policy. +pub async fn update_workflow_rollout( + State(state): State>, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let workflow_id = WorkflowId(match id.parse() { + Ok(u) => u, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid workflow ID"})), + ); + } + }); + + let parse_path = |field: &str| -> Result, String> { + match req.get(field) { + Some(value) => serde_json::from_value::(value.clone()) + .map(Some) + .map_err(|_| format!("Invalid '{}' value", field)), + None => Ok(None), + } + }; + + let primary_path = match parse_path("primary_path") { + Ok(value) => value, + Err(error) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": error})), + ); + } + }; + let stable_path = match parse_path("stable_path") { + Ok(value) => value, + Err(error) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": error})), + ); + } + }; + let shadow_enabled = req.get("shadow_enabled").and_then(|value| value.as_bool()); + let rollback_window_secs = req + .get("rollback_window_secs") + .and_then(|value| value.as_u64()); + + match state + .kernel + .workflows + .update_rollout_state( + workflow_id, + primary_path, + stable_path, + shadow_enabled, + rollback_window_secs, + ) + .await + { + Ok(rollout) => ( + StatusCode::OK, + Json(serde_json::to_value(rollout).unwrap_or_default()), + ), + Err(error) if error.contains("not found") => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": error})), + ), + Err(error) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": error})), + ), + } +} + +/// POST /api/workflows/:id/rollback — Switch primary traffic back to the last stable path. +pub async fn rollback_workflow_to_stable_path( + State(state): State>, + Path(id): Path, +) -> impl IntoResponse { + let workflow_id = WorkflowId(match id.parse() { + Ok(u) => u, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid workflow ID"})), + ); + } + }); + + match state + .kernel + .workflows + .rollback_to_stable_path(workflow_id) + .await + { + Ok(rollout) => { + let rollback = rollout.last_rollback.clone(); + ( + StatusCode::OK, + Json(serde_json::json!({ + "workflow_id": workflow_id.to_string(), + "rollout": rollout, + "rollback": rollback, + })), + ) + } + Err(error) if error.contains("not found") => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": error})), + ), + Err(error) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": error})), + ), + } +} + /// GET /api/workflows/:id/runs — List runs for a workflow. pub async fn list_workflow_runs( State(state): State>, @@ -718,9 +954,17 @@ pub async fn list_workflow_runs( .map(|r| { serde_json::json!({ "id": r.id.to_string(), + "trace_id": r.trace_id, "workflow_name": r.workflow_name, "state": serde_json::to_value(&r.state).unwrap_or_default(), "steps_completed": r.step_results.len(), + "audit_events": r.audit_events.len(), + "shadow": r.shadow.as_ref().map(|shadow| serde_json::json!({ + "matches": shadow.matches, + "normalized_matches": shadow.normalized_matches, + "first_mismatch_index": shadow.first_mismatch_index, + "compared_at": shadow.compared_at.to_rfc3339(), + })), "started_at": r.started_at.to_rfc3339(), "completed_at": r.completed_at.map(|t| t.to_rfc3339()), }) @@ -729,6 +973,47 @@ pub async fn list_workflow_runs( Json(list) } +/// GET /api/workflows/traces/:trace_id/events — List audit events for one workflow trace. +pub async fn list_workflow_trace_events( + State(state): State>, + Path(trace_id): Path, +) -> impl IntoResponse { + let events = state + .kernel + .workflows + .list_audit_events_by_trace_id(&trace_id) + .await; + + let payload: Vec = events + .into_iter() + .map(|event| { + serde_json::json!({ + "event_id": event.event_id.to_string(), + "trace_id": event.trace_id, + "run_id": event.run_id.to_string(), + "workflow_id": event.workflow_id.to_string(), + "step_name": event.step_name, + "event_type": event.event_type, + "detail": event.detail, + "outcome": event.outcome, + "timestamp": event.timestamp.to_rfc3339(), + }) + }) + .collect(); + + Json(serde_json::json!({ + "trace_id": trace_id, + "events": payload, + })) +} + +/// GET /api/workflows/metrics — Aggregated workflow observability metrics. +pub async fn workflow_observability_metrics( + State(state): State>, +) -> impl IntoResponse { + Json(state.kernel.workflows.observability_metrics().await) +} + // --------------------------------------------------------------------------- // Trigger routes // --------------------------------------------------------------------------- @@ -1042,22 +1327,28 @@ pub async fn send_message_stream( .into_response(); } + let requested_session_id = match parse_requested_session_id(req.session_id.as_deref()) { + Ok(session_id) => session_id, + Err(response) => return response.into_response(), + }; + let kernel_handle: Arc = state.kernel.clone() as Arc; - let (rx, _handle) = - match state - .kernel - .send_message_streaming(agent_id, &req.message, Some(kernel_handle)) - { - Ok(pair) => pair, - Err(e) => { - tracing::warn!("Streaming message failed for agent {id}: {e}"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": "Streaming message failed"})), - ) - .into_response(); - } - }; + let (rx, _handle) = match state.kernel.send_message_streaming_in_session( + agent_id, + &req.message, + Some(kernel_handle), + requested_session_id, + ) { + Ok(pair) => pair, + Err(e) => { + tracing::warn!("Streaming message failed for agent {id}: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "Streaming message failed"})), + ) + .into_response(); + } + }; let sse_stream = stream::unfold(rx, |mut rx| async move { match rx.recv().await { @@ -1866,9 +2157,7 @@ fn build_field_json( val.clone() }; field["value"] = display_val; - if !val.is_null() - && val.as_str().map(|s| !s.is_empty()).unwrap_or(true) - { + if !val.is_null() && val.as_str().map(|s| !s.is_empty()).unwrap_or(true) { field["has_value"] = serde_json::Value::Bool(true); } } @@ -1888,46 +2177,166 @@ fn channel_config_values( name: &str, ) -> Option { match name { - "telegram" => config.telegram.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "discord" => config.discord.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "slack" => config.slack.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "whatsapp" => config.whatsapp.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "signal" => config.signal.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "matrix" => config.matrix.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "email" => config.email.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "teams" => config.teams.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "mattermost" => config.mattermost.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "irc" => config.irc.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "google_chat" => config.google_chat.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "twitch" => config.twitch.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "rocketchat" => config.rocketchat.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "zulip" => config.zulip.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "xmpp" => config.xmpp.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "line" => config.line.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "viber" => config.viber.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "messenger" => config.messenger.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "reddit" => config.reddit.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "mastodon" => config.mastodon.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "bluesky" => config.bluesky.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "feishu" => config.feishu.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "revolt" => config.revolt.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "nextcloud" => config.nextcloud.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "guilded" => config.guilded.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "keybase" => config.keybase.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "threema" => config.threema.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "nostr" => config.nostr.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "webex" => config.webex.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "pumble" => config.pumble.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "flock" => config.flock.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "twist" => config.twist.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "mumble" => config.mumble.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "dingtalk" => config.dingtalk.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "discourse" => config.discourse.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "gitter" => config.gitter.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "ntfy" => config.ntfy.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "gotify" => config.gotify.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "webhook" => config.webhook.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "linkedin" => config.linkedin.as_ref().and_then(|c| serde_json::to_value(c).ok()), + "telegram" => config + .telegram + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "discord" => config + .discord + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "slack" => config + .slack + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "whatsapp" => config + .whatsapp + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "signal" => config + .signal + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "matrix" => config + .matrix + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "email" => config + .email + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "teams" => config + .teams + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "mattermost" => config + .mattermost + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "irc" => config + .irc + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "google_chat" => config + .google_chat + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "twitch" => config + .twitch + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "rocketchat" => config + .rocketchat + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "zulip" => config + .zulip + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "xmpp" => config + .xmpp + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "line" => config + .line + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "viber" => config + .viber + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "messenger" => config + .messenger + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "reddit" => config + .reddit + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "mastodon" => config + .mastodon + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "bluesky" => config + .bluesky + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "feishu" => config + .feishu + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "revolt" => config + .revolt + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "nextcloud" => config + .nextcloud + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "guilded" => config + .guilded + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "keybase" => config + .keybase + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "threema" => config + .threema + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "nostr" => config + .nostr + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "webex" => config + .webex + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "pumble" => config + .pumble + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "flock" => config + .flock + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "twist" => config + .twist + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "mumble" => config + .mumble + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "dingtalk" => config + .dingtalk + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "discourse" => config + .discourse + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "gitter" => config + .gitter + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "ntfy" => config + .ntfy + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "gotify" => config + .gotify + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "webhook" => config + .webhook + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "linkedin" => config + .linkedin + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), _ => None, } } @@ -2049,7 +2458,10 @@ pub async fn configure_channel( ); } else { // Config field — collect for TOML write with type info - config_fields.insert(field_def.key.to_string(), (value.to_string(), field_def.field_type)); + config_fields.insert( + field_def.key.to_string(), + (value.to_string(), field_def.field_type), + ); } } @@ -2737,6 +3149,55 @@ pub async fn prometheus_metrics(State(state): State>) -> impl Into health.restart_count )); + // Workflow observability rates + let wf_metrics = state.kernel.workflows.observability_metrics().await; + out.push_str("# HELP openfang_workflow_success_rate Successful terminal workflow run rate.\n"); + out.push_str("# TYPE openfang_workflow_success_rate gauge\n"); + out.push_str(&format!( + "openfang_workflow_success_rate {}\n", + wf_metrics.success_rate + )); + out.push_str( + "# HELP openfang_workflow_failure_rate Failed/blocked terminal workflow run rate.\n", + ); + out.push_str("# TYPE openfang_workflow_failure_rate gauge\n"); + out.push_str(&format!( + "openfang_workflow_failure_rate {}\n", + wf_metrics.failure_rate + )); + out.push_str("# HELP openfang_workflow_retry_rate Workflow execution retry event rate.\n"); + out.push_str("# TYPE openfang_workflow_retry_rate gauge\n"); + out.push_str(&format!( + "openfang_workflow_retry_rate {}\n", + wf_metrics.retry_rate + )); + out.push_str("# HELP openfang_workflow_reject_rate Workflow review rejection rate.\n"); + out.push_str("# TYPE openfang_workflow_reject_rate gauge\n"); + out.push_str(&format!( + "openfang_workflow_reject_rate {}\n", + wf_metrics.reject_rate + )); + out.push_str( + "# HELP openfang_workflow_resume_time_ms Average workflow resume delay in milliseconds.\n", + ); + out.push_str("# TYPE openfang_workflow_resume_time_ms gauge\n"); + out.push_str(&format!( + "openfang_workflow_resume_time_ms {}\n", + wf_metrics.resume_time_ms + )); + out.push_str("# HELP openfang_workflow_runs_total Total tracked workflow runs.\n"); + out.push_str("# TYPE openfang_workflow_runs_total gauge\n"); + out.push_str(&format!( + "openfang_workflow_runs_total {}\n", + wf_metrics.runs_total + )); + out.push_str("# HELP openfang_workflow_terminal_runs_total Total terminal workflow runs.\n"); + out.push_str("# TYPE openfang_workflow_terminal_runs_total gauge\n"); + out.push_str(&format!( + "openfang_workflow_terminal_runs_total {}\n\n", + wf_metrics.terminal_runs_total + )); + // Version info out.push_str("# HELP openfang_info OpenFang version and build info.\n"); out.push_str("# TYPE openfang_info gauge\n"); @@ -2949,7 +3410,9 @@ pub async fn clawhub_search( "items": items, "next_cursor": null, }); - state.clawhub_cache.insert(cache_key, (Instant::now(), resp.clone())); + state + .clawhub_cache + .insert(cache_key, (Instant::now(), resp.clone())); (StatusCode::OK, Json(resp)) } Err(e) => { @@ -2963,9 +3426,7 @@ pub async fn clawhub_search( }; ( status, - Json( - serde_json::json!({"items": [], "next_cursor": null, "error": msg}), - ), + Json(serde_json::json!({"items": [], "next_cursor": null, "error": msg})), ) } } @@ -3018,7 +3479,9 @@ pub async fn clawhub_browse( "items": items, "next_cursor": results.next_cursor, }); - state.clawhub_cache.insert(cache_key, (Instant::now(), resp.clone())); + state + .clawhub_cache + .insert(cache_key, (Instant::now(), resp.clone())); (StatusCode::OK, Json(resp)) } Err(e) => { @@ -3031,9 +3494,7 @@ pub async fn clawhub_browse( }; ( status, - Json( - serde_json::json!({"items": [], "next_cursor": null, "error": msg}), - ), + Json(serde_json::json!({"items": [], "next_cursor": null, "error": msg})), ) } } @@ -3200,7 +3661,10 @@ pub async fn clawhub_install( StatusCode::FORBIDDEN } else if msg.contains("429") || msg.contains("rate limit") { StatusCode::TOO_MANY_REQUESTS - } else if msg.contains("Network error") || msg.contains("returned 4") || msg.contains("returned 5") { + } else if msg.contains("Network error") + || msg.contains("returned 4") + || msg.contains("returned 5") + { StatusCode::BAD_GATEWAY } else { StatusCode::INTERNAL_SERVER_ERROR @@ -3699,7 +4163,12 @@ pub async fn activate_hand( // If the hand agent has a non-reactive schedule (autonomous hands), // start its background loop so it begins running immediately. if let Some(agent_id) = instance.agent_id { - let entry = state.kernel.registry.list().into_iter().find(|e| e.id == agent_id); + let entry = state + .kernel + .registry + .list() + .into_iter() + .find(|e| e.id == agent_id); if let Some(entry) = entry { if !matches!( entry.manifest.schedule, @@ -3855,7 +4324,9 @@ pub async fn update_hand_settings( }, None => ( StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": format!("No active instance for hand: {hand_id}. Activate the hand first.")})), + Json( + serde_json::json!({"error": format!("No active instance for hand: {hand_id}. Activate the hand first.")}), + ), ), } } @@ -3982,7 +4453,10 @@ pub async fn hand_instance_browser( content = data["content"].as_str().unwrap_or("").to_string(); // Truncate content to avoid huge payloads (UTF-8 safe) if content.len() > 2000 { - content = format!("{}... (truncated)", openfang_types::truncate_str(&content, 2000)); + content = format!( + "{}... (truncated)", + openfang_types::truncate_str(&content, 2000) + ); } } } @@ -4638,7 +5112,9 @@ pub async fn update_agent_budget( if hourly.is_none() && daily.is_none() && monthly.is_none() { return ( StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Provide at least one of: max_cost_per_hour_usd, max_cost_per_day_usd, max_cost_per_month_usd"})), + Json( + serde_json::json!({"error": "Provide at least one of: max_cost_per_hour_usd, max_cost_per_day_usd, max_cost_per_month_usd"}), + ), ); } @@ -6142,10 +6618,7 @@ pub async fn set_agent_tools( .kernel .set_agent_tool_filters(agent_id, allowlist, blocklist) { - Ok(()) => ( - StatusCode::OK, - Json(serde_json::json!({"status": "ok"})), - ), + Ok(()) => (StatusCode::OK, Json(serde_json::json!({"status": "ok"}))), Err(e) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("{e}")})), @@ -6617,8 +7090,7 @@ pub async fn set_provider_url( } // Probe reachability at the new URL - let probe = - openfang_runtime::provider_health::probe_provider(&name, &base_url).await; + let probe = openfang_runtime::provider_health::probe_provider(&name, &base_url).await; // Merge discovered models into catalog if !probe.discovered_models.is_empty() { @@ -7513,7 +7985,11 @@ pub async fn run_schedule( ); let kernel_handle: Arc = state.kernel.clone() as Arc; - match state.kernel.send_message_with_handle(target_agent, &run_message, Some(kernel_handle)).await { + match state + .kernel + .send_message_with_handle(target_agent, &run_message, Some(kernel_handle)) + .await + { Ok(result) => ( StatusCode::OK, Json(serde_json::json!({ @@ -7660,7 +8136,9 @@ pub async fn patch_agent_config( if name.len() > MAX_NAME_LEN { return ( StatusCode::PAYLOAD_TOO_LARGE, - Json(serde_json::json!({"error": format!("Name exceeds max length ({MAX_NAME_LEN} chars)")})), + Json( + serde_json::json!({"error": format!("Name exceeds max length ({MAX_NAME_LEN} chars)")}), + ), ); } } @@ -7668,7 +8146,9 @@ pub async fn patch_agent_config( if desc.len() > MAX_DESC_LEN { return ( StatusCode::PAYLOAD_TOO_LARGE, - Json(serde_json::json!({"error": format!("Description exceeds max length ({MAX_DESC_LEN} chars)")})), + Json( + serde_json::json!({"error": format!("Description exceeds max length ({MAX_DESC_LEN} chars)")}), + ), ); } } @@ -7676,7 +8156,9 @@ pub async fn patch_agent_config( if prompt.len() > MAX_PROMPT_LEN { return ( StatusCode::PAYLOAD_TOO_LARGE, - Json(serde_json::json!({"error": format!("System prompt exceeds max length ({MAX_PROMPT_LEN} chars)")})), + Json( + serde_json::json!({"error": format!("System prompt exceeds max length ({MAX_PROMPT_LEN} chars)")}), + ), ); } } @@ -8653,12 +9135,18 @@ pub async fn config_reload(State(state): State>) -> impl IntoRespo // --------------------------------------------------------------------------- /// GET /api/config/schema — Return a simplified JSON description of the config structure. -pub async fn config_schema( - State(state): State>, -) -> impl IntoResponse { +pub async fn config_schema(State(state): State>) -> impl IntoResponse { // Build provider/model options from model catalog for dropdowns - let catalog = state.kernel.model_catalog.read().unwrap_or_else(|e| e.into_inner()); - let provider_options: Vec = catalog.list_providers().iter().map(|p| p.id.clone()).collect(); + let catalog = state + .kernel + .model_catalog + .read() + .unwrap_or_else(|e| e.into_inner()); + let provider_options: Vec = catalog + .list_providers() + .iter() + .map(|p| p.id.clone()) + .collect(); let model_options: Vec = catalog .list_models() .iter() @@ -9511,8 +9999,7 @@ pub async fn copilot_oauth_start() -> impl IntoResponse { CopilotFlowState { device_code: resp.device_code, interval: resp.interval, - expires_at: Instant::now() - + std::time::Duration::from_secs(resp.expires_in), + expires_at: Instant::now() + std::time::Duration::from_secs(resp.expires_in), }, ); @@ -9576,7 +10063,9 @@ pub async fn copilot_oauth_poll( if let Err(e) = write_secret_env(&secrets_path, "GITHUB_TOKEN", &access_token) { return ( StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"status": "error", "error": format!("Failed to save token: {e}")})), + Json( + serde_json::json!({"status": "error", "error": format!("Failed to save token: {e}")}), + ), ); } @@ -9780,15 +10269,30 @@ fn audit_to_comms_event( // Format detail: "tokens_in=X, tokens_out=Y" → readable summary let detail = if entry.detail.starts_with("tokens_in=") { let parts: Vec<&str> = entry.detail.split(", ").collect(); - let in_tok = parts.first().and_then(|p| p.strip_prefix("tokens_in=")).unwrap_or("?"); - let out_tok = parts.get(1).and_then(|p| p.strip_prefix("tokens_out=")).unwrap_or("?"); + let in_tok = parts + .first() + .and_then(|p| p.strip_prefix("tokens_in=")) + .unwrap_or("?"); + let out_tok = parts + .get(1) + .and_then(|p| p.strip_prefix("tokens_out=")) + .unwrap_or("?"); if entry.outcome == "ok" { format!("{} in / {} out tokens", in_tok, out_tok) } else { - format!("{} in / {} out — {}", in_tok, out_tok, openfang_types::truncate_str(&entry.outcome, 80)) + format!( + "{} in / {} out — {}", + in_tok, + out_tok, + openfang_types::truncate_str(&entry.outcome, 80) + ) } } else if entry.outcome != "ok" { - format!("{} — {}", openfang_types::truncate_str(&entry.detail, 80), openfang_types::truncate_str(&entry.outcome, 80)) + format!( + "{} — {}", + openfang_types::truncate_str(&entry.detail, 80), + openfang_types::truncate_str(&entry.outcome, 80) + ) } else { openfang_types::truncate_str(&entry.detail, 200).to_string() }; @@ -9796,12 +10300,18 @@ fn audit_to_comms_event( } "AgentSpawn" => ( CommsEventKind::AgentSpawned, - format!("Agent spawned: {}", openfang_types::truncate_str(&entry.detail, 100)), + format!( + "Agent spawned: {}", + openfang_types::truncate_str(&entry.detail, 100) + ), "", ), "AgentKill" => ( CommsEventKind::AgentTerminated, - format!("Agent killed: {}", openfang_types::truncate_str(&entry.detail, 100)), + format!( + "Agent killed: {}", + openfang_types::truncate_str(&entry.detail, 100) + ), "", ), _ => return None, @@ -9813,8 +10323,16 @@ fn audit_to_comms_event( kind, source_id: entry.agent_id.clone(), source_name: resolve_name(&entry.agent_id), - target_id: if target_label.is_empty() { String::new() } else { target_label.to_string() }, - target_name: if target_label.is_empty() { String::new() } else { target_label.to_string() }, + target_id: if target_label.is_empty() { + String::new() + } else { + target_label.to_string() + }, + target_name: if target_label.is_empty() { + String::new() + } else { + target_label.to_string() + }, detail, }) } @@ -9865,9 +10383,7 @@ pub async fn comms_events( /// GET /api/comms/events/stream — SSE stream of inter-agent communication events. /// /// Polls the audit log every 500ms for new inter-agent events. -pub async fn comms_events_stream( - State(state): State>, -) -> axum::response::Response { +pub async fn comms_events_stream(State(state): State>) -> axum::response::Response { use axum::response::sse::{Event, KeepAlive, Sse}; let (tx, rx) = tokio::sync::mpsc::channel::< diff --git a/crates/openfang-api/src/server.rs b/crates/openfang-api/src/server.rs index 11393184d..76b84a922 100644 --- a/crates/openfang-api/src/server.rs +++ b/crates/openfang-api/src/server.rs @@ -290,10 +290,26 @@ pub async fn build_router( "/api/workflows/{id}/run", axum::routing::post(routes::run_workflow), ) + .route( + "/api/workflows/{id}/rollout", + axum::routing::get(routes::get_workflow_rollout).put(routes::update_workflow_rollout), + ) + .route( + "/api/workflows/{id}/rollback", + axum::routing::post(routes::rollback_workflow_to_stable_path), + ) .route( "/api/workflows/{id}/runs", axum::routing::get(routes::list_workflow_runs), ) + .route( + "/api/workflows/metrics", + axum::routing::get(routes::workflow_observability_metrics), + ) + .route( + "/api/workflows/traces/{trace_id}/events", + axum::routing::get(routes::list_workflow_trace_events), + ) // Skills endpoints .route("/api/skills", axum::routing::get(routes::list_skills)) .route( @@ -354,8 +370,7 @@ pub async fn build_router( ) .route( "/api/hands/{hand_id}/settings", - axum::routing::get(routes::get_hand_settings) - .put(routes::update_hand_settings), + axum::routing::get(routes::get_hand_settings).put(routes::update_hand_settings), ) .route( "/api/hands/instances/{id}/pause", @@ -412,14 +427,8 @@ pub async fn build_router( "/api/comms/events/stream", axum::routing::get(routes::comms_events_stream), ) - .route( - "/api/comms/send", - axum::routing::post(routes::comms_send), - ) - .route( - "/api/comms/task", - axum::routing::post(routes::comms_task), - ) + .route("/api/comms/send", axum::routing::post(routes::comms_send)) + .route("/api/comms/task", axum::routing::post(routes::comms_task)) // Tools endpoint .route("/api/tools", axum::routing::get(routes::list_tools)) // Config endpoints @@ -464,8 +473,7 @@ pub async fn build_router( ) .route( "/api/budget/agents/{id}", - axum::routing::get(routes::agent_budget_status) - .put(routes::update_agent_budget), + axum::routing::get(routes::agent_budget_status).put(routes::update_agent_budget), ) // Session endpoints .route("/api/sessions", axum::routing::get(routes::list_sessions)) @@ -787,8 +795,7 @@ pub async fn run_daemon( socket.set_nonblocking(true)?; socket.bind(&addr.into())?; socket.listen(1024)?; - let listener = - tokio::net::TcpListener::from_std(std::net::TcpListener::from(socket))?; + let listener = tokio::net::TcpListener::from_std(std::net::TcpListener::from(socket))?; // Run server with graceful shutdown. // SECURITY: `into_make_service_with_connect_info` injects the peer @@ -919,11 +926,8 @@ fn is_daemon_responding(addr: &str) -> bool { .or_else(|| addr.strip_prefix("https://")) .unwrap_or(addr); if let Ok(sock_addr) = addr_only.parse::() { - std::net::TcpStream::connect_timeout( - &sock_addr, - std::time::Duration::from_millis(500), - ) - .is_ok() + std::net::TcpStream::connect_timeout(&sock_addr, std::time::Duration::from_millis(500)) + .is_ok() } else { // Fallback: try connecting to hostname std::net::TcpStream::connect(addr_only) diff --git a/crates/openfang-api/src/types.rs b/crates/openfang-api/src/types.rs index 9245e6b7e..6b700ffd3 100644 --- a/crates/openfang-api/src/types.rs +++ b/crates/openfang-api/src/types.rs @@ -34,6 +34,8 @@ pub struct AttachmentRef { #[derive(Debug, Deserialize)] pub struct MessageRequest { pub message: String, + #[serde(default)] + pub session_id: Option, /// Optional file attachments (uploaded via /upload endpoint). #[serde(default)] pub attachments: Vec, diff --git a/crates/openfang-api/src/ws.rs b/crates/openfang-api/src/ws.rs index 6b6a526e9..aebd1b49c 100644 --- a/crates/openfang-api/src/ws.rs +++ b/crates/openfang-api/src/ws.rs @@ -427,6 +427,11 @@ async fn handle_text_message( } // Resolve file attachments into image content blocks + let requested_session_id = parsed["session_id"] + .as_str() + .and_then(|sid| sid.parse::().ok()) + .map(openfang_types::agent::SessionId); + let mut has_images = false; if let Some(attachments) = parsed["attachments"].as_array() { let refs: Vec = attachments @@ -440,6 +445,7 @@ async fn handle_text_message( crate::routes::inject_attachments_into_session( &state.kernel, agent_id, + requested_session_id, image_blocks, ); } @@ -491,10 +497,12 @@ async fn handle_text_message( // Send message to agent with streaming let kernel_handle: Arc = state.kernel.clone() as Arc; - match state - .kernel - .send_message_streaming(agent_id, &content, Some(kernel_handle)) - { + match state.kernel.send_message_streaming_in_session( + agent_id, + &content, + Some(kernel_handle), + requested_session_id, + ) { Ok((mut rx, handle)) => { // Forward stream events to WebSocket with debouncing let sender_stream = Arc::clone(sender); @@ -797,7 +805,10 @@ async fn handle_command( match state.kernel.set_agent_model(agent_id, args) { Ok(()) => { let msg = if let Some(entry) = state.kernel.registry.get(agent_id) { - format!("Model switched to: {} (provider: {})", entry.manifest.model.model, entry.manifest.model.provider) + format!( + "Model switched to: {} (provider: {})", + entry.manifest.model.model, entry.manifest.model.provider + ) } else { format!("Model switched to: {args}") }; @@ -1117,11 +1128,13 @@ fn classify_streaming_error(err: &openfang_kernel::error::KernelError) -> String if inner.contains("localhost:11434") || inner.contains("ollama") { "Model not found on Ollama. Run `ollama pull ` to download it, then try again. Use /model to see options.".to_string() } else { - "Model unavailable. Use /model to see options or check your provider configuration.".to_string() + "Model unavailable. Use /model to see options or check your provider configuration." + .to_string() } } llm_errors::LlmErrorCategory::Format => { - "LLM request failed. Check your API key and model configuration in Settings.".to_string() + "LLM request failed. Check your API key and model configuration in Settings." + .to_string() } _ => classified.sanitized_message, } diff --git a/crates/openfang-api/tests/api_integration_test.rs b/crates/openfang-api/tests/api_integration_test.rs index cef9cd90b..91d169453 100644 --- a/crates/openfang-api/tests/api_integration_test.rs +++ b/crates/openfang-api/tests/api_integration_test.rs @@ -115,6 +115,14 @@ async fn start_test_server_with_provider( "/api/workflows/{id}/run", axum::routing::post(routes::run_workflow), ) + .route( + "/api/workflows/{id}/rollout", + axum::routing::get(routes::get_workflow_rollout).put(routes::update_workflow_rollout), + ) + .route( + "/api/workflows/{id}/rollback", + axum::routing::post(routes::rollback_workflow_to_stable_path), + ) .route( "/api/workflows/{id}/runs", axum::routing::get(routes::list_workflow_runs), @@ -160,6 +168,30 @@ memory_read = ["*"] memory_write = ["self.*"] "#; +const WORKFLOW_ECHO_WAT: &str = r#" + (module + (memory (export "memory") 1) + (global $bump (mut i32) (i32.const 1024)) + + (func (export "alloc") (param $size i32) (result i32) + (local $ptr i32) + (local.set $ptr (global.get $bump)) + (global.set $bump (i32.add (global.get $bump) (local.get $size))) + (local.get $ptr) + ) + + (func (export "execute") (param $ptr i32) (param $len i32) (result i64) + (i64.or + (i64.shl + (i64.extend_i32_u (local.get $ptr)) + (i64.const 32) + ) + (i64.extend_i32_u (local.get $len)) + ) + ) + ) +"#; + /// Manifest that uses Groq for real LLM tests. const LLM_MANIFEST: &str = r#" name = "test-agent" @@ -365,6 +397,202 @@ async fn test_send_message_with_llm() { assert!(session["message_count"].as_u64().unwrap() > 0); } +#[tokio::test] +async fn test_workflow_shadow_run_compares_against_production_output() { + let server = start_test_server().await; + let client = reqwest::Client::new(); + + std::fs::write( + server._tmp.path().join("workflow-echo.wat"), + WORKFLOW_ECHO_WAT, + ) + .unwrap(); + + let wasm_manifest = r#" +name = "workflow-echo" +version = "0.1.0" +description = "Workflow echo wasm" +author = "test" +module = "wasm:workflow-echo.wat" + +[model] +provider = "ollama" +model = "test-model" +system_prompt = "Echo workflow agent." + +[capabilities] +memory_read = ["*"] +memory_write = ["self.*"] +"#; + + let resp = client + .post(format!("{}/api/agents", server.base_url)) + .json(&serde_json::json!({"manifest_toml": wasm_manifest})) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 201); + let body: serde_json::Value = resp.json().await.unwrap(); + let agent_name = body["name"].as_str().unwrap().to_string(); + + let resp = client + .post(format!("{}/api/workflows", server.base_url)) + .json(&serde_json::json!({ + "name": "shadow-workflow", + "description": "Compare workflow output against production", + "steps": [ + { + "name": "step1", + "agent_name": agent_name, + "prompt": "Echo: {{input}}", + "mode": "sequential", + "timeout_secs": 30 + } + ] + })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 201); + let workflow: serde_json::Value = resp.json().await.unwrap(); + let workflow_id = workflow["workflow_id"].as_str().unwrap(); + + let resp = client + .post(format!( + "{}/api/workflows/{}/run", + server.base_url, workflow_id + )) + .json(&serde_json::json!({ + "input": "hello shadow", + "shadow": { + "enabled": true, + "production_output": "legacy: hello shadow" + } + })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["status"], "shadow_completed"); + assert_eq!(body["output"], "legacy: hello shadow"); + assert!(body["shadow_output"] + .as_str() + .unwrap() + .contains("hello shadow")); + assert_eq!(body["shadow"]["matches"], false); + assert!(body["shadow"]["first_mismatch_index"].is_number()); + + let resp = client + .get(format!( + "{}/api/workflows/{}/runs", + server.base_url, workflow_id + )) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let runs: Vec = resp.json().await.unwrap(); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0]["shadow"]["matches"], false); + assert!(runs[0]["shadow"]["compared_at"].is_string()); +} + +#[tokio::test] +async fn test_workflow_rollout_controls_promote_and_rollback_with_checklist() { + let server = start_test_server().await; + let client = reqwest::Client::new(); + + let resp = client + .post(format!("{}/api/agents", server.base_url)) + .json(&serde_json::json!({"manifest_toml": TEST_MANIFEST})) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 201); + let body: serde_json::Value = resp.json().await.unwrap(); + let agent_name = body["name"].as_str().unwrap().to_string(); + + let resp = client + .post(format!("{}/api/workflows", server.base_url)) + .json(&serde_json::json!({ + "name": "rollback-workflow", + "description": "Rollback control integration test", + "steps": [ + { + "name": "step1", + "agent_name": agent_name, + "prompt": "Echo: {{input}}", + "mode": "sequential", + "timeout_secs": 30 + } + ] + })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 201); + let workflow: serde_json::Value = resp.json().await.unwrap(); + let workflow_id = workflow["workflow_id"].as_str().unwrap(); + + let resp = client + .get(format!( + "{}/api/workflows/{}/rollout", + server.base_url, workflow_id + )) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let rollout: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(rollout["primary_path"], "production"); + assert_eq!(rollout["stable_path"], "production"); + assert_eq!(rollout["shadow_enabled"], false); + assert!(rollout["rollback_checklist"].as_array().unwrap().len() >= 4); + + let resp = client + .put(format!( + "{}/api/workflows/{}/rollout", + server.base_url, workflow_id + )) + .json(&serde_json::json!({ + "primary_path": "openfang", + "stable_path": "production", + "shadow_enabled": true, + "rollback_window_secs": 300 + })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let rollout: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(rollout["primary_path"], "openfang"); + assert_eq!(rollout["stable_path"], "production"); + assert_eq!(rollout["shadow_enabled"], true); + assert_eq!(rollout["rollback_window_secs"], 300); + + let resp = client + .post(format!( + "{}/api/workflows/{}/rollback", + server.base_url, workflow_id + )) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let rollback: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(rollback["rollout"]["primary_path"], "production"); + assert_eq!(rollback["rollout"]["stable_path"], "production"); + assert_eq!(rollback["rollout"]["shadow_enabled"], false); + assert_eq!(rollback["rollback"]["from_path"], "openfang"); + assert_eq!(rollback["rollback"]["to_path"], "production"); + assert_eq!(rollback["rollback"]["shadow_enabled_before"], true); + assert_eq!(rollback["rollback"]["shadow_enabled_after"], false); + assert_eq!(rollback["rollback"]["within_window"], true); + assert!(rollback["rollback"]["duration_ms"].as_u64().unwrap() <= 300_000); + assert!(rollback["rollback"]["checklist"].as_array().unwrap().len() >= 4); +} + #[tokio::test] async fn test_workflow_crud() { let server = start_test_server().await; @@ -745,6 +973,14 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer { "/api/workflows/{id}/run", axum::routing::post(routes::run_workflow), ) + .route( + "/api/workflows/{id}/rollout", + axum::routing::get(routes::get_workflow_rollout).put(routes::update_workflow_rollout), + ) + .route( + "/api/workflows/{id}/rollback", + axum::routing::post(routes::rollback_workflow_to_stable_path), + ) .route( "/api/workflows/{id}/runs", axum::routing::get(routes::list_workflow_runs), diff --git a/crates/openfang-channels/src/bridge.rs b/crates/openfang-channels/src/bridge.rs index efb9ca525..a74d0df7c 100644 --- a/crates/openfang-channels/src/bridge.rs +++ b/crates/openfang-channels/src/bridge.rs @@ -408,7 +408,9 @@ async fn dispatch_message( } GroupPolicy::MentionOnly => { // Only allow messages where the bot was @mentioned or commands. - let was_mentioned = message.metadata.get("was_mentioned") + let was_mentioned = message + .metadata + .get("was_mentioned") .and_then(|v| v.as_bool()) .unwrap_or(false); let is_command = matches!(&message.content, ChannelContent::Command { .. }); diff --git a/crates/openfang-channels/src/discord.rs b/crates/openfang-channels/src/discord.rs index 993bb490b..5586249a0 100644 --- a/crates/openfang-channels/src/discord.rs +++ b/crates/openfang-channels/src/discord.rs @@ -314,9 +314,13 @@ impl ChannelAdapter for DiscordAdapter { } "MESSAGE_CREATE" | "MESSAGE_UPDATE" => { - if let Some(msg) = - parse_discord_message(d, &bot_user_id, &allowed_guilds, &allowed_users) - .await + if let Some(msg) = parse_discord_message( + d, + &bot_user_id, + &allowed_guilds, + &allowed_users, + ) + .await { debug!( "Discord {event_name} from {}: {:?}", @@ -512,8 +516,8 @@ async fn parse_discord_message( .map(|arr| arr.iter().any(|m| m["id"].as_str() == Some(bid.as_str()))) .unwrap_or(false); // Also check content for <@bot_id> or <@!bot_id> patterns - let mentioned_in_content = - content_text.contains(&format!("<@{bid}>")) || content_text.contains(&format!("<@!{bid}>")); + let mentioned_in_content = content_text.contains(&format!("<@{bid}>")) + || content_text.contains(&format!("<@!{bid}>")); mentioned_in_array || mentioned_in_content } else { false @@ -736,7 +740,8 @@ mod tests { }); // Not in allowed users - let msg = parse_discord_message(&d, &bot_id, &[], &["user111".into(), "user222".into()]).await; + let msg = + parse_discord_message(&d, &bot_id, &[], &["user111".into(), "user222".into()]).await; assert!(msg.is_none()); // In allowed users @@ -769,7 +774,10 @@ mod tests { let msg = parse_discord_message(&d, &bot_id, &[], &[]).await.unwrap(); assert!(msg.is_group); - assert_eq!(msg.metadata.get("was_mentioned").and_then(|v| v.as_bool()), Some(true)); + assert_eq!( + msg.metadata.get("was_mentioned").and_then(|v| v.as_bool()), + Some(true) + ); // Message without mention in group let d2 = serde_json::json!({ @@ -811,7 +819,12 @@ mod tests { #[test] fn test_discord_adapter_creation() { - let adapter = DiscordAdapter::new("test-token".to_string(), vec!["123".to_string(), "456".to_string()], vec![], 37376); + let adapter = DiscordAdapter::new( + "test-token".to_string(), + vec!["123".to_string(), "456".to_string()], + vec![], + 37376, + ); assert_eq!(adapter.name(), "discord"); assert_eq!(adapter.channel_type(), ChannelType::Discord); } diff --git a/crates/openfang-channels/src/email.rs b/crates/openfang-channels/src/email.rs index 7d7ae2d4d..f9ffe7072 100644 --- a/crates/openfang-channels/src/email.rs +++ b/crates/openfang-channels/src/email.rs @@ -124,8 +124,7 @@ impl EmailAdapter { async fn build_smtp_transport( &self, ) -> Result, Box> { - let creds = - Credentials::new(self.username.clone(), self.password.as_str().to_string()); + let creds = Credentials::new(self.username.clone(), self.password.as_str().to_string()); let transport = if self.smtp_port == 465 { // Implicit TLS (port 465) @@ -200,8 +199,8 @@ fn fetch_unseen_emails( .build() .map_err(|e| format!("TLS connector error: {e}"))?; - let client = imap::connect((host, port), host, &tls) - .map_err(|e| format!("IMAP connect failed: {e}"))?; + let client = + imap::connect((host, port), host, &tls).map_err(|e| format!("IMAP connect failed: {e}"))?; let mut session = client .login(username, password) @@ -362,8 +361,7 @@ impl ChannelAdapter for EmailAdapter { } // Extract target agent from subject brackets (stored in metadata for router) - let _target_agent = - EmailAdapter::extract_agent_from_subject(&subject); + let _target_agent = EmailAdapter::extract_agent_from_subject(&subject); let clean_subject = EmailAdapter::strip_agent_tag(&subject); // Build the message body: prepend subject context diff --git a/crates/openfang-channels/src/telegram.rs b/crates/openfang-channels/src/telegram.rs index 3b07b08f0..24424f930 100644 --- a/crates/openfang-channels/src/telegram.rs +++ b/crates/openfang-channels/src/telegram.rs @@ -536,7 +536,17 @@ pub fn calculate_backoff(current: Duration) -> Duration { /// Everything else (e.g. ``, ``) gets escaped to `<...>`. fn sanitize_telegram_html(text: &str) -> String { const ALLOWED: &[&str] = &[ - "b", "i", "u", "s", "em", "strong", "a", "code", "pre", "blockquote", "tg-spoiler", + "b", + "i", + "u", + "s", + "em", + "strong", + "a", + "code", + "pre", + "blockquote", + "tg-spoiler", "tg-emoji", ]; diff --git a/crates/openfang-channels/src/whatsapp.rs b/crates/openfang-channels/src/whatsapp.rs index 82ad5840d..9f45fcd46 100644 --- a/crates/openfang-channels/src/whatsapp.rs +++ b/crates/openfang-channels/src/whatsapp.rs @@ -222,11 +222,9 @@ impl ChannelAdapter for WhatsAppAdapter { if let Some(ref gw) = self.gateway_url { let text = match &content { ChannelContent::Text(t) => t.clone(), - ChannelContent::Image { caption, .. } => { - caption - .clone() - .unwrap_or_else(|| "(Image — not supported in Web mode)".to_string()) - } + ChannelContent::Image { caption, .. } => caption + .clone() + .unwrap_or_else(|| "(Image — not supported in Web mode)".to_string()), ChannelContent::File { filename, .. } => { format!("(File: {filename} — not supported in Web mode)") } diff --git a/crates/openfang-cli/src/bundled_agents.rs b/crates/openfang-cli/src/bundled_agents.rs index d1e036c9b..0194859b1 100644 --- a/crates/openfang-cli/src/bundled_agents.rs +++ b/crates/openfang-cli/src/bundled_agents.rs @@ -7,34 +7,112 @@ /// Returns all bundled agent templates as `(name, toml_content)` pairs. pub fn bundled_agents() -> Vec<(&'static str, &'static str)> { vec![ - ("analyst", include_str!("../../../agents/analyst/agent.toml")), - ("architect", include_str!("../../../agents/architect/agent.toml")), - ("assistant", include_str!("../../../agents/assistant/agent.toml")), + ( + "analyst", + include_str!("../../../agents/analyst/agent.toml"), + ), + ( + "architect", + include_str!("../../../agents/architect/agent.toml"), + ), + ( + "assistant", + include_str!("../../../agents/assistant/agent.toml"), + ), ("coder", include_str!("../../../agents/coder/agent.toml")), - ("code-reviewer", include_str!("../../../agents/code-reviewer/agent.toml")), - ("customer-support", include_str!("../../../agents/customer-support/agent.toml")), - ("data-scientist", include_str!("../../../agents/data-scientist/agent.toml")), - ("debugger", include_str!("../../../agents/debugger/agent.toml")), - ("devops-lead", include_str!("../../../agents/devops-lead/agent.toml")), - ("doc-writer", include_str!("../../../agents/doc-writer/agent.toml")), - ("email-assistant", include_str!("../../../agents/email-assistant/agent.toml")), - ("health-tracker", include_str!("../../../agents/health-tracker/agent.toml")), - ("hello-world", include_str!("../../../agents/hello-world/agent.toml")), - ("home-automation", include_str!("../../../agents/home-automation/agent.toml")), - ("legal-assistant", include_str!("../../../agents/legal-assistant/agent.toml")), - ("meeting-assistant", include_str!("../../../agents/meeting-assistant/agent.toml")), + ( + "code-reviewer", + include_str!("../../../agents/code-reviewer/agent.toml"), + ), + ( + "customer-support", + include_str!("../../../agents/customer-support/agent.toml"), + ), + ( + "data-scientist", + include_str!("../../../agents/data-scientist/agent.toml"), + ), + ( + "debugger", + include_str!("../../../agents/debugger/agent.toml"), + ), + ( + "devops-lead", + include_str!("../../../agents/devops-lead/agent.toml"), + ), + ( + "doc-writer", + include_str!("../../../agents/doc-writer/agent.toml"), + ), + ( + "email-assistant", + include_str!("../../../agents/email-assistant/agent.toml"), + ), + ( + "health-tracker", + include_str!("../../../agents/health-tracker/agent.toml"), + ), + ( + "hello-world", + include_str!("../../../agents/hello-world/agent.toml"), + ), + ( + "home-automation", + include_str!("../../../agents/home-automation/agent.toml"), + ), + ( + "legal-assistant", + include_str!("../../../agents/legal-assistant/agent.toml"), + ), + ( + "meeting-assistant", + include_str!("../../../agents/meeting-assistant/agent.toml"), + ), ("ops", include_str!("../../../agents/ops/agent.toml")), - ("orchestrator", include_str!("../../../agents/orchestrator/agent.toml")), - ("personal-finance", include_str!("../../../agents/personal-finance/agent.toml")), - ("planner", include_str!("../../../agents/planner/agent.toml")), - ("recruiter", include_str!("../../../agents/recruiter/agent.toml")), - ("researcher", include_str!("../../../agents/researcher/agent.toml")), - ("sales-assistant", include_str!("../../../agents/sales-assistant/agent.toml")), - ("security-auditor", include_str!("../../../agents/security-auditor/agent.toml")), - ("social-media", include_str!("../../../agents/social-media/agent.toml")), - ("test-engineer", include_str!("../../../agents/test-engineer/agent.toml")), - ("translator", include_str!("../../../agents/translator/agent.toml")), - ("travel-planner", include_str!("../../../agents/travel-planner/agent.toml")), + ( + "orchestrator", + include_str!("../../../agents/orchestrator/agent.toml"), + ), + ( + "personal-finance", + include_str!("../../../agents/personal-finance/agent.toml"), + ), + ( + "planner", + include_str!("../../../agents/planner/agent.toml"), + ), + ( + "recruiter", + include_str!("../../../agents/recruiter/agent.toml"), + ), + ( + "researcher", + include_str!("../../../agents/researcher/agent.toml"), + ), + ( + "sales-assistant", + include_str!("../../../agents/sales-assistant/agent.toml"), + ), + ( + "security-auditor", + include_str!("../../../agents/security-auditor/agent.toml"), + ), + ( + "social-media", + include_str!("../../../agents/social-media/agent.toml"), + ), + ( + "test-engineer", + include_str!("../../../agents/test-engineer/agent.toml"), + ), + ( + "translator", + include_str!("../../../agents/translator/agent.toml"), + ), + ( + "travel-planner", + include_str!("../../../agents/travel-planner/agent.toml"), + ), ("tutor", include_str!("../../../agents/tutor/agent.toml")), ("writer", include_str!("../../../agents/writer/agent.toml")), ] diff --git a/crates/openfang-cli/src/main.rs b/crates/openfang-cli/src/main.rs index afaf60303..be5fe1f91 100644 --- a/crates/openfang-cli/src/main.rs +++ b/crates/openfang-cli/src/main.rs @@ -1021,7 +1021,10 @@ fn main() { SystemCommands::Version { json } => cmd_system_version(json), }, Some(Commands::Reset { confirm }) => cmd_reset(confirm), - Some(Commands::Uninstall { confirm, keep_config }) => cmd_uninstall(confirm, keep_config), + Some(Commands::Uninstall { + confirm, + keep_config, + }) => cmd_uninstall(confirm, keep_config), } } @@ -2154,7 +2157,9 @@ decay_rate = 0.05 if !json { ui::check_ok(&format!("Port {api_listen} is available")); } - checks.push(serde_json::json!({"check": "port", "status": "ok", "address": api_listen})); + checks.push( + serde_json::json!({"check": "port", "status": "ok", "address": api_listen}), + ); } Err(_) => { if !json { @@ -3929,7 +3934,10 @@ fn cmd_hand_install(path: &str) { body["name"].as_str().unwrap_or("?"), body["id"].as_str().unwrap_or("?"), ); - println!("Use `openfang hand activate {}` to start it.", body["id"].as_str().unwrap_or("?")); + println!( + "Use `openfang hand activate {}` to start it.", + body["id"].as_str().unwrap_or("?") + ); } fn cmd_hand_list() { @@ -3954,10 +3962,7 @@ fn cmd_hand_list() { println!("No hands available."); return; } - println!( - "{:<14} {:<20} {:<10} DESCRIPTION", - "ID", "NAME", "CATEGORY" - ); + println!("{:<14} {:<20} {:<10} DESCRIPTION", "ID", "NAME", "CATEGORY"); println!("{}", "-".repeat(72)); for h in arr { println!( @@ -3965,7 +3970,12 @@ fn cmd_hand_list() { h["id"].as_str().unwrap_or("?"), h["name"].as_str().unwrap_or("?"), h["category"].as_str().unwrap_or("?"), - h["description"].as_str().unwrap_or("").chars().take(40).collect::(), + h["description"] + .as_str() + .unwrap_or("") + .chars() + .take(40) + .collect::(), ); } println!("\nUse `openfang hand activate ` to activate a hand."); @@ -3987,10 +3997,7 @@ fn cmd_hand_active() { println!("No active hands."); return; } - println!( - "{:<38} {:<14} {:<10} AGENT", - "INSTANCE", "HAND", "STATUS" - ); + println!("{:<38} {:<14} {:<10} AGENT", "INSTANCE", "HAND", "STATUS"); println!("{}", "-".repeat(72)); for i in &arr { println!( @@ -4078,10 +4085,7 @@ fn cmd_hand_info(id: &str) { let client = daemon_client(); let body = daemon_json(client.get(format!("{base}/api/hands/{id}")).send()); if body.get("error").is_some() { - eprintln!( - "Hand not found: {}", - body["error"].as_str().unwrap_or(id) - ); + eprintln!("Hand not found: {}", body["error"].as_str().unwrap_or(id)); std::process::exit(1); } println!( @@ -5400,7 +5404,15 @@ fn cmd_cron_create(agent: &str, spec: &str, prompt: &str, explicit_name: Option< .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_') .take(64) .collect(); - format!("{}-{}", agent, if short_prompt.is_empty() { "job" } else { &short_prompt }) + format!( + "{}-{}", + agent, + if short_prompt.is_empty() { + "job" + } else { + &short_prompt + } + ) }; let body = daemon_json( @@ -6154,10 +6166,7 @@ fn cmd_uninstall(confirm: bool, keep_config: bool) { } else { match std::fs::remove_dir_all(&openfang_dir) { Ok(()) => ui::success(&format!("Removed {}", openfang_dir.display())), - Err(e) => ui::error(&format!( - "Failed to remove {}: {e}", - openfang_dir.display() - )), + Err(e) => ui::error(&format!("Failed to remove {}: {e}", openfang_dir.display())), } } } @@ -6166,10 +6175,7 @@ fn cmd_uninstall(confirm: bool, keep_config: bool) { if cargo_bin.exists() && exe_path.as_ref().is_none_or(|e| *e != cargo_bin) { match std::fs::remove_file(&cargo_bin) { Ok(()) => ui::success(&format!("Removed {}", cargo_bin.display())), - Err(e) => ui::error(&format!( - "Failed to remove {}: {e}", - cargo_bin.display() - )), + Err(e) => ui::error(&format!("Failed to remove {}: {e}", cargo_bin.display())), } } @@ -6409,7 +6415,10 @@ fn remove_self_binary(exe_path: &std::path::Path) { .creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS) .spawn(); - ui::success(&format!("Removed {} (deferred cleanup)", exe_path.display())); + ui::success(&format!( + "Removed {} (deferred cleanup)", + exe_path.display() + )); } } diff --git a/crates/openfang-cli/src/tui/chat_runner.rs b/crates/openfang-cli/src/tui/chat_runner.rs index c2ca8fede..9ad032f11 100644 --- a/crates/openfang-cli/src/tui/chat_runner.rs +++ b/crates/openfang-cli/src/tui/chat_runner.rs @@ -394,10 +394,7 @@ impl StandaloneChat { .as_str() .unwrap_or("") .to_string(), - provider: m["provider"] - .as_str() - .unwrap_or("") - .to_string(), + provider: m["provider"].as_str().unwrap_or("").to_string(), tier: m["tier"].as_str().unwrap_or("Balanced").to_string(), }) .collect() @@ -459,16 +456,13 @@ impl StandaloneChat { .send() { if let Ok(body) = resp.json::() { - let provider = - body["model_provider"].as_str().unwrap_or("?"); + let provider = body["model_provider"].as_str().unwrap_or("?"); let model = body["model_name"].as_str().unwrap_or("?"); self.chat.model_label = format!("{provider}/{model}"); } } - self.chat.push_message( - Role::System, - format!("Switched to {model_id}"), - ); + self.chat + .push_message(Role::System, format!("Switched to {model_id}")); } _ => { self.chat.push_message( @@ -506,16 +500,12 @@ impl StandaloneChat { .unwrap_or_else(|| "?".to_string()) }); self.chat.model_label = format!("{prov_label}/{model_id}"); - self.chat.push_message( - Role::System, - format!("Switched to {model_id}"), - ); + self.chat + .push_message(Role::System, format!("Switched to {model_id}")); } Err(e) => { - self.chat.push_message( - Role::System, - format!("Switch failed: {e}"), - ); + self.chat + .push_message(Role::System, format!("Switch failed: {e}")); } } } diff --git a/crates/openfang-cli/src/tui/mod.rs b/crates/openfang-cli/src/tui/mod.rs index 907c0aed6..dce704345 100644 --- a/crates/openfang-cli/src/tui/mod.rs +++ b/crates/openfang-cli/src/tui/mod.rs @@ -516,8 +516,7 @@ impl App { } AppEvent::CommsEventsLoaded(events) => { self.comms.events = events; - if !self.comms.events.is_empty() - && self.comms.event_list_state.selected().is_none() + if !self.comms.events.is_empty() && self.comms.event_list_state.selected().is_none() { self.comms.event_list_state.select(Some(0)); } @@ -1869,14 +1868,8 @@ impl App { .as_str() .unwrap_or("") .to_string(), - provider: m["provider"] - .as_str() - .unwrap_or("") - .to_string(), - tier: m["tier"] - .as_str() - .unwrap_or("Balanced") - .to_string(), + provider: m["provider"].as_str().unwrap_or("").to_string(), + tier: m["tier"].as_str().unwrap_or("Balanced").to_string(), }) .collect() }) @@ -1935,8 +1928,7 @@ impl App { .send() { if let Ok(body) = resp.json::() { - let provider = - body["model_provider"].as_str().unwrap_or("?"); + let provider = body["model_provider"].as_str().unwrap_or("?"); let model = body["model_name"].as_str().unwrap_or("?"); self.chat.model_label = format!("{provider}/{model}"); } @@ -1988,10 +1980,8 @@ impl App { ); } Err(e) => { - self.chat.push_message( - chat::Role::System, - format!("Switch failed: {e}"), - ); + self.chat + .push_message(chat::Role::System, format!("Switch failed: {e}")); } } } diff --git a/crates/openfang-cli/src/tui/screens/agents.rs b/crates/openfang-cli/src/tui/screens/agents.rs index 26ecb3469..9121dc938 100644 --- a/crates/openfang-cli/src/tui/screens/agents.rs +++ b/crates/openfang-cli/src/tui/screens/agents.rs @@ -1524,6 +1524,9 @@ fn truncate(s: &str, max: usize) -> String { if s.len() <= max { s.to_string() } else { - format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1))) + format!( + "{}\u{2026}", + openfang_types::truncate_str(s, max.saturating_sub(1)) + ) } } diff --git a/crates/openfang-cli/src/tui/screens/audit.rs b/crates/openfang-cli/src/tui/screens/audit.rs index 2fd3d2fe5..f7aa45379 100644 --- a/crates/openfang-cli/src/tui/screens/audit.rs +++ b/crates/openfang-cli/src/tui/screens/audit.rs @@ -341,6 +341,9 @@ fn truncate(s: &str, max: usize) -> String { if s.len() <= max { s.to_string() } else { - format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1))) + format!( + "{}\u{2026}", + openfang_types::truncate_str(s, max.saturating_sub(1)) + ) } } diff --git a/crates/openfang-cli/src/tui/screens/chat.rs b/crates/openfang-cli/src/tui/screens/chat.rs index 96fe994e1..681d8b385 100644 --- a/crates/openfang-cli/src/tui/screens/chat.rs +++ b/crates/openfang-cli/src/tui/screens/chat.rs @@ -483,8 +483,7 @@ fn draw_model_picker(f: &mut Frame, area: Rect, state: &ChatState) { return; // Too small to show picker } let popup_w = area.width.clamp(30, 54); - let popup_h = (filtered.len() as u16 + 4) - .clamp(5, area.height.saturating_sub(2)); + let popup_h = (filtered.len() as u16 + 4).clamp(5, area.height.saturating_sub(2)); let x = area.x + (area.width.saturating_sub(popup_w)) / 2; let y = area.y + (area.height.saturating_sub(popup_h)) / 2; let popup_area = Rect::new(x, y, popup_w, popup_h); @@ -548,7 +547,12 @@ fn draw_model_picker(f: &mut Frame, area: Rect, state: &ChatState) { let mut lines: Vec = Vec::new(); let max_name = (chunks[1].width as usize).saturating_sub(14); - for (i, entry) in filtered.iter().enumerate().skip(scroll_start).take(visible_h) { + for (i, entry) in filtered + .iter() + .enumerate() + .skip(scroll_start) + .take(visible_h) + { let selected = i == state.model_picker_idx; let indicator = if selected { "\u{25b6} " } else { " " }; @@ -882,6 +886,9 @@ fn truncate_line(s: &str, max_len: usize) -> String { if s.len() <= max_len { s.to_string() } else { - format!("{}\u{2026}", openfang_types::truncate_str(s, max_len.saturating_sub(1))) + format!( + "{}\u{2026}", + openfang_types::truncate_str(s, max_len.saturating_sub(1)) + ) } } diff --git a/crates/openfang-cli/src/tui/screens/comms.rs b/crates/openfang-cli/src/tui/screens/comms.rs index 40ffc8e5d..b0a5b84b2 100644 --- a/crates/openfang-cli/src/tui/screens/comms.rs +++ b/crates/openfang-cli/src/tui/screens/comms.rs @@ -158,11 +158,7 @@ impl CommsState { KeyCode::Up | KeyCode::Char('k') => { if self.focus == CommsFocus::EventList && !self.events.is_empty() { let i = self.event_list_state.selected().unwrap_or(0); - let next = if i == 0 { - self.events.len() - 1 - } else { - i - 1 - }; + let next = if i == 0 { self.events.len() - 1 } else { i - 1 }; self.event_list_state.select(Some(next)); } } @@ -339,12 +335,12 @@ pub fn draw(f: &mut Frame, area: Rect, state: &mut CommsState) { f.render_widget(block, area); let chunks = Layout::vertical([ - Constraint::Length(2), // header - Constraint::Length(1), // separator + Constraint::Length(2), // header + Constraint::Length(1), // separator Constraint::Percentage(35), // topology - Constraint::Length(1), // separator - Constraint::Min(4), // event list - Constraint::Length(1), // hints + Constraint::Length(1), // separator + Constraint::Min(4), // event list + Constraint::Length(1), // hints ]) .split(inner); @@ -441,10 +437,7 @@ fn draw_topology(f: &mut Frame, area: Rect, state: &CommsState) { if state.nodes.is_empty() { f.render_widget( - Paragraph::new(Span::styled( - " No agents running.", - theme::dim_style(), - )), + Paragraph::new(Span::styled(" No agents running.", theme::dim_style())), area, ); return; @@ -491,7 +484,10 @@ fn draw_topology(f: &mut Frame, area: Rect, state: &CommsState) { Span::styled(" ", Style::default()), Span::styled(branch, theme::dim_style()), Span::styled(format!("[{}]", child.state), state_color(&child.state)), - Span::styled(format!(" {} ", child.name), Style::default().fg(theme::TEXT)), + Span::styled( + format!(" {} ", child.name), + Style::default().fg(theme::TEXT), + ), Span::styled(format!("({})", child.model), theme::dim_style()), ])); } @@ -679,7 +675,10 @@ fn draw_task_modal(f: &mut Frame, area: Rect, state: &CommsState) { rows[3], ); f.render_widget( - Paragraph::new(Span::styled("Assign to (agent ID, optional):", field_style(2))), + Paragraph::new(Span::styled( + "Assign to (agent ID, optional):", + field_style(2), + )), rows[4], ); f.render_widget( diff --git a/crates/openfang-cli/src/tui/screens/dashboard.rs b/crates/openfang-cli/src/tui/screens/dashboard.rs index 4a6bac7b5..a6b8b25e8 100644 --- a/crates/openfang-cli/src/tui/screens/dashboard.rs +++ b/crates/openfang-cli/src/tui/screens/dashboard.rs @@ -273,6 +273,9 @@ fn truncate(s: &str, max: usize) -> String { if s.len() <= max { s.to_string() } else { - format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1))) + format!( + "{}\u{2026}", + openfang_types::truncate_str(s, max.saturating_sub(1)) + ) } } diff --git a/crates/openfang-cli/src/tui/screens/init_wizard.rs b/crates/openfang-cli/src/tui/screens/init_wizard.rs index 89e774f6c..048cc87c4 100644 --- a/crates/openfang-cli/src/tui/screens/init_wizard.rs +++ b/crates/openfang-cli/src/tui/screens/init_wizard.rs @@ -828,7 +828,9 @@ fn handle_migration_key( let target_dir = if let Ok(h) = std::env::var("OPENFANG_HOME") { PathBuf::from(h) } else { - dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(".openfang") + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".openfang") }; let tx = migrate_tx.clone(); std::thread::spawn(move || { diff --git a/crates/openfang-cli/src/tui/screens/logs.rs b/crates/openfang-cli/src/tui/screens/logs.rs index d69077e6d..40b6656a2 100644 --- a/crates/openfang-cli/src/tui/screens/logs.rs +++ b/crates/openfang-cli/src/tui/screens/logs.rs @@ -405,6 +405,9 @@ fn truncate(s: &str, max: usize) -> String { if s.len() <= max { s.to_string() } else { - format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1))) + format!( + "{}\u{2026}", + openfang_types::truncate_str(s, max.saturating_sub(1)) + ) } } diff --git a/crates/openfang-cli/src/tui/screens/memory.rs b/crates/openfang-cli/src/tui/screens/memory.rs index e8bea52dd..259a2a2ec 100644 --- a/crates/openfang-cli/src/tui/screens/memory.rs +++ b/crates/openfang-cli/src/tui/screens/memory.rs @@ -549,6 +549,9 @@ fn truncate(s: &str, max: usize) -> String { if s.len() <= max { s.to_string() } else { - format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1))) + format!( + "{}\u{2026}", + openfang_types::truncate_str(s, max.saturating_sub(1)) + ) } } diff --git a/crates/openfang-cli/src/tui/screens/mod.rs b/crates/openfang-cli/src/tui/screens/mod.rs index 9b395dcee..25897100f 100644 --- a/crates/openfang-cli/src/tui/screens/mod.rs +++ b/crates/openfang-cli/src/tui/screens/mod.rs @@ -1,8 +1,8 @@ pub mod agents; pub mod audit; pub mod channels; -pub mod comms; pub mod chat; +pub mod comms; pub mod dashboard; pub mod extensions; pub mod hands; diff --git a/crates/openfang-cli/src/tui/screens/peers.rs b/crates/openfang-cli/src/tui/screens/peers.rs index d9b3a95ce..a7039687f 100644 --- a/crates/openfang-cli/src/tui/screens/peers.rs +++ b/crates/openfang-cli/src/tui/screens/peers.rs @@ -208,6 +208,9 @@ fn truncate(s: &str, max: usize) -> String { if s.len() <= max { s.to_string() } else { - format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1))) + format!( + "{}\u{2026}", + openfang_types::truncate_str(s, max.saturating_sub(1)) + ) } } diff --git a/crates/openfang-cli/src/tui/screens/sessions.rs b/crates/openfang-cli/src/tui/screens/sessions.rs index 1c47893a7..b9eae648f 100644 --- a/crates/openfang-cli/src/tui/screens/sessions.rs +++ b/crates/openfang-cli/src/tui/screens/sessions.rs @@ -308,6 +308,9 @@ fn truncate(s: &str, max: usize) -> String { if s.len() <= max { s.to_string() } else { - format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1))) + format!( + "{}\u{2026}", + openfang_types::truncate_str(s, max.saturating_sub(1)) + ) } } diff --git a/crates/openfang-cli/src/tui/screens/settings.rs b/crates/openfang-cli/src/tui/screens/settings.rs index 978b6ad8b..b97e34605 100644 --- a/crates/openfang-cli/src/tui/screens/settings.rs +++ b/crates/openfang-cli/src/tui/screens/settings.rs @@ -604,7 +604,10 @@ fn truncate(s: &str, max: usize) -> String { if s.len() <= max { s.to_string() } else { - format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1))) + format!( + "{}\u{2026}", + openfang_types::truncate_str(s, max.saturating_sub(1)) + ) } } diff --git a/crates/openfang-cli/src/tui/screens/skills.rs b/crates/openfang-cli/src/tui/screens/skills.rs index 662d51043..5494ebaf6 100644 --- a/crates/openfang-cli/src/tui/screens/skills.rs +++ b/crates/openfang-cli/src/tui/screens/skills.rs @@ -612,7 +612,10 @@ fn truncate(s: &str, max: usize) -> String { if s.len() <= max { s.to_string() } else { - format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1))) + format!( + "{}\u{2026}", + openfang_types::truncate_str(s, max.saturating_sub(1)) + ) } } diff --git a/crates/openfang-cli/src/tui/screens/templates.rs b/crates/openfang-cli/src/tui/screens/templates.rs index b8a3a87c1..eae8faa72 100644 --- a/crates/openfang-cli/src/tui/screens/templates.rs +++ b/crates/openfang-cli/src/tui/screens/templates.rs @@ -399,6 +399,9 @@ fn truncate(s: &str, max: usize) -> String { if s.len() <= max { s.to_string() } else { - format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1))) + format!( + "{}\u{2026}", + openfang_types::truncate_str(s, max.saturating_sub(1)) + ) } } diff --git a/crates/openfang-cli/src/tui/screens/triggers.rs b/crates/openfang-cli/src/tui/screens/triggers.rs index 979f958c6..d55ddb6d5 100644 --- a/crates/openfang-cli/src/tui/screens/triggers.rs +++ b/crates/openfang-cli/src/tui/screens/triggers.rs @@ -549,6 +549,9 @@ fn truncate(s: &str, max: usize) -> String { if s.len() <= max { s.to_string() } else { - format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1))) + format!( + "{}\u{2026}", + openfang_types::truncate_str(s, max.saturating_sub(1)) + ) } } diff --git a/crates/openfang-cli/src/tui/screens/usage.rs b/crates/openfang-cli/src/tui/screens/usage.rs index 9e2fb1e53..e0f829cf2 100644 --- a/crates/openfang-cli/src/tui/screens/usage.rs +++ b/crates/openfang-cli/src/tui/screens/usage.rs @@ -439,6 +439,9 @@ fn truncate(s: &str, max: usize) -> String { if s.len() <= max { s.to_string() } else { - format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1))) + format!( + "{}\u{2026}", + openfang_types::truncate_str(s, max.saturating_sub(1)) + ) } } diff --git a/crates/openfang-cli/src/tui/screens/workflows.rs b/crates/openfang-cli/src/tui/screens/workflows.rs index 50abde224..21514923a 100644 --- a/crates/openfang-cli/src/tui/screens/workflows.rs +++ b/crates/openfang-cli/src/tui/screens/workflows.rs @@ -697,6 +697,9 @@ fn truncate(s: &str, max: usize) -> String { if s.len() <= max { s.to_string() } else { - format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1))) + format!( + "{}\u{2026}", + openfang_types::truncate_str(s, max.saturating_sub(1)) + ) } } diff --git a/crates/openfang-extensions/src/oauth.rs b/crates/openfang-extensions/src/oauth.rs index 811484dfe..beabe01b9 100644 --- a/crates/openfang-extensions/src/oauth.rs +++ b/crates/openfang-extensions/src/oauth.rs @@ -27,9 +27,7 @@ pub fn default_client_ids() -> HashMap<&'static str, &'static str> { } /// Resolve OAuth client IDs with config overrides applied on top of defaults. -pub fn resolve_client_ids( - config: &openfang_types::config::OAuthConfig, -) -> HashMap { +pub fn resolve_client_ids(config: &openfang_types::config::OAuthConfig) -> HashMap { let defaults = default_client_ids(); let mut resolved: HashMap = defaults .into_iter() diff --git a/crates/openfang-hands/src/registry.rs b/crates/openfang-hands/src/registry.rs index 22ad9452a..b58221eb0 100644 --- a/crates/openfang-hands/src/registry.rs +++ b/crates/openfang-hands/src/registry.rs @@ -117,7 +117,8 @@ impl HandRegistry { /// List all known hand definitions. pub fn list_definitions(&self) -> Vec { - let mut defs: Vec = self.definitions.iter().map(|r| r.value().clone()).collect(); + let mut defs: Vec = + self.definitions.iter().map(|r| r.value().clone()).collect(); defs.sort_by(|a, b| a.name.cmp(&b.name)); defs } diff --git a/crates/openfang-kernel/src/config_reload.rs b/crates/openfang-kernel/src/config_reload.rs index b194db454..df9d5b7a7 100644 --- a/crates/openfang-kernel/src/config_reload.rs +++ b/crates/openfang-kernel/src/config_reload.rs @@ -411,7 +411,10 @@ mod tests { let mut b = default_cfg(); b.default_model.model = "gpt-4".to_string(); let plan = build_reload_plan(&a, &b); - assert!(!plan.restart_required, "default_model should be hot-reloadable"); + assert!( + !plan.restart_required, + "default_model should be hot-reloadable" + ); assert!(plan.hot_actions.contains(&HotAction::UpdateDefaultModel)); } diff --git a/crates/openfang-kernel/src/cron.rs b/crates/openfang-kernel/src/cron.rs index d304190a8..e20494a94 100644 --- a/crates/openfang-kernel/src/cron.rs +++ b/crates/openfang-kernel/src/cron.rs @@ -287,8 +287,7 @@ impl CronScheduler { ); meta.job.enabled = false; } else { - meta.job.next_run = - Some(compute_next_run_after(&meta.job.schedule, Utc::now())); + meta.job.next_run = Some(compute_next_run_after(&meta.job.schedule, Utc::now())); } } } diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index 59ee1c19d..42bad1d21 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -11,7 +11,10 @@ use crate::registry::AgentRegistry; use crate::scheduler::AgentScheduler; use crate::supervisor::Supervisor; use crate::triggers::{TriggerEngine, TriggerId, TriggerPattern}; -use crate::workflow::{StepAgent, Workflow, WorkflowEngine, WorkflowId, WorkflowRunId}; +use crate::workflow::{ + StepAgent, Workflow, WorkflowEngine, WorkflowId, WorkflowRouteRequest, WorkflowRunId, + WorkflowShadowComparison, +}; use openfang_memory::MemorySubstrate; use openfang_runtime::agent_loop::{ @@ -148,9 +151,11 @@ pub struct OpenFangKernel { /// WhatsApp Web gateway child process PID (for shutdown cleanup). pub whatsapp_gateway_pid: Arc>>, /// Channel adapters registered at bridge startup (for proactive `channel_send` tool). - pub channel_adapters: dashmap::DashMap>, + pub channel_adapters: + dashmap::DashMap>, /// Hot-reloadable default model override (set via config hot-reload, read at agent spawn). - pub default_model_override: std::sync::RwLock>, + pub default_model_override: + std::sync::RwLock>, /// Weak self-reference for trigger dispatch (set after Arc wrapping). self_handle: OnceLock>, } @@ -474,6 +479,30 @@ fn read_identity_file(workspace: &Path, filename: &str) -> Option { } } +fn read_identity_file_from_workspaces( + workspace: Option<&Path>, + fallback_workspace: Option<&Path>, + filename: &str, +) -> Option { + workspace + .and_then(|w| read_identity_file(w, filename)) + .or_else(|| fallback_workspace.and_then(|w| read_identity_file(w, filename))) +} + +fn session_workspace_root(base_workspace: &Path, session_id: SessionId) -> PathBuf { + let already_scoped = base_workspace + .parent() + .and_then(|parent| parent.file_name()) + .and_then(|name| name.to_str()) + == Some(".session-workspaces"); + if already_scoped { + return base_workspace.to_path_buf(); + } + base_workspace + .join(".session-workspaces") + .join(session_id.to_string()) +} + /// Get the system hostname as a String. fn gethostname() -> Option { #[cfg(unix)] @@ -550,11 +579,12 @@ impl OpenFangKernel { let driver_config = DriverConfig { provider: config.default_model.provider.clone(), api_key: std::env::var(&config.default_model.api_key_env).ok(), - base_url: config - .default_model - .base_url - .clone() - .or_else(|| config.provider_urls.get(&config.default_model.provider).cloned()), + base_url: config.default_model.base_url.clone().or_else(|| { + config + .provider_urls + .get(&config.default_model.provider) + .cloned() + }), }; // Primary driver failure is non-fatal: the dashboard should remain accessible // even if the LLM provider is misconfigured. Users can fix config via dashboard. @@ -954,11 +984,16 @@ impl OpenFangKernel { Ok(disk_manifest) => { // Compare key fields to detect changes let changed = disk_manifest.name != entry.manifest.name - || disk_manifest.description != entry.manifest.description - || disk_manifest.model.system_prompt != entry.manifest.model.system_prompt - || disk_manifest.model.provider != entry.manifest.model.provider - || disk_manifest.model.model != entry.manifest.model.model - || disk_manifest.capabilities.tools != entry.manifest.capabilities.tools; + || disk_manifest.description + != entry.manifest.description + || disk_manifest.model.system_prompt + != entry.manifest.model.system_prompt + || disk_manifest.model.provider + != entry.manifest.model.provider + || disk_manifest.model.model + != entry.manifest.model.model + || disk_manifest.capabilities.tools + != entry.manifest.capabilities.tools; if changed { info!( agent = %name, @@ -1031,11 +1066,20 @@ impl OpenFangKernel { if !dm.model.is_empty() { restored_entry.manifest.model.model = dm.model.clone(); } - if !dm.api_key_env.is_empty() && restored_entry.manifest.model.api_key_env.is_none() { - restored_entry.manifest.model.api_key_env = Some(dm.api_key_env.clone()); + if !dm.api_key_env.is_empty() + && restored_entry.manifest.model.api_key_env.is_none() + { + restored_entry.manifest.model.api_key_env = + Some(dm.api_key_env.clone()); } - if dm.base_url.is_some() && restored_entry.manifest.model.base_url.is_none() { - restored_entry.manifest.model.base_url.clone_from(&dm.base_url); + if dm.base_url.is_some() + && restored_entry.manifest.model.base_url.is_none() + { + restored_entry + .manifest + .model + .base_url + .clone_from(&dm.base_url); } } } @@ -1113,15 +1157,16 @@ impl OpenFangKernel { parent: Option, ) -> KernelResult { let agent_id = AgentId::new(); - let session_id = SessionId::new(); let name = manifest.name.clone(); info!(agent = %name, id = %agent_id, parent = ?parent, "Spawning agent"); // Create session - self.memory + let session_id = self + .memory .create_session(agent_id) - .map_err(KernelError::OpenFang)?; + .map_err(KernelError::OpenFang)? + .id; // Inherit kernel exec_policy as fallback if agent manifest doesn't have one let mut manifest = manifest; @@ -1174,9 +1219,10 @@ impl OpenFangKernel { apply_budget_defaults(&self.config.budget, &mut manifest.resources); // Create workspace directory for the agent (name-based, so SOUL.md survives recreation) - let workspace_dir = manifest.workspace.clone().unwrap_or_else(|| { - self.config.effective_workspaces_dir().join(&name) - }); + let workspace_dir = manifest + .workspace + .clone() + .unwrap_or_else(|| self.config.effective_workspaces_dir().join(&name)); ensure_workspace(&workspace_dir)?; if manifest.generate_identity_files { generate_identity_files(&workspace_dir, &manifest); @@ -1281,6 +1327,45 @@ impl OpenFangKernel { Ok(signed.manifest) } + fn has_concurrent_agent_sessions(&self, agent_id: AgentId) -> bool { + match self.memory.list_agent_sessions(agent_id) { + Ok(sessions) => sessions.len() > 1, + Err(e) => { + warn!(agent_id = %agent_id, "Failed to list agent sessions for isolation guardrails: {e}"); + true + } + } + } + + fn resolve_requested_session_id( + &self, + entry: &AgentEntry, + requested_session_id: Option, + ) -> KernelResult { + let Some(session_id) = requested_session_id else { + return Ok(entry.session_id); + }; + + let session = self + .memory + .get_session(session_id) + .map_err(KernelError::OpenFang)? + .ok_or_else(|| { + KernelError::OpenFang(OpenFangError::Internal(format!( + "Session not found: {session_id}", + ))) + })?; + + if session.agent_id != entry.id { + return Err(KernelError::OpenFang(OpenFangError::Internal(format!( + "Session {session_id} does not belong to agent {}", + entry.id + )))); + } + + Ok(session_id) + } + /// Send a message to an agent and get a response. /// /// Automatically upgrades the kernel handle from `self_handle` so that @@ -1290,13 +1375,22 @@ impl OpenFangKernel { &self, agent_id: AgentId, message: &str, + ) -> KernelResult { + self.send_message_in_session(agent_id, message, None).await + } + + pub async fn send_message_in_session( + &self, + agent_id: AgentId, + message: &str, + requested_session_id: Option, ) -> KernelResult { let handle: Option> = self .self_handle .get() .and_then(|w| w.upgrade()) .map(|arc| arc as Arc); - self.send_message_with_handle(agent_id, message, handle) + self.send_message_with_handle_in_session(agent_id, message, handle, requested_session_id) .await } @@ -1306,6 +1400,17 @@ impl OpenFangKernel { agent_id: AgentId, message: &str, kernel_handle: Option>, + ) -> KernelResult { + self.send_message_with_handle_in_session(agent_id, message, kernel_handle, None) + .await + } + + pub async fn send_message_with_handle_in_session( + &self, + agent_id: AgentId, + message: &str, + kernel_handle: Option>, + requested_session_id: Option, ) -> KernelResult { // Enforce quota before running the agent loop self.scheduler @@ -1315,6 +1420,8 @@ impl OpenFangKernel { let entry = self.registry.get(agent_id).ok_or_else(|| { KernelError::OpenFang(OpenFangError::AgentNotFound(agent_id.to_string())) })?; + let resolved_session_id = + self.resolve_requested_session_id(&entry, requested_session_id)?; // Dispatch based on module type let result = if entry.manifest.module.starts_with("wasm:") { @@ -1324,19 +1431,20 @@ impl OpenFangKernel { self.execute_python_agent(&entry, agent_id, message).await } else { // Default: LLM agent loop (builtin:chat or any unrecognized module) - self.execute_llm_agent(&entry, agent_id, message, kernel_handle) - .await + self.execute_llm_agent( + &entry, + agent_id, + message, + kernel_handle, + resolved_session_id, + ) + .await }; match result { Ok(result) => { - // Record token usage for quota tracking self.scheduler.record_usage(agent_id, &result.total_usage); - - // Update last active time let _ = self.registry.set_state(agent_id, AgentState::Running); - - // SECURITY: Record successful message in audit trail self.audit_log.record( agent_id.to_string(), openfang_runtime::audit::AuditAction::AgentMessage, @@ -1346,19 +1454,15 @@ impl OpenFangKernel { ), "ok", ); - Ok(result) } Err(e) => { - // SECURITY: Record failed message in audit trail self.audit_log.record( agent_id.to_string(), openfang_runtime::audit::AuditAction::AgentMessage, "agent loop failed", format!("error: {e}"), ); - - // Record the failure in supervisor for health reporting self.supervisor.record_panic(); warn!(agent_id = %agent_id, error = %e, "Agent loop failed — recorded in supervisor"); Err(e) @@ -1383,7 +1487,19 @@ impl OpenFangKernel { tokio::sync::mpsc::Receiver, tokio::task::JoinHandle>, )> { - // Enforce quota before spawning the streaming task + self.send_message_streaming_in_session(agent_id, message, kernel_handle, None) + } + + pub fn send_message_streaming_in_session( + self: &Arc, + agent_id: AgentId, + message: &str, + kernel_handle: Option>, + requested_session_id: Option, + ) -> KernelResult<( + tokio::sync::mpsc::Receiver, + tokio::task::JoinHandle>, + )> { self.scheduler .check_quota(agent_id) .map_err(KernelError::OpenFang)?; @@ -1391,6 +1507,8 @@ impl OpenFangKernel { let entry = self.registry.get(agent_id).ok_or_else(|| { KernelError::OpenFang(OpenFangError::AgentNotFound(agent_id.to_string())) })?; + let resolved_session_id = + self.resolve_requested_session_id(&entry, requested_session_id)?; let is_wasm = entry.manifest.module.starts_with("wasm:"); let is_python = entry.manifest.module.starts_with("python:"); @@ -1449,10 +1567,10 @@ impl OpenFangKernel { // LLM agent: true streaming via agent loop let mut session = self .memory - .get_session(entry.session_id) + .get_session(resolved_session_id) .map_err(KernelError::OpenFang)? .unwrap_or_else(|| openfang_memory::session::Session { - id: entry.session_id, + id: resolved_session_id, agent_id, messages: Vec::new(), context_window_tokens: 0, @@ -1496,8 +1614,9 @@ impl OpenFangKernel { let (tx, rx) = tokio::sync::mpsc::channel::(64); let mut manifest = entry.manifest.clone(); + let session_isolation_enabled = self.has_concurrent_agent_sessions(agent_id); - // Lazy backfill: create workspace for existing agents spawned before workspaces + // Lazy backfill: create base workspace for existing agents spawned before workspaces if manifest.workspace.is_none() { let workspace_dir = self.config.effective_workspaces_dir().join(&manifest.name); if let Err(e) = ensure_workspace(&workspace_dir) { @@ -1510,6 +1629,20 @@ impl OpenFangKernel { } } + let base_workspace = manifest.workspace.clone(); + if session_isolation_enabled { + if let Some(session_workspace) = base_workspace + .as_deref() + .map(|workspace| session_workspace_root(workspace, resolved_session_id)) + { + if let Err(e) = ensure_workspace(&session_workspace) { + warn!(agent_id = %agent_id, "Failed to prepare session workspace (streaming): {e}"); + } else { + manifest.workspace = Some(session_workspace); + } + } + } + // Build the structured system prompt via prompt_builder { let mcp_tool_count = self.mcp_tools.lock().map(|t| t.len()).unwrap_or(0); @@ -1548,23 +1681,29 @@ impl OpenFangKernel { String::new() }, workspace_path: manifest.workspace.as_ref().map(|p| p.display().to_string()), - soul_md: manifest - .workspace - .as_ref() - .and_then(|w| read_identity_file(w, "SOUL.md")), - user_md: manifest - .workspace - .as_ref() - .and_then(|w| read_identity_file(w, "USER.md")), - memory_md: manifest - .workspace - .as_ref() - .and_then(|w| read_identity_file(w, "MEMORY.md")), - canonical_context: self - .memory - .canonical_context(agent_id, None) - .ok() - .and_then(|(s, _)| s), + soul_md: read_identity_file_from_workspaces( + manifest.workspace.as_deref(), + base_workspace.as_deref(), + "SOUL.md", + ), + user_md: read_identity_file_from_workspaces( + manifest.workspace.as_deref(), + base_workspace.as_deref(), + "USER.md", + ), + memory_md: read_identity_file_from_workspaces( + manifest.workspace.as_deref(), + base_workspace.as_deref(), + "MEMORY.md", + ), + canonical_context: if session_isolation_enabled { + None + } else { + self.memory + .canonical_context(agent_id, None) + .ok() + .and_then(|(s, _)| s) + }, user_name, channel_type: None, is_subagent: manifest @@ -1573,36 +1712,45 @@ impl OpenFangKernel { .and_then(|v| v.as_bool()) .unwrap_or(false), is_autonomous: manifest.autonomous.is_some(), - agents_md: manifest - .workspace - .as_ref() - .and_then(|w| read_identity_file(w, "AGENTS.md")), - bootstrap_md: manifest - .workspace - .as_ref() - .and_then(|w| read_identity_file(w, "BOOTSTRAP.md")), + agents_md: read_identity_file_from_workspaces( + manifest.workspace.as_deref(), + base_workspace.as_deref(), + "AGENTS.md", + ), + bootstrap_md: read_identity_file_from_workspaces( + manifest.workspace.as_deref(), + base_workspace.as_deref(), + "BOOTSTRAP.md", + ), workspace_context: manifest.workspace.as_ref().map(|w| { let mut ws_ctx = openfang_runtime::workspace_context::WorkspaceContext::detect(w); ws_ctx.build_context_section() }), - identity_md: manifest - .workspace - .as_ref() - .and_then(|w| read_identity_file(w, "IDENTITY.md")), + identity_md: read_identity_file_from_workspaces( + manifest.workspace.as_deref(), + base_workspace.as_deref(), + "IDENTITY.md", + ), heartbeat_md: if manifest.autonomous.is_some() { - manifest - .workspace - .as_ref() - .and_then(|w| read_identity_file(w, "HEARTBEAT.md")) + read_identity_file_from_workspaces( + manifest.workspace.as_deref(), + base_workspace.as_deref(), + "HEARTBEAT.md", + ) } else { None }, peer_agents, - current_date: Some(chrono::Local::now().format("%A, %B %d, %Y (%Y-%m-%d %H:%M %Z)").to_string()), + current_date: Some( + chrono::Local::now() + .format("%A, %B %d, %Y (%Y-%m-%d %H:%M %Z)") + .to_string(), + ), }; manifest.model.system_prompt = openfang_runtime::prompt_builder::build_system_prompt(&prompt_ctx); + manifest.metadata.remove("canonical_context_msg"); // Store canonical context separately for injection as user message // (keeps system prompt stable across turns for provider prompt caching) if let Some(cc_msg) = @@ -1718,7 +1866,7 @@ impl OpenFangKernel { match result { Ok(result) => { // Append new messages to canonical session for cross-channel memory - if session.messages.len() > messages_before { + if !session_isolation_enabled && session.messages.len() > messages_before { let new_messages = session.messages[messages_before..].to_vec(); if let Err(e) = memory.append_canonical(agent_id, &new_messages, None) { warn!(agent_id = %agent_id, "Failed to update canonical session (streaming): {e}"); @@ -1931,6 +2079,7 @@ impl OpenFangKernel { agent_id: AgentId, message: &str, kernel_handle: Option>, + resolved_session_id: SessionId, ) -> KernelResult { // Check metering quota before starting self.metering @@ -1939,10 +2088,10 @@ impl OpenFangKernel { let mut session = self .memory - .get_session(entry.session_id) + .get_session(resolved_session_id) .map_err(KernelError::OpenFang)? .unwrap_or_else(|| openfang_memory::session::Session { - id: entry.session_id, + id: resolved_session_id, agent_id, messages: Vec::new(), context_window_tokens: 0, @@ -1964,8 +2113,9 @@ impl OpenFangKernel { // Apply model routing if configured (disabled in Stable mode) let mut manifest = entry.manifest.clone(); + let session_isolation_enabled = self.has_concurrent_agent_sessions(agent_id); - // Lazy backfill: create workspace for existing agents spawned before workspaces + // Lazy backfill: create base workspace for existing agents spawned before workspaces if manifest.workspace.is_none() { let workspace_dir = self.config.effective_workspaces_dir().join(&manifest.name); if let Err(e) = ensure_workspace(&workspace_dir) { @@ -1979,6 +2129,20 @@ impl OpenFangKernel { } } + let base_workspace = manifest.workspace.clone(); + if session_isolation_enabled { + if let Some(session_workspace) = base_workspace + .as_deref() + .map(|workspace| session_workspace_root(workspace, resolved_session_id)) + { + if let Err(e) = ensure_workspace(&session_workspace) { + warn!(agent_id = %agent_id, "Failed to prepare session workspace: {e}"); + } else { + manifest.workspace = Some(session_workspace); + } + } + } + // Build the structured system prompt via prompt_builder { let mcp_tool_count = self.mcp_tools.lock().map(|t| t.len()).unwrap_or(0); @@ -2017,23 +2181,29 @@ impl OpenFangKernel { String::new() }, workspace_path: manifest.workspace.as_ref().map(|p| p.display().to_string()), - soul_md: manifest - .workspace - .as_ref() - .and_then(|w| read_identity_file(w, "SOUL.md")), - user_md: manifest - .workspace - .as_ref() - .and_then(|w| read_identity_file(w, "USER.md")), - memory_md: manifest - .workspace - .as_ref() - .and_then(|w| read_identity_file(w, "MEMORY.md")), - canonical_context: self - .memory - .canonical_context(agent_id, None) - .ok() - .and_then(|(s, _)| s), + soul_md: read_identity_file_from_workspaces( + manifest.workspace.as_deref(), + base_workspace.as_deref(), + "SOUL.md", + ), + user_md: read_identity_file_from_workspaces( + manifest.workspace.as_deref(), + base_workspace.as_deref(), + "USER.md", + ), + memory_md: read_identity_file_from_workspaces( + manifest.workspace.as_deref(), + base_workspace.as_deref(), + "MEMORY.md", + ), + canonical_context: if session_isolation_enabled { + None + } else { + self.memory + .canonical_context(agent_id, None) + .ok() + .and_then(|(s, _)| s) + }, user_name, channel_type: None, is_subagent: manifest @@ -2042,36 +2212,45 @@ impl OpenFangKernel { .and_then(|v| v.as_bool()) .unwrap_or(false), is_autonomous: manifest.autonomous.is_some(), - agents_md: manifest - .workspace - .as_ref() - .and_then(|w| read_identity_file(w, "AGENTS.md")), - bootstrap_md: manifest - .workspace - .as_ref() - .and_then(|w| read_identity_file(w, "BOOTSTRAP.md")), + agents_md: read_identity_file_from_workspaces( + manifest.workspace.as_deref(), + base_workspace.as_deref(), + "AGENTS.md", + ), + bootstrap_md: read_identity_file_from_workspaces( + manifest.workspace.as_deref(), + base_workspace.as_deref(), + "BOOTSTRAP.md", + ), workspace_context: manifest.workspace.as_ref().map(|w| { let mut ws_ctx = openfang_runtime::workspace_context::WorkspaceContext::detect(w); ws_ctx.build_context_section() }), - identity_md: manifest - .workspace - .as_ref() - .and_then(|w| read_identity_file(w, "IDENTITY.md")), + identity_md: read_identity_file_from_workspaces( + manifest.workspace.as_deref(), + base_workspace.as_deref(), + "IDENTITY.md", + ), heartbeat_md: if manifest.autonomous.is_some() { - manifest - .workspace - .as_ref() - .and_then(|w| read_identity_file(w, "HEARTBEAT.md")) + read_identity_file_from_workspaces( + manifest.workspace.as_deref(), + base_workspace.as_deref(), + "HEARTBEAT.md", + ) } else { None }, peer_agents, - current_date: Some(chrono::Local::now().format("%A, %B %d, %Y (%Y-%m-%d %H:%M %Z)").to_string()), + current_date: Some( + chrono::Local::now() + .format("%A, %B %d, %Y (%Y-%m-%d %H:%M %Z)") + .to_string(), + ), }; manifest.model.system_prompt = openfang_runtime::prompt_builder::build_system_prompt(&prompt_ctx); + manifest.metadata.remove("canonical_context_msg"); // Store canonical context separately for injection as user message // (keeps system prompt stable across turns for provider prompt caching) if let Some(cc_msg) = @@ -2197,7 +2376,7 @@ impl OpenFangKernel { .map_err(KernelError::OpenFang)?; // Append new messages to canonical session for cross-channel memory - if session.messages.len() > messages_before { + if !session_isolation_enabled && session.messages.len() > messages_before { let new_messages = session.messages[messages_before..].to_vec(); if let Err(e) = self.memory.append_canonical(agent_id, &new_messages, None) { warn!("Failed to update canonical session: {e}"); @@ -2476,7 +2655,15 @@ impl OpenFangKernel { .structured_set(agent_id, &key, serde_json::Value::String(summary.clone())); // Also write to workspace memory/ dir if workspace exists - if let Some(ref workspace) = entry.manifest.workspace { + let summary_workspace = entry.manifest.workspace.as_ref().map(|workspace| { + if self.has_concurrent_agent_sessions(agent_id) { + session_workspace_root(workspace, entry.session_id) + } else { + workspace.to_path_buf() + } + }); + if let Some(workspace) = summary_workspace { + let _ = ensure_workspace(&workspace); let mem_dir = workspace.join("memory"); let filename = format!("{date}-{slug}.md"); let _ = std::fs::write(mem_dir.join(&filename), &summary); @@ -2492,15 +2679,11 @@ impl OpenFangKernel { /// Switch an agent's model. pub fn set_agent_model(&self, agent_id: AgentId, model: &str) -> KernelResult<()> { // Resolve provider from model catalog so switching models also switches provider - let resolved_provider = self - .model_catalog - .read() - .ok() - .and_then(|catalog| { - catalog - .find_model(model) - .map(|entry| entry.provider.clone()) - }); + let resolved_provider = self.model_catalog.read().ok().and_then(|catalog| { + catalog + .find_model(model) + .map(|entry| entry.provider.clone()) + }); // If catalog lookup failed, try to infer provider from model name prefix let provider = resolved_provider.or_else(|| infer_provider_from_model(model)); @@ -2721,9 +2904,11 @@ impl OpenFangKernel { .map_err(|e| KernelError::OpenFang(OpenFangError::Internal(e)))?; // Store the LLM summary in the canonical session - self.memory - .store_llm_summary(agent_id, &result.summary, result.kept_messages.clone()) - .map_err(KernelError::OpenFang)?; + if !self.has_concurrent_agent_sessions(agent_id) { + self.memory + .store_llm_summary(agent_id, &result.summary, result.kept_messages.clone()) + .map_err(KernelError::OpenFang)?; + } // Post-compaction audit: validate and repair the kept messages let (repaired_messages, repair_stats) = @@ -2961,7 +3146,11 @@ impl OpenFangKernel { } // If an agent with this hand's name already exists, remove it first - let existing = self.registry.list().into_iter().find(|e| e.name == def.agent.name); + let existing = self + .registry + .list() + .into_iter() + .find(|e| e.name == def.agent.name); if let Some(old) = existing { info!(agent = %old.name, id = %old.id, "Removing existing hand agent for reactivation"); let _ = self.kill_agent(old.id); @@ -3286,6 +3475,42 @@ impl OpenFangKernel { Ok((run_id, output)) } + pub async fn run_workflow_shadow( + &self, + workflow_id: WorkflowId, + input: String, + production_output: String, + ) -> KernelResult<(WorkflowRunId, WorkflowShadowComparison)> { + let (run_id, _shadow_output) = self.run_workflow(workflow_id, input).await?; + let comparison = self + .workflows + .record_shadow_comparison(run_id, production_output) + .await + .map_err(|error| { + KernelError::OpenFang(OpenFangError::Internal(format!( + "Shadow comparison failed: {error}" + ))) + })?; + Ok((run_id, comparison)) + } + + pub async fn route_workflow(&self, request: &WorkflowRouteRequest) -> Option { + self.workflows.route_workflow(request).await + } + + pub async fn run_routed_workflow( + &self, + request: WorkflowRouteRequest, + input: String, + ) -> KernelResult<(WorkflowRunId, String)> { + let workflow_id = self.route_workflow(&request).await.ok_or_else(|| { + KernelError::OpenFang(OpenFangError::Internal( + "No matching workflow route rule".to_string(), + )) + })?; + self.run_workflow(workflow_id, input).await + } + /// Start background loops for all non-reactive agents. /// /// Must be called after the kernel is wrapped in `Arc` (e.g., from the daemon). @@ -3293,14 +3518,17 @@ impl OpenFangKernel { /// `Continuous`, `Periodic`, or `Proactive` schedules. pub fn start_background_agents(self: &Arc) { let agents = self.registry.list(); - let mut bg_agents: Vec<(openfang_types::agent::AgentId, String, ScheduleMode)> = - Vec::new(); + let mut bg_agents: Vec<(openfang_types::agent::AgentId, String, ScheduleMode)> = Vec::new(); for entry in &agents { if matches!(entry.manifest.schedule, ScheduleMode::Reactive) { continue; } - bg_agents.push((entry.id, entry.name.clone(), entry.manifest.schedule.clone())); + bg_agents.push(( + entry.id, + entry.name.clone(), + entry.manifest.schedule.clone(), + )); } if !bg_agents.is_empty() { @@ -3507,7 +3735,9 @@ impl OpenFangKernel { let timeout_s = timeout_secs.unwrap_or(120); let timeout = std::time::Duration::from_secs(timeout_s); let delivery = job.delivery.clone(); - let kh: std::sync::Arc = kernel.clone(); + let kh: std::sync::Arc< + dyn openfang_runtime::kernel_handle::KernelHandle, + > = kernel.clone(); match tokio::time::timeout( timeout, kernel.send_message_with_handle(agent_id, message, Some(kh)), @@ -3878,14 +4108,18 @@ impl OpenFangKernel { let base_url = if has_custom_url { manifest.model.base_url.clone() } else if agent_provider == default_provider { - self.config - .default_model - .base_url - .clone() - .or_else(|| self.config.provider_urls.get(agent_provider.as_str()).cloned()) + self.config.default_model.base_url.clone().or_else(|| { + self.config + .provider_urls + .get(agent_provider.as_str()) + .cloned() + }) } else { // Check provider_urls before falling back to hardcoded defaults - self.config.provider_urls.get(agent_provider.as_str()).cloned() + self.config + .provider_urls + .get(agent_provider.as_str()) + .cloned() }; let driver_config = DriverConfig { @@ -3902,8 +4136,10 @@ impl OpenFangKernel { // If fallback models are configured, wrap in FallbackDriver if !manifest.fallback_models.is_empty() { // Primary driver uses the agent's own model name (already set in request) - let mut chain: Vec<(std::sync::Arc, String)> = - vec![(primary.clone(), String::new())]; + let mut chain: Vec<( + std::sync::Arc, + String, + )> = vec![(primary.clone(), String::new())]; for fb in &manifest.fallback_models { let config = DriverConfig { provider: fb.provider.clone(), @@ -4328,7 +4564,12 @@ impl OpenFangKernel { // Apply per-agent tool allowlist/blocklist (manifest-level filtering) let (tool_allowlist, tool_blocklist) = entry .as_ref() - .map(|e| (e.manifest.tool_allowlist.clone(), e.manifest.tool_blocklist.clone())) + .map(|e| { + ( + e.manifest.tool_allowlist.clone(), + e.manifest.tool_blocklist.clone(), + ) + }) .unwrap_or_default(); if !tool_allowlist.is_empty() { @@ -4487,7 +4728,8 @@ impl OpenFangKernel { tool_names.join(", ") )); } - summary.push_str("MCP tools are prefixed with mcp_{server}_ and work like regular tools.\n"); + summary + .push_str("MCP tools are prefixed with mcp_{server}_ and work like regular tools.\n"); // Add filesystem-specific guidance when a filesystem MCP server is connected let has_filesystem = servers.keys().any(|s| s.contains("filesystem")); if has_filesystem { @@ -4666,8 +4908,8 @@ fn infer_provider_from_model(model: &str) -> Option { "minimax" | "gemini" | "anthropic" | "openai" | "groq" | "deepseek" | "mistral" | "cohere" | "xai" | "ollama" | "together" | "fireworks" | "perplexity" | "cerebras" | "sambanova" | "replicate" | "huggingface" | "ai21" | "codex" - | "claude-code" | "copilot" | "github-copilot" | "qwen" | "zhipu" | "zai" | "moonshot" - | "openrouter" | "volcengine" | "doubao" | "dashscope" => { + | "claude-code" | "copilot" | "github-copilot" | "qwen" | "zhipu" | "zai" + | "moonshot" | "openrouter" | "volcengine" | "doubao" | "dashscope" => { return Some(prefix.to_string()); } _ => {} @@ -4680,16 +4922,26 @@ fn infer_provider_from_model(model: &str) -> Option { Some("gemini".to_string()) } else if lower.starts_with("claude") { Some("anthropic".to_string()) - } else if lower.starts_with("gpt") || lower.starts_with("o1") || lower.starts_with("o3") || lower.starts_with("o4") { + } else if lower.starts_with("gpt") + || lower.starts_with("o1") + || lower.starts_with("o3") + || lower.starts_with("o4") + { Some("openai".to_string()) - } else if lower.starts_with("llama") || lower.starts_with("mixtral") || lower.starts_with("qwen") { + } else if lower.starts_with("llama") + || lower.starts_with("mixtral") + || lower.starts_with("qwen") + { // These could be on multiple providers; don't infer None } else if lower.starts_with("grok") { Some("xai".to_string()) } else if lower.starts_with("deepseek") { Some("deepseek".to_string()) - } else if lower.starts_with("mistral") || lower.starts_with("codestral") || lower.starts_with("pixtral") { + } else if lower.starts_with("mistral") + || lower.starts_with("codestral") + || lower.starts_with("pixtral") + { Some("mistral".to_string()) } else if lower.starts_with("command") || lower.starts_with("embed-") { Some("cohere".to_string()) @@ -5257,7 +5509,10 @@ impl KernelHandle for OpenFangKernel { }; adapter - .send(&user, openfang_channels::types::ChannelContent::Text(message.to_string())) + .send( + &user, + openfang_channels::types::ChannelContent::Text(message.to_string()), + ) .await .map_err(|e| format!("Channel send failed: {e}"))?; @@ -5576,4 +5831,56 @@ mod tests { .iter() .any(|c| matches!(c, Capability::ToolInvoke(name) if name == "shell_exec"))); } + #[test] + fn test_session_workspace_root_is_scoped_by_session_id() { + let base_workspace = PathBuf::from("/tmp/openfang-agent"); + let session_id = SessionId::new(); + let scoped = session_workspace_root(&base_workspace, session_id); + assert_eq!( + scoped, + base_workspace + .join(".session-workspaces") + .join(session_id.to_string()) + ); + } + + #[test] + fn test_session_workspace_root_is_idempotent_for_scoped_workspaces() { + let session_id = SessionId::new(); + let scoped = PathBuf::from("/tmp/openfang-agent") + .join(".session-workspaces") + .join(session_id.to_string()); + assert_eq!(session_workspace_root(&scoped, session_id), scoped); + } + + #[test] + fn test_read_identity_file_prefers_session_workspace_then_falls_back() { + let temp = tempfile::tempdir().unwrap(); + let base_workspace = temp.path().join("agent"); + let session_workspace = base_workspace.join(".session-workspaces").join("session-a"); + std::fs::create_dir_all(&base_workspace).unwrap(); + std::fs::create_dir_all(&session_workspace).unwrap(); + + std::fs::write(base_workspace.join("USER.md"), "base-user").unwrap(); + assert_eq!( + read_identity_file_from_workspaces( + Some(session_workspace.as_path()), + Some(base_workspace.as_path()), + "USER.md", + ) + .as_deref(), + Some("base-user") + ); + + std::fs::write(session_workspace.join("USER.md"), "session-user").unwrap(); + assert_eq!( + read_identity_file_from_workspaces( + Some(session_workspace.as_path()), + Some(base_workspace.as_path()), + "USER.md", + ) + .as_deref(), + Some("session-user") + ); + } } diff --git a/crates/openfang-kernel/src/scheduler.rs b/crates/openfang-kernel/src/scheduler.rs index 31c2b0731..94c91a943 100644 --- a/crates/openfang-kernel/src/scheduler.rs +++ b/crates/openfang-kernel/src/scheduler.rs @@ -88,8 +88,7 @@ impl AgentScheduler { // Reset the window if an hour has passed tracker.reset_if_expired(); - if quota.max_llm_tokens_per_hour > 0 - && tracker.total_tokens > quota.max_llm_tokens_per_hour + if quota.max_llm_tokens_per_hour > 0 && tracker.total_tokens > quota.max_llm_tokens_per_hour { return Err(OpenFangError::QuotaExceeded(format!( "Token limit exceeded: {} / {}", diff --git a/crates/openfang-kernel/src/whatsapp_gateway.rs b/crates/openfang-kernel/src/whatsapp_gateway.rs index a4214a744..17b3aa017 100644 --- a/crates/openfang-kernel/src/whatsapp_gateway.rs +++ b/crates/openfang-kernel/src/whatsapp_gateway.rs @@ -10,10 +10,8 @@ use std::sync::Arc; use tracing::{info, warn}; /// Gateway source files embedded at compile time. -const GATEWAY_INDEX_JS: &str = - include_str!("../../../packages/whatsapp-gateway/index.js"); -const GATEWAY_PACKAGE_JSON: &str = - include_str!("../../../packages/whatsapp-gateway/package.json"); +const GATEWAY_INDEX_JS: &str = include_str!("../../../packages/whatsapp-gateway/index.js"); +const GATEWAY_PACKAGE_JSON: &str = include_str!("../../../packages/whatsapp-gateway/package.json"); /// Default port for the WhatsApp Web gateway. const DEFAULT_GATEWAY_PORT: u16 = 3009; @@ -69,8 +67,8 @@ async fn ensure_gateway_installed() -> Result { let package_path = dir.join("package.json"); // Write files only if content changed (avoids unnecessary npm install) - let index_changed = - write_if_changed(&index_path, GATEWAY_INDEX_JS).map_err(|e| format!("Write index.js: {e}"))?; + let index_changed = write_if_changed(&index_path, GATEWAY_INDEX_JS) + .map_err(|e| format!("Write index.js: {e}"))?; let package_changed = write_if_changed(&package_path, GATEWAY_PACKAGE_JSON) .map_err(|e| format!("Write package.json: {e}"))?; @@ -164,7 +162,10 @@ pub async fn start_whatsapp_gateway(kernel: &Arc) .to_string(); // Auto-set the env var so the rest of the system finds the gateway - std::env::set_var("WHATSAPP_WEB_GATEWAY_URL", format!("http://127.0.0.1:{port}")); + std::env::set_var( + "WHATSAPP_WEB_GATEWAY_URL", + format!("http://127.0.0.1:{port}"), + ); info!("WHATSAPP_WEB_GATEWAY_URL set to http://127.0.0.1:{port}"); // Spawn with crash monitoring @@ -247,9 +248,7 @@ pub async fn start_whatsapp_gateway(kernel: &Arc) restarts += 1; if restarts >= MAX_RESTARTS { - warn!( - "WhatsApp gateway exceeded max restarts ({MAX_RESTARTS}), giving up" - ); + warn!("WhatsApp gateway exceeded max restarts ({MAX_RESTARTS}), giving up"); return; } diff --git a/crates/openfang-kernel/src/workflow.rs b/crates/openfang-kernel/src/workflow.rs index 26c838871..8a4843ec5 100644 --- a/crates/openfang-kernel/src/workflow.rs +++ b/crates/openfang-kernel/src/workflow.rs @@ -5,6 +5,7 @@ //! - Pass their output as input to the next step //! - Run in sequence (pipeline) or in parallel (fan-out) //! - Conditionally skip based on previous output +//! - Let review steps reject and return to planning //! - Loop until a condition is met //! - Store outputs in named variables for later reference //! @@ -12,9 +13,13 @@ use chrono::{DateTime, Utc}; use openfang_types::agent::AgentId; +use openfang_types::approval::RiskLevel; +use openfang_types::task_state::{DurableTaskState, TaskExecutionState}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use std::path::Path; use std::sync::Arc; +use tokio::process::Command; use tokio::sync::RwLock; use tracing::{debug, info, warn}; use uuid::Uuid; @@ -78,6 +83,90 @@ pub struct Workflow { pub created_at: DateTime, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkflowRouteRequest { + pub user_id: String, + pub channel: String, + pub task_type: String, + pub risk_level: RiskLevel, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkflowRiskPolicy { + #[default] + Any, + Max(RiskLevel), + AllowList(Vec), +} + +impl WorkflowRiskPolicy { + fn allows(&self, risk_level: RiskLevel) -> bool { + match self { + WorkflowRiskPolicy::Any => true, + WorkflowRiskPolicy::Max(max_risk) => risk_rank(risk_level) <= risk_rank(*max_risk), + WorkflowRiskPolicy::AllowList(levels) => levels.contains(&risk_level), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkflowRouteRule { + pub workflow_id: WorkflowId, + #[serde(default)] + pub user_id: Option, + #[serde(default)] + pub channel: Option, + #[serde(default)] + pub task_type: Option, + #[serde(default)] + pub risk_policy: WorkflowRiskPolicy, + #[serde(default)] + pub priority: i32, +} + +impl WorkflowRouteRule { + fn matches(&self, request: &WorkflowRouteRequest) -> bool { + matches_field(&self.user_id, &request.user_id) + && matches_field(&self.channel, &request.channel) + && matches_field(&self.task_type, &request.task_type) + && self.risk_policy.allows(request.risk_level) + } + + fn score(&self) -> i32 { + let mut score = self.priority.saturating_mul(100); + if self.user_id.is_some() { + score += 8; + } + if self.channel.is_some() { + score += 4; + } + if self.task_type.is_some() { + score += 2; + } + if self.risk_policy != WorkflowRiskPolicy::Any { + score += 1; + } + score + } +} + +fn matches_field(expected: &Option, actual: &str) -> bool { + expected + .as_ref() + .map(|value| value.eq_ignore_ascii_case(actual)) + .unwrap_or(true) +} + +fn risk_rank(level: RiskLevel) -> u8 { + match level { + RiskLevel::Low => 0, + RiskLevel::Medium => 1, + RiskLevel::High => 2, + RiskLevel::Critical => 3, + } +} + /// A single step in a workflow. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorkflowStep { @@ -104,6 +193,22 @@ fn default_timeout() -> u64 { 120 } +fn default_max_rejects() -> u32 { + 3 +} + +fn default_task_state() -> DurableTaskState { + DurableTaskState::new(Utc::now()) +} + +fn default_trace_id() -> String { + Uuid::new_v4().to_string() +} + +fn default_rollback_window_secs() -> u64 { + 300 +} + /// How to identify the agent for a step. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] @@ -125,6 +230,16 @@ pub enum StepMode { FanOut, /// Collect results from all preceding fan-out steps. Collect, + /// Review output and optionally reject by returning to an upstream step. + Review { + /// If review output contains this substring (case-insensitive), reject. + reject_if_contains: String, + /// Step name to return to when rejected. + return_to_step: String, + /// Maximum number of reject-and-return cycles allowed. + #[serde(default = "default_max_rejects")] + max_rejects: u32, + }, /// Conditional — skip this step if previous output doesn't contain `condition` (case-insensitive). Conditional { condition: String }, /// Loop — repeat this step until output contains `until` or `max_iterations` reached. @@ -152,6 +267,142 @@ pub enum WorkflowRunState { Running, Completed, Failed, + Blocked, +} + +/// Audit event category emitted during workflow orchestration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkflowAuditEventType { + Decision, + Dispatch, + Execution, + Review, +} + +/// Audit event correlated by workflow trace ID. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkflowAuditEvent { + /// Unique event identifier. + pub event_id: Uuid, + /// Correlation ID shared by all events in a run. + pub trace_id: String, + /// Owning workflow run. + pub run_id: WorkflowRunId, + /// Workflow definition that produced this event. + pub workflow_id: WorkflowId, + /// Optional step name associated with this event. + pub step_name: Option, + /// Event category. + pub event_type: WorkflowAuditEventType, + /// Human-readable event detail. + pub detail: String, + /// Event outcome summary. + pub outcome: String, + /// Event timestamp. + pub timestamp: DateTime, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct WorkflowRunRecoveryState { + pub next_step_index: usize, + pub current_input: String, + #[serde(default)] + pub all_outputs: Vec, + #[serde(default)] + pub pending_fan_out_outputs: Vec, + #[serde(default)] + pub review_reject_counts: HashMap, + #[serde(default)] + pub variables: HashMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkflowStepQualityGate { + pub acceptance_criteria: String, + pub validation_command: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkflowStepQualityGateConfig { + pub workflow_id: WorkflowId, + pub step_name: String, + pub gate: WorkflowStepQualityGate, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkflowStepQualityGateLog { + pub step_name: String, + pub acceptance_criteria: String, + pub validation_command: String, + pub exit_code: Option, + pub output: String, + pub attempted_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkflowShadowComparison { + pub production_output: String, + pub shadow_output: String, + pub matches: bool, + pub normalized_matches: bool, + pub first_mismatch_index: Option, + pub compared_at: DateTime, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkflowTrafficPath { + Production, + Openfang, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkflowRollbackChecklistItem { + pub step: String, + pub description: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkflowRollbackRecord { + pub from_path: WorkflowTrafficPath, + pub to_path: WorkflowTrafficPath, + pub shadow_enabled_before: bool, + pub shadow_enabled_after: bool, + pub checklist: Vec, + pub initiated_at: DateTime, + pub completed_at: DateTime, + pub duration_ms: u64, + pub within_window: bool, + pub rollback_window_secs: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkflowRolloutState { + pub workflow_id: WorkflowId, + pub primary_path: WorkflowTrafficPath, + pub stable_path: WorkflowTrafficPath, + pub shadow_enabled: bool, + #[serde(default = "default_rollback_window_secs")] + pub rollback_window_secs: u64, + #[serde(default = "WorkflowEngine::default_rollback_checklist")] + pub rollback_checklist: Vec, + pub updated_at: DateTime, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_rollback: Option, +} + +impl WorkflowRunRecoveryState { + fn new(input: String) -> Self { + Self { + next_step_index: 0, + current_input: input, + all_outputs: Vec::new(), + pending_fan_out_outputs: Vec::new(), + review_reject_counts: HashMap::new(), + variables: HashMap::new(), + } + } } /// A running workflow instance. @@ -165,20 +416,71 @@ pub struct WorkflowRun { pub workflow_name: String, /// Initial input to the workflow. pub input: String, + /// Correlation ID used to query all run events. + #[serde(default = "default_trace_id")] + pub trace_id: String, /// Current state. pub state: WorkflowRunState, + /// Durable canonical task state for long-running orchestration and recovery. + #[serde(default = "default_task_state")] + pub task_state: DurableTaskState, /// Results from each completed step. pub step_results: Vec, /// Final output (set when workflow completes). pub output: Option, /// Error message if failed. pub error: Option, + /// Recovery state used to safely continue interrupted runs. + #[serde(default)] + pub recovery: WorkflowRunRecoveryState, + /// Step-level audit trail for this run. + #[serde(default)] + pub audit_events: Vec, + /// Structured quality-gate execution logs for this run. + #[serde(default)] + pub quality_gate_logs: Vec, + /// Optional production-vs-shadow comparison captured for shadow runs. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub shadow: Option, /// Started at. pub started_at: DateTime, /// Completed at. pub completed_at: Option>, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkflowRecoverySnapshot { + pub version: u32, + pub workflows: Vec, + pub route_rules: Vec, + #[serde(default)] + pub gate_enforced_workflows: Vec, + #[serde(default)] + pub step_quality_gates: Vec, + #[serde(default)] + pub rollout_states: Vec, + pub runs: Vec, +} + +/// Aggregated workflow observability metrics. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkflowObservabilityMetrics { + /// Total number of tracked runs. + pub runs_total: usize, + /// Total terminal runs (completed/failed/blocked). + pub terminal_runs_total: usize, + /// Fraction of terminal runs that completed successfully. + pub success_rate: f64, + /// Fraction of terminal runs that failed or blocked. + pub failure_rate: f64, + /// Fraction of execution events that involved retry semantics. + pub retry_rate: f64, + /// Fraction of review events that were rejected. + pub reject_rate: f64, + /// Average resume delay in milliseconds from failed/blocked back to in-progress. + pub resume_time_ms: f64, +} + /// Result from a single workflow step. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StepResult { @@ -203,6 +505,10 @@ pub struct WorkflowEngine { workflows: Arc>>, /// Active and completed workflow runs. runs: Arc>>, + route_rules: Arc>>, + gate_enforced_workflows: Arc>>, + step_quality_gates: Arc>>, + rollout_states: Arc>>, } impl WorkflowEngine { @@ -211,6 +517,54 @@ impl WorkflowEngine { Self { workflows: Arc::new(RwLock::new(HashMap::new())), runs: Arc::new(RwLock::new(HashMap::new())), + route_rules: Arc::new(RwLock::new(Vec::new())), + gate_enforced_workflows: Arc::new(RwLock::new(HashSet::new())), + step_quality_gates: Arc::new(RwLock::new(HashMap::new())), + rollout_states: Arc::new(RwLock::new(HashMap::new())), + } + } + + fn default_rollback_checklist() -> Vec { + vec![ + WorkflowRollbackChecklistItem { + step: "freeze_candidate".to_string(), + description: "Freeze the candidate path and stop new promotion changes." + .to_string(), + }, + WorkflowRollbackChecklistItem { + step: "switch_primary".to_string(), + description: "Switch the primary path back to the last stable production route." + .to_string(), + }, + WorkflowRollbackChecklistItem { + step: "disable_shadow".to_string(), + description: "Disable shadow traffic until the incident is understood.".to_string(), + }, + WorkflowRollbackChecklistItem { + step: "verify_sli".to_string(), + description: + "Verify success rate, failure rate, and recent trace anomalies after rollback." + .to_string(), + }, + WorkflowRollbackChecklistItem { + step: "capture_incident".to_string(), + description: + "Capture the rollback timestamp, operator, and follow-up incident notes." + .to_string(), + }, + ] + } + + fn default_rollout_state(workflow_id: WorkflowId) -> WorkflowRolloutState { + WorkflowRolloutState { + workflow_id, + primary_path: WorkflowTrafficPath::Production, + stable_path: WorkflowTrafficPath::Production, + shadow_enabled: false, + rollback_window_secs: default_rollback_window_secs(), + rollback_checklist: Self::default_rollback_checklist(), + updated_at: Utc::now(), + last_rollback: None, } } @@ -218,6 +572,11 @@ impl WorkflowEngine { pub async fn register(&self, workflow: Workflow) -> WorkflowId { let id = workflow.id; self.workflows.write().await.insert(id, workflow); + self.rollout_states + .write() + .await + .entry(id) + .or_insert_with(|| Self::default_rollout_state(id)); info!(workflow_id = %id, "Workflow registered"); id } @@ -234,12 +593,232 @@ impl WorkflowEngine { /// Remove a workflow definition. pub async fn remove_workflow(&self, id: WorkflowId) -> bool { + self.rollout_states.write().await.remove(&id); self.workflows.write().await.remove(&id).is_some() } + pub async fn set_route_rules(&self, rules: Vec) { + *self.route_rules.write().await = rules; + } + + pub async fn add_route_rule(&self, rule: WorkflowRouteRule) { + self.route_rules.write().await.push(rule); + } + + pub async fn clear_route_rules(&self) { + self.route_rules.write().await.clear(); + } + + pub async fn list_route_rules(&self) -> Vec { + self.route_rules.read().await.clone() + } + + pub async fn route_workflow(&self, request: &WorkflowRouteRequest) -> Option { + let rules = self.route_rules.read().await; + let workflows = self.workflows.read().await; + + let mut selected: Option<(WorkflowId, i32)> = None; + for rule in rules.iter() { + if !workflows.contains_key(&rule.workflow_id) || !rule.matches(request) { + continue; + } + let score = rule.score(); + if selected + .as_ref() + .map(|(_, best_score)| score > *best_score) + .unwrap_or(true) + { + selected = Some((rule.workflow_id, score)); + } + } + + selected.map(|(workflow_id, _)| workflow_id) + } + + fn resolve_step_name(workflow: &Workflow, step_name: &str) -> Option { + workflow + .steps + .iter() + .find(|step| step.name.eq_ignore_ascii_case(step_name)) + .map(|step| step.name.clone()) + } + + pub async fn get_rollout_state(&self, workflow_id: WorkflowId) -> Option { + if !self.workflows.read().await.contains_key(&workflow_id) { + return None; + } + + if let Some(state) = self.rollout_states.read().await.get(&workflow_id).cloned() { + return Some(state); + } + + let default = Self::default_rollout_state(workflow_id); + self.rollout_states + .write() + .await + .insert(workflow_id, default.clone()); + Some(default) + } + + pub async fn update_rollout_state( + &self, + workflow_id: WorkflowId, + primary_path: Option, + stable_path: Option, + shadow_enabled: Option, + rollback_window_secs: Option, + ) -> Result { + if !self.workflows.read().await.contains_key(&workflow_id) { + return Err(format!("Workflow '{}' not found", workflow_id)); + } + if let Some(window) = rollback_window_secs { + if window == 0 { + return Err("rollback_window_secs must be greater than zero".to_string()); + } + } + + let mut rollout_states = self.rollout_states.write().await; + let state = rollout_states + .entry(workflow_id) + .or_insert_with(|| Self::default_rollout_state(workflow_id)); + + if let Some(primary_path) = primary_path { + state.primary_path = primary_path; + } + if let Some(stable_path) = stable_path { + state.stable_path = stable_path; + } + if let Some(shadow_enabled) = shadow_enabled { + state.shadow_enabled = shadow_enabled; + } + if let Some(rollback_window_secs) = rollback_window_secs { + state.rollback_window_secs = rollback_window_secs; + } + state.updated_at = Utc::now(); + Ok(state.clone()) + } + + pub async fn rollback_to_stable_path( + &self, + workflow_id: WorkflowId, + ) -> Result { + if !self.workflows.read().await.contains_key(&workflow_id) { + return Err(format!("Workflow '{}' not found", workflow_id)); + } + + let initiated_at = Utc::now(); + let started = std::time::Instant::now(); + let mut rollout_states = self.rollout_states.write().await; + let state = rollout_states + .entry(workflow_id) + .or_insert_with(|| Self::default_rollout_state(workflow_id)); + let from_path = state.primary_path; + let to_path = state.stable_path; + let shadow_enabled_before = state.shadow_enabled; + + state.primary_path = to_path; + state.shadow_enabled = false; + let completed_at = Utc::now(); + let duration_ms = started.elapsed().as_millis() as u64; + let record = WorkflowRollbackRecord { + from_path, + to_path, + shadow_enabled_before, + shadow_enabled_after: state.shadow_enabled, + checklist: state.rollback_checklist.clone(), + initiated_at, + completed_at, + duration_ms, + within_window: duration_ms <= state.rollback_window_secs.saturating_mul(1000), + rollback_window_secs: state.rollback_window_secs, + }; + state.updated_at = completed_at; + state.last_rollback = Some(record); + Ok(state.clone()) + } + + pub async fn enable_step_quality_gates(&self, workflow_id: WorkflowId) -> Result<(), String> { + if !self.workflows.read().await.contains_key(&workflow_id) { + return Err(format!("Workflow '{}' not found", workflow_id)); + } + + self.gate_enforced_workflows + .write() + .await + .insert(workflow_id); + Ok(()) + } + + pub async fn disable_step_quality_gates(&self, workflow_id: WorkflowId) -> bool { + self.gate_enforced_workflows + .write() + .await + .remove(&workflow_id) + } + + pub async fn step_quality_gates_enabled(&self, workflow_id: WorkflowId) -> bool { + self.gate_enforced_workflows + .read() + .await + .contains(&workflow_id) + } + + pub async fn set_step_quality_gate( + &self, + workflow_id: WorkflowId, + step_name: impl Into, + gate: WorkflowStepQualityGate, + ) -> Result<(), String> { + if gate.acceptance_criteria.trim().is_empty() || gate.validation_command.trim().is_empty() { + return Err( + "quality gate requires non-empty acceptance criteria and validation command" + .to_string(), + ); + } + + let requested_step_name = step_name.into(); + let canonical_step_name = { + let workflows = self.workflows.read().await; + let workflow = workflows + .get(&workflow_id) + .ok_or_else(|| format!("Workflow '{}' not found", workflow_id))?; + Self::resolve_step_name(workflow, &requested_step_name).ok_or_else(|| { + format!( + "Workflow '{}' does not contain step '{}'", + workflow_id, requested_step_name + ) + })? + }; + + self.step_quality_gates + .write() + .await + .insert((workflow_id, canonical_step_name), gate); + Ok(()) + } + + pub async fn get_step_quality_gate( + &self, + workflow_id: WorkflowId, + step_name: &str, + ) -> Option { + let canonical_step_name = { + let workflows = self.workflows.read().await; + let workflow = workflows.get(&workflow_id)?; + Self::resolve_step_name(workflow, step_name)? + }; + + self.step_quality_gates + .read() + .await + .get(&(workflow_id, canonical_step_name)) + .cloned() + } + /// Maximum number of retained workflow runs. Oldest completed/failed /// runs are evicted when this limit is exceeded. const MAX_RETAINED_RUNS: usize = 200; + const RECOVERY_SNAPSHOT_VERSION: u32 = 1; /// Start a workflow run. Returns the run ID and a handle to check progress. /// @@ -252,17 +831,24 @@ impl WorkflowEngine { ) -> Option { let workflow = self.workflows.read().await.get(&workflow_id)?.clone(); let run_id = WorkflowRunId::new(); + let started_at = Utc::now(); let run = WorkflowRun { id: run_id, workflow_id, workflow_name: workflow.name, - input, + input: input.clone(), + trace_id: default_trace_id(), state: WorkflowRunState::Pending, + task_state: DurableTaskState::new(started_at), step_results: Vec::new(), output: None, error: None, - started_at: Utc::now(), + recovery: WorkflowRunRecoveryState::new(input), + audit_events: Vec::new(), + quality_gate_logs: Vec::new(), + shadow: None, + started_at, completed_at: None, }; @@ -276,7 +862,9 @@ impl WorkflowEngine { .filter(|(_, r)| { matches!( r.state, - WorkflowRunState::Completed | WorkflowRunState::Failed + WorkflowRunState::Completed + | WorkflowRunState::Failed + | WorkflowRunState::Blocked ) }) .map(|(id, r)| (*id, r.started_at)) @@ -300,50 +888,709 @@ impl WorkflowEngine { self.runs.read().await.get(&run_id).cloned() } - /// List all workflow runs (optionally filtered by state). - pub async fn list_runs(&self, state_filter: Option<&str>) -> Vec { + fn normalize_recovery_state(run: &mut WorkflowRun) { + if run.recovery.current_input.is_empty() { + run.recovery.current_input = run + .output + .clone() + .or_else(|| run.step_results.last().map(|step| step.output.clone())) + .unwrap_or_else(|| run.input.clone()); + } + } + + fn reconcile_recovered_run(run: &mut WorkflowRun) { + Self::normalize_recovery_state(run); + + if matches!(run.state, WorkflowRunState::Running) + || matches!(run.task_state.state, TaskExecutionState::InProgress) + { + let now = Utc::now(); + run.state = WorkflowRunState::Blocked; + run.error = + Some("Recovered interrupted workflow run; ready to resume safely".to_string()); + Self::transition_task_state(run, TaskExecutionState::Blocked, now); + run.completed_at = None; + Self::append_audit_event( + run, + WorkflowAuditEventType::Decision, + None, + "recovered interrupted workflow run".to_string(), + "blocked_for_resume".to_string(), + now, + ); + } + } + + fn update_recovery_state( + run: &mut WorkflowRun, + next_step_index: usize, + current_input: &str, + all_outputs: &[String], + pending_fan_out_outputs: &[String], + review_reject_counts: &HashMap, + variables: &HashMap, + ) { + run.recovery.next_step_index = next_step_index; + run.recovery.current_input = current_input.to_string(); + run.recovery.all_outputs = all_outputs.to_vec(); + run.recovery.pending_fan_out_outputs = pending_fan_out_outputs.to_vec(); + run.recovery.review_reject_counts = review_reject_counts.clone(); + run.recovery.variables = variables.clone(); + } + + async fn persist_recovery_state( + &self, + run_id: WorkflowRunId, + next_step_index: usize, + current_input: &str, + all_outputs: &[String], + pending_fan_out_outputs: &[String], + review_reject_counts: &HashMap, + variables: &HashMap, + ) { + if let Some(run) = self.runs.write().await.get_mut(&run_id) { + Self::update_recovery_state( + run, + next_step_index, + current_input, + all_outputs, + pending_fan_out_outputs, + review_reject_counts, + variables, + ); + } + } + + pub async fn recovery_snapshot(&self) -> WorkflowRecoverySnapshot { + let mut gate_enforced_workflows: Vec = self + .gate_enforced_workflows + .read() + .await + .iter() + .copied() + .collect(); + gate_enforced_workflows.sort_by_key(|workflow_id| workflow_id.to_string()); + + let mut step_quality_gates: Vec = self + .step_quality_gates + .read() + .await + .iter() + .map( + |((workflow_id, step_name), gate)| WorkflowStepQualityGateConfig { + workflow_id: *workflow_id, + step_name: step_name.clone(), + gate: gate.clone(), + }, + ) + .collect(); + step_quality_gates.sort_by(|left, right| { + left.workflow_id + .to_string() + .cmp(&right.workflow_id.to_string()) + .then_with(|| left.step_name.cmp(&right.step_name)) + }); + + let mut rollout_states: Vec = + self.rollout_states.read().await.values().cloned().collect(); + rollout_states.sort_by_key(|state| state.workflow_id.to_string()); + + WorkflowRecoverySnapshot { + version: Self::RECOVERY_SNAPSHOT_VERSION, + workflows: self.list_workflows().await, + route_rules: self.list_route_rules().await, + gate_enforced_workflows, + step_quality_gates, + rollout_states, + runs: self.list_runs(None).await, + } + } + + pub async fn save_recovery_snapshot>(&self, path: P) -> Result<(), String> { + let snapshot = self.recovery_snapshot().await; + let bytes = serde_json::to_vec_pretty(&snapshot) + .map_err(|error| format!("failed to serialize recovery snapshot: {error}"))?; + std::fs::write(path.as_ref(), bytes) + .map_err(|error| format!("failed to write recovery snapshot: {error}")) + } + + pub async fn load_recovery_snapshot>(path: P) -> Result { + let bytes = std::fs::read(path.as_ref()) + .map_err(|error| format!("failed to read recovery snapshot: {error}"))?; + let snapshot: WorkflowRecoverySnapshot = serde_json::from_slice(&bytes) + .map_err(|error| format!("failed to parse recovery snapshot: {error}"))?; + + if snapshot.version != Self::RECOVERY_SNAPSHOT_VERSION { + return Err(format!( + "unsupported recovery snapshot version: {}", + snapshot.version + )); + } + + let WorkflowRecoverySnapshot { + workflows, + route_rules, + gate_enforced_workflows, + step_quality_gates, + rollout_states, + runs, + .. + } = snapshot; + + let engine = Self::new(); + *engine.workflows.write().await = workflows + .into_iter() + .map(|workflow| (workflow.id, workflow)) + .collect(); + *engine.route_rules.write().await = route_rules; + *engine.gate_enforced_workflows.write().await = + gate_enforced_workflows.into_iter().collect(); + *engine.step_quality_gates.write().await = step_quality_gates + .into_iter() + .map(|config| ((config.workflow_id, config.step_name), config.gate)) + .collect(); + *engine.rollout_states.write().await = rollout_states + .into_iter() + .map(|state| (state.workflow_id, state)) + .collect(); + { + let workflow_ids: Vec = + engine.workflows.read().await.keys().copied().collect(); + let mut rollout_states = engine.rollout_states.write().await; + for workflow_id in workflow_ids { + rollout_states + .entry(workflow_id) + .or_insert_with(|| Self::default_rollout_state(workflow_id)); + } + } + + let mut runs: HashMap = runs + .into_iter() + .map(|mut run| { + Self::reconcile_recovered_run(&mut run); + (run.id, run) + }) + .collect(); + *engine.runs.write().await = std::mem::take(&mut runs); + + Ok(engine) + } + + /// Get a workflow run by trace ID. + pub async fn get_run_by_trace_id(&self, trace_id: &str) -> Option { self.runs .read() .await .values() - .filter(|r| { - state_filter - .map(|f| match f { - "pending" => matches!(r.state, WorkflowRunState::Pending), - "running" => matches!(r.state, WorkflowRunState::Running), - "completed" => matches!(r.state, WorkflowRunState::Completed), - "failed" => matches!(r.state, WorkflowRunState::Failed), - _ => true, - }) - .unwrap_or(true) - }) + .find(|run| run.trace_id == trace_id) .cloned() - .collect() } - /// Replace `{{var_name}}` references in a template with stored variable values. - fn expand_variables(template: &str, input: &str, vars: &HashMap) -> String { - let mut result = template.replace("{{input}}", input); - for (key, value) in vars { - result = result.replace(&format!("{{{{{key}}}}}"), value); + /// List audit events for a specific trace ID in timestamp order. + pub async fn list_audit_events_by_trace_id(&self, trace_id: &str) -> Vec { + let mut events: Vec = self + .runs + .read() + .await + .values() + .filter(|run| run.trace_id == trace_id) + .flat_map(|run| run.audit_events.clone().into_iter()) + .collect(); + + events.sort_by_key(|event| event.timestamp); + events + } + + fn compute_rate(numerator: usize, denominator: usize) -> f64 { + if denominator == 0 { + 0.0 + } else { + numerator as f64 / denominator as f64 } - result } - /// Execute a single step with error mode handling. Returns (output, input_tokens, output_tokens). - async fn execute_step_with_error_mode( - step: &WorkflowStep, - agent_id: AgentId, - prompt: String, - send_message: &F, - ) -> Result, String> - where - F: Fn(AgentId, String) -> Fut, - Fut: std::future::Future>, - { - let timeout_dur = std::time::Duration::from_secs(step.timeout_secs); + fn maybe_resume_duration_ms( + timestamps: &openfang_types::task_state::TaskStateTimestamps, + ) -> Option { + let resumed_at = timestamps.in_progress_at?; - match &step.error_mode { + let resume_origin = [timestamps.blocked_at, timestamps.failed_at] + .into_iter() + .flatten() + .filter(|at| *at < resumed_at) + .max()?; + + Some((resumed_at - resume_origin).num_milliseconds() as f64) + } + + /// Aggregate workflow observability metrics for dashboard/API consumption. + pub async fn observability_metrics(&self) -> WorkflowObservabilityMetrics { + let runs = self.runs.read().await; + let runs_total = runs.len(); + + let mut terminal_runs_total = 0usize; + let mut success_runs = 0usize; + let mut failure_runs = 0usize; + + let mut execution_events_total = 0usize; + let mut retry_events_total = 0usize; + let mut review_events_total = 0usize; + let mut rejected_events_total = 0usize; + + let mut resume_samples_ms: Vec = Vec::new(); + + for run in runs.values() { + match run.state { + WorkflowRunState::Completed => { + terminal_runs_total += 1; + success_runs += 1; + } + WorkflowRunState::Failed | WorkflowRunState::Blocked => { + terminal_runs_total += 1; + failure_runs += 1; + } + WorkflowRunState::Pending | WorkflowRunState::Running => {} + } + + if let Some(duration_ms) = Self::maybe_resume_duration_ms(&run.task_state.timestamps) { + resume_samples_ms.push(duration_ms); + } + + for event in &run.audit_events { + match event.event_type { + WorkflowAuditEventType::Execution => { + execution_events_total += 1; + let detail = event.detail.to_lowercase(); + if detail.contains("retry") || detail.contains("retries") { + retry_events_total += 1; + } + } + WorkflowAuditEventType::Review => { + review_events_total += 1; + if event.outcome.eq_ignore_ascii_case("rejected") { + rejected_events_total += 1; + } + } + WorkflowAuditEventType::Decision | WorkflowAuditEventType::Dispatch => {} + } + } + } + + let resume_time_ms = if resume_samples_ms.is_empty() { + 0.0 + } else { + resume_samples_ms.iter().sum::() / resume_samples_ms.len() as f64 + }; + + WorkflowObservabilityMetrics { + runs_total, + terminal_runs_total, + success_rate: Self::compute_rate(success_runs, terminal_runs_total), + failure_rate: Self::compute_rate(failure_runs, terminal_runs_total), + retry_rate: Self::compute_rate(retry_events_total, execution_events_total), + reject_rate: Self::compute_rate(rejected_events_total, review_events_total), + resume_time_ms, + } + } + + /// List all workflow runs (optionally filtered by state). + pub async fn list_runs(&self, state_filter: Option<&str>) -> Vec { + self.runs + .read() + .await + .values() + .filter(|r| { + state_filter + .map(|f| match f { + "pending" => matches!(r.state, WorkflowRunState::Pending), + "running" | "in_progress" => { + matches!(r.task_state.state, TaskExecutionState::InProgress) + } + "completed" | "done" => { + matches!(r.task_state.state, TaskExecutionState::Done) + } + "failed" => matches!(r.task_state.state, TaskExecutionState::Failed), + "blocked" => matches!(r.task_state.state, TaskExecutionState::Blocked), + "canceled" => matches!(r.task_state.state, TaskExecutionState::Canceled), + _ => true, + }) + .unwrap_or(true) + }) + .cloned() + .collect() + } + + fn transition_task_state(run: &mut WorkflowRun, next: TaskExecutionState, at: DateTime) { + if let Err(error) = run.task_state.transition(next, at) { + warn!( + from = ?run.task_state.state, + to = ?next, + %error, + "Task state transition rejected" + ); + } + } + + fn append_audit_event( + run: &mut WorkflowRun, + event_type: WorkflowAuditEventType, + step_name: Option, + detail: String, + outcome: String, + timestamp: DateTime, + ) { + run.audit_events.push(WorkflowAuditEvent { + event_id: Uuid::new_v4(), + trace_id: run.trace_id.clone(), + run_id: run.id, + workflow_id: run.workflow_id, + step_name, + event_type, + detail, + outcome, + timestamp, + }); + } + + async fn record_audit_event( + &self, + run_id: WorkflowRunId, + event_type: WorkflowAuditEventType, + step_name: Option<&str>, + detail: impl Into, + outcome: impl Into, + ) { + if let Some(run) = self.runs.write().await.get_mut(&run_id) { + Self::append_audit_event( + run, + event_type, + step_name.map(|name| name.to_string()), + detail.into(), + outcome.into(), + Utc::now(), + ); + } + } + + fn terminal_failure_state_for_step( + step: &WorkflowStep, + ) -> (WorkflowRunState, TaskExecutionState) { + if matches!(step.error_mode, ErrorMode::Retry { .. }) { + (WorkflowRunState::Blocked, TaskExecutionState::Blocked) + } else { + (WorkflowRunState::Failed, TaskExecutionState::Failed) + } + } + + async fn set_run_terminal_error( + &self, + run_id: WorkflowRunId, + run_state: WorkflowRunState, + task_state: TaskExecutionState, + error: String, + ) { + if let Some(r) = self.runs.write().await.get_mut(&run_id) { + r.state = run_state; + r.error = Some(error); + let now = Utc::now(); + Self::transition_task_state(r, task_state, now); + r.completed_at = Some(now); + } + } + + fn format_gate_stream(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes).trim().to_string() + } + + fn combine_gate_output(stdout: &str, stderr: &str) -> String { + match (stdout.is_empty(), stderr.is_empty()) { + (true, true) => String::new(), + (false, true) => stdout.to_string(), + (true, false) => stderr.to_string(), + (false, false) => format!( + "stdout: +{} + +stderr: +{}", + stdout, stderr + ), + } + } + + fn normalize_shadow_output(output: &str) -> String { + output.split_whitespace().collect::>().join(" ") + } + + fn first_mismatch_index(left: &str, right: &str) -> Option { + let mut mismatch = None; + for (idx, (lhs, rhs)) in left.chars().zip(right.chars()).enumerate() { + if lhs != rhs { + mismatch = Some(idx); + break; + } + } + + mismatch.or_else(|| { + let left_len = left.chars().count(); + let right_len = right.chars().count(); + (left_len != right_len).then_some(left_len.min(right_len)) + }) + } + + pub async fn record_shadow_comparison( + &self, + run_id: WorkflowRunId, + production_output: impl Into, + ) -> Result { + let production_output = production_output.into(); + let compared_at = Utc::now(); + let mut runs = self.runs.write().await; + let run = runs + .get_mut(&run_id) + .ok_or_else(|| format!("Workflow run '{}' not found", run_id))?; + let shadow_output = run + .output + .clone() + .or_else(|| run.step_results.last().map(|step| step.output.clone())) + .unwrap_or_default(); + let comparison = WorkflowShadowComparison { + matches: production_output == shadow_output, + normalized_matches: Self::normalize_shadow_output(&production_output) + == Self::normalize_shadow_output(&shadow_output), + first_mismatch_index: Self::first_mismatch_index(&production_output, &shadow_output), + production_output, + shadow_output, + compared_at, + }; + let outcome = if comparison.matches { + "shadow_match" + } else if comparison.normalized_matches { + "shadow_normalized_match" + } else { + "shadow_mismatch" + }; + let detail = format!( + "shadow compared against production output (normalized_match={}, first_mismatch_index={:?})", + comparison.normalized_matches, comparison.first_mismatch_index + ); + run.shadow = Some(comparison.clone()); + Self::append_audit_event( + run, + WorkflowAuditEventType::Decision, + None, + detail, + outcome.to_string(), + compared_at, + ); + Ok(comparison) + } + + async fn record_quality_gate_log( + &self, + run_id: WorkflowRunId, + step_name: &str, + gate: &WorkflowStepQualityGate, + exit_code: Option, + output: String, + ) { + if let Some(run) = self.runs.write().await.get_mut(&run_id) { + run.quality_gate_logs.push(WorkflowStepQualityGateLog { + step_name: step_name.to_string(), + acceptance_criteria: gate.acceptance_criteria.clone(), + validation_command: gate.validation_command.clone(), + exit_code, + output, + attempted_at: Utc::now(), + }); + } + } + + async fn maybe_execute_step_quality_gate( + &self, + workflow_id: WorkflowId, + run_id: WorkflowRunId, + step_name: &str, + output: &str, + ) -> Result<(), String> { + if !self + .gate_enforced_workflows + .read() + .await + .contains(&workflow_id) + { + return Ok(()); + } + + let gate = match self + .step_quality_gates + .read() + .await + .get(&(workflow_id, step_name.to_string())) + .cloned() + { + Some(gate) => gate, + None => { + let error = format!( + "Step '{}' requires acceptance criteria and validation command before completion", + step_name + ); + self.record_audit_event( + run_id, + WorkflowAuditEventType::Execution, + Some(step_name), + error.clone(), + "quality_gate_missing", + ) + .await; + return Err(error); + } + }; + + if gate.acceptance_criteria.trim().is_empty() || gate.validation_command.trim().is_empty() { + let error = format!( + "Step '{}' requires non-empty acceptance criteria and validation command", + step_name + ); + self.record_audit_event( + run_id, + WorkflowAuditEventType::Execution, + Some(step_name), + error.clone(), + "quality_gate_missing", + ) + .await; + return Err(error); + } + + self.record_audit_event( + run_id, + WorkflowAuditEventType::Decision, + Some(step_name), + format!( + "running quality gate acceptance_criteria={:?} command={:?}", + gate.acceptance_criteria, gate.validation_command + ), + "quality_gate_started", + ) + .await; + + let command_output = match Command::new("/bin/sh") + .arg("-lc") + .arg(&gate.validation_command) + .env("OPENFANG_WORKFLOW_ID", workflow_id.to_string()) + .env("OPENFANG_WORKFLOW_RUN_ID", run_id.to_string()) + .env("OPENFANG_STEP_NAME", step_name) + .env("OPENFANG_STEP_OUTPUT", output) + .env("OPENFANG_ACCEPTANCE_CRITERIA", &gate.acceptance_criteria) + .kill_on_drop(true) + .output() + .await + { + Ok(output) => output, + Err(error) => { + let detail = format!( + "quality gate acceptance_criteria={:?} command={:?} error={:?}", + gate.acceptance_criteria, gate.validation_command, error + ); + self.record_quality_gate_log(run_id, step_name, &gate, None, error.to_string()) + .await; + self.record_audit_event( + run_id, + WorkflowAuditEventType::Execution, + Some(step_name), + detail, + "quality_gate_failed", + ) + .await; + return Err(format!( + "Step '{}' quality gate command failed to start: {}", + step_name, error + )); + } + }; + + let exit_code = command_output + .status + .code() + .map(|code| code.to_string()) + .unwrap_or_else(|| "signal".to_string()); + let stdout = Self::format_gate_stream(&command_output.stdout); + let stderr = Self::format_gate_stream(&command_output.stderr); + let combined_output = Self::combine_gate_output(&stdout, &stderr); + let summary = if stderr.is_empty() { + stdout.clone() + } else { + stderr.clone() + }; + let gate_detail = format!( + "quality gate acceptance_criteria={:?} command={:?} exit_code={} stdout={:?} stderr={:?}", + gate.acceptance_criteria, gate.validation_command, exit_code, stdout, stderr + ); + self.record_quality_gate_log( + run_id, + step_name, + &gate, + command_output.status.code(), + combined_output, + ) + .await; + + if command_output.status.success() { + self.record_audit_event( + run_id, + WorkflowAuditEventType::Execution, + Some(step_name), + gate_detail, + "quality_gate_passed", + ) + .await; + Ok(()) + } else { + self.record_audit_event( + run_id, + WorkflowAuditEventType::Execution, + Some(step_name), + gate_detail, + "quality_gate_failed", + ) + .await; + Err(format!( + "Step '{}' quality gate failed (exit_code={}): {}", + step_name, + exit_code, + if summary.is_empty() { + "validation command exited non-zero".to_string() + } else { + summary + } + )) + } + } + + /// Replace `{{var_name}}` references in a template with stored variable values. + fn expand_variables(template: &str, input: &str, vars: &HashMap) -> String { + let mut result = template.replace("{{input}}", input); + for (key, value) in vars { + result = result.replace(&format!("{{{{{key}}}}}"), value); + } + result + } + + /// Execute a single step with error mode handling. Returns (output, input_tokens, output_tokens). + async fn execute_step_with_error_mode( + step: &WorkflowStep, + agent_id: AgentId, + prompt: String, + send_message: &F, + ) -> Result, String> + where + F: Fn(AgentId, String) -> Fut, + Fut: std::future::Future>, + { + let timeout_dur = std::time::Duration::from_secs(step.timeout_secs); + + match &step.error_mode { ErrorMode::Fail => { let result = tokio::time::timeout(timeout_dur, send_message(agent_id, prompt)) .await @@ -424,10 +1671,26 @@ impl WorkflowEngine { Fut: std::future::Future>, { // Get the run and workflow - let (workflow, input) = { + let ( + workflow, + mut current_input, + mut all_outputs, + mut pending_fan_out_outputs, + mut review_reject_counts, + mut variables, + mut i, + resumed, + ) = { let mut runs = self.runs.write().await; let run = runs.get_mut(&run_id).ok_or("Workflow run not found")?; + Self::normalize_recovery_state(run); + let resumed = run.recovery.next_step_index > 0 + || matches!( + run.task_state.state, + TaskExecutionState::Failed | TaskExecutionState::Blocked + ); run.state = WorkflowRunState::Running; + Self::transition_task_state(run, TaskExecutionState::InProgress, Utc::now()); let workflow = self .workflows @@ -437,7 +1700,16 @@ impl WorkflowEngine { .ok_or("Workflow definition not found")? .clone(); - (workflow, run.input.clone()) + ( + workflow, + run.recovery.current_input.clone(), + run.recovery.all_outputs.clone(), + run.recovery.pending_fan_out_outputs.clone(), + run.recovery.review_reject_counts.clone(), + run.recovery.variables.clone(), + run.recovery.next_step_index, + resumed, + ) }; info!( @@ -446,14 +1718,25 @@ impl WorkflowEngine { steps = workflow.steps.len(), "Starting workflow execution" ); - - let mut current_input = input; - let mut all_outputs: Vec = Vec::new(); - let mut variables: HashMap = HashMap::new(); - let mut i = 0; + self.record_audit_event( + run_id, + WorkflowAuditEventType::Decision, + None, + format!( + "workflow '{}' {} with {} step(s)", + workflow.name, + if resumed { "resumed" } else { "started" }, + workflow.steps.len() + ), + if resumed { "resumed" } else { "in_progress" }, + ) + .await; while i < workflow.steps.len() { let step = &workflow.steps[i]; + if !matches!(step.mode, StepMode::FanOut | StepMode::Collect) { + pending_fan_out_outputs.clear(); + } debug!( step = i + 1, @@ -468,6 +1751,14 @@ impl WorkflowEngine { let prompt = Self::expand_variables(&step.prompt_template, ¤t_input, &variables); + self.record_audit_event( + run_id, + WorkflowAuditEventType::Dispatch, + Some(&step.name), + format!("dispatch to agent '{}' ({agent_id})", agent_name), + "sent", + ) + .await; let start = std::time::Instant::now(); let result = @@ -477,6 +1768,37 @@ impl WorkflowEngine { match result { Ok(Some((output, input_tokens, output_tokens))) => { + if let Err(error) = self + .maybe_execute_step_quality_gate( + workflow.id, + run_id, + &step.name, + &output, + ) + .await + { + self.persist_recovery_state( + run_id, + i, + ¤t_input, + &all_outputs, + &pending_fan_out_outputs, + &review_reject_counts, + &variables, + ) + .await; + let (run_state, task_state) = + Self::terminal_failure_state_for_step(step); + self.set_run_terminal_error( + run_id, + run_state, + task_state, + error.clone(), + ) + .await; + return Err(error); + } + let step_result = StepResult { step_name: step.name.clone(), agent_id: agent_id.to_string(), @@ -497,17 +1819,50 @@ impl WorkflowEngine { all_outputs.push(output.clone()); current_input = output; info!(step = i + 1, name = %step.name, duration_ms, "Step completed"); + self.record_audit_event( + run_id, + WorkflowAuditEventType::Execution, + Some(&step.name), + format!("completed in {duration_ms}ms"), + "ok", + ) + .await; } Ok(None) => { // Step was skipped (ErrorMode::Skip) info!(step = i + 1, name = %step.name, "Step skipped"); + self.record_audit_event( + run_id, + WorkflowAuditEventType::Execution, + Some(&step.name), + "step skipped by error mode", + "skipped", + ) + .await; } Err(e) => { - if let Some(r) = self.runs.write().await.get_mut(&run_id) { - r.state = WorkflowRunState::Failed; - r.error = Some(e.clone()); - r.completed_at = Some(Utc::now()); - } + self.record_audit_event( + run_id, + WorkflowAuditEventType::Execution, + Some(&step.name), + format!("step execution failed: {e}"), + "failed", + ) + .await; + self.persist_recovery_state( + run_id, + i, + ¤t_input, + &all_outputs, + &pending_fan_out_outputs, + &review_reject_counts, + &variables, + ) + .await; + let (run_state, task_state) = + Self::terminal_failure_state_for_step(step); + self.set_run_terminal_error(run_id, run_state, task_state, e.clone()) + .await; return Err(e); } } @@ -525,6 +1880,15 @@ impl WorkflowEngine { break; } } + pending_fan_out_outputs.clear(); + self.record_audit_event( + run_id, + WorkflowAuditEventType::Decision, + Some(&step.name), + format!("fan_out group size={}", fan_out_steps.len()), + "parallel_dispatch", + ) + .await; // Build all futures let mut futures = Vec::new(); @@ -540,12 +1904,20 @@ impl WorkflowEngine { ¤t_input, &variables, ); - let timeout_dur = std::time::Duration::from_secs(fan_step.timeout_secs); - + self.record_audit_event( + run_id, + WorkflowAuditEventType::Dispatch, + Some(&fan_step.name), + format!("dispatch to agent '{}' ({agent_id})", agent_name), + "sent", + ) + .await; step_infos.push((*idx, fan_step.name.clone(), agent_id, agent_name)); - futures.push(tokio::time::timeout( - timeout_dur, - send_message(agent_id, prompt), + futures.push(Self::execute_step_with_error_mode( + fan_step, + agent_id, + prompt, + &send_message, )); } @@ -558,7 +1930,38 @@ impl WorkflowEngine { let fan_step = fan_out_steps[k].1; match result { - Ok(Ok((output, input_tokens, output_tokens))) => { + Ok(Some((output, input_tokens, output_tokens))) => { + if let Err(error) = self + .maybe_execute_step_quality_gate( + workflow.id, + run_id, + step_name, + &output, + ) + .await + { + let (run_state, task_state) = + Self::terminal_failure_state_for_step(fan_step); + self.persist_recovery_state( + run_id, + i, + ¤t_input, + &all_outputs, + &pending_fan_out_outputs, + &review_reject_counts, + &variables, + ) + .await; + self.set_run_terminal_error( + run_id, + run_state, + task_state, + error.clone(), + ) + .await; + return Err(error); + } + let step_result = StepResult { step_name: step_name.clone(), agent_id: agent_id.to_string(), @@ -575,30 +1978,62 @@ impl WorkflowEngine { variables.insert(var.clone(), output.clone()); } all_outputs.push(output.clone()); + pending_fan_out_outputs.push(output.clone()); current_input = output; + self.record_audit_event( + run_id, + WorkflowAuditEventType::Execution, + Some(step_name), + format!("fan_out step completed in {duration_ms}ms"), + "ok", + ) + .await; + } + Ok(None) => { + info!( + step_name = %step_name, + "FanOut step skipped by error mode" + ); + self.record_audit_event( + run_id, + WorkflowAuditEventType::Execution, + Some(step_name), + "fan_out step skipped by error mode", + "skipped", + ) + .await; } - Ok(Err(e)) => { + Err(e) => { let error_msg = format!("FanOut step '{}' failed: {}", step_name, e); warn!(%error_msg); - if let Some(r) = self.runs.write().await.get_mut(&run_id) { - r.state = WorkflowRunState::Failed; - r.error = Some(error_msg.clone()); - r.completed_at = Some(Utc::now()); - } - return Err(error_msg); - } - Err(_) => { - let error_msg = format!( - "FanOut step '{}' timed out after {}s", - step_name, fan_step.timeout_secs - ); - warn!(%error_msg); - if let Some(r) = self.runs.write().await.get_mut(&run_id) { - r.state = WorkflowRunState::Failed; - r.error = Some(error_msg.clone()); - r.completed_at = Some(Utc::now()); - } + self.record_audit_event( + run_id, + WorkflowAuditEventType::Execution, + Some(step_name), + error_msg.clone(), + "failed", + ) + .await; + let (run_state, task_state) = + Self::terminal_failure_state_for_step(fan_step); + self.persist_recovery_state( + run_id, + i, + ¤t_input, + &all_outputs, + &pending_fan_out_outputs, + &review_reject_counts, + &variables, + ) + .await; + self.set_run_terminal_error( + run_id, + run_state, + task_state, + error_msg.clone(), + ) + .await; return Err(error_msg); } } @@ -611,16 +2046,303 @@ impl WorkflowEngine { // Skip past the fan-out steps we just processed i = j; + self.persist_recovery_state( + run_id, + i, + ¤t_input, + &all_outputs, + &pending_fan_out_outputs, + &review_reject_counts, + &variables, + ) + .await; continue; } StepMode::Collect => { - current_input = all_outputs.join("\n\n---\n\n"); + let collected_outputs = if pending_fan_out_outputs.is_empty() { + all_outputs.clone() + } else { + pending_fan_out_outputs.clone() + }; + let collected_input = if collected_outputs.is_empty() { + current_input.clone() + } else { + collected_outputs.join( + " + +--- + +", + ) + }; + + if let Err(error) = self + .maybe_execute_step_quality_gate( + workflow.id, + run_id, + &step.name, + &collected_input, + ) + .await + { + self.persist_recovery_state( + run_id, + i, + ¤t_input, + &all_outputs, + &pending_fan_out_outputs, + &review_reject_counts, + &variables, + ) + .await; + let (run_state, task_state) = Self::terminal_failure_state_for_step(step); + self.set_run_terminal_error(run_id, run_state, task_state, error.clone()) + .await; + return Err(error); + } + + current_input = collected_input; + pending_fan_out_outputs.clear(); all_outputs.clear(); all_outputs.push(current_input.clone()); if let Some(ref var) = step.output_var { variables.insert(var.clone(), current_input.clone()); } + self.record_audit_event( + run_id, + WorkflowAuditEventType::Decision, + Some(&step.name), + format!("collect aggregated {} output(s)", collected_outputs.len()), + "aggregated", + ) + .await; + } + + StepMode::Review { + reject_if_contains, + return_to_step, + max_rejects, + } => { + let (agent_id, agent_name) = agent_resolver(&step.agent) + .ok_or_else(|| format!("Agent not found for step '{}'", step.name))?; + + let prompt = + Self::expand_variables(&step.prompt_template, ¤t_input, &variables); + self.record_audit_event( + run_id, + WorkflowAuditEventType::Dispatch, + Some(&step.name), + format!("dispatch review to agent '{}' ({agent_id})", agent_name), + "sent", + ) + .await; + + let start = std::time::Instant::now(); + let result = + Self::execute_step_with_error_mode(step, agent_id, prompt, &send_message) + .await; + let duration_ms = start.elapsed().as_millis() as u64; + + match result { + Ok(Some((output, input_tokens, output_tokens))) => { + if let Err(error) = self + .maybe_execute_step_quality_gate( + workflow.id, + run_id, + &step.name, + &output, + ) + .await + { + self.persist_recovery_state( + run_id, + i, + ¤t_input, + &all_outputs, + &pending_fan_out_outputs, + &review_reject_counts, + &variables, + ) + .await; + let (run_state, task_state) = + Self::terminal_failure_state_for_step(step); + self.set_run_terminal_error( + run_id, + run_state, + task_state, + error.clone(), + ) + .await; + return Err(error); + } + + let step_result = StepResult { + step_name: step.name.clone(), + agent_id: agent_id.to_string(), + agent_name, + output: output.clone(), + input_tokens, + output_tokens, + duration_ms, + }; + if let Some(r) = self.runs.write().await.get_mut(&run_id) { + r.step_results.push(step_result); + } + + if let Some(ref var) = step.output_var { + variables.insert(var.clone(), output.clone()); + } + + all_outputs.push(output.clone()); + current_input = output.clone(); + + if output + .to_lowercase() + .contains(&reject_if_contains.to_lowercase()) + { + let target_idx = workflow + .steps + .iter() + .position(|candidate| { + candidate.name.eq_ignore_ascii_case(return_to_step) + }) + .ok_or_else(|| { + format!( + "Review step '{}' return_to_step '{}' not found", + step.name, return_to_step + ) + })?; + self.record_audit_event( + run_id, + WorkflowAuditEventType::Review, + Some(&step.name), + format!( + "review rejected output containing '{}': {}", + reject_if_contains, output + ), + "rejected", + ) + .await; + + let reject_count = review_reject_counts.entry(i).or_insert(0); + *reject_count += 1; + if *reject_count > *max_rejects { + let error_msg = format!( + "Review step '{}' exceeded max_rejects={} (last feedback: {})", + step.name, max_rejects, output + ); + self.record_audit_event( + run_id, + WorkflowAuditEventType::Review, + Some(&step.name), + error_msg.clone(), + "failed", + ) + .await; + if let Some(r) = self.runs.write().await.get_mut(&run_id) { + r.state = WorkflowRunState::Failed; + r.error = Some(error_msg.clone()); + let now = Utc::now(); + Self::transition_task_state( + r, + TaskExecutionState::Failed, + now, + ); + r.completed_at = Some(now); + } + self.persist_recovery_state( + run_id, + i, + ¤t_input, + &all_outputs, + &pending_fan_out_outputs, + &review_reject_counts, + &variables, + ) + .await; + return Err(error_msg); + } + + info!( + review_step = %step.name, + return_to_step, + reject_count = *reject_count, + max_rejects, + "Review rejected output; returning to upstream step" + ); + self.record_audit_event( + run_id, + WorkflowAuditEventType::Decision, + Some(&step.name), + format!( + "review rejected; returning to step '{}' (reject_count={})", + return_to_step, reject_count + ), + "return_to_step", + ) + .await; + i = target_idx; + self.persist_recovery_state( + run_id, + i, + ¤t_input, + &all_outputs, + &pending_fan_out_outputs, + &review_reject_counts, + &variables, + ) + .await; + continue; + } + + info!(step = i + 1, name = %step.name, duration_ms, "Review step approved"); + self.record_audit_event( + run_id, + WorkflowAuditEventType::Review, + Some(&step.name), + format!("review approved in {duration_ms}ms"), + "approved", + ) + .await; + } + Ok(None) => { + info!(step = i + 1, name = %step.name, "Review step skipped"); + self.record_audit_event( + run_id, + WorkflowAuditEventType::Review, + Some(&step.name), + "review step skipped by error mode", + "skipped", + ) + .await; + } + Err(e) => { + self.record_audit_event( + run_id, + WorkflowAuditEventType::Review, + Some(&step.name), + format!("review execution failed: {e}"), + "failed", + ) + .await; + self.persist_recovery_state( + run_id, + i, + ¤t_input, + &all_outputs, + &pending_fan_out_outputs, + &review_reject_counts, + &variables, + ) + .await; + let (run_state, task_state) = + Self::terminal_failure_state_for_step(step); + self.set_run_terminal_error(run_id, run_state, task_state, e.clone()) + .await; + return Err(e); + } + } } StepMode::Conditional { condition } => { @@ -634,9 +2356,35 @@ impl WorkflowEngine { condition, "Conditional step skipped (condition not met)" ); + self.record_audit_event( + run_id, + WorkflowAuditEventType::Decision, + Some(&step.name), + format!("condition '{}' not met", condition), + "skipped", + ) + .await; i += 1; + self.persist_recovery_state( + run_id, + i, + ¤t_input, + &all_outputs, + &pending_fan_out_outputs, + &review_reject_counts, + &variables, + ) + .await; continue; } + self.record_audit_event( + run_id, + WorkflowAuditEventType::Decision, + Some(&step.name), + format!("condition '{}' met", condition), + "execute", + ) + .await; // Condition met — execute like sequential let (agent_id, agent_name) = agent_resolver(&step.agent) @@ -644,6 +2392,14 @@ impl WorkflowEngine { let prompt = Self::expand_variables(&step.prompt_template, ¤t_input, &variables); + self.record_audit_event( + run_id, + WorkflowAuditEventType::Dispatch, + Some(&step.name), + format!("dispatch to agent '{}' ({agent_id})", agent_name), + "sent", + ) + .await; let start = std::time::Instant::now(); let result = @@ -653,6 +2409,37 @@ impl WorkflowEngine { match result { Ok(Some((output, input_tokens, output_tokens))) => { + if let Err(error) = self + .maybe_execute_step_quality_gate( + workflow.id, + run_id, + &step.name, + &output, + ) + .await + { + self.persist_recovery_state( + run_id, + i, + ¤t_input, + &all_outputs, + &pending_fan_out_outputs, + &review_reject_counts, + &variables, + ) + .await; + let (run_state, task_state) = + Self::terminal_failure_state_for_step(step); + self.set_run_terminal_error( + run_id, + run_state, + task_state, + error.clone(), + ) + .await; + return Err(error); + } + let step_result = StepResult { step_name: step.name.clone(), agent_id: agent_id.to_string(), @@ -670,14 +2457,48 @@ impl WorkflowEngine { } all_outputs.push(output.clone()); current_input = output; + self.record_audit_event( + run_id, + WorkflowAuditEventType::Execution, + Some(&step.name), + format!("conditional step completed in {duration_ms}ms"), + "ok", + ) + .await; } - Ok(None) => {} - Err(e) => { - if let Some(r) = self.runs.write().await.get_mut(&run_id) { - r.state = WorkflowRunState::Failed; - r.error = Some(e.clone()); - r.completed_at = Some(Utc::now()); - } + Ok(None) => { + self.record_audit_event( + run_id, + WorkflowAuditEventType::Execution, + Some(&step.name), + "conditional step skipped by error mode", + "skipped", + ) + .await; + } + Err(e) => { + self.record_audit_event( + run_id, + WorkflowAuditEventType::Execution, + Some(&step.name), + format!("conditional step failed: {e}"), + "failed", + ) + .await; + self.persist_recovery_state( + run_id, + i, + ¤t_input, + &all_outputs, + &pending_fan_out_outputs, + &review_reject_counts, + &variables, + ) + .await; + let (run_state, task_state) = + Self::terminal_failure_state_for_step(step); + self.set_run_terminal_error(run_id, run_state, task_state, e.clone()) + .await; return Err(e); } } @@ -691,6 +2512,17 @@ impl WorkflowEngine { .ok_or_else(|| format!("Agent not found for step '{}'", step.name))?; let until_lower = until.to_lowercase(); + self.record_audit_event( + run_id, + WorkflowAuditEventType::Decision, + Some(&step.name), + format!( + "loop start (max_iterations={}, until='{}')", + max_iterations, until + ), + "started", + ) + .await; for loop_iter in 0..*max_iterations { let prompt = Self::expand_variables( @@ -698,6 +2530,18 @@ impl WorkflowEngine { ¤t_input, &variables, ); + self.record_audit_event( + run_id, + WorkflowAuditEventType::Dispatch, + Some(&step.name), + format!( + "dispatch loop iteration {} to agent '{}' ({agent_id})", + loop_iter + 1, + agent_name + ), + "sent", + ) + .await; let start = std::time::Instant::now(); let result = Self::execute_step_with_error_mode( @@ -711,6 +2555,37 @@ impl WorkflowEngine { match result { Ok(Some((output, input_tokens, output_tokens))) => { + if let Err(error) = self + .maybe_execute_step_quality_gate( + workflow.id, + run_id, + &step.name, + &output, + ) + .await + { + let (run_state, task_state) = + Self::terminal_failure_state_for_step(step); + self.persist_recovery_state( + run_id, + i, + ¤t_input, + &all_outputs, + &pending_fan_out_outputs, + &review_reject_counts, + &variables, + ) + .await; + self.set_run_terminal_error( + run_id, + run_state, + task_state, + error.clone(), + ) + .await; + return Err(error); + } + let step_result = StepResult { step_name: format!("{} (iter {})", step.name, loop_iter + 1), agent_id: agent_id.to_string(), @@ -725,6 +2600,18 @@ impl WorkflowEngine { } current_input = output.clone(); + self.record_audit_event( + run_id, + WorkflowAuditEventType::Execution, + Some(&step.name), + format!( + "loop iteration {} completed in {}ms", + loop_iter + 1, + duration_ms + ), + "ok", + ) + .await; if output.to_lowercase().contains(&until_lower) { info!( @@ -733,6 +2620,17 @@ impl WorkflowEngine { iterations = loop_iter + 1, "Loop terminated (until condition met)" ); + self.record_audit_event( + run_id, + WorkflowAuditEventType::Decision, + Some(&step.name), + format!( + "loop terminated on iteration {} (until condition met)", + loop_iter + 1 + ), + "until_met", + ) + .await; break; } @@ -742,15 +2640,61 @@ impl WorkflowEngine { name = %step.name, "Loop terminated (max iterations reached)" ); + self.record_audit_event( + run_id, + WorkflowAuditEventType::Decision, + Some(&step.name), + format!( + "loop terminated after {} iteration(s) (max reached)", + max_iterations + ), + "max_iterations_reached", + ) + .await; } } - Ok(None) => break, + Ok(None) => { + self.record_audit_event( + run_id, + WorkflowAuditEventType::Execution, + Some(&step.name), + format!( + "loop iteration {} skipped by error mode", + loop_iter + 1 + ), + "skipped", + ) + .await; + break; + } Err(e) => { - if let Some(r) = self.runs.write().await.get_mut(&run_id) { - r.state = WorkflowRunState::Failed; - r.error = Some(e.clone()); - r.completed_at = Some(Utc::now()); - } + self.record_audit_event( + run_id, + WorkflowAuditEventType::Execution, + Some(&step.name), + format!("loop iteration {} failed: {}", loop_iter + 1, e), + "failed", + ) + .await; + let (run_state, task_state) = + Self::terminal_failure_state_for_step(step); + self.persist_recovery_state( + run_id, + i, + ¤t_input, + &all_outputs, + &pending_fan_out_outputs, + &review_reject_counts, + &variables, + ) + .await; + self.set_run_terminal_error( + run_id, + run_state, + task_state, + e.clone(), + ) + .await; return Err(e); } } @@ -763,6 +2707,16 @@ impl WorkflowEngine { } } + self.persist_recovery_state( + run_id, + i + 1, + ¤t_input, + &all_outputs, + &pending_fan_out_outputs, + &review_reject_counts, + &variables, + ) + .await; i += 1; } @@ -771,8 +2725,28 @@ impl WorkflowEngine { if let Some(r) = self.runs.write().await.get_mut(&run_id) { r.state = WorkflowRunState::Completed; r.output = Some(final_output.clone()); - r.completed_at = Some(Utc::now()); + let now = Utc::now(); + Self::transition_task_state(r, TaskExecutionState::Done, now); + r.completed_at = Some(now); } + self.persist_recovery_state( + run_id, + workflow.steps.len(), + &final_output, + std::slice::from_ref(&final_output), + &[], + &review_reject_counts, + &variables, + ) + .await; + self.record_audit_event( + run_id, + WorkflowAuditEventType::Decision, + None, + "workflow run completed", + "done", + ) + .await; info!(run_id = %run_id, "Workflow completed successfully"); Ok(final_output) @@ -851,6 +2825,9 @@ mod tests { let run = engine.get_run(run_id.unwrap()).await.unwrap(); assert_eq!(run.input, "test input"); assert!(matches!(run.state, WorkflowRunState::Pending)); + assert_eq!(run.task_state.state, TaskExecutionState::Pending); + assert!(run.task_state.timestamps.in_progress_at.is_none()); + assert!(run.task_state.timestamps.done_at.is_none()); } #[tokio::test] @@ -895,6 +2872,9 @@ mod tests { let run = engine.get_run(run_id).await.unwrap(); assert!(matches!(run.state, WorkflowRunState::Completed)); + assert_eq!(run.task_state.state, TaskExecutionState::Done); + assert!(run.task_state.timestamps.in_progress_at.is_some()); + assert!(run.task_state.timestamps.done_at.is_some()); assert_eq!(run.step_results.len(), 2); assert!(run.output.is_some()); } @@ -1187,6 +3167,48 @@ mod tests { assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 3); } + #[tokio::test] + async fn test_error_mode_retry_exhaustion_escalates_to_blocked() { + let engine = WorkflowEngine::new(); + let wf = Workflow { + id: WorkflowId::new(), + name: "retry-block-test".to_string(), + description: "".to_string(), + steps: vec![WorkflowStep { + name: "worker".to_string(), + agent: StepAgent::ByName { + name: "worker".to_string(), + }, + prompt_template: "{{input}}".to_string(), + mode: StepMode::Sequential, + timeout_secs: 5, + error_mode: ErrorMode::Retry { max_retries: 1 }, + output_var: None, + }], + created_at: Utc::now(), + }; + let wf_id = engine.register(wf).await; + let run_id = engine + .create_run(wf_id, "payload".to_string()) + .await + .unwrap(); + + let sender = + |_id: AgentId, _msg: String| async move { Err("persistent error".to_string()) }; + + let result = engine.execute_run(run_id, mock_resolver, sender).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("failed after 1 retries")); + + let run = engine.get_run(run_id).await.unwrap(); + assert!(matches!(run.state, WorkflowRunState::Blocked)); + assert_eq!(run.task_state.state, TaskExecutionState::Blocked); + assert!(run + .error + .unwrap_or_default() + .contains("failed after 1 retries")); + } + #[tokio::test] async fn test_output_variables() { let engine = WorkflowEngine::new(); @@ -1318,50 +3340,1370 @@ mod tests { } #[tokio::test] - async fn test_expand_variables() { - let mut vars = HashMap::new(); - vars.insert("name".to_string(), "Alice".to_string()); - vars.insert("task".to_string(), "code review".to_string()); + async fn test_collect_aggregates_only_latest_fan_out_group() { + let engine = WorkflowEngine::new(); + let wf = Workflow { + id: WorkflowId::new(), + name: "fanout-collect-scope-test".to_string(), + description: "".to_string(), + steps: vec![ + WorkflowStep { + name: "prepare".to_string(), + agent: StepAgent::ByName { + name: "prep".to_string(), + }, + prompt_template: "Prepare: {{input}}".to_string(), + mode: StepMode::Sequential, + timeout_secs: 10, + error_mode: ErrorMode::Fail, + output_var: None, + }, + WorkflowStep { + name: "task-a".to_string(), + agent: StepAgent::ByName { + name: "a".to_string(), + }, + prompt_template: "Task A: {{input}}".to_string(), + mode: StepMode::FanOut, + timeout_secs: 10, + error_mode: ErrorMode::Fail, + output_var: None, + }, + WorkflowStep { + name: "task-b".to_string(), + agent: StepAgent::ByName { + name: "b".to_string(), + }, + prompt_template: "Task B: {{input}}".to_string(), + mode: StepMode::FanOut, + timeout_secs: 10, + error_mode: ErrorMode::Fail, + output_var: None, + }, + WorkflowStep { + name: "collect".to_string(), + agent: StepAgent::ByName { + name: "collector".to_string(), + }, + prompt_template: "unused".to_string(), + mode: StepMode::Collect, + timeout_secs: 10, + error_mode: ErrorMode::Fail, + output_var: None, + }, + ], + created_at: Utc::now(), + }; - let result = WorkflowEngine::expand_variables( - "Hello {{name}}, please do {{task}} on {{input}}", - "main.rs", - &vars, - ); - assert_eq!(result, "Hello Alice, please do code review on main.rs"); + let wf_id = engine.register(wf).await; + let run_id = engine.create_run(wf_id, "seed".to_string()).await.unwrap(); + + let sender = |_id: AgentId, msg: String| async move { + let output = if msg.starts_with("Prepare:") { + "prep-output".to_string() + } else if msg.starts_with("Task A:") { + "branch-a".to_string() + } else if msg.starts_with("Task B:") { + "branch-b".to_string() + } else { + format!("unexpected: {msg}") + }; + Ok((output, 10u64, 5u64)) + }; + + let result = engine.execute_run(run_id, mock_resolver, sender).await; + assert!(result.is_ok()); + + let output = result.unwrap(); + assert!(output.contains("branch-a")); + assert!(output.contains("branch-b")); + assert!(!output.contains("prep-output")); } #[tokio::test] - async fn test_error_mode_serialization() { - let fail_json = serde_json::to_string(&ErrorMode::Fail).unwrap(); - assert_eq!(fail_json, "\"fail\""); + async fn test_fan_out_retry_exhaustion_escalates_to_blocked() { + let engine = WorkflowEngine::new(); + let wf = Workflow { + id: WorkflowId::new(), + name: "fanout-retry-block-test".to_string(), + description: "".to_string(), + steps: vec![WorkflowStep { + name: "worker-branch".to_string(), + agent: StepAgent::ByName { + name: "worker".to_string(), + }, + prompt_template: "Work: {{input}}".to_string(), + mode: StepMode::FanOut, + timeout_secs: 5, + error_mode: ErrorMode::Retry { max_retries: 1 }, + output_var: None, + }], + created_at: Utc::now(), + }; + let wf_id = engine.register(wf).await; + let run_id = engine + .create_run(wf_id, "payload".to_string()) + .await + .unwrap(); - let skip_json = serde_json::to_string(&ErrorMode::Skip).unwrap(); - assert_eq!(skip_json, "\"skip\""); + let sender = |_id: AgentId, _msg: String| async move { Err("branch failure".to_string()) }; - let retry_json = serde_json::to_string(&ErrorMode::Retry { max_retries: 3 }).unwrap(); - let retry: ErrorMode = serde_json::from_str(&retry_json).unwrap(); - assert!(matches!(retry, ErrorMode::Retry { max_retries: 3 })); + let result = engine.execute_run(run_id, mock_resolver, sender).await; + assert!(result.is_err()); + + let run = engine.get_run(run_id).await.unwrap(); + assert!(matches!(run.state, WorkflowRunState::Blocked)); + assert_eq!(run.task_state.state, TaskExecutionState::Blocked); + assert!(run + .error + .unwrap_or_default() + .contains("failed after 1 retries")); } #[tokio::test] - async fn test_step_mode_conditional_serialization() { - let mode = StepMode::Conditional { - condition: "error".to_string(), + async fn test_review_reject_and_return_to_planning() { + let engine = WorkflowEngine::new(); + let wf = Workflow { + id: WorkflowId::new(), + name: "review-return-test".to_string(), + description: "".to_string(), + steps: vec![ + WorkflowStep { + name: "planning".to_string(), + agent: StepAgent::ByName { + name: "planner".to_string(), + }, + prompt_template: "Plan: {{input}}".to_string(), + mode: StepMode::Sequential, + timeout_secs: 10, + error_mode: ErrorMode::Fail, + output_var: None, + }, + WorkflowStep { + name: "review".to_string(), + agent: StepAgent::ByName { + name: "reviewer".to_string(), + }, + prompt_template: "Review: {{input}}".to_string(), + mode: StepMode::Review { + reject_if_contains: "reject".to_string(), + return_to_step: "planning".to_string(), + max_rejects: 2, + }, + timeout_secs: 10, + error_mode: ErrorMode::Fail, + output_var: None, + }, + WorkflowStep { + name: "dispatch".to_string(), + agent: StepAgent::ByName { + name: "dispatcher".to_string(), + }, + prompt_template: "Dispatch: {{input}}".to_string(), + mode: StepMode::Sequential, + timeout_secs: 10, + error_mode: ErrorMode::Fail, + output_var: None, + }, + ], + created_at: Utc::now(), }; - let json = serde_json::to_string(&mode).unwrap(); - let parsed: StepMode = serde_json::from_str(&json).unwrap(); - assert!(matches!(parsed, StepMode::Conditional { condition } if condition == "error")); + let wf_id = engine.register(wf).await; + let run_id = engine + .create_run(wf_id, "initial request".to_string()) + .await + .unwrap(); + + let prompts = Arc::new(std::sync::Mutex::new(Vec::::new())); + let plan_count = Arc::new(std::sync::atomic::AtomicU32::new(0)); + let review_count = Arc::new(std::sync::atomic::AtomicU32::new(0)); + let prompts_ref = prompts.clone(); + let plan_ref = plan_count.clone(); + let review_ref = review_count.clone(); + + let sender = move |_id: AgentId, msg: String| { + let prompts_ref = prompts_ref.clone(); + let plan_ref = plan_ref.clone(); + let review_ref = review_ref.clone(); + async move { + prompts_ref.lock().unwrap().push(msg.clone()); + if msg.starts_with("Plan:") { + let n = plan_ref.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if n == 0 { + Ok(("plan-v1".to_string(), 10u64, 5u64)) + } else { + Ok(("plan-v2".to_string(), 10u64, 5u64)) + } + } else if msg.starts_with("Review:") { + let n = review_ref.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if n == 0 { + Ok(("REJECT: missing details".to_string(), 10u64, 5u64)) + } else { + Ok(("APPROVED: looks good".to_string(), 10u64, 5u64)) + } + } else { + Ok((format!("dispatch-final: {msg}"), 10u64, 5u64)) + } + } + }; + + let result = engine.execute_run(run_id, mock_resolver, sender).await; + assert!(result.is_ok()); + assert!(result + .unwrap() + .contains("dispatch-final: Dispatch: APPROVED")); + + let run = engine.get_run(run_id).await.unwrap(); + assert!(matches!(run.state, WorkflowRunState::Completed)); + assert_eq!(run.step_results.len(), 5); // planning x2 + review x2 + dispatch x1 + assert_eq!(plan_count.load(std::sync::atomic::Ordering::SeqCst), 2); + assert_eq!(review_count.load(std::sync::atomic::Ordering::SeqCst), 2); + + let prompts = prompts.lock().unwrap(); + let plan_prompts: Vec<&String> = + prompts.iter().filter(|p| p.starts_with("Plan:")).collect(); + assert_eq!(plan_prompts.len(), 2); + assert!(plan_prompts[1].contains("REJECT: missing details")); } #[tokio::test] - async fn test_step_mode_loop_serialization() { - let mode = StepMode::Loop { - max_iterations: 5, - until: "done".to_string(), + async fn test_review_reject_exceeds_max_retries() { + let engine = WorkflowEngine::new(); + let wf = Workflow { + id: WorkflowId::new(), + name: "review-reject-limit-test".to_string(), + description: "".to_string(), + steps: vec![ + WorkflowStep { + name: "planning".to_string(), + agent: StepAgent::ByName { + name: "planner".to_string(), + }, + prompt_template: "Plan: {{input}}".to_string(), + mode: StepMode::Sequential, + timeout_secs: 10, + error_mode: ErrorMode::Fail, + output_var: None, + }, + WorkflowStep { + name: "review".to_string(), + agent: StepAgent::ByName { + name: "reviewer".to_string(), + }, + prompt_template: "Review: {{input}}".to_string(), + mode: StepMode::Review { + reject_if_contains: "reject".to_string(), + return_to_step: "planning".to_string(), + max_rejects: 1, + }, + timeout_secs: 10, + error_mode: ErrorMode::Fail, + output_var: None, + }, + ], + created_at: Utc::now(), }; - let json = serde_json::to_string(&mode).unwrap(); - let parsed: StepMode = serde_json::from_str(&json).unwrap(); - assert!(matches!(parsed, StepMode::Loop { max_iterations: 5, until } if until == "done")); + let wf_id = engine.register(wf).await; + let run_id = engine + .create_run(wf_id, "initial".to_string()) + .await + .unwrap(); + + let sender = |_id: AgentId, msg: String| async move { + if msg.starts_with("Plan:") { + Ok(("plan-proposal".to_string(), 10u64, 5u64)) + } else { + Ok(("reject: not good enough".to_string(), 10u64, 5u64)) + } + }; + + let result = engine.execute_run(run_id, mock_resolver, sender).await; + assert!(result.is_err()); + assert!(result.err().unwrap().contains("exceeded max_rejects")); + + let run = engine.get_run(run_id).await.unwrap(); + assert!(matches!(run.state, WorkflowRunState::Failed)); + assert_eq!(run.task_state.state, TaskExecutionState::Failed); + } + + #[tokio::test] + async fn test_trace_id_queries_audit_events_across_decision_dispatch_execution_and_review() { + let engine = WorkflowEngine::new(); + let wf = Workflow { + id: WorkflowId::new(), + name: "trace-audit-test".to_string(), + description: "".to_string(), + steps: vec![ + WorkflowStep { + name: "planning".to_string(), + agent: StepAgent::ByName { + name: "planner".to_string(), + }, + prompt_template: "Plan: {{input}}".to_string(), + mode: StepMode::Sequential, + timeout_secs: 10, + error_mode: ErrorMode::Fail, + output_var: None, + }, + WorkflowStep { + name: "review".to_string(), + agent: StepAgent::ByName { + name: "reviewer".to_string(), + }, + prompt_template: "Review: {{input}}".to_string(), + mode: StepMode::Review { + reject_if_contains: "reject".to_string(), + return_to_step: "planning".to_string(), + max_rejects: 2, + }, + timeout_secs: 10, + error_mode: ErrorMode::Fail, + output_var: None, + }, + WorkflowStep { + name: "dispatch".to_string(), + agent: StepAgent::ByName { + name: "dispatcher".to_string(), + }, + prompt_template: "Dispatch: {{input}}".to_string(), + mode: StepMode::Sequential, + timeout_secs: 10, + error_mode: ErrorMode::Fail, + output_var: None, + }, + ], + created_at: Utc::now(), + }; + + let wf_id = engine.register(wf).await; + let run_id = engine + .create_run(wf_id, "initial".to_string()) + .await + .unwrap(); + + let plan_count = Arc::new(std::sync::atomic::AtomicU32::new(0)); + let review_count = Arc::new(std::sync::atomic::AtomicU32::new(0)); + let plan_ref = plan_count.clone(); + let review_ref = review_count.clone(); + let sender = move |_id: AgentId, msg: String| { + let plan_ref = plan_ref.clone(); + let review_ref = review_ref.clone(); + async move { + if msg.starts_with("Plan:") { + let n = plan_ref.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if n == 0 { + Ok(("plan-v1".to_string(), 10u64, 5u64)) + } else { + Ok(("plan-v2".to_string(), 10u64, 5u64)) + } + } else if msg.starts_with("Review:") { + let n = review_ref.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if n == 0 { + Ok(("REJECT: missing details".to_string(), 10u64, 5u64)) + } else { + Ok(("APPROVED".to_string(), 10u64, 5u64)) + } + } else { + Ok(("dispatch-ok".to_string(), 10u64, 5u64)) + } + } + }; + + let result = engine.execute_run(run_id, mock_resolver, sender).await; + assert!(result.is_ok()); + + let run = engine.get_run(run_id).await.unwrap(); + assert!(!run.trace_id.is_empty()); + + let queried_run = engine.get_run_by_trace_id(&run.trace_id).await.unwrap(); + assert_eq!(queried_run.id, run_id); + + let events = engine.list_audit_events_by_trace_id(&run.trace_id).await; + assert!(!events.is_empty()); + assert!(events.iter().all(|event| event.trace_id == run.trace_id)); + assert!(events + .iter() + .any(|event| event.event_type == WorkflowAuditEventType::Decision)); + assert!(events + .iter() + .any(|event| event.event_type == WorkflowAuditEventType::Dispatch)); + assert!(events + .iter() + .any(|event| event.event_type == WorkflowAuditEventType::Execution)); + assert!(events + .iter() + .any(|event| event.event_type == WorkflowAuditEventType::Review)); + } + + #[tokio::test] + async fn test_trace_id_query_returns_empty_for_unknown_trace() { + let engine = WorkflowEngine::new(); + assert!(engine + .list_audit_events_by_trace_id("trace-does-not-exist") + .await + .is_empty()); + assert!(engine + .get_run_by_trace_id("trace-does-not-exist") + .await + .is_none()); + } + + #[tokio::test] + async fn test_observability_metrics_expose_success_failure_retry_and_reject_rates() { + let engine = WorkflowEngine::new(); + + let review_workflow = Workflow { + id: WorkflowId::new(), + name: "metrics-review-run".to_string(), + description: "".to_string(), + steps: vec![ + WorkflowStep { + name: "planning".to_string(), + agent: StepAgent::ByName { + name: "planner".to_string(), + }, + prompt_template: "Plan: {{input}}".to_string(), + mode: StepMode::Sequential, + timeout_secs: 10, + error_mode: ErrorMode::Fail, + output_var: None, + }, + WorkflowStep { + name: "review".to_string(), + agent: StepAgent::ByName { + name: "reviewer".to_string(), + }, + prompt_template: "Review: {{input}}".to_string(), + mode: StepMode::Review { + reject_if_contains: "reject".to_string(), + return_to_step: "planning".to_string(), + max_rejects: 2, + }, + timeout_secs: 10, + error_mode: ErrorMode::Fail, + output_var: None, + }, + WorkflowStep { + name: "dispatch".to_string(), + agent: StepAgent::ByName { + name: "dispatcher".to_string(), + }, + prompt_template: "Dispatch: {{input}}".to_string(), + mode: StepMode::Sequential, + timeout_secs: 10, + error_mode: ErrorMode::Fail, + output_var: None, + }, + ], + created_at: Utc::now(), + }; + + let retry_workflow = Workflow { + id: WorkflowId::new(), + name: "metrics-retry-run".to_string(), + description: "".to_string(), + steps: vec![WorkflowStep { + name: "worker".to_string(), + agent: StepAgent::ByName { + name: "worker".to_string(), + }, + prompt_template: "Work: {{input}}".to_string(), + mode: StepMode::Sequential, + timeout_secs: 10, + error_mode: ErrorMode::Retry { max_retries: 1 }, + output_var: None, + }], + created_at: Utc::now(), + }; + + let review_id = engine.register(review_workflow).await; + let retry_id = engine.register(retry_workflow).await; + + let review_run_id = engine + .create_run(review_id, "initial".to_string()) + .await + .unwrap(); + let retry_run_id = engine + .create_run(retry_id, "payload".to_string()) + .await + .unwrap(); + + let review_count = Arc::new(std::sync::atomic::AtomicU32::new(0)); + let review_ref = review_count.clone(); + let review_sender = move |_id: AgentId, msg: String| { + let review_ref = review_ref.clone(); + async move { + if msg.starts_with("Review:") { + let n = review_ref.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if n == 0 { + Ok(("REJECT: add more detail".to_string(), 10u64, 5u64)) + } else { + Ok(("APPROVED".to_string(), 10u64, 5u64)) + } + } else { + Ok(("ok".to_string(), 10u64, 5u64)) + } + } + }; + + let retry_sender = + |_id: AgentId, _msg: String| async move { Err("always failing".to_string()) }; + + assert!(engine + .execute_run(review_run_id, mock_resolver, review_sender) + .await + .is_ok()); + assert!(engine + .execute_run(retry_run_id, mock_resolver, retry_sender) + .await + .is_err()); + + let metrics = engine.observability_metrics().await; + let epsilon = 1e-9; + + assert_eq!(metrics.runs_total, 2); + assert_eq!(metrics.terminal_runs_total, 2); + assert!((metrics.success_rate - 0.5).abs() < epsilon); + assert!((metrics.failure_rate - 0.5).abs() < epsilon); + assert!(metrics.retry_rate > 0.0); + assert!((metrics.reject_rate - 0.5).abs() < epsilon); + assert_eq!(metrics.resume_time_ms, 0.0); + } + + #[tokio::test] + async fn test_observability_metrics_resume_time_ms_uses_resume_transitions() { + let engine = WorkflowEngine::new(); + let wf_id = engine.register(test_workflow()).await; + let run_id = engine + .create_run(wf_id, "resume-case".to_string()) + .await + .unwrap(); + + let t0 = Utc::now(); + let t1 = t0 + chrono::Duration::seconds(1); + let t2 = t1 + chrono::Duration::seconds(2); + let t3 = t2 + chrono::Duration::milliseconds(1500); + let t4 = t3 + chrono::Duration::seconds(1); + + let mut task_state = DurableTaskState::new(t0); + task_state + .transition(TaskExecutionState::InProgress, t1) + .unwrap(); + task_state + .transition(TaskExecutionState::Blocked, t2) + .unwrap(); + task_state + .transition(TaskExecutionState::InProgress, t3) + .unwrap(); + task_state.transition(TaskExecutionState::Done, t4).unwrap(); + + { + let mut runs = engine.runs.write().await; + let run = runs.get_mut(&run_id).unwrap(); + run.state = WorkflowRunState::Completed; + run.task_state = task_state; + } + + let metrics = engine.observability_metrics().await; + let expected_resume_ms = 1500.0; + let epsilon = 1e-9; + assert!((metrics.resume_time_ms - expected_resume_ms).abs() < epsilon); + } + + #[tokio::test] + async fn test_recovery_snapshot_restores_failed_run_and_resumes_from_saved_step() { + let engine = WorkflowEngine::new(); + let wf_id = engine.register(test_workflow()).await; + let run_id = engine + .create_run(wf_id, "raw data".to_string()) + .await + .unwrap(); + + let sender = |_id: AgentId, msg: String| async move { + if msg.starts_with("Analyze this:") { + Ok(("analysis-ready".to_string(), 10u64, 5u64)) + } else { + Err("transient summary failure".to_string()) + } + }; + + let result = engine.execute_run(run_id, mock_resolver, sender).await; + assert!(result.is_err()); + + let failed_run = engine.get_run(run_id).await.unwrap(); + assert!(matches!(failed_run.state, WorkflowRunState::Failed)); + assert_eq!(failed_run.step_results.len(), 1); + assert_eq!(failed_run.recovery.next_step_index, 1); + assert_eq!(failed_run.recovery.current_input, "analysis-ready"); + + let tempdir = tempfile::tempdir().unwrap(); + let snapshot_path = tempdir.path().join("workflow-recovery.json"); + engine.save_recovery_snapshot(&snapshot_path).await.unwrap(); + + let recovered_engine = WorkflowEngine::load_recovery_snapshot(&snapshot_path) + .await + .unwrap(); + let resumed_prompts = Arc::new(std::sync::Mutex::new(Vec::::new())); + let prompts_ref = resumed_prompts.clone(); + let resumed_sender = move |_id: AgentId, msg: String| { + let prompts_ref = prompts_ref.clone(); + async move { + prompts_ref.lock().unwrap().push(msg.clone()); + if msg.starts_with("Analyze this:") { + Err("first step should not rerun after recovery".to_string()) + } else { + Ok(("summary-ready".to_string(), 10u64, 5u64)) + } + } + }; + + let resumed_output = recovered_engine + .execute_run(run_id, mock_resolver, resumed_sender) + .await + .unwrap(); + assert_eq!(resumed_output, "summary-ready"); + + let resumed_run = recovered_engine.get_run(run_id).await.unwrap(); + assert!(matches!(resumed_run.state, WorkflowRunState::Completed)); + assert_eq!(resumed_run.step_results.len(), 2); + let resumed_prompts = resumed_prompts.lock().unwrap(); + assert_eq!(resumed_prompts.len(), 1); + assert!(resumed_prompts[0].starts_with("Summarize this analysis:")); + } + + #[tokio::test] + async fn test_recovery_snapshot_blocks_interrupted_run_before_resume() { + let engine = WorkflowEngine::new(); + let wf_id = engine.register(test_workflow()).await; + let run_id = engine + .create_run(wf_id, "interrupted input".to_string()) + .await + .unwrap(); + + { + let mut runs = engine.runs.write().await; + let run = runs.get_mut(&run_id).unwrap(); + run.state = WorkflowRunState::Running; + run.task_state + .transition(TaskExecutionState::InProgress, Utc::now()) + .unwrap(); + run.step_results.push(StepResult { + step_name: "analyze".to_string(), + agent_id: AgentId::new().to_string(), + agent_name: "mock-agent".to_string(), + output: "analysis-resume".to_string(), + input_tokens: 10, + output_tokens: 5, + duration_ms: 1, + }); + run.recovery = WorkflowRunRecoveryState { + next_step_index: 1, + current_input: "analysis-resume".to_string(), + all_outputs: vec!["analysis-resume".to_string()], + pending_fan_out_outputs: Vec::new(), + review_reject_counts: HashMap::new(), + variables: HashMap::new(), + }; + } + + let tempdir = tempfile::tempdir().unwrap(); + let snapshot_path = tempdir.path().join("interrupted-workflow.json"); + engine.save_recovery_snapshot(&snapshot_path).await.unwrap(); + + let recovered_engine = WorkflowEngine::load_recovery_snapshot(&snapshot_path) + .await + .unwrap(); + let recovered_run = recovered_engine.get_run(run_id).await.unwrap(); + assert!(matches!(recovered_run.state, WorkflowRunState::Blocked)); + assert_eq!(recovered_run.task_state.state, TaskExecutionState::Blocked); + assert_eq!(recovered_run.recovery.next_step_index, 1); + + let resumed_sender = |_id: AgentId, msg: String| async move { + if msg.starts_with("Analyze this:") { + Err("interrupted step must not restart from zero".to_string()) + } else { + Ok(("resumed-summary".to_string(), 10u64, 5u64)) + } + }; + + let resumed_output = recovered_engine + .execute_run(run_id, mock_resolver, resumed_sender) + .await + .unwrap(); + assert_eq!(resumed_output, "resumed-summary"); + } + + #[tokio::test] + async fn test_step_quality_gate_enforced_workflow_requires_gate_config() { + let engine = WorkflowEngine::new(); + let wf_id = engine.register(test_workflow()).await; + engine.enable_step_quality_gates(wf_id).await.unwrap(); + let run_id = engine + .create_run(wf_id, "raw data".to_string()) + .await + .unwrap(); + + let sender = |_id: AgentId, msg: String| async move { + Ok((format!("Processed: {msg}"), 10u64, 5u64)) + }; + + let error = engine + .execute_run(run_id, mock_resolver, sender) + .await + .unwrap_err(); + assert!(error.contains("requires acceptance criteria and validation command")); + + let run = engine.get_run(run_id).await.unwrap(); + assert!(matches!(run.state, WorkflowRunState::Failed)); + assert_eq!(run.step_results.len(), 0); + assert!(run.audit_events.iter().any(|event| { + event.step_name.as_deref() == Some("analyze") && event.outcome == "quality_gate_missing" + })); + } + + #[tokio::test] + async fn test_step_quality_gate_failure_blocks_step_completion() { + let engine = WorkflowEngine::new(); + let workflow = Workflow { + id: WorkflowId::new(), + name: "quality-gate-fail".to_string(), + description: "single step quality gate failure".to_string(), + steps: vec![WorkflowStep { + name: "analyze".to_string(), + agent: StepAgent::ByName { + name: "analyst".to_string(), + }, + prompt_template: "Analyze this: {{input}}".to_string(), + mode: StepMode::Sequential, + timeout_secs: 30, + error_mode: ErrorMode::Fail, + output_var: None, + }], + created_at: Utc::now(), + }; + let wf_id = workflow.id; + engine.register(workflow).await; + engine.enable_step_quality_gates(wf_id).await.unwrap(); + engine + .set_step_quality_gate( + wf_id, + "analyze", + WorkflowStepQualityGate { + acceptance_criteria: "output must equal approved".to_string(), + validation_command: r#"test "$OPENFANG_STEP_OUTPUT" = "approved""#.to_string(), + }, + ) + .await + .unwrap(); + + let run_id = engine + .create_run(wf_id, "raw data".to_string()) + .await + .unwrap(); + let sender = |_id: AgentId, _msg: String| async move { + Ok(("analysis output".to_string(), 10u64, 5u64)) + }; + + let error = engine + .execute_run(run_id, mock_resolver, sender) + .await + .unwrap_err(); + assert!(error.contains("quality gate failed")); + + let run = engine.get_run(run_id).await.unwrap(); + assert!(matches!(run.state, WorkflowRunState::Failed)); + assert_eq!(run.step_results.len(), 0); + assert!(run.audit_events.iter().any(|event| { + event.step_name.as_deref() == Some("analyze") && event.outcome == "quality_gate_failed" + })); + } + + #[tokio::test] + async fn test_step_quality_gate_snapshot_restore_and_pass() { + let engine = WorkflowEngine::new(); + let wf_id = engine.register(test_workflow()).await; + engine.enable_step_quality_gates(wf_id).await.unwrap(); + engine + .set_step_quality_gate( + wf_id, + "analyze", + WorkflowStepQualityGate { + acceptance_criteria: "analyze step receives output and criteria".to_string(), + validation_command: r#"test "$OPENFANG_STEP_NAME" = "analyze" && test -n "$OPENFANG_STEP_OUTPUT" && test -n "$OPENFANG_ACCEPTANCE_CRITERIA""#.to_string(), + }, + ) + .await + .unwrap(); + engine + .set_step_quality_gate( + wf_id, + "summarize", + WorkflowStepQualityGate { + acceptance_criteria: "summarize step receives output and criteria".to_string(), + validation_command: r#"test "$OPENFANG_STEP_NAME" = "summarize" && test -n "$OPENFANG_STEP_OUTPUT" && test -n "$OPENFANG_ACCEPTANCE_CRITERIA""#.to_string(), + }, + ) + .await + .unwrap(); + + let tempdir = tempfile::tempdir().unwrap(); + let snapshot_path = tempdir.path().join("workflow-gates.json"); + engine.save_recovery_snapshot(&snapshot_path).await.unwrap(); + + let recovered_engine = WorkflowEngine::load_recovery_snapshot(&snapshot_path) + .await + .unwrap(); + assert!(recovered_engine.step_quality_gates_enabled(wf_id).await); + let analyze_gate = recovered_engine + .get_step_quality_gate(wf_id, "analyze") + .await + .unwrap(); + assert_eq!( + analyze_gate.acceptance_criteria, + "analyze step receives output and criteria" + ); + + let run_id = recovered_engine + .create_run(wf_id, "raw data".to_string()) + .await + .unwrap(); + let sender = |_id: AgentId, msg: String| async move { + Ok((format!("Processed: {msg}"), 10u64, 5u64)) + }; + + let output = recovered_engine + .execute_run(run_id, mock_resolver, sender) + .await + .unwrap(); + assert!(output.contains("Processed:")); + + let run = recovered_engine.get_run(run_id).await.unwrap(); + assert!(matches!(run.state, WorkflowRunState::Completed)); + assert!(run.audit_events.iter().any(|event| { + event.step_name.as_deref() == Some("analyze") && event.outcome == "quality_gate_passed" + })); + assert!(run.audit_events.iter().any(|event| { + event.step_name.as_deref() == Some("summarize") + && event.outcome == "quality_gate_passed" + })); + } + + #[tokio::test] + async fn test_step_quality_gate_logs_capture_command_exit_output_and_timestamp() { + let engine = WorkflowEngine::new(); + let workflow = Workflow { + id: WorkflowId::new(), + name: "quality-gate-log-failure".to_string(), + description: "capture failed gate log".to_string(), + steps: vec![WorkflowStep { + name: "analyze".to_string(), + agent: StepAgent::ByName { + name: "analyst".to_string(), + }, + prompt_template: "Analyze this: {{input}}".to_string(), + mode: StepMode::Sequential, + timeout_secs: 30, + error_mode: ErrorMode::Fail, + output_var: None, + }], + created_at: Utc::now(), + }; + let wf_id = workflow.id; + engine.register(workflow).await; + engine.enable_step_quality_gates(wf_id).await.unwrap(); + let command = r#"printf 'gate failed output'; exit 7"#; + engine + .set_step_quality_gate( + wf_id, + "analyze", + WorkflowStepQualityGate { + acceptance_criteria: "must pass shell validation".to_string(), + validation_command: command.to_string(), + }, + ) + .await + .unwrap(); + + let run_id = engine + .create_run(wf_id, "raw data".to_string()) + .await + .unwrap(); + let before = Utc::now(); + let sender = |_id: AgentId, _msg: String| async move { + Ok(("analysis output".to_string(), 10u64, 5u64)) + }; + + let error = engine + .execute_run(run_id, mock_resolver, sender) + .await + .unwrap_err(); + let after = Utc::now(); + assert!(error.contains("quality gate failed")); + + let run = engine.get_run(run_id).await.unwrap(); + assert_eq!(run.quality_gate_logs.len(), 1); + let log = &run.quality_gate_logs[0]; + assert_eq!(log.step_name, "analyze"); + assert_eq!(log.validation_command, command); + assert_eq!(log.exit_code, Some(7)); + assert!(log.output.contains("gate failed output")); + assert!(log.attempted_at >= before); + assert!(log.attempted_at <= after); + } + + #[tokio::test] + async fn test_step_quality_gate_logs_capture_every_loop_attempt() { + let engine = WorkflowEngine::new(); + let workflow = Workflow { + id: WorkflowId::new(), + name: "quality-gate-loop-log".to_string(), + description: "capture every loop gate attempt".to_string(), + steps: vec![WorkflowStep { + name: "refine".to_string(), + agent: StepAgent::ByName { + name: "refiner".to_string(), + }, + prompt_template: "Refine: {{input}}".to_string(), + mode: StepMode::Loop { + max_iterations: 2, + until: "done".to_string(), + }, + timeout_secs: 30, + error_mode: ErrorMode::Fail, + output_var: None, + }], + created_at: Utc::now(), + }; + let wf_id = workflow.id; + engine.register(workflow).await; + engine.enable_step_quality_gates(wf_id).await.unwrap(); + engine + .set_step_quality_gate( + wf_id, + "refine", + WorkflowStepQualityGate { + acceptance_criteria: "loop outputs are logged".to_string(), + validation_command: r#"printf '%s' "$OPENFANG_STEP_OUTPUT""#.to_string(), + }, + ) + .await + .unwrap(); + + let run_id = engine.create_run(wf_id, "draft".to_string()).await.unwrap(); + let counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counter_ref = counter.clone(); + let sender = move |_id: AgentId, _msg: String| { + let counter_ref = counter_ref.clone(); + async move { + let current = counter_ref.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if current == 0 { + Ok(("keep going".to_string(), 10u64, 5u64)) + } else { + Ok(("done".to_string(), 10u64, 5u64)) + } + } + }; + + let output = engine + .execute_run(run_id, mock_resolver, sender) + .await + .unwrap(); + assert_eq!(output, "done"); + + let run = engine.get_run(run_id).await.unwrap(); + assert_eq!(run.quality_gate_logs.len(), 2); + assert_eq!(run.quality_gate_logs[0].output, "keep going"); + assert_eq!(run.quality_gate_logs[1].output, "done"); + assert_eq!(run.quality_gate_logs[0].exit_code, Some(0)); + assert_eq!(run.quality_gate_logs[1].exit_code, Some(0)); + } + + #[tokio::test] + async fn test_step_quality_gate_logs_persist_across_snapshot_restore() { + let engine = WorkflowEngine::new(); + let wf_id = engine.register(test_workflow()).await; + engine.enable_step_quality_gates(wf_id).await.unwrap(); + engine + .set_step_quality_gate( + wf_id, + "analyze", + WorkflowStepQualityGate { + acceptance_criteria: "store analyze gate output".to_string(), + validation_command: r#"printf '%s' "$OPENFANG_STEP_NAME""#.to_string(), + }, + ) + .await + .unwrap(); + engine + .set_step_quality_gate( + wf_id, + "summarize", + WorkflowStepQualityGate { + acceptance_criteria: "store summarize gate output".to_string(), + validation_command: r#"printf '%s' "$OPENFANG_STEP_NAME""#.to_string(), + }, + ) + .await + .unwrap(); + + let run_id = engine + .create_run(wf_id, "raw data".to_string()) + .await + .unwrap(); + let sender = |_id: AgentId, msg: String| async move { + Ok((format!("Processed: {msg}"), 10u64, 5u64)) + }; + engine + .execute_run(run_id, mock_resolver, sender) + .await + .unwrap(); + + let tempdir = tempfile::tempdir().unwrap(); + let snapshot_path = tempdir.path().join("workflow-gate-logs.json"); + engine.save_recovery_snapshot(&snapshot_path).await.unwrap(); + + let recovered_engine = WorkflowEngine::load_recovery_snapshot(&snapshot_path) + .await + .unwrap(); + let run = recovered_engine.get_run(run_id).await.unwrap(); + assert_eq!(run.quality_gate_logs.len(), 2); + assert_eq!(run.quality_gate_logs[0].output, "analyze"); + assert_eq!(run.quality_gate_logs[1].output, "summarize"); + assert_eq!(run.quality_gate_logs[0].exit_code, Some(0)); + assert_eq!(run.quality_gate_logs[1].exit_code, Some(0)); + assert!(run.quality_gate_logs[0].attempted_at <= run.quality_gate_logs[1].attempted_at); + } + + #[tokio::test] + async fn test_shadow_run_comparison_records_exact_and_normalized_matches() { + let engine = WorkflowEngine::new(); + let wf_id = engine.register(test_workflow()).await; + let run_id = engine + .create_run(wf_id, "raw data".to_string()) + .await + .unwrap(); + let sender = |_id: AgentId, msg: String| async move { + Ok((format!("Processed: {msg}"), 10u64, 5u64)) + }; + engine + .execute_run(run_id, mock_resolver, sender) + .await + .unwrap(); + + let exact = engine + .record_shadow_comparison( + run_id, + "Processed: Summarize this analysis: Processed: Analyze this: raw data", + ) + .await + .unwrap(); + assert!(exact.matches); + assert!(exact.normalized_matches); + assert_eq!(exact.first_mismatch_index, None); + + let normalized_only = engine + .record_shadow_comparison( + run_id, + "Processed: Summarize this analysis: +Processed: Analyze this: raw data", + ) + .await + .unwrap(); + assert!(!normalized_only.matches); + assert!(normalized_only.normalized_matches); + assert_eq!(normalized_only.first_mismatch_index, Some(11)); + + let run = engine.get_run(run_id).await.unwrap(); + let shadow = run.shadow.expect("shadow comparison should be stored"); + assert!(!shadow.matches); + assert!(shadow.normalized_matches); + assert_eq!(shadow.first_mismatch_index, Some(11)); + assert!(run.audit_events.iter().any(|event| { + event.outcome == "shadow_match" || event.outcome == "shadow_normalized_match" + })); + } + + #[tokio::test] + async fn test_shadow_run_comparison_persists_across_snapshot_restore() { + let engine = WorkflowEngine::new(); + let wf_id = engine.register(test_workflow()).await; + let run_id = engine + .create_run(wf_id, "raw data".to_string()) + .await + .unwrap(); + let sender = |_id: AgentId, msg: String| async move { + Ok((format!("Processed: {msg}"), 10u64, 5u64)) + }; + engine + .execute_run(run_id, mock_resolver, sender) + .await + .unwrap(); + engine + .record_shadow_comparison(run_id, "legacy production output") + .await + .unwrap(); + + let tempdir = tempfile::tempdir().unwrap(); + let snapshot_path = tempdir.path().join("workflow-shadow-comparison.json"); + engine.save_recovery_snapshot(&snapshot_path).await.unwrap(); + + let recovered_engine = WorkflowEngine::load_recovery_snapshot(&snapshot_path) + .await + .unwrap(); + let run = recovered_engine.get_run(run_id).await.unwrap(); + let shadow = run.shadow.expect("shadow comparison should persist"); + assert_eq!(shadow.production_output, "legacy production output"); + assert!(shadow + .shadow_output + .contains("Processed: Summarize this analysis")); + assert!(!shadow.matches); + assert!(run + .audit_events + .iter() + .any(|event| event.outcome == "shadow_mismatch")); + } + + #[tokio::test] + async fn test_workflow_rollout_state_defaults_and_fast_rollback() { + let engine = WorkflowEngine::new(); + let wf_id = engine.register(test_workflow()).await; + + let default_state = engine.get_rollout_state(wf_id).await.unwrap(); + assert_eq!(default_state.primary_path, WorkflowTrafficPath::Production); + assert_eq!(default_state.stable_path, WorkflowTrafficPath::Production); + assert!(!default_state.shadow_enabled); + assert_eq!(default_state.rollback_window_secs, 300); + assert!(default_state.rollback_checklist.len() >= 4); + + let promoted = engine + .update_rollout_state( + wf_id, + Some(WorkflowTrafficPath::Openfang), + Some(WorkflowTrafficPath::Production), + Some(true), + Some(300), + ) + .await + .unwrap(); + assert_eq!(promoted.primary_path, WorkflowTrafficPath::Openfang); + assert_eq!(promoted.stable_path, WorkflowTrafficPath::Production); + assert!(promoted.shadow_enabled); + + let rolled_back = engine.rollback_to_stable_path(wf_id).await.unwrap(); + assert_eq!(rolled_back.primary_path, WorkflowTrafficPath::Production); + assert_eq!(rolled_back.stable_path, WorkflowTrafficPath::Production); + assert!(!rolled_back.shadow_enabled); + let record = rolled_back + .last_rollback + .expect("rollback record should exist"); + assert_eq!(record.from_path, WorkflowTrafficPath::Openfang); + assert_eq!(record.to_path, WorkflowTrafficPath::Production); + assert!(record.shadow_enabled_before); + assert!(!record.shadow_enabled_after); + assert!(record.within_window); + assert!(record.duration_ms <= 300_000); + assert_eq!(record.checklist.len(), rolled_back.rollback_checklist.len()); + } + + #[tokio::test] + async fn test_workflow_rollout_state_persists_across_snapshot_restore() { + let engine = WorkflowEngine::new(); + let wf_id = engine.register(test_workflow()).await; + engine + .update_rollout_state( + wf_id, + Some(WorkflowTrafficPath::Openfang), + Some(WorkflowTrafficPath::Production), + Some(true), + Some(300), + ) + .await + .unwrap(); + engine.rollback_to_stable_path(wf_id).await.unwrap(); + + let tempdir = tempfile::tempdir().unwrap(); + let snapshot_path = tempdir.path().join("workflow-rollout-state.json"); + engine.save_recovery_snapshot(&snapshot_path).await.unwrap(); + + let recovered_engine = WorkflowEngine::load_recovery_snapshot(&snapshot_path) + .await + .unwrap(); + let recovered = recovered_engine.get_rollout_state(wf_id).await.unwrap(); + assert_eq!(recovered.primary_path, WorkflowTrafficPath::Production); + assert_eq!(recovered.stable_path, WorkflowTrafficPath::Production); + assert!(!recovered.shadow_enabled); + assert_eq!(recovered.rollback_window_secs, 300); + let record = recovered + .last_rollback + .expect("rollback record should persist across snapshot restore"); + assert_eq!(record.from_path, WorkflowTrafficPath::Openfang); + assert_eq!(record.to_path, WorkflowTrafficPath::Production); + assert!(record.within_window); + assert!(!record.checklist.is_empty()); + } + + #[tokio::test] + async fn test_expand_variables() { + let mut vars = HashMap::new(); + vars.insert("name".to_string(), "Alice".to_string()); + vars.insert("task".to_string(), "code review".to_string()); + + let result = WorkflowEngine::expand_variables( + "Hello {{name}}, please do {{task}} on {{input}}", + "main.rs", + &vars, + ); + assert_eq!(result, "Hello Alice, please do code review on main.rs"); + } + + #[tokio::test] + async fn test_error_mode_serialization() { + let fail_json = serde_json::to_string(&ErrorMode::Fail).unwrap(); + assert_eq!(fail_json, "\"fail\""); + + let skip_json = serde_json::to_string(&ErrorMode::Skip).unwrap(); + assert_eq!(skip_json, "\"skip\""); + + let retry_json = serde_json::to_string(&ErrorMode::Retry { max_retries: 3 }).unwrap(); + let retry: ErrorMode = serde_json::from_str(&retry_json).unwrap(); + assert!(matches!(retry, ErrorMode::Retry { max_retries: 3 })); + } + + #[tokio::test] + async fn test_step_mode_conditional_serialization() { + let mode = StepMode::Conditional { + condition: "error".to_string(), + }; + let json = serde_json::to_string(&mode).unwrap(); + let parsed: StepMode = serde_json::from_str(&json).unwrap(); + assert!(matches!(parsed, StepMode::Conditional { condition } if condition == "error")); + } + + #[tokio::test] + async fn test_step_mode_loop_serialization() { + let mode = StepMode::Loop { + max_iterations: 5, + until: "done".to_string(), + }; + let json = serde_json::to_string(&mode).unwrap(); + let parsed: StepMode = serde_json::from_str(&json).unwrap(); + assert!(matches!(parsed, StepMode::Loop { max_iterations: 5, until } if until == "done")); + } + + #[tokio::test] + async fn test_route_workflow_by_channel_task_type_and_risk() { + let engine = WorkflowEngine::new(); + + let general = test_workflow(); + let general_id = general.id; + engine.register(general).await; + + let incident = Workflow { + id: WorkflowId::new(), + name: "incident-workflow".to_string(), + description: "incident route".to_string(), + steps: vec![], + created_at: Utc::now(), + }; + let incident_id = incident.id; + engine.register(incident).await; + + engine + .set_route_rules(vec![ + WorkflowRouteRule { + workflow_id: incident_id, + user_id: None, + channel: Some("feishu".to_string()), + task_type: Some("incident".to_string()), + risk_policy: WorkflowRiskPolicy::Max(RiskLevel::High), + priority: 10, + }, + WorkflowRouteRule { + workflow_id: general_id, + user_id: None, + channel: None, + task_type: None, + risk_policy: WorkflowRiskPolicy::Any, + priority: 1, + }, + ]) + .await; + + let request = WorkflowRouteRequest { + user_id: "u-1".to_string(), + channel: "feishu".to_string(), + task_type: "incident".to_string(), + risk_level: RiskLevel::Medium, + }; + assert_eq!(engine.route_workflow(&request).await, Some(incident_id)); + + let critical = WorkflowRouteRequest { + risk_level: RiskLevel::Critical, + ..request + }; + assert_eq!(engine.route_workflow(&critical).await, Some(general_id)); + } + + #[tokio::test] + async fn test_route_prefers_specific_user_rule() { + let engine = WorkflowEngine::new(); + + let default_workflow = test_workflow(); + let default_id = default_workflow.id; + engine.register(default_workflow).await; + + let vip_workflow = Workflow { + id: WorkflowId::new(), + name: "vip".to_string(), + description: "vip route".to_string(), + steps: vec![], + created_at: Utc::now(), + }; + let vip_id = vip_workflow.id; + engine.register(vip_workflow).await; + + engine + .set_route_rules(vec![ + WorkflowRouteRule { + workflow_id: default_id, + user_id: None, + channel: Some("telegram".to_string()), + task_type: Some("support".to_string()), + risk_policy: WorkflowRiskPolicy::Any, + priority: 5, + }, + WorkflowRouteRule { + workflow_id: vip_id, + user_id: Some("vip-user".to_string()), + channel: Some("telegram".to_string()), + task_type: Some("support".to_string()), + risk_policy: WorkflowRiskPolicy::AllowList(vec![ + RiskLevel::Low, + RiskLevel::Medium, + RiskLevel::High, + ]), + priority: 5, + }, + ]) + .await; + + let vip_request = WorkflowRouteRequest { + user_id: "vip-user".to_string(), + channel: "telegram".to_string(), + task_type: "support".to_string(), + risk_level: RiskLevel::High, + }; + assert_eq!(engine.route_workflow(&vip_request).await, Some(vip_id)); + + let normal_request = WorkflowRouteRequest { + user_id: "regular-user".to_string(), + channel: "telegram".to_string(), + task_type: "support".to_string(), + risk_level: RiskLevel::High, + }; + assert_eq!( + engine.route_workflow(&normal_request).await, + Some(default_id) + ); } } diff --git a/crates/openfang-kernel/tests/session_resume_integration_test.rs b/crates/openfang-kernel/tests/session_resume_integration_test.rs new file mode 100644 index 000000000..3379a103b --- /dev/null +++ b/crates/openfang-kernel/tests/session_resume_integration_test.rs @@ -0,0 +1,254 @@ +//! End-to-end integration tests for multi-session isolation and workflow resume. +//! +//! These tests avoid real LLM calls while still exercising the public kernel / +//! workflow APIs used by the multi-agent foundation features. + +use openfang_kernel::workflow::{ + ErrorMode, StepAgent, StepMode, Workflow, WorkflowEngine, WorkflowId, WorkflowRunState, + WorkflowStep, +}; +use openfang_kernel::OpenFangKernel; +use openfang_memory::session::Session; +use openfang_types::agent::{AgentId, AgentManifest, SessionId}; +use uuid::Uuid; +use openfang_types::config::{DefaultModelConfig, KernelConfig}; +use openfang_types::message::Message; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +fn test_config(tmp: &tempfile::TempDir) -> KernelConfig { + KernelConfig { + home_dir: tmp.path().to_path_buf(), + data_dir: tmp.path().join("data"), + default_model: DefaultModelConfig { + provider: "ollama".to_string(), + model: "test-model".to_string(), + api_key_env: "OLLAMA_API_KEY".to_string(), + base_url: None, + }, + ..KernelConfig::default() + } +} + +fn session_test_manifest(workspace: PathBuf) -> AgentManifest { + let mut manifest = AgentManifest::default(); + manifest.name = "session-e2e-agent".to_string(); + manifest.description = "Session isolation e2e test agent".to_string(); + manifest.author = "test".to_string(); + manifest.module = "builtin:chat".to_string(); + manifest.model.provider = "ollama".to_string(); + manifest.model.model = "test-model".to_string(); + manifest.model.api_key_env = Some("OLLAMA_API_KEY".to_string()); + manifest.model.system_prompt = "Test agent".to_string(); + manifest.capabilities.memory_read = vec!["*".to_string()]; + manifest.capabilities.memory_write = vec!["self.*".to_string()]; + manifest.workspace = Some(workspace); + manifest +} + +fn write_session_messages( + kernel: &OpenFangKernel, + session_id: SessionId, + agent_id: AgentId, + user_text: &str, + assistant_text: &str, +) { + let session = Session { + id: session_id, + agent_id, + messages: vec![Message::user(user_text), Message::assistant(assistant_text)], + context_window_tokens: 0, + label: None, + }; + kernel.memory.save_session(&session).unwrap(); +} + +fn only_markdown_file(dir: &Path) -> PathBuf { + let entries: Vec = std::fs::read_dir(dir) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("md")) + .collect(); + assert_eq!(entries.len(), 1, "expected one markdown file in {}", dir.display()); + entries[0].clone() +} + +#[test] +fn test_multi_session_e2e_session_summaries_stay_scoped() { + let tmp = tempfile::tempdir().unwrap(); + let workspace = tmp.path().join("workspaces").join("session-e2e-agent"); + let config = test_config(&tmp); + let kernel = OpenFangKernel::boot_with_config(config).expect("Kernel should boot"); + + let agent_id = kernel + .spawn_agent(session_test_manifest(workspace.clone())) + .expect("Agent should spawn"); + + let session_a = kernel.registry.get(agent_id).unwrap().session_id; + let session_b = kernel + .create_agent_session(agent_id, Some("session-b")) + .unwrap()["session_id"] + .as_str() + .map(|raw| SessionId(Uuid::parse_str(raw).unwrap())) + .unwrap(); + + write_session_messages( + &kernel, + session_a, + agent_id, + "alpha confidential thread", + "alpha assistant reply", + ); + write_session_messages( + &kernel, + session_b, + agent_id, + "bravo isolated topic", + "bravo assistant reply", + ); + + kernel.switch_agent_session(agent_id, session_a).unwrap(); + kernel.reset_session(agent_id).unwrap(); + + kernel.switch_agent_session(agent_id, session_b).unwrap(); + kernel.reset_session(agent_id).unwrap(); + + let session_a_memory_dir = workspace + .join(".session-workspaces") + .join(session_a.to_string()) + .join("memory"); + let session_b_memory_dir = workspace + .join(".session-workspaces") + .join(session_b.to_string()) + .join("memory"); + + let session_a_summary = std::fs::read_to_string(only_markdown_file(&session_a_memory_dir)).unwrap(); + let session_b_summary = std::fs::read_to_string(only_markdown_file(&session_b_memory_dir)).unwrap(); + + assert!(session_a_summary.contains("alpha confidential thread")); + assert!(!session_a_summary.contains("bravo isolated topic")); + assert!(session_b_summary.contains("bravo isolated topic")); + assert!(!session_b_summary.contains("alpha confidential thread")); + + let base_memory_entries: Vec<_> = std::fs::read_dir(workspace.join("memory")) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect(); + assert!( + base_memory_entries.is_empty(), + "base workspace memory should stay empty when concurrent sessions are isolated" + ); + + kernel.shutdown(); +} + +#[tokio::test] +async fn test_workflow_e2e_resume_after_interrupted_snapshot_without_llm() { + let engine = Arc::new(WorkflowEngine::new()); + let workflow = Workflow { + id: WorkflowId::new(), + name: "e2e-session-resume".to_string(), + description: "integration e2e for interrupted snapshot resume".to_string(), + steps: vec![ + WorkflowStep { + name: "analyze".to_string(), + agent: StepAgent::ByName { + name: "planner".to_string(), + }, + prompt_template: "Analyze this: {{input}}".to_string(), + mode: StepMode::Sequential, + timeout_secs: 30, + error_mode: ErrorMode::Fail, + output_var: Some("analysis".to_string()), + }, + WorkflowStep { + name: "summarize".to_string(), + agent: StepAgent::ByName { + name: "writer".to_string(), + }, + prompt_template: "Summarize this analysis: {{input}}".to_string(), + mode: StepMode::Sequential, + timeout_secs: 30, + error_mode: ErrorMode::Fail, + output_var: None, + }, + ], + created_at: chrono::Utc::now(), + }; + + let workflow_id = engine.register(workflow).await; + let run_id = engine + .create_run(workflow_id, "interrupted raw input".to_string()) + .await + .unwrap(); + + let resolver = |_agent: &StepAgent| Some((AgentId::new(), "mock-agent".to_string())); + let summarize_started = Arc::new(tokio::sync::Notify::new()); + let allow_summary_finish = Arc::new(tokio::sync::Notify::new()); + let summarize_started_ref = summarize_started.clone(); + let allow_summary_finish_ref = allow_summary_finish.clone(); + + let engine_for_run = engine.clone(); + let run_handle = tokio::spawn(async move { + let sender = move |_agent_id: AgentId, message: String| { + let summarize_started_ref = summarize_started_ref.clone(); + let allow_summary_finish_ref = allow_summary_finish_ref.clone(); + async move { + if message.starts_with("Analyze this:") { + Ok(("analysis-ready".to_string(), 10u64, 5u64)) + } else { + summarize_started_ref.notify_one(); + allow_summary_finish_ref.notified().await; + Ok(("summary-ready".to_string(), 10u64, 5u64)) + } + } + }; + + engine_for_run.execute_run(run_id, resolver, sender).await + }); + + summarize_started.notified().await; + + let tempdir = tempfile::tempdir().unwrap(); + let snapshot_path = tempdir.path().join("interrupted-workflow.json"); + engine.save_recovery_snapshot(&snapshot_path).await.unwrap(); + + run_handle.abort(); + let _ = run_handle.await; + + let recovered_engine = WorkflowEngine::load_recovery_snapshot(&snapshot_path) + .await + .unwrap(); + let recovered_run = recovered_engine.get_run(run_id).await.unwrap(); + assert!(matches!(recovered_run.state, WorkflowRunState::Blocked)); + assert_eq!(recovered_run.step_results.len(), 1); + assert_eq!(recovered_run.step_results[0].output, "analysis-ready"); + + let resumed_prompts = Arc::new(std::sync::Mutex::new(Vec::::new())); + let resumed_prompts_ref = resumed_prompts.clone(); + let resumed_sender = move |_agent_id: AgentId, message: String| { + let resumed_prompts_ref = resumed_prompts_ref.clone(); + async move { + resumed_prompts_ref.lock().unwrap().push(message.clone()); + if message.starts_with("Analyze this:") { + Err("analyze step should not rerun after interrupted recovery".to_string()) + } else { + Ok(("summary-ready".to_string(), 10u64, 5u64)) + } + } + }; + + let resumed_output = recovered_engine + .execute_run(run_id, resolver, resumed_sender) + .await + .unwrap(); + assert_eq!(resumed_output, "summary-ready"); + + let resumed_run = recovered_engine.get_run(run_id).await.unwrap(); + assert!(matches!(resumed_run.state, WorkflowRunState::Completed)); + assert_eq!(resumed_run.step_results.len(), 2); + + let prompts = resumed_prompts.lock().unwrap(); + assert_eq!(prompts.len(), 1); + assert!(prompts[0].starts_with("Summarize this analysis:")); +} diff --git a/crates/openfang-kernel/tests/workflow_integration_test.rs b/crates/openfang-kernel/tests/workflow_integration_test.rs index 570e18a94..30ced74df 100644 --- a/crates/openfang-kernel/tests/workflow_integration_test.rs +++ b/crates/openfang-kernel/tests/workflow_integration_test.rs @@ -7,10 +7,11 @@ //! workflow wiring without making real API calls. use openfang_kernel::workflow::{ - ErrorMode, StepAgent, StepMode, Workflow, WorkflowId, WorkflowStep, + ErrorMode, StepAgent, StepMode, Workflow, WorkflowEngine, WorkflowId, WorkflowRunState, + WorkflowStep, }; use openfang_kernel::OpenFangKernel; -use openfang_types::agent::AgentManifest; +use openfang_types::agent::{AgentId, AgentManifest}; use openfang_types::config::{DefaultModelConfig, KernelConfig}; use std::sync::Arc; @@ -295,6 +296,228 @@ memory_write = ["self.*"] kernel.shutdown(); } +/// End-to-end workflow engine test (no LLM): review rejection returns to planning, +/// then approved output proceeds to dispatch. +#[tokio::test] +async fn test_workflow_e2e_reject_return_without_llm() { + let engine = WorkflowEngine::new(); + let workflow = Workflow { + id: WorkflowId::new(), + name: "e2e-review-return".to_string(), + description: "integration e2e for reject-return".to_string(), + steps: vec![ + WorkflowStep { + name: "planning".to_string(), + agent: StepAgent::ByName { + name: "planner".to_string(), + }, + prompt_template: "Plan: {{input}}".to_string(), + mode: StepMode::Sequential, + timeout_secs: 30, + error_mode: ErrorMode::Fail, + output_var: None, + }, + WorkflowStep { + name: "review".to_string(), + agent: StepAgent::ByName { + name: "reviewer".to_string(), + }, + prompt_template: "Review: {{input}}".to_string(), + mode: StepMode::Review { + reject_if_contains: "reject".to_string(), + return_to_step: "planning".to_string(), + max_rejects: 2, + }, + timeout_secs: 30, + error_mode: ErrorMode::Fail, + output_var: None, + }, + WorkflowStep { + name: "dispatch".to_string(), + agent: StepAgent::ByName { + name: "dispatcher".to_string(), + }, + prompt_template: "Dispatch: {{input}}".to_string(), + mode: StepMode::Sequential, + timeout_secs: 30, + error_mode: ErrorMode::Fail, + output_var: None, + }, + ], + created_at: chrono::Utc::now(), + }; + + let workflow_id = engine.register(workflow).await; + let run_id = engine + .create_run(workflow_id, "initial request".to_string()) + .await + .unwrap(); + + let prompts = Arc::new(std::sync::Mutex::new(Vec::::new())); + let plan_count = Arc::new(std::sync::atomic::AtomicU32::new(0)); + let review_count = Arc::new(std::sync::atomic::AtomicU32::new(0)); + + let prompts_ref = prompts.clone(); + let plan_ref = plan_count.clone(); + let review_ref = review_count.clone(); + + let resolver = |_agent: &StepAgent| Some((AgentId::new(), "mock-agent".to_string())); + let sender = move |_agent_id: AgentId, message: String| { + let prompts_ref = prompts_ref.clone(); + let plan_ref = plan_ref.clone(); + let review_ref = review_ref.clone(); + async move { + prompts_ref.lock().unwrap().push(message.clone()); + if message.starts_with("Plan:") { + let n = plan_ref.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if n == 0 { + Ok(("plan-v1".to_string(), 10u64, 5u64)) + } else { + Ok(("plan-v2".to_string(), 10u64, 5u64)) + } + } else if message.starts_with("Review:") { + let n = review_ref.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if n == 0 { + Ok(("REJECT: incomplete".to_string(), 10u64, 5u64)) + } else { + Ok(("APPROVED: good".to_string(), 10u64, 5u64)) + } + } else { + Ok((format!("dispatch-final: {message}"), 10u64, 5u64)) + } + } + }; + + let result = engine.execute_run(run_id, resolver, sender).await; + assert!( + result.is_ok(), + "workflow should complete: {:?}", + result.err() + ); + let output = result.unwrap(); + assert!(output.contains("dispatch-final: Dispatch: APPROVED")); + + let run = engine.get_run(run_id).await.unwrap(); + assert!(matches!(run.state, WorkflowRunState::Completed)); + assert_eq!(run.step_results.len(), 5); // planning x2 + review x2 + dispatch x1 + assert_eq!(plan_count.load(std::sync::atomic::Ordering::SeqCst), 2); + assert_eq!(review_count.load(std::sync::atomic::Ordering::SeqCst), 2); + + let prompts = prompts.lock().unwrap(); + let planning_prompts: Vec<&String> = + prompts.iter().filter(|p| p.starts_with("Plan:")).collect(); + assert_eq!(planning_prompts.len(), 2); + assert!(planning_prompts[1].contains("REJECT: incomplete")); +} + +/// End-to-end workflow engine test (no LLM): fan-out branches aggregate into a +/// single collected payload consumed by downstream step. +#[tokio::test] +async fn test_workflow_e2e_parallel_fanout_aggregation_without_llm() { + let engine = WorkflowEngine::new(); + let workflow = Workflow { + id: WorkflowId::new(), + name: "e2e-fanout-collect".to_string(), + description: "integration e2e for fan-out/fan-in".to_string(), + steps: vec![ + WorkflowStep { + name: "prepare".to_string(), + agent: StepAgent::ByName { + name: "planner".to_string(), + }, + prompt_template: "Prepare: {{input}}".to_string(), + mode: StepMode::Sequential, + timeout_secs: 30, + error_mode: ErrorMode::Fail, + output_var: None, + }, + WorkflowStep { + name: "branch-a".to_string(), + agent: StepAgent::ByName { + name: "worker-a".to_string(), + }, + prompt_template: "Branch A: {{input}}".to_string(), + mode: StepMode::FanOut, + timeout_secs: 30, + error_mode: ErrorMode::Fail, + output_var: None, + }, + WorkflowStep { + name: "branch-b".to_string(), + agent: StepAgent::ByName { + name: "worker-b".to_string(), + }, + prompt_template: "Branch B: {{input}}".to_string(), + mode: StepMode::FanOut, + timeout_secs: 30, + error_mode: ErrorMode::Fail, + output_var: None, + }, + WorkflowStep { + name: "collect".to_string(), + agent: StepAgent::ByName { + name: "collector".to_string(), + }, + prompt_template: "unused".to_string(), + mode: StepMode::Collect, + timeout_secs: 30, + error_mode: ErrorMode::Fail, + output_var: None, + }, + WorkflowStep { + name: "finalize".to_string(), + agent: StepAgent::ByName { + name: "finalizer".to_string(), + }, + prompt_template: "Finalize: {{input}}".to_string(), + mode: StepMode::Sequential, + timeout_secs: 30, + error_mode: ErrorMode::Fail, + output_var: None, + }, + ], + created_at: chrono::Utc::now(), + }; + + let workflow_id = engine.register(workflow).await; + let run_id = engine + .create_run(workflow_id, "raw-task".to_string()) + .await + .unwrap(); + + let resolver = |_agent: &StepAgent| Some((AgentId::new(), "mock-agent".to_string())); + let sender = |_agent_id: AgentId, message: String| async move { + let output = if message.starts_with("Prepare:") { + "prepared".to_string() + } else if message.starts_with("Branch A:") { + "branch-a-result".to_string() + } else if message.starts_with("Branch B:") { + "branch-b-result".to_string() + } else if message.starts_with("Finalize:") { + format!("finalized: {message}") + } else { + format!("unexpected: {message}") + }; + Ok((output, 10u64, 5u64)) + }; + + let result = engine.execute_run(run_id, resolver, sender).await; + assert!( + result.is_ok(), + "workflow should complete: {:?}", + result.err() + ); + let output = result.unwrap(); + + assert!(output.contains("branch-a-result")); + assert!(output.contains("branch-b-result")); + assert!(!output.contains("prepared")); + + let run = engine.get_run(run_id).await.unwrap(); + assert!(matches!(run.state, WorkflowRunState::Completed)); + assert_eq!(run.step_results.len(), 4); // prepare + 2 fanout branches + finalize +} + // --------------------------------------------------------------------------- // Full E2E with real LLM (skip if no GROQ_API_KEY) // --------------------------------------------------------------------------- diff --git a/crates/openfang-memory/src/structured.rs b/crates/openfang-memory/src/structured.rs index 0288c835a..655b00bbe 100644 --- a/crates/openfang-memory/src/structured.rs +++ b/crates/openfang-memory/src/structured.rs @@ -99,13 +99,12 @@ impl StructuredStore { let mut pairs = Vec::new(); for row in rows { let (key, blob) = row.map_err(|e| OpenFangError::Memory(e.to_string()))?; - let value: serde_json::Value = serde_json::from_slice(&blob) - .unwrap_or_else(|_| { - // Fallback: try as UTF-8 string - String::from_utf8(blob) - .map(serde_json::Value::String) - .unwrap_or(serde_json::Value::Null) - }); + let value: serde_json::Value = serde_json::from_slice(&blob).unwrap_or_else(|_| { + // Fallback: try as UTF-8 string + String::from_utf8(blob) + .map(serde_json::Value::String) + .unwrap_or(serde_json::Value::Null) + }); pairs.push((key, value)); } Ok(pairs) diff --git a/crates/openfang-migrate/src/openclaw.rs b/crates/openfang-migrate/src/openclaw.rs index 5bd9a262c..83ccb1a4e 100644 --- a/crates/openfang-migrate/src/openclaw.rs +++ b/crates/openfang-migrate/src/openclaw.rs @@ -71,6 +71,55 @@ struct OpenClawRootTools { deny: Option>, } +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +enum OpenClawIdentity { + Text(String), + Object(serde_json::Map), +} + +impl OpenClawIdentity { + fn system_prompt(&self) -> Option { + match self { + Self::Text(text) => { + let trimmed = text.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + } + Self::Object(map) => Self::system_prompt_from_map(map), + } + } + + fn system_prompt_from_map(map: &serde_json::Map) -> Option { + for key in [ + "systemPrompt", + "system_prompt", + "prompt", + "persona", + "instructions", + "content", + "text", + "description", + "role", + ] { + if let Some(prompt) = map.get(key).and_then(Self::system_prompt_from_value) { + return Some(prompt); + } + } + None + } + + fn system_prompt_from_value(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::String(text) => { + let trimmed = text.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + } + serde_json::Value::Object(map) => Self::system_prompt_from_map(map), + _ => None, + } + } +} + #[derive(Debug, Default, Deserialize)] #[serde(default, rename_all = "camelCase")] struct OpenClawAgents { @@ -84,7 +133,7 @@ struct OpenClawAgentDefaults { model: Option, workspace: Option, tools: Option, - identity: Option, + identity: Option, } /// Agent model reference — either `"provider/model"` or `{ primary, fallbacks }`. @@ -111,7 +160,7 @@ struct OpenClawAgentEntry { tools: Option, workspace: Option, skills: Option>, - identity: Option, + identity: Option, } #[derive(Debug, Default, Clone, Deserialize)] @@ -263,6 +312,19 @@ struct OpenClawFeishuConfig { app_secret: Option, domain: Option, dm_policy: Option, + allow_from: Option>, + default_agent: Option, + #[serde(alias = "routeMap")] + routes: Option, + #[serde( + alias = "userBindings", + alias = "agentBindings", + alias = "routeBindings" + )] + bindings: Option, + #[serde(alias = "userAgentMap")] + user_to_agent: Option, + user_routes: Option, enabled: Option, } @@ -398,6 +460,8 @@ struct OpenFangConfig { network: OpenFangNetworkSection, #[serde(skip_serializing_if = "Option::is_none")] channels: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + bindings: Vec, } #[derive(Serialize)] @@ -427,6 +491,219 @@ struct OpenFangNetworkSection { // Secrets & policy helpers // --------------------------------------------------------------------------- +fn string_like_json(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::String(text) => { + let trimmed = text.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + } + serde_json::Value::Number(number) => Some(number.to_string()), + serde_json::Value::Bool(flag) => Some(flag.to_string()), + _ => None, + } +} + +fn feishu_binding(peer_id: impl Into, agent: impl Into) -> AgentBinding { + AgentBinding { + agent: agent.into(), + match_rule: BindingMatchRule { + channel: Some("feishu".to_string()), + peer_id: Some(peer_id.into()), + ..Default::default() + }, + } +} + +fn parse_feishu_binding_entry( + entry: &serde_json::Value, + fallback_peer_id: Option<&str>, +) -> Option { + match entry { + serde_json::Value::String(agent) => fallback_peer_id.and_then(|peer_id| { + let peer_id = peer_id.trim(); + let agent = agent.trim(); + (!peer_id.is_empty() && !agent.is_empty()) + .then(|| feishu_binding(peer_id.to_string(), agent.to_string())) + }), + serde_json::Value::Object(map) => { + let agent = [ + "agent", + "agentId", + "agent_id", + "target", + "targetAgent", + "target_agent", + "defaultAgent", + "default_agent", + ] + .iter() + .find_map(|key| map.get(*key).and_then(string_like_json))?; + + let peer_id = [ + "peerId", + "peer_id", + "user", + "userId", + "user_id", + "openId", + "open_id", + "senderId", + "sender_id", + "chatId", + "chat_id", + ] + .iter() + .find_map(|key| map.get(*key).and_then(string_like_json)) + .or_else(|| fallback_peer_id.map(|peer_id| peer_id.trim().to_string()))?; + + (!peer_id.is_empty() && !agent.is_empty()).then(|| feishu_binding(peer_id, agent)) + } + _ => None, + } +} + +fn extend_feishu_bindings_from_json_value( + value: &serde_json::Value, + source_label: &str, + out: &mut Vec, + report: &mut MigrationReport, +) { + match value { + serde_json::Value::Object(map) => { + let mut imported_any = false; + for (fallback_peer_id, entry) in map { + if let Some(binding) = parse_feishu_binding_entry(entry, Some(fallback_peer_id)) { + out.push(binding); + imported_any = true; + } + } + if !imported_any && !map.is_empty() { + report.warnings.push(format!( + "Feishu {source_label} entries were present but no valid peer→agent routes were found" + )); + } + } + serde_json::Value::Array(entries) => { + let mut imported_any = false; + for entry in entries { + if let Some(binding) = parse_feishu_binding_entry(entry, None) { + out.push(binding); + imported_any = true; + } + } + if !imported_any && !entries.is_empty() { + report.warnings.push(format!( + "Feishu {source_label} array was present but no valid peer→agent routes were found" + )); + } + } + serde_json::Value::Null => {} + _ => report.warnings.push(format!( + "Feishu {source_label} must be an object or array to migrate routes" + )), + } +} + +fn dedupe_feishu_bindings(mut bindings: Vec) -> Vec { + let mut seen = std::collections::BTreeSet::new(); + bindings.retain(|binding| { + let key = ( + binding.agent.clone(), + binding.match_rule.channel.clone(), + binding.match_rule.account_id.clone(), + binding.match_rule.peer_id.clone(), + binding.match_rule.guild_id.clone(), + binding.match_rule.roles.clone(), + ); + seen.insert(key) + }); + bindings.sort_by(|left, right| { + left.match_rule + .peer_id + .cmp(&right.match_rule.peer_id) + .then(left.agent.cmp(&right.agent)) + }); + bindings +} + +fn extract_json5_feishu_bindings( + root: &OpenClawRoot, + report: &mut MigrationReport, +) -> Vec { + let Some(feishu) = root + .channels + .as_ref() + .and_then(|channels| channels.feishu.as_ref()) + else { + return Vec::new(); + }; + + let mut bindings = Vec::new(); + for (label, value) in [ + ("userToAgent", feishu.user_to_agent.as_ref()), + ("userRoutes", feishu.user_routes.as_ref()), + ("routes", feishu.routes.as_ref()), + ("bindings", feishu.bindings.as_ref()), + ] { + if let Some(value) = value { + extend_feishu_bindings_from_json_value(value, label, &mut bindings, report); + } + } + + if let Some(allowed_users) = feishu.allow_from.as_ref() { + if let Some(default_agent) = feishu.default_agent.as_ref() { + for peer_id in allowed_users { + let peer_id = peer_id.trim(); + if !peer_id.is_empty() { + bindings.push(feishu_binding(peer_id.to_string(), default_agent.clone())); + } + } + } else if !allowed_users.is_empty() { + report.warnings.push( + "Feishu allowFrom users were found but no defaultAgent was set, so no user bindings were migrated" + .to_string(), + ); + } + } + + dedupe_feishu_bindings(bindings) +} + +fn extract_legacy_feishu_bindings( + source: &Path, + report: &mut MigrationReport, +) -> Result, MigrateError> { + let yaml_path = source.join("messaging").join("feishu.yaml"); + if !yaml_path.exists() { + return Ok(Vec::new()); + } + + let yaml_str = std::fs::read_to_string(&yaml_path)?; + let channel: LegacyYamlChannelConfig = serde_yaml::from_str(&yaml_str).unwrap_or_default(); + if channel.allowed_users.is_empty() { + return Ok(Vec::new()); + } + + let Some(default_agent) = channel.default_agent else { + report.warnings.push( + "Legacy Feishu allowed_users were found but default_agent is missing, so no user bindings were migrated" + .to_string(), + ); + return Ok(Vec::new()); + }; + + Ok(dedupe_feishu_bindings( + channel + .allowed_users + .into_iter() + .filter_map(|peer_id| { + let peer_id = peer_id.trim().to_string(); + (!peer_id.is_empty()).then(|| feishu_binding(peer_id, default_agent.clone())) + }) + .collect(), + )) +} + /// Write or update a key in a secrets.env file. /// File format: one `KEY=value` per line. Existing keys are overwritten. fn write_secret_env(path: &Path, key: &str, value: &str) -> Result<(), std::io::Error> { @@ -615,6 +892,7 @@ fn find_config_file(dir: &Path) -> Option { } // Tool name mapping and recognition are shared with the skill system. +use openfang_types::config::{AgentBinding, BindingMatchRule}; use openfang_types::tool_compat::{is_known_openfang_tool, map_tool_name}; /// Map OpenClaw tool profile to OpenFang capability tool list. @@ -634,9 +912,10 @@ fn tools_for_profile(profile: &str) -> Vec { /// Map OpenClaw provider name to OpenFang provider name. fn map_provider(openclaw_provider: &str) -> String { - match openclaw_provider.to_lowercase().as_str() { + let normalized = openclaw_provider.trim().to_lowercase().replace('-', "_"); + match normalized.as_str() { "anthropic" | "claude" => "anthropic".to_string(), - "openai" | "gpt" => "openai".to_string(), + "openai" | "gpt" | "codex" | "openai_codex" => "openai".to_string(), "groq" => "groq".to_string(), "ollama" => "ollama".to_string(), "openrouter" => "openrouter".to_string(), @@ -648,13 +927,23 @@ fn map_provider(openclaw_provider: &str) -> String { "xai" | "grok" => "xai".to_string(), "cerebras" => "cerebras".to_string(), "sambanova" => "sambanova".to_string(), + "moonshot" | "kimi" | "kimicode" => "moonshot".to_string(), + "qwen" | "dashscope" | "qwencode" => "qwen".to_string(), + "minimax" => "minimax".to_string(), + "zhipu" | "glm" => "zhipu".to_string(), + "zhipu_coding" | "codegeex" => "zhipu_coding".to_string(), + "zai" => "zai".to_string(), + "zai_coding" => "zai_coding".to_string(), + "qianfan" | "baidu" => "qianfan".to_string(), + "volcengine" | "doubao" => "volcengine".to_string(), + "github_copilot" | "copilot" => "github-copilot".to_string(), other => other.to_string(), } } /// Map OpenClaw provider to its default API key env var. fn default_api_key_env(provider: &str) -> String { - match provider { + match map_provider(provider).as_str() { "anthropic" => "ANTHROPIC_API_KEY".to_string(), "openai" => "OPENAI_API_KEY".to_string(), "groq" => "GROQ_API_KEY".to_string(), @@ -667,8 +956,15 @@ fn default_api_key_env(provider: &str) -> String { "xai" => "XAI_API_KEY".to_string(), "cerebras" => "CEREBRAS_API_KEY".to_string(), "sambanova" => "SAMBANOVA_API_KEY".to_string(), + "moonshot" => "MOONSHOT_API_KEY".to_string(), + "qwen" => "DASHSCOPE_API_KEY".to_string(), + "minimax" => "MINIMAX_API_KEY".to_string(), + "zhipu" | "zhipu_coding" | "zai" | "zai_coding" => "ZHIPU_API_KEY".to_string(), + "qianfan" => "QIANFAN_API_KEY".to_string(), + "volcengine" => "VOLCENGINE_API_KEY".to_string(), + "github-copilot" => "GITHUB_TOKEN".to_string(), "ollama" => String::new(), // Ollama doesn't need an API key - _ => format!("{}_API_KEY", provider.to_uppercase()), + other => format!("{}_API_KEY", other.to_uppercase()), } } @@ -1161,6 +1457,7 @@ fn migrate_config_from_json( // Extract channels (writes secrets.env) let channels = migrate_channels_from_json(root, target, dry_run, report); + let bindings = extract_json5_feishu_bindings(root, report); let of_config = OpenFangConfig { default_model: OpenFangModelConfig { @@ -1174,6 +1471,7 @@ fn migrate_config_from_json( listen_addr: "127.0.0.1:4200".to_string(), }, channels, + bindings, }; let toml_str = toml::to_string_pretty(&of_config)?; @@ -1639,6 +1937,16 @@ fn migrate_channels_from_json( if let Some(ref domain) = fs.domain { fields.push(("domain", toml::Value::String(domain.clone()))); } + if fs.allow_from.as_ref().is_none_or(|users| users.is_empty()) { + if let Some(ref default_agent) = fs.default_agent { + fields.push(("default_agent", toml::Value::String(default_agent.clone()))); + } + } else if fs.default_agent.is_some() { + report.warnings.push( + "Feishu allowFrom was migrated as explicit bindings; omitted [channels.feishu].default_agent to preserve scoped routing" + .to_string(), + ); + } channels_table.insert( "feishu".to_string(), build_channel_table(fields, fs.dm_policy.as_deref(), None, None), @@ -1818,8 +2126,13 @@ fn convert_agent_from_json( // System prompt from identity let system_prompt = entry .identity - .clone() - .or_else(|| defaults.and_then(|d| d.identity.clone())) + .as_ref() + .and_then(OpenClawIdentity::system_prompt) + .or_else(|| { + defaults + .and_then(|d| d.identity.as_ref()) + .and_then(OpenClawIdentity::system_prompt) + }) .unwrap_or_else(|| { format!( "You are {display_name}, an AI agent running on the OpenFang Agent OS. You are helpful, concise, and accurate." @@ -2363,6 +2676,7 @@ fn migrate_legacy_config( let api_key_env = oc_config .api_key_env .unwrap_or_else(|| default_api_key_env(&provider)); + let bindings = extract_legacy_feishu_bindings(source, report)?; let of_config = OpenFangConfig { default_model: OpenFangModelConfig { @@ -2382,6 +2696,7 @@ fn migrate_legacy_config( listen_addr: "127.0.0.1:4200".to_string(), }, channels, + bindings, }; let toml_str = toml::to_string_pretty(&of_config)?; @@ -2599,10 +2914,15 @@ fn parse_legacy_channels( }); } "feishu" => { - let fields: Vec<(&str, toml::Value)> = vec![( + let mut fields: Vec<(&str, toml::Value)> = vec![( "app_secret_env", toml::Value::String("FEISHU_APP_SECRET".into()), )]; + if ch.allowed_users.is_empty() { + if let Some(ref da) = ch.default_agent { + fields.push(("default_agent", toml::Value::String(da.clone()))); + } + } channels_table.insert( "feishu".to_string(), build_channel_table(fields, None, None, None), @@ -3398,6 +3718,57 @@ mod tests { } } + #[test] + fn test_json5_identity_parses_string_and_object_forms() { + let json5_content = r##"{ + agents: { + defaults: { + identity: { + systemPrompt: "Default identity prompt", + emoji: "🧠" + } + }, + list: [ + { + id: "object-agent", + identity: { + prompt: "Prompt from identity object", + color: "#FF5C00" + } + }, + { + id: "string-agent", + identity: "Prompt from identity string" + } + ] + } +}"##; + + let root: OpenClawRoot = json5::from_str(json5_content).unwrap(); + let agents = root.agents.unwrap(); + let defaults = agents.defaults.unwrap(); + assert_eq!( + defaults.identity.unwrap().system_prompt().as_deref(), + Some("Default identity prompt") + ); + assert_eq!( + agents.list[0] + .identity + .as_ref() + .and_then(OpenClawIdentity::system_prompt) + .as_deref(), + Some("Prompt from identity object") + ); + assert_eq!( + agents.list[1] + .identity + .as_ref() + .and_then(OpenClawIdentity::system_prompt) + .as_deref(), + Some("Prompt from identity string") + ); + } + #[test] fn test_json5_channel_extraction() { let target = TempDir::new().unwrap(); @@ -3775,6 +4146,102 @@ mod tests { assert_eq!(m, ""); } + #[test] + fn test_provider_alias_compatibility_split_model_ref_and_env() { + let (p, m) = split_model_ref("kimi/moonshot-v1-8k"); + assert_eq!(p, "moonshot"); + assert_eq!(m, "moonshot-v1-8k"); + + let (p, m) = split_model_ref("kimicode/moonshot-v1-8k"); + assert_eq!(p, "moonshot"); + assert_eq!(m, "moonshot-v1-8k"); + + let (p, m) = split_model_ref("dashscope/qwen-max"); + assert_eq!(p, "qwen"); + assert_eq!(m, "qwen-max"); + + let (p, m) = split_model_ref("qwencode/qwen-max"); + assert_eq!(p, "qwen"); + assert_eq!(m, "qwen-max"); + + let (p, m) = split_model_ref("glm/glm-4.5"); + assert_eq!(p, "zhipu"); + assert_eq!(m, "glm-4.5"); + + let (p, m) = split_model_ref("codegeex/glm-4.5"); + assert_eq!(p, "zhipu_coding"); + assert_eq!(m, "glm-4.5"); + + let (p, m) = split_model_ref("copilot/gpt-4o"); + assert_eq!(p, "github-copilot"); + assert_eq!(m, "gpt-4o"); + + assert_eq!(default_api_key_env("kimi"), "MOONSHOT_API_KEY"); + assert_eq!(default_api_key_env("kimicode"), "MOONSHOT_API_KEY"); + assert_eq!(default_api_key_env("dashscope"), "DASHSCOPE_API_KEY"); + assert_eq!(default_api_key_env("qwencode"), "DASHSCOPE_API_KEY"); + assert_eq!(default_api_key_env("glm"), "ZHIPU_API_KEY"); + assert_eq!(default_api_key_env("codegeex"), "ZHIPU_API_KEY"); + assert_eq!(default_api_key_env("baidu"), "QIANFAN_API_KEY"); + assert_eq!(default_api_key_env("doubao"), "VOLCENGINE_API_KEY"); + assert_eq!(default_api_key_env("copilot"), "GITHUB_TOKEN"); + assert_eq!(default_api_key_env("github-copilot"), "GITHUB_TOKEN"); + assert_eq!(default_api_key_env("github_copilot"), "GITHUB_TOKEN"); + } + + #[test] + fn test_provider_alias_compatibility_json5_migration() { + let source = TempDir::new().unwrap(); + let target = TempDir::new().unwrap(); + + let json5_content = r#"{ + agents: { + list: [ + { + id: "alias-agent", + model: { + primary: "kimicode/moonshot-v1-8k", + fallbacks: ["qwencode/qwen-max", "copilot/gpt-4o"] + } + }, + { + id: "search-agent", + model: "baidu/ernie-4.0-8k" + } + ] + } +}"#; + std::fs::write(source.path().join("openclaw.json"), json5_content).unwrap(); + + let options = MigrateOptions { + source: crate::MigrateSource::OpenClaw, + source_dir: source.path().to_path_buf(), + target_dir: target.path().to_path_buf(), + dry_run: false, + }; + + let report = migrate(&options).unwrap(); + assert!(report.imported.iter().any(|i| i.kind == ItemKind::Agent)); + + let alias_agent_toml = + std::fs::read_to_string(target.path().join("agents/alias-agent/agent.toml")).unwrap(); + assert!(alias_agent_toml.contains("provider = \"moonshot\"")); + assert!(alias_agent_toml.contains("model = \"moonshot-v1-8k\"")); + assert!(alias_agent_toml.contains("api_key_env = \"MOONSHOT_API_KEY\"")); + assert!(alias_agent_toml.contains("provider = \"qwen\"")); + assert!(alias_agent_toml.contains("model = \"qwen-max\"")); + assert!(alias_agent_toml.contains("api_key_env = \"DASHSCOPE_API_KEY\"")); + assert!(alias_agent_toml.contains("provider = \"github-copilot\"")); + assert!(alias_agent_toml.contains("model = \"gpt-4o\"")); + assert!(alias_agent_toml.contains("api_key_env = \"GITHUB_TOKEN\"")); + + let search_agent_toml = + std::fs::read_to_string(target.path().join("agents/search-agent/agent.toml")).unwrap(); + assert!(search_agent_toml.contains("provider = \"qianfan\"")); + assert!(search_agent_toml.contains("model = \"ernie-4.0-8k\"")); + assert!(search_agent_toml.contains("api_key_env = \"QIANFAN_API_KEY\"")); + } + #[test] fn test_json5_unknown_provider_passthrough() { let source = TempDir::new().unwrap(); @@ -3924,6 +4391,23 @@ mod tests { assert_eq!(map_provider("grok"), "xai"); } + #[test] + fn test_provider_alias_compatibility_mapping() { + assert_eq!(map_provider("codex"), "openai"); + assert_eq!(map_provider("openai-codex"), "openai"); + assert_eq!(map_provider("kimi"), "moonshot"); + assert_eq!(map_provider("kimicode"), "moonshot"); + assert_eq!(map_provider("dashscope"), "qwen"); + assert_eq!(map_provider("qwencode"), "qwen"); + assert_eq!(map_provider("glm"), "zhipu"); + assert_eq!(map_provider("codegeex"), "zhipu_coding"); + assert_eq!(map_provider("baidu"), "qianfan"); + assert_eq!(map_provider("doubao"), "volcengine"); + assert_eq!(map_provider("copilot"), "github-copilot"); + assert_eq!(map_provider("github-copilot"), "github-copilot"); + assert_eq!(map_provider("github_copilot"), "github-copilot"); + } + #[test] fn test_tools_for_profile() { let minimal = tools_for_profile("minimal"); @@ -3942,6 +4426,60 @@ mod tests { assert!(automation.contains(&"web_fetch".to_string())); } + #[test] + fn test_convert_agent_from_json_identity_object_uses_prompt_without_parse_blocker() { + let json5_content = r#"{ + agents: { + defaults: { + model: "anthropic/claude-sonnet-4-20250514", + identity: { + systemPrompt: "Default fallback prompt" + } + }, + list: [ + { + id: "coder", + name: "Coder", + model: "deepseek/deepseek-chat", + tools: { allow: ["Read", "Write"] }, + identity: { + prompt: "You are an expert software engineer.", + emoji: "🧑‍💻" + } + }, + { + id: "reviewer", + model: "groq/llama-3.3-70b-versatile", + tools: { profile: "research" }, + identity: { + emoji: "🔍" + } + } + ] + } +}"#; + let root: OpenClawRoot = json5::from_str(json5_content).unwrap(); + let agents = root.agents.as_ref().unwrap(); + + let (coder_toml, coder_unmapped) = + convert_agent_from_json(&agents.list[0], agents.defaults.as_ref()).unwrap(); + assert!(coder_unmapped.is_empty()); + assert!(coder_toml.contains( + r#"system_prompt = """ +You are an expert software engineer. +""""# + )); + + let (reviewer_toml, reviewer_unmapped) = + convert_agent_from_json(&agents.list[1], agents.defaults.as_ref()).unwrap(); + assert!(reviewer_unmapped.is_empty()); + assert!(reviewer_toml.contains( + r#"system_prompt = """ +Default fallback prompt +""""# + )); + } + #[test] fn test_convert_agent() { let dir = TempDir::new().unwrap(); @@ -4109,6 +4647,129 @@ mod tests { ); } + #[test] + fn test_json5_feishu_allow_from_migrates_to_bindings_without_default_agent_broadening() { + let source = TempDir::new().unwrap(); + let target = TempDir::new().unwrap(); + + let json5_content = r#"{ + channels: { + feishu: { + appId: "cli_feishu123", + appSecret: "feishu-secret-xyz", + defaultAgent: "assistant", + allowFrom: ["oc_chat_alice", "oc_chat_bob"] + } + } +}"#; + std::fs::write(source.path().join("openclaw.json"), json5_content).unwrap(); + + let options = MigrateOptions { + source: crate::MigrateSource::OpenClaw, + source_dir: source.path().to_path_buf(), + target_dir: target.path().to_path_buf(), + dry_run: false, + }; + + let report = migrate(&options).unwrap(); + assert!(report + .warnings + .iter() + .any(|warning| warning.contains("Feishu allowFrom was migrated as explicit bindings"))); + + let config_toml = std::fs::read_to_string(target.path().join("config.toml")).unwrap(); + assert!(config_toml.contains("[channels.feishu]")); + assert!(config_toml.contains("app_id = \"cli_feishu123\"")); + assert!(config_toml.contains("app_secret_env = \"FEISHU_APP_SECRET\"")); + assert!(!config_toml.contains("default_agent = \"assistant\"")); + assert!(config_toml.contains("[[bindings]]")); + assert!(config_toml.contains("agent = \"assistant\"")); + assert!(config_toml.contains("channel = \"feishu\"")); + assert!(config_toml.contains("peer_id = \"oc_chat_alice\"")); + assert!(config_toml.contains("peer_id = \"oc_chat_bob\"")); + } + + #[test] + fn test_json5_feishu_explicit_routes_migrate_to_bindings_and_preserve_default_agent() { + let source = TempDir::new().unwrap(); + let target = TempDir::new().unwrap(); + + let json5_content = r#"{ + channels: { + feishu: { + appId: "cli_feishu123", + appSecret: "feishu-secret-xyz", + defaultAgent: "assistant", + userToAgent: { + "oc_chat_alice": "researcher" + }, + bindings: [ + { chatId: "oc_chat_bob", agent: "coder" } + ] + } + } +}"#; + std::fs::write(source.path().join("openclaw.json"), json5_content).unwrap(); + + let options = MigrateOptions { + source: crate::MigrateSource::OpenClaw, + source_dir: source.path().to_path_buf(), + target_dir: target.path().to_path_buf(), + dry_run: false, + }; + + migrate(&options).unwrap(); + + let config_toml = std::fs::read_to_string(target.path().join("config.toml")).unwrap(); + assert!(config_toml.contains("[channels.feishu]")); + assert!(config_toml.contains("default_agent = \"assistant\"")); + assert!(config_toml.contains("agent = \"researcher\"")); + assert!(config_toml.contains("agent = \"coder\"")); + assert!(config_toml.contains("peer_id = \"oc_chat_alice\"")); + assert!(config_toml.contains("peer_id = \"oc_chat_bob\"")); + } + + #[test] + fn test_legacy_feishu_allowed_users_migrate_to_bindings() { + let source = TempDir::new().unwrap(); + let target = TempDir::new().unwrap(); + + std::fs::write( + source.path().join("config.yaml"), + r#"provider: anthropic +model: claude-sonnet-4-20250514 +"#, + ) + .unwrap(); + std::fs::create_dir_all(source.path().join("messaging")).unwrap(); + std::fs::write( + source.path().join("messaging/feishu.yaml"), + r#"type: feishu +default_agent: support +allowed_users: + - oc_chat_legacy +"#, + ) + .unwrap(); + + let options = MigrateOptions { + source: crate::MigrateSource::OpenClaw, + source_dir: source.path().to_path_buf(), + target_dir: target.path().to_path_buf(), + dry_run: false, + }; + + migrate(&options).unwrap(); + + let config_toml = std::fs::read_to_string(target.path().join("config.toml")).unwrap(); + assert!(config_toml.contains("[channels.feishu]")); + assert!(!config_toml.contains("default_agent = \"support\"")); + assert!(config_toml.contains("[[bindings]]")); + assert!(config_toml.contains("agent = \"support\"")); + assert!(config_toml.contains("channel = \"feishu\"")); + assert!(config_toml.contains("peer_id = \"oc_chat_legacy\"")); + } + #[test] fn test_policy_migration() { let target = TempDir::new().unwrap(); diff --git a/crates/openfang-runtime/src/agent_loop.rs b/crates/openfang-runtime/src/agent_loop.rs index c326c8b62..b608eb5a6 100644 --- a/crates/openfang-runtime/src/agent_loop.rs +++ b/crates/openfang-runtime/src/agent_loop.rs @@ -685,10 +685,13 @@ pub async fn run_agent_loop( } // Detect approval denials and inject guidance to prevent infinite retry loops - let denial_count = tool_result_blocks.iter().filter(|b| { - matches!(b, ContentBlock::ToolResult { content, is_error: true, .. } + let denial_count = tool_result_blocks + .iter() + .filter(|b| { + matches!(b, ContentBlock::ToolResult { content, is_error: true, .. } if content.contains("requires human approval and was denied")) - }).count(); + }) + .count(); if denial_count > 0 { tool_result_blocks.push(ContentBlock::Text { text: format!( @@ -1608,10 +1611,13 @@ pub async fn run_agent_loop_streaming( } // Detect approval denials and inject guidance to prevent infinite retry loops - let denial_count = tool_result_blocks.iter().filter(|b| { - matches!(b, ContentBlock::ToolResult { content, is_error: true, .. } + let denial_count = tool_result_blocks + .iter() + .filter(|b| { + matches!(b, ContentBlock::ToolResult { content, is_error: true, .. } if content.contains("requires human approval and was denied")) - }).count(); + }) + .count(); if denial_count > 0 { tool_result_blocks.push(ContentBlock::Text { text: format!( diff --git a/crates/openfang-runtime/src/browser.rs b/crates/openfang-runtime/src/browser.rs index 4bb0f2e79..cf800e8fb 100644 --- a/crates/openfang-runtime/src/browser.rs +++ b/crates/openfang-runtime/src/browser.rs @@ -141,13 +141,13 @@ impl CdpConnection { if let Some(id) = json.get("id").and_then(|v| v.as_u64()) { if let Some((_, sender)) = pending.remove(&id) { if let Some(error) = json.get("error") { - let msg = error["message"] - .as_str() - .unwrap_or("CDP error") - .to_string(); + let msg = error["message"].as_str().unwrap_or("CDP error").to_string(); let _ = sender.send(Err(msg)); } else { - let result = json.get("result").cloned().unwrap_or(serde_json::Value::Null); + let result = json + .get("result") + .cloned() + .unwrap_or(serde_json::Value::Null); let _ = sender.send(Ok(result)); } } @@ -287,9 +287,12 @@ impl BrowserSession { } } - let mut child = cmd - .spawn() - .map_err(|e| format!("Failed to launch Chromium at {}: {e}", chrome_path.display()))?; + let mut child = cmd.spawn().map_err(|e| { + format!( + "Failed to launch Chromium at {}: {e}", + chrome_path.display() + ) + })?; // Parse stderr for the DevTools WebSocket URL let stderr = child.stderr.take().ok_or("No stderr from Chromium")?; @@ -453,7 +456,10 @@ impl BrowserSession { .unwrap_or(val); if parsed["success"].as_bool() == Some(false) { return BrowserResponse::err( - parsed["error"].as_str().unwrap_or("Click failed").to_string(), + parsed["error"] + .as_str() + .unwrap_or("Click failed") + .to_string(), ); } // Wait briefly for any navigation triggered by click @@ -632,7 +638,11 @@ impl BrowserSession { .and_then(|s| serde_json::from_str(s).ok()) .unwrap_or(info); - let content_val = self.cdp.run_js(EXTRACT_CONTENT_JS).await.unwrap_or_default(); + let content_val = self + .cdp + .run_js(EXTRACT_CONTENT_JS) + .await + .unwrap_or_default(); let content_obj: serde_json::Value = content_val .as_str() .and_then(|s| serde_json::from_str(s).ok()) @@ -987,9 +997,7 @@ pub async fn tool_browser_read_page( mgr: &BrowserManager, agent_id: &str, ) -> Result { - let resp = mgr - .send_command(agent_id, BrowserCommand::ReadPage) - .await?; + let resp = mgr.send_command(agent_id, BrowserCommand::ReadPage).await?; if !resp.success { return Err(resp.error.unwrap_or_else(|| "ReadPage failed".to_string())); } @@ -1293,7 +1301,10 @@ mod tests { #[test] fn test_chromium_candidates_not_empty() { let paths = chromium_candidates(); - assert!(!paths.is_empty(), "Should have platform-specific candidates"); + assert!( + !paths.is_empty(), + "Should have platform-specific candidates" + ); } #[test] diff --git a/crates/openfang-runtime/src/compactor.rs b/crates/openfang-runtime/src/compactor.rs index 6fd7056e3..f62d9fea4 100644 --- a/crates/openfang-runtime/src/compactor.rs +++ b/crates/openfang-runtime/src/compactor.rs @@ -343,7 +343,11 @@ fn build_conversation_text(messages: &[Message], config: &CompactionConfig) -> S if oversized { let limit = config.max_chunk_chars / 4; let truncated = if s.len() > limit { - format!("{}...[truncated from {} chars]", safe_truncate_str(s, limit), s.len()) + format!( + "{}...[truncated from {} chars]", + safe_truncate_str(s, limit), + s.len() + ) } else { s.clone() }; @@ -431,7 +435,11 @@ async fn summarize_messages( let safe_start = if conversation_text.is_char_boundary(start) { start } else { - conversation_text[start..].char_indices().next().map(|(i, _)| start + i).unwrap_or(conversation_text.len()) + conversation_text[start..] + .char_indices() + .next() + .map(|(i, _)| start + i) + .unwrap_or(conversation_text.len()) }; conversation_text = conversation_text[safe_start..].to_string(); } diff --git a/crates/openfang-runtime/src/context_budget.rs b/crates/openfang-runtime/src/context_budget.rs index cbf844905..a0ea0bb4e 100644 --- a/crates/openfang-runtime/src/context_budget.rs +++ b/crates/openfang-runtime/src/context_budget.rs @@ -68,7 +68,11 @@ pub fn truncate_tool_result_dynamic(content: &str, budget: &ContextBudget) -> St let safe_cap = if content.is_char_boundary(cap) { cap } else { - content[..cap].char_indices().next_back().map(|(i, _)| i).unwrap_or(0) + content[..cap] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0) }; let search_start = safe_cap.saturating_sub(200); let break_point = content[search_start..safe_cap] @@ -79,7 +83,11 @@ pub fn truncate_tool_result_dynamic(content: &str, budget: &ContextBudget) -> St let break_point = if content.is_char_boundary(break_point) { break_point } else { - content[..break_point].char_indices().next_back().map(|(i, _)| i).unwrap_or(0) + content[..break_point] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0) }; format!( diff --git a/crates/openfang-runtime/src/context_overflow.rs b/crates/openfang-runtime/src/context_overflow.rs index 69ce02f77..b4744e441 100644 --- a/crates/openfang-runtime/src/context_overflow.rs +++ b/crates/openfang-runtime/src/context_overflow.rs @@ -107,7 +107,11 @@ pub fn recover_from_overflow( let safe_keep = if content.is_char_boundary(keep) { keep } else { - content[..keep].char_indices().next_back().map(|(i, _)| i).unwrap_or(0) + content[..keep] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0) }; *content = format!( "{}\n\n[OVERFLOW RECOVERY: truncated from {} to {} chars]", diff --git a/crates/openfang-runtime/src/copilot_oauth.rs b/crates/openfang-runtime/src/copilot_oauth.rs index b63d69a21..0fe33360a 100644 --- a/crates/openfang-runtime/src/copilot_oauth.rs +++ b/crates/openfang-runtime/src/copilot_oauth.rs @@ -89,10 +89,7 @@ pub async fn poll_device_flow(device_code: &str) -> DeviceFlowStatus { .header("Accept", "application/json") .form(&[ ("client_id", COPILOT_CLIENT_ID), - ( - "grant_type", - "urn:ietf:params:oauth:grant-type:device_code", - ), + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), ("device_code", device_code), ]) .send() @@ -112,10 +109,7 @@ pub async fn poll_device_flow(device_code: &str) -> DeviceFlowStatus { return match error { "authorization_pending" => DeviceFlowStatus::Pending, "slow_down" => { - let interval = body - .get("interval") - .and_then(|v| v.as_u64()) - .unwrap_or(10); + let interval = body.get("interval").and_then(|v| v.as_u64()).unwrap_or(10); DeviceFlowStatus::SlowDown { new_interval: interval, } diff --git a/crates/openfang-runtime/src/drivers/claude_code.rs b/crates/openfang-runtime/src/drivers/claude_code.rs index b9243761b..af7bbd13d 100644 --- a/crates/openfang-runtime/src/drivers/claude_code.rs +++ b/crates/openfang-runtime/src/drivers/claude_code.rs @@ -70,9 +70,7 @@ impl ClaudeCodeDriver { /// Map a model ID like "claude-code/opus" to CLI --model flag value. fn model_flag(model: &str) -> Option { - let stripped = model - .strip_prefix("claude-code/") - .unwrap_or(model); + let stripped = model.strip_prefix("claude-code/").unwrap_or(model); match stripped { "opus" => Some("opus".to_string()), "sonnet" => Some("sonnet".to_string()), @@ -124,10 +122,7 @@ struct ClaudeStreamEvent { #[async_trait] impl LlmDriver for ClaudeCodeDriver { - async fn complete( - &self, - request: CompletionRequest, - ) -> Result { + async fn complete(&self, request: CompletionRequest) -> Result { let prompt = Self::build_prompt(&request); let model_flag = Self::model_flag(&request.model); @@ -164,7 +159,8 @@ impl LlmDriver for ClaudeCodeDriver { // Try JSON parse first if let Ok(parsed) = serde_json::from_str::(&stdout) { - let text = parsed.result + let text = parsed + .result .or(parsed.content) .or(parsed.text) .unwrap_or_default(); @@ -288,9 +284,7 @@ impl LlmDriver for ClaudeCodeDriver { // Not valid JSON — treat as raw text warn!(line = %line, error = %e, "Non-JSON line from Claude CLI"); full_text.push_str(&line); - let _ = tx - .send(StreamEvent::TextDelta { text: line }) - .await; + let _ = tx.send(StreamEvent::TextDelta { text: line }).await; } } } @@ -323,8 +317,7 @@ impl LlmDriver for ClaudeCodeDriver { /// Check if the Claude Code CLI is available. pub fn claude_code_available() -> bool { - ClaudeCodeDriver::detect().is_some() - || claude_credentials_exist() + ClaudeCodeDriver::detect().is_some() || claude_credentials_exist() } /// Check if Claude credentials file exists (~/.claude/.credentials.json). @@ -340,7 +333,9 @@ fn claude_credentials_exist() -> bool { fn home_dir() -> Option { #[cfg(target_os = "windows")] { - std::env::var("USERPROFILE").ok().map(std::path::PathBuf::from) + std::env::var("USERPROFILE") + .ok() + .map(std::path::PathBuf::from) } #[cfg(not(target_os = "windows"))] { diff --git a/crates/openfang-runtime/src/drivers/gemini.rs b/crates/openfang-runtime/src/drivers/gemini.rs index f0acd32b4..14196ee85 100644 --- a/crates/openfang-runtime/src/drivers/gemini.rs +++ b/crates/openfang-runtime/src/drivers/gemini.rs @@ -334,10 +334,7 @@ fn convert_response(resp: GeminiResponse) -> Result { - let reason = candidate - .finish_reason - .as_deref() - .unwrap_or("unknown"); + let reason = candidate.finish_reason.as_deref().unwrap_or("unknown"); warn!(finish_reason = %reason, "Gemini returned candidate with no content"); return Err(LlmError::Parse(format!( "Gemini returned empty response (finish_reason: {reason})" diff --git a/crates/openfang-runtime/src/drivers/mod.rs b/crates/openfang-runtime/src/drivers/mod.rs index e88460944..c8f22853d 100644 --- a/crates/openfang-runtime/src/drivers/mod.rs +++ b/crates/openfang-runtime/src/drivers/mod.rs @@ -16,9 +16,9 @@ use openfang_types::model_catalog::{ AI21_BASE_URL, ANTHROPIC_BASE_URL, CEREBRAS_BASE_URL, COHERE_BASE_URL, DEEPSEEK_BASE_URL, FIREWORKS_BASE_URL, GEMINI_BASE_URL, GROQ_BASE_URL, HUGGINGFACE_BASE_URL, LMSTUDIO_BASE_URL, MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, OLLAMA_BASE_URL, OPENAI_BASE_URL, - OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL, - REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VLLM_BASE_URL, VOLCENGINE_BASE_URL, - XAI_BASE_URL, ZAI_BASE_URL, ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL, + OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL, REPLICATE_BASE_URL, + SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VLLM_BASE_URL, VOLCENGINE_BASE_URL, XAI_BASE_URL, + ZAI_BASE_URL, ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL, }; use std::sync::Arc; @@ -261,9 +261,7 @@ pub fn create_driver(config: &DriverConfig) -> Result, LlmErr .or_else(|| std::env::var("OPENAI_API_KEY").ok()) .or_else(crate::model_catalog::read_codex_credential) .ok_or_else(|| { - LlmError::MissingApiKey( - "Set OPENAI_API_KEY or install Codex CLI".to_string(), - ) + LlmError::MissingApiKey("Set OPENAI_API_KEY or install Codex CLI".to_string()) })?; let base_url = config .base_url diff --git a/crates/openfang-runtime/src/drivers/openai.rs b/crates/openfang-runtime/src/drivers/openai.rs index f44f98448..d6da48d71 100644 --- a/crates/openfang-runtime/src/drivers/openai.rs +++ b/crates/openfang-runtime/src/drivers/openai.rs @@ -377,9 +377,16 @@ impl LlmDriver for OpenAIDriver { // Auto-cap max_tokens when model rejects our value (e.g. Groq Maverick limit 8192) if status == 400 && body.contains("max_tokens") && attempt < max_retries { - let current = oai_request.max_tokens.or(oai_request.max_completion_tokens).unwrap_or(4096); + let current = oai_request + .max_tokens + .or(oai_request.max_completion_tokens) + .unwrap_or(4096); let cap = extract_max_tokens_limit(&body).unwrap_or(current / 2); - warn!(old = current, new = cap, "Auto-capping max_tokens to model limit"); + warn!( + old = current, + new = cap, + "Auto-capping max_tokens to model limit" + ); if oai_request.max_completion_tokens.is_some() { oai_request.max_completion_tokens = Some(cap); } else { @@ -679,7 +686,10 @@ impl LlmDriver for OpenAIDriver { // Auto-cap max_tokens when model rejects our value if status == 400 && body.contains("max_tokens") && attempt < max_retries { - let current = oai_request.max_tokens.or(oai_request.max_completion_tokens).unwrap_or(4096); + let current = oai_request + .max_tokens + .or(oai_request.max_completion_tokens) + .unwrap_or(4096); let cap = extract_max_tokens_limit(&body).unwrap_or(current / 2); warn!(old = current, new = cap, "Auto-capping max_tokens (stream)"); if oai_request.max_completion_tokens.is_some() { diff --git a/crates/openfang-runtime/src/lib.rs b/crates/openfang-runtime/src/lib.rs index 77fa4fcfe..ed8fe854b 100644 --- a/crates/openfang-runtime/src/lib.rs +++ b/crates/openfang-runtime/src/lib.rs @@ -11,9 +11,9 @@ pub mod auth_cooldown; pub mod browser; pub mod command_lane; pub mod compactor; -pub mod copilot_oauth; pub mod context_budget; pub mod context_overflow; +pub mod copilot_oauth; pub mod docker_sandbox; pub mod drivers; pub mod embedding; diff --git a/crates/openfang-runtime/src/mcp.rs b/crates/openfang-runtime/src/mcp.rs index 82ebf33b4..843d511fe 100644 --- a/crates/openfang-runtime/src/mcp.rs +++ b/crates/openfang-runtime/src/mcp.rs @@ -404,9 +404,7 @@ impl McpConnection { let has_cmd = std::env::var("PATH") .unwrap_or_default() .split(';') - .any(|dir| { - std::path::Path::new(dir).join(&cmd_variant).exists() - }); + .any(|dir| std::path::Path::new(dir).join(&cmd_variant).exists()); if has_cmd { cmd_variant } else { diff --git a/crates/openfang-runtime/src/model_catalog.rs b/crates/openfang-runtime/src/model_catalog.rs index 679500a0b..595d1ead8 100644 --- a/crates/openfang-runtime/src/model_catalog.rs +++ b/crates/openfang-runtime/src/model_catalog.rs @@ -58,8 +58,7 @@ impl ModelCatalog { let has_fallback = match provider.id.as_str() { "gemini" => std::env::var("GOOGLE_API_KEY").is_ok(), "codex" => { - std::env::var("OPENAI_API_KEY").is_ok() - || read_codex_credential().is_some() + std::env::var("OPENAI_API_KEY").is_ok() || read_codex_credential().is_some() } "claude-code" => crate::drivers::claude_code::claude_code_available(), _ => false, @@ -3115,10 +3114,7 @@ mod tests { #[test] fn test_resolve_alias() { let catalog = ModelCatalog::new(); - assert_eq!( - catalog.resolve_alias("sonnet"), - Some("claude-sonnet-4-6") - ); + assert_eq!(catalog.resolve_alias("sonnet"), Some("claude-sonnet-4-6")); assert_eq!( catalog.resolve_alias("haiku"), Some("claude-haiku-4-5-20251001") diff --git a/crates/openfang-runtime/src/process_manager.rs b/crates/openfang-runtime/src/process_manager.rs index 70c480c73..d2e7f8ff6 100644 --- a/crates/openfang-runtime/src/process_manager.rs +++ b/crates/openfang-runtime/src/process_manager.rs @@ -233,6 +233,23 @@ impl ProcessManager { .collect() } + /// Verify that a process belongs to the provided agent/session scope. + pub fn verify_owner(&self, process_id: &str, agent_id: &str) -> Result<(), String> { + let entry = self + .processes + .get(process_id) + .ok_or_else(|| format!("Process '{}' not found", process_id))?; + + if entry.value().agent_id != agent_id { + return Err(format!( + "Process '{}' is not owned by '{}'", + process_id, agent_id + )); + } + + Ok(()) + } + /// Cleanup: kill processes older than timeout. pub async fn cleanup(&self, max_age_secs: u64) { let to_remove: Vec = self diff --git a/crates/openfang-runtime/src/str_utils.rs b/crates/openfang-runtime/src/str_utils.rs index beb13a8bc..00ba72ce3 100644 --- a/crates/openfang-runtime/src/str_utils.rs +++ b/crates/openfang-runtime/src/str_utils.rs @@ -44,7 +44,7 @@ mod tests { fn multibyte_chinese() { // Each Chinese character is 3 bytes in UTF-8 let s = "\u{4f60}\u{597d}\u{4e16}\u{754c}"; // "hello world" in Chinese, 12 bytes - // Truncating at 7 bytes should not split the 3rd char (bytes 6..9) + // Truncating at 7 bytes should not split the 3rd char (bytes 6..9) let t = safe_truncate_str(s, 7); assert_eq!(t, "\u{4f60}\u{597d}"); // 6 bytes, 2 chars assert!(t.len() <= 7); diff --git a/crates/openfang-runtime/src/tool_runner.rs b/crates/openfang-runtime/src/tool_runner.rs index c96e64077..45490309f 100644 --- a/crates/openfang-runtime/src/tool_runner.rs +++ b/crates/openfang-runtime/src/tool_runner.rs @@ -17,6 +17,25 @@ use tracing::{debug, warn}; /// Maximum inter-agent call depth to prevent infinite recursion (A->B->C->...). const MAX_AGENT_CALL_DEPTH: u32 = 5; +fn isolated_tool_scope(caller_agent_id: Option<&str>, workspace_root: Option<&Path>) -> String { + let agent_id = caller_agent_id.unwrap_or("default"); + let session_scope = workspace_root + .filter(|root| { + root.parent() + .and_then(|parent| parent.file_name()) + .and_then(|name| name.to_str()) + == Some(".session-workspaces") + }) + .and_then(|root| root.file_name()) + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()); + + match session_scope { + Some(scope) => format!("{scope}-{agent_id}"), + None => agent_id.to_string(), + } +} + /// Check if a shell command should be blocked by taint tracking. /// /// Commands containing patterns that look like injected external data @@ -120,7 +139,11 @@ pub async fn execute_tool( // Capability enforcement: reject tools not in the allowed list if let Some(allowed) = allowed_tools { if !allowed.iter().any(|t| t == tool_name) { - warn!(tool_name, "Capability denied: tool not in allowed list"); + warn!( + tool_name, + agent_id = caller_agent_id.unwrap_or("unknown"), + "Capability denied: tool not in allowed list" + ); return ToolResult { tool_use_id: tool_use_id.to_string(), content: format!( @@ -143,10 +166,18 @@ pub async fn execute_tool( ); match kh.request_approval(agent_id_str, tool_name, &summary).await { Ok(true) => { - debug!(tool_name, "Approval granted — proceeding with execution"); + debug!( + tool_name, + agent_id = agent_id_str, + "Approval granted — proceeding with execution" + ); } Ok(false) => { - warn!(tool_name, "Approval denied — blocking tool execution"); + warn!( + tool_name, + agent_id = agent_id_str, + "Approval denied — blocking tool execution" + ); return ToolResult { tool_use_id: tool_use_id.to_string(), content: format!( @@ -157,7 +188,12 @@ pub async fn execute_tool( }; } Err(e) => { - warn!(tool_name, error = %e, "Approval system error"); + warn!( + tool_name, + agent_id = agent_id_str, + error = %e, + "Approval system error" + ); return ToolResult { tool_use_id: tool_use_id.to_string(), content: format!("Approval system error: {e}"), @@ -168,6 +204,8 @@ pub async fn execute_tool( } } + let tool_scope = isolated_tool_scope(caller_agent_id, workspace_root); + debug!(tool_name, "Executing tool"); let result = match tool_name { // Filesystem tools @@ -191,7 +229,9 @@ pub async fn execute_tool( let headers = input.get("headers").and_then(|v| v.as_object()); let body = input["body"].as_str(); if let Some(ctx) = web_ctx { - ctx.fetch.fetch_with_options(url, method, headers, body).await + ctx.fetch + .fetch_with_options(url, method, headers, body) + .await } else { tool_web_fetch_legacy(input).await } @@ -290,7 +330,7 @@ pub async fn execute_tool( // Docker sandbox tool "docker_exec" => { - tool_docker_exec(input, docker_config, workspace_root, caller_agent_id).await + tool_docker_exec(input, docker_config, workspace_root, tool_scope.as_str()).await } // Location tool @@ -305,11 +345,11 @@ pub async fn execute_tool( "channel_send" => tool_channel_send(input, kernel).await, // Persistent process tools - "process_start" => tool_process_start(input, process_manager, caller_agent_id).await, - "process_poll" => tool_process_poll(input, process_manager).await, - "process_write" => tool_process_write(input, process_manager).await, - "process_kill" => tool_process_kill(input, process_manager).await, - "process_list" => tool_process_list(process_manager, caller_agent_id).await, + "process_start" => tool_process_start(input, process_manager, tool_scope.as_str()).await, + "process_poll" => tool_process_poll(input, process_manager, tool_scope.as_str()).await, + "process_write" => tool_process_write(input, process_manager, tool_scope.as_str()).await, + "process_kill" => tool_process_kill(input, process_manager, tool_scope.as_str()).await, + "process_list" => tool_process_list(process_manager, tool_scope.as_str()).await, // Hand tools (curated autonomous capability packages) "hand_list" => tool_hand_list(kernel).await, @@ -333,77 +373,70 @@ pub async fn execute_tool( } match browser_ctx { Some(mgr) => { - let aid = caller_agent_id.unwrap_or("default"); - crate::browser::tool_browser_navigate(input, mgr, aid).await + crate::browser::tool_browser_navigate(input, mgr, tool_scope.as_str()).await } None => Err( - "Browser tools not available. Ensure Chrome/Chromium is installed." - .to_string(), + "Browser tools not available. Ensure Chrome/Chromium is installed.".to_string(), ), } } "browser_click" => match browser_ctx { - Some(mgr) => { - let aid = caller_agent_id.unwrap_or("default"); - crate::browser::tool_browser_click(input, mgr, aid).await + Some(mgr) => crate::browser::tool_browser_click(input, mgr, tool_scope.as_str()).await, + None => { + Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()) } - None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()), }, "browser_type" => match browser_ctx { - Some(mgr) => { - let aid = caller_agent_id.unwrap_or("default"); - crate::browser::tool_browser_type(input, mgr, aid).await + Some(mgr) => crate::browser::tool_browser_type(input, mgr, tool_scope.as_str()).await, + None => { + Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()) } - None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()), }, "browser_screenshot" => match browser_ctx { Some(mgr) => { - let aid = caller_agent_id.unwrap_or("default"); - crate::browser::tool_browser_screenshot(input, mgr, aid).await + crate::browser::tool_browser_screenshot(input, mgr, tool_scope.as_str()).await + } + None => { + Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()) } - None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()), }, "browser_read_page" => match browser_ctx { Some(mgr) => { - let aid = caller_agent_id.unwrap_or("default"); - crate::browser::tool_browser_read_page(input, mgr, aid).await + crate::browser::tool_browser_read_page(input, mgr, tool_scope.as_str()).await + } + None => { + Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()) } - None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()), }, "browser_close" => match browser_ctx { - Some(mgr) => { - let aid = caller_agent_id.unwrap_or("default"); - crate::browser::tool_browser_close(input, mgr, aid).await + Some(mgr) => crate::browser::tool_browser_close(input, mgr, tool_scope.as_str()).await, + None => { + Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()) } - None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()), }, "browser_scroll" => match browser_ctx { - Some(mgr) => { - let aid = caller_agent_id.unwrap_or("default"); - crate::browser::tool_browser_scroll(input, mgr, aid).await + Some(mgr) => crate::browser::tool_browser_scroll(input, mgr, tool_scope.as_str()).await, + None => { + Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()) } - None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()), }, "browser_wait" => match browser_ctx { - Some(mgr) => { - let aid = caller_agent_id.unwrap_or("default"); - crate::browser::tool_browser_wait(input, mgr, aid).await + Some(mgr) => crate::browser::tool_browser_wait(input, mgr, tool_scope.as_str()).await, + None => { + Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()) } - None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()), }, "browser_run_js" => match browser_ctx { - Some(mgr) => { - let aid = caller_agent_id.unwrap_or("default"); - crate::browser::tool_browser_run_js(input, mgr, aid).await + Some(mgr) => crate::browser::tool_browser_run_js(input, mgr, tool_scope.as_str()).await, + None => { + Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()) } - None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()), }, "browser_back" => match browser_ctx { - Some(mgr) => { - let aid = caller_agent_id.unwrap_or("default"); - crate::browser::tool_browser_back(input, mgr, aid).await + Some(mgr) => crate::browser::tool_browser_back(input, mgr, tool_scope.as_str()).await, + None => { + Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()) } - None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()), }, // Canvas / A2UI tool @@ -2803,7 +2836,7 @@ async fn tool_docker_exec( input: &serde_json::Value, docker_config: Option<&openfang_types::config::DockerSandboxConfig>, workspace_root: Option<&Path>, - caller_agent_id: Option<&str>, + caller_scope: &str, ) -> Result { let config = docker_config.ok_or("Docker sandbox not configured")?; @@ -2816,7 +2849,6 @@ async fn tool_docker_exec( .ok_or("Missing 'command' parameter")?; let workspace = workspace_root.ok_or("Docker exec requires a workspace directory")?; - let agent_id = caller_agent_id.unwrap_or("default"); // Check Docker availability if !crate::docker_sandbox::is_docker_available().await { @@ -2826,7 +2858,7 @@ async fn tool_docker_exec( } // Create sandbox container - let container = crate::docker_sandbox::create_sandbox(config, agent_id, workspace).await?; + let container = crate::docker_sandbox::create_sandbox(config, caller_scope, workspace).await?; // Execute command with timeout let timeout = std::time::Duration::from_secs(config.timeout_secs); @@ -2857,10 +2889,9 @@ async fn tool_docker_exec( async fn tool_process_start( input: &serde_json::Value, pm: Option<&crate::process_manager::ProcessManager>, - caller_agent_id: Option<&str>, + caller_scope: &str, ) -> Result { let pm = pm.ok_or("Process manager not available")?; - let agent_id = caller_agent_id.unwrap_or("default"); let command = input["command"] .as_str() .ok_or("Missing 'command' parameter")?; @@ -2873,7 +2904,7 @@ async fn tool_process_start( }) .unwrap_or_default(); - let proc_id = pm.start(agent_id, command, &args).await?; + let proc_id = pm.start(caller_scope, command, &args).await?; Ok(serde_json::json!({ "process_id": proc_id, "status": "started" @@ -2885,11 +2916,13 @@ async fn tool_process_start( async fn tool_process_poll( input: &serde_json::Value, pm: Option<&crate::process_manager::ProcessManager>, + caller_scope: &str, ) -> Result { let pm = pm.ok_or("Process manager not available")?; let proc_id = input["process_id"] .as_str() .ok_or("Missing 'process_id' parameter")?; + pm.verify_owner(proc_id, caller_scope)?; let (stdout, stderr) = pm.read(proc_id).await?; Ok(serde_json::json!({ "stdout": stdout, @@ -2902,11 +2935,13 @@ async fn tool_process_poll( async fn tool_process_write( input: &serde_json::Value, pm: Option<&crate::process_manager::ProcessManager>, + caller_scope: &str, ) -> Result { let pm = pm.ok_or("Process manager not available")?; let proc_id = input["process_id"] .as_str() .ok_or("Missing 'process_id' parameter")?; + pm.verify_owner(proc_id, caller_scope)?; let data = input["data"].as_str().ok_or("Missing 'data' parameter")?; // Always append newline if not present (common expectation for REPLs) let data = if data.ends_with('\n') { @@ -2922,11 +2957,13 @@ async fn tool_process_write( async fn tool_process_kill( input: &serde_json::Value, pm: Option<&crate::process_manager::ProcessManager>, + caller_scope: &str, ) -> Result { let pm = pm.ok_or("Process manager not available")?; let proc_id = input["process_id"] .as_str() .ok_or("Missing 'process_id' parameter")?; + pm.verify_owner(proc_id, caller_scope)?; pm.kill(proc_id).await?; Ok(r#"{"status": "killed"}"#.to_string()) } @@ -2934,11 +2971,10 @@ async fn tool_process_kill( /// List processes for the current agent. async fn tool_process_list( pm: Option<&crate::process_manager::ProcessManager>, - caller_agent_id: Option<&str>, + caller_scope: &str, ) -> Result { let pm = pm.ok_or("Process manager not available")?; - let agent_id = caller_agent_id.unwrap_or("default"); - let procs = pm.list(agent_id); + let procs = pm.list(caller_scope); let list: Vec = procs .iter() .map(|p| { @@ -3059,6 +3095,138 @@ async fn tool_canvas_present( #[cfg(test)] mod tests { use super::*; + use async_trait::async_trait; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, RwLock, + }; + + #[derive(Debug)] + struct MockApprovalKernel { + approval_required_tools: std::collections::HashSet, + approval_result: RwLock>, + approval_requests: AtomicUsize, + } + + impl MockApprovalKernel { + fn new(approval_required_tools: &[&str], approval_result: Result) -> Self { + Self { + approval_required_tools: approval_required_tools + .iter() + .map(|tool| (*tool).to_string()) + .collect(), + approval_result: RwLock::new(approval_result), + approval_requests: AtomicUsize::new(0), + } + } + + fn approval_request_count(&self) -> usize { + self.approval_requests.load(Ordering::SeqCst) + } + } + + #[async_trait] + impl KernelHandle for MockApprovalKernel { + async fn spawn_agent( + &self, + _manifest_toml: &str, + _parent_id: Option<&str>, + ) -> Result<(String, String), String> { + Err("not implemented".to_string()) + } + + async fn send_to_agent(&self, _agent_id: &str, _message: &str) -> Result { + Err("not implemented".to_string()) + } + + fn list_agents(&self) -> Vec { + vec![] + } + + fn kill_agent(&self, _agent_id: &str) -> Result<(), String> { + Ok(()) + } + + fn memory_store(&self, _key: &str, _value: serde_json::Value) -> Result<(), String> { + Ok(()) + } + + fn memory_recall(&self, _key: &str) -> Result, String> { + Ok(None) + } + + fn find_agents(&self, _query: &str) -> Vec { + vec![] + } + + async fn task_post( + &self, + _title: &str, + _description: &str, + _assigned_to: Option<&str>, + _created_by: Option<&str>, + ) -> Result { + Ok("task-1".to_string()) + } + + async fn task_claim(&self, _agent_id: &str) -> Result, String> { + Ok(None) + } + + async fn task_complete(&self, _task_id: &str, _result: &str) -> Result<(), String> { + Ok(()) + } + + async fn task_list(&self, _status: Option<&str>) -> Result, String> { + Ok(vec![]) + } + + async fn publish_event( + &self, + _event_type: &str, + _payload: serde_json::Value, + ) -> Result<(), String> { + Ok(()) + } + + async fn knowledge_add_entity( + &self, + _entity: openfang_types::memory::Entity, + ) -> Result { + Ok("entity-1".to_string()) + } + + async fn knowledge_add_relation( + &self, + _relation: openfang_types::memory::Relation, + ) -> Result { + Ok("relation-1".to_string()) + } + + async fn knowledge_query( + &self, + _pattern: openfang_types::memory::GraphPattern, + ) -> Result, String> { + Ok(vec![]) + } + + fn requires_approval(&self, tool_name: &str) -> bool { + self.approval_required_tools.contains(tool_name) + } + + async fn request_approval( + &self, + _agent_id: &str, + _tool_name: &str, + _action_summary: &str, + ) -> Result { + self.approval_requests.fetch_add(1, Ordering::SeqCst); + self.approval_result + .read() + .unwrap_or_else(|e| e.into_inner()) + .clone() + } + } #[test] fn test_builtin_tool_definitions() { @@ -3391,6 +3559,106 @@ mod tests { assert!(result.content.contains("Failed to read")); } + #[tokio::test] + async fn test_approval_required_denied() { + let kernel_impl = Arc::new(MockApprovalKernel::new(&["file_read"], Ok(false))); + let kernel: Arc = kernel_impl.clone(); + let allowed = vec!["file_read".to_string()]; + + let result = execute_tool( + "test-id", + "file_read", + &serde_json::json!({"path": "/tmp/should-not-run.txt"}), + Some(&kernel), + Some(&allowed), + Some("agent-1"), + None, + None, + None, + None, + None, + None, + None, + None, // exec_policy + None, // tts_engine + None, // docker_config + None, // process_manager + ) + .await; + + assert!(result.is_error); + assert!(result.content.contains("Execution denied")); + assert_eq!(kernel_impl.approval_request_count(), 1); + } + + #[tokio::test] + async fn test_approval_required_approved() { + let kernel_impl = Arc::new(MockApprovalKernel::new(&["file_read"], Ok(true))); + let kernel: Arc = kernel_impl.clone(); + let allowed = vec!["file_read".to_string()]; + + let result = execute_tool( + "test-id", + "file_read", + &serde_json::json!({"path": "/tmp/does-not-exist.txt"}), + Some(&kernel), + Some(&allowed), + Some("agent-2"), + None, + None, + None, + None, + None, + None, + None, + None, // exec_policy + None, // tts_engine + None, // docker_config + None, // process_manager + ) + .await; + + assert!(result.is_error); + assert!(!result.content.contains("Execution denied")); + assert!(result.content.contains("Failed to read")); + assert_eq!(kernel_impl.approval_request_count(), 1); + } + + #[tokio::test] + async fn test_approval_system_error_denied() { + let kernel_impl = Arc::new(MockApprovalKernel::new( + &["file_read"], + Err("approval backend unavailable".to_string()), + )); + let kernel: Arc = kernel_impl.clone(); + let allowed = vec!["file_read".to_string()]; + + let result = execute_tool( + "test-id", + "file_read", + &serde_json::json!({"path": "/tmp/should-not-run.txt"}), + Some(&kernel), + Some(&allowed), + Some("agent-3"), + None, + None, + None, + None, + None, + None, + None, + None, // exec_policy + None, // tts_engine + None, // docker_config + None, // process_manager + ) + .await; + + assert!(result.is_error); + assert!(result.content.contains("Approval system error")); + assert_eq!(kernel_impl.approval_request_count(), 1); + } + // --- Schedule parser tests --- #[test] fn test_parse_schedule_every_minutes() { @@ -3689,4 +3957,166 @@ mod tests { // Cleanup let _ = std::fs::remove_dir_all(&tmp); } + + fn process_scope_dir(name: &str) -> PathBuf { + let temp = tempfile::tempdir().unwrap(); + let dir = temp + .keep() + .join("agent") + .join(".session-workspaces") + .join(name); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn persistent_process_command() -> (String, Vec) { + if cfg!(windows) { + ( + "cmd".to_string(), + vec![ + "/C".to_string(), + "timeout".to_string(), + "/t".to_string(), + "30".to_string(), + ], + ) + } else { + ("cat".to_string(), vec![]) + } + } + + async fn execute_process_tool_for_scope( + tool_name: &str, + input: serde_json::Value, + workspace: &Path, + pm: &crate::process_manager::ProcessManager, + ) -> ToolResult { + execute_tool( + "test-id", + tool_name, + &input, + None, + None, + Some("agent-under-test"), + None, + None, + None, + None, + None, + Some(workspace), + None, + None, + None, + None, + Some(pm), + ) + .await + } + + #[tokio::test] + async fn test_process_list_filters_by_scope() { + let pm = crate::process_manager::ProcessManager::new(5); + let scope_a = process_scope_dir("session-a"); + let scope_b = process_scope_dir("session-b"); + let (command_a, args_a) = persistent_process_command(); + + let start_a = execute_process_tool_for_scope( + "process_start", + serde_json::json!({"command": command_a, "args": args_a}), + &scope_a, + &pm, + ) + .await; + assert!(!start_a.is_error); + let started_a: serde_json::Value = serde_json::from_str(&start_a.content).unwrap(); + let proc_id_a = started_a["process_id"].as_str().unwrap().to_string(); + + let (command_b, args_b) = persistent_process_command(); + let start_b = execute_process_tool_for_scope( + "process_start", + serde_json::json!({"command": command_b, "args": args_b}), + &scope_b, + &pm, + ) + .await; + assert!(!start_b.is_error); + let started_b: serde_json::Value = serde_json::from_str(&start_b.content).unwrap(); + let proc_id_b = started_b["process_id"].as_str().unwrap().to_string(); + + let list_a = + execute_process_tool_for_scope("process_list", serde_json::json!({}), &scope_a, &pm) + .await; + assert!(!list_a.is_error); + let list_a_json: serde_json::Value = serde_json::from_str(&list_a.content).unwrap(); + let list_a_items = list_a_json.as_array().unwrap(); + assert_eq!(list_a_items.len(), 1); + assert_eq!(list_a_items[0]["id"].as_str(), Some(proc_id_a.as_str())); + + let list_b = + execute_process_tool_for_scope("process_list", serde_json::json!({}), &scope_b, &pm) + .await; + assert!(!list_b.is_error); + let list_b_json: serde_json::Value = serde_json::from_str(&list_b.content).unwrap(); + let list_b_items = list_b_json.as_array().unwrap(); + assert_eq!(list_b_items.len(), 1); + assert_eq!(list_b_items[0]["id"].as_str(), Some(proc_id_b.as_str())); + } + + #[tokio::test] + async fn test_process_poll_denies_cross_scope_access() { + let pm = crate::process_manager::ProcessManager::new(5); + let scope_a = process_scope_dir("session-a"); + let scope_b = process_scope_dir("session-b"); + let (command, args) = persistent_process_command(); + + let start = execute_process_tool_for_scope( + "process_start", + serde_json::json!({"command": command, "args": args}), + &scope_a, + &pm, + ) + .await; + assert!(!start.is_error); + let started: serde_json::Value = serde_json::from_str(&start.content).unwrap(); + let proc_id = started["process_id"].as_str().unwrap().to_string(); + + let denied = execute_process_tool_for_scope( + "process_poll", + serde_json::json!({"process_id": proc_id.clone()}), + &scope_b, + &pm, + ) + .await; + assert!(denied.is_error); + assert!(denied.content.contains("not owned")); + } + + #[tokio::test] + async fn test_process_kill_denies_cross_scope_access() { + let pm = crate::process_manager::ProcessManager::new(5); + let scope_a = process_scope_dir("session-a"); + let scope_b = process_scope_dir("session-b"); + let (command, args) = persistent_process_command(); + + let start = execute_process_tool_for_scope( + "process_start", + serde_json::json!({"command": command, "args": args}), + &scope_a, + &pm, + ) + .await; + assert!(!start.is_error); + let started: serde_json::Value = serde_json::from_str(&start.content).unwrap(); + let proc_id = started["process_id"].as_str().unwrap().to_string(); + + let denied = execute_process_tool_for_scope( + "process_kill", + serde_json::json!({"process_id": proc_id.clone()}), + &scope_b, + &pm, + ) + .await; + assert!(denied.is_error); + assert!(denied.content.contains("not owned")); + } } diff --git a/crates/openfang-runtime/src/web_content.rs b/crates/openfang-runtime/src/web_content.rs index d24c4198e..f8d32d926 100644 --- a/crates/openfang-runtime/src/web_content.rs +++ b/crates/openfang-runtime/src/web_content.rs @@ -424,7 +424,10 @@ mod tests { let html = "İstanbul ẞtraße bold text"; let md = html_to_markdown(html); assert!(md.contains("**bold**"), "Expected bold, got: {md}"); - assert!(md.contains("İstanbul"), "Expected unicode preserved, got: {md}"); + assert!( + md.contains("İstanbul"), + "Expected unicode preserved, got: {md}" + ); } #[test] diff --git a/crates/openfang-runtime/src/web_fetch.rs b/crates/openfang-runtime/src/web_fetch.rs index b76ea08cb..4adb12b15 100644 --- a/crates/openfang-runtime/src/web_fetch.rs +++ b/crates/openfang-runtime/src/web_fetch.rs @@ -186,9 +186,7 @@ pub(crate) fn check_ssrf(url: &str) -> Result<(), String> { let host = extract_host(url); // For IPv6 bracket notation like [::1]:80, extract [::1] as hostname let hostname = if host.starts_with('[') { - host.find(']') - .map(|i| &host[..=i]) - .unwrap_or(&host) + host.find(']').map(|i| &host[..=i]).unwrap_or(&host) } else { host.split(':').next().unwrap_or(&host) }; diff --git a/crates/openfang-types/src/approval.rs b/crates/openfang-types/src/approval.rs index a53dd45fc..157efed27 100644 --- a/crates/openfang-types/src/approval.rs +++ b/crates/openfang-types/src/approval.rs @@ -562,8 +562,7 @@ mod tests { #[test] fn policy_require_approval_bool_true() { // require_approval = true → ["shell_exec"] - let policy: ApprovalPolicy = - serde_json::from_str(r#"{"require_approval": true}"#).unwrap(); + let policy: ApprovalPolicy = serde_json::from_str(r#"{"require_approval": true}"#).unwrap(); assert_eq!(policy.require_approval, vec!["shell_exec"]); } diff --git a/crates/openfang-types/src/lib.rs b/crates/openfang-types/src/lib.rs index 339337b1f..ac34c0c46 100644 --- a/crates/openfang-types/src/lib.rs +++ b/crates/openfang-types/src/lib.rs @@ -18,6 +18,7 @@ pub mod model_catalog; pub mod scheduler; pub mod serde_compat; pub mod taint; +pub mod task_state; pub mod tool; pub mod tool_compat; pub mod webhook; diff --git a/crates/openfang-types/src/task_state.rs b/crates/openfang-types/src/task_state.rs new file mode 100644 index 000000000..6efe9927a --- /dev/null +++ b/crates/openfang-types/src/task_state.rs @@ -0,0 +1,182 @@ +//! Durable task state model for long-running orchestration. +//! +//! Defines canonical task states and per-state timestamps so task progress can +//! be serialized, persisted, and recovered across sessions. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// Canonical state for orchestrated tasks. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskExecutionState { + Pending, + InProgress, + Failed, + Blocked, + Done, + Canceled, +} + +impl TaskExecutionState { + pub fn is_terminal(self) -> bool { + matches!(self, Self::Done | Self::Canceled) + } +} + +/// Timestamp record for each task state. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TaskStateTimestamps { + pub pending_at: DateTime, + pub in_progress_at: Option>, + pub failed_at: Option>, + pub blocked_at: Option>, + pub done_at: Option>, + pub canceled_at: Option>, + pub updated_at: DateTime, +} + +impl TaskStateTimestamps { + pub fn new(now: DateTime) -> Self { + Self { + pending_at: now, + in_progress_at: None, + failed_at: None, + blocked_at: None, + done_at: None, + canceled_at: None, + updated_at: now, + } + } + + pub fn mark(&mut self, state: TaskExecutionState, at: DateTime) { + match state { + TaskExecutionState::Pending => self.pending_at = at, + TaskExecutionState::InProgress => self.in_progress_at = Some(at), + TaskExecutionState::Failed => self.failed_at = Some(at), + TaskExecutionState::Blocked => self.blocked_at = Some(at), + TaskExecutionState::Done => self.done_at = Some(at), + TaskExecutionState::Canceled => self.canceled_at = Some(at), + } + self.updated_at = at; + } +} + +/// Durable task state + transition metadata. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DurableTaskState { + pub state: TaskExecutionState, + pub timestamps: TaskStateTimestamps, +} + +impl DurableTaskState { + pub fn new(now: DateTime) -> Self { + Self { + state: TaskExecutionState::Pending, + timestamps: TaskStateTimestamps::new(now), + } + } + + pub fn transition( + &mut self, + next: TaskExecutionState, + at: DateTime, + ) -> Result<(), String> { + if self.state == next { + self.timestamps.updated_at = at; + return Ok(()); + } + + if self.state.is_terminal() { + return Err(format!( + "cannot transition from terminal state {:?} to {:?}", + self.state, next + )); + } + + if !is_valid_transition(self.state, next) { + return Err(format!( + "invalid state transition: {:?} -> {:?}", + self.state, next + )); + } + + self.state = next; + self.timestamps.mark(next, at); + Ok(()) + } +} + +fn is_valid_transition(current: TaskExecutionState, next: TaskExecutionState) -> bool { + use TaskExecutionState as S; + matches!( + (current, next), + ( + S::Pending, + S::InProgress | S::Failed | S::Blocked | S::Canceled + ) | ( + S::InProgress, + S::Failed | S::Blocked | S::Done | S::Canceled + ) | (S::Failed, S::InProgress | S::Blocked | S::Canceled) + | (S::Blocked, S::InProgress | S::Failed | S::Canceled) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + #[test] + fn new_state_starts_pending_with_timestamps() { + let now = Utc.with_ymd_and_hms(2026, 3, 5, 12, 0, 0).unwrap(); + let state = DurableTaskState::new(now); + assert_eq!(state.state, TaskExecutionState::Pending); + assert_eq!(state.timestamps.pending_at, now); + assert_eq!(state.timestamps.updated_at, now); + assert!(state.timestamps.in_progress_at.is_none()); + assert!(state.timestamps.done_at.is_none()); + } + + #[test] + fn transition_records_state_timestamps() { + let t0 = Utc.with_ymd_and_hms(2026, 3, 5, 12, 0, 0).unwrap(); + let t1 = Utc.with_ymd_and_hms(2026, 3, 5, 12, 1, 0).unwrap(); + let t2 = Utc.with_ymd_and_hms(2026, 3, 5, 12, 2, 0).unwrap(); + let t3 = Utc.with_ymd_and_hms(2026, 3, 5, 12, 3, 0).unwrap(); + + let mut state = DurableTaskState::new(t0); + state + .transition(TaskExecutionState::InProgress, t1) + .unwrap(); + state.transition(TaskExecutionState::Blocked, t2).unwrap(); + state.transition(TaskExecutionState::Canceled, t3).unwrap(); + + assert_eq!(state.state, TaskExecutionState::Canceled); + assert_eq!(state.timestamps.in_progress_at, Some(t1)); + assert_eq!(state.timestamps.blocked_at, Some(t2)); + assert_eq!(state.timestamps.canceled_at, Some(t3)); + assert_eq!(state.timestamps.updated_at, t3); + } + + #[test] + fn terminal_state_rejects_further_transition() { + let t0 = Utc.with_ymd_and_hms(2026, 3, 5, 12, 0, 0).unwrap(); + let t1 = Utc.with_ymd_and_hms(2026, 3, 5, 12, 1, 0).unwrap(); + let t2 = Utc.with_ymd_and_hms(2026, 3, 5, 12, 2, 0).unwrap(); + + let mut state = DurableTaskState::new(t0); + state + .transition(TaskExecutionState::InProgress, t1) + .unwrap(); + state.transition(TaskExecutionState::Done, t2).unwrap(); + + let err = state + .transition( + TaskExecutionState::Failed, + Utc.with_ymd_and_hms(2026, 3, 5, 12, 3, 0).unwrap(), + ) + .unwrap_err(); + assert!(err.contains("terminal state")); + } +} diff --git a/docs/multi-agent-foundation.md b/docs/multi-agent-foundation.md new file mode 100644 index 000000000..a38818b9e --- /dev/null +++ b/docs/multi-agent-foundation.md @@ -0,0 +1,132 @@ +# Multi-Agent Foundation Configuration Guide + +This guide explains how admins and end users enable and tune OpenFang multi-agent workflows safely. + +## Scope + +The foundation covers: + +- Workflow orchestration (`plan/review/dispatch/worker` patterns) +- Review reject-and-return loops +- Parallel fan-out/fan-in aggregation +- Retry/block escalation behavior +- Traceable audit events and observability metrics + +## Admin Setup + +### 1) Define the workflow with safe defaults + +Create workflows through `POST /api/workflows` using conservative execution controls: + +- Set per-step `timeout_secs` (avoid unbounded runtime). +- Prefer `error_mode: "fail"` by default. +- Use `error_mode: { "retry": { "max_retries": N } }` only for idempotent steps. +- Add a `review` step for high-impact outputs. +- Use `StepMode::Review` reject-return settings to enforce quality gates before dispatch. + +### 2) Register role-specific agents + +Use distinct agents for planning, review, dispatch, and workers. Keep capabilities minimal per role: + +- Planner: analysis/planning only. +- Reviewer: validation/rejection decisions. +- Dispatcher: final packaging/routing. +- Worker: bounded task execution. + +This reduces blast radius and makes audit trails clearer. + +### 3) Enforce approval and permission boundaries + +For sensitive tools/actions: + +- Require explicit approval before execution. +- Deny unauthorized actions by default. +- Keep allowlists narrow and role-specific. + +### 4) Enable observability and auditing + +Use these endpoints for runtime governance: + +- `GET /api/workflows/{id}/runs`: run list with `trace_id`. +- `GET /api/workflows/traces/{trace_id}/events`: decision/dispatch/execution/review event stream. +- `GET /api/workflows/metrics`: workflow success/failure/retry/reject/resume metrics. +- `GET /api/metrics`: Prometheus metrics (includes workflow observability gauges). + +## User Operation + +### 1) Run a workflow + +Execute: + +- `POST /api/workflows/{id}/run` + +Response includes: + +- `run_id` +- `trace_id` +- `status` +- `output` + +### 1.5) Run in shadow against the current production path + +When OpenFang is still the candidate path, keep the production output authoritative and pass it into the workflow run request: + +- `POST /api/workflows/{id}/run` with `shadow.enabled = true` +- Include `shadow.production_output` from the current production path (for example OpenClaw) +- OpenFang runs the workflow, stores the shadow comparison on the run, and returns: + - `output`: the production output + - `shadow_output`: the OpenFang output + - `shadow.matches` / `shadow.normalized_matches` / `shadow.first_mismatch_index` + +This keeps rollout safe while making output drift visible before promotion. + +### 1.6) Prepare a fast rollback before promotion + +Use rollout controls to keep the stable path explicit and make rollback a one-call operation: + +- `GET /api/workflows/{id}/rollout` inspects the current primary path, stable path, shadow flag, and rollback checklist. +- `PUT /api/workflows/{id}/rollout` updates rollout intent, for example promoting `primary_path` to `openfang` while keeping `stable_path` as `production`. +- `POST /api/workflows/{id}/rollback` immediately switches traffic back to the stable path, disables shadow by default, and returns a recorded checklist plus rollback duration. + +Recommended rollback checklist: + +1. Freeze the candidate path. +2. Switch the primary path back to the last stable route. +3. Disable shadow traffic until the incident is understood. +4. Verify SLI/SLO signals and recent traces. +5. Capture operator notes and incident follow-up. + +### 2) Track progress and outcomes + +After execution: + +- Query `GET /api/workflows/{id}/runs` for lifecycle state. +- Use `trace_id` with `GET /api/workflows/traces/{trace_id}/events` to inspect: + - Why review rejected/approved + - Which dispatch happened + - Which execution steps retried or failed + +### 3) Interpret review/retry behavior + +- Reject-return loops: review can send work back to planning with explicit feedback. +- Retry escalation: repeated failures can promote a run from `FAILED` behavior to `BLOCKED` semantics (depending on error mode policy). + +## Safe Tuning Checklist + +Before widening scale or risk: + +1. Keep `max_retries` low (start with `1` or `2`). +2. Keep `timeout_secs` explicit on every step. +3. Require review for externally visible or high-risk actions. +4. Verify `reject_rate` and `retry_rate` from `/api/workflows/metrics` before increasing concurrency. +5. Investigate any high `resume_time_ms` before production promotion. +6. Use `trace_id` event logs for incident review and rollback decisions. + +## Minimal Rollout Plan + +1. Start with one workflow and one user path. +2. Monitor `success_rate`, `failure_rate`, `retry_rate`, and `reject_rate`. +3. Fix noisy steps (high retries/rejects) before enabling broader traffic. +4. Expand gradually to more task types and channels. + +This staged rollout keeps multi-agent orchestration observable, recoverable, and safe. From c5853a8ee9e0cd5419780fc11cedd92102460349 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Mar 2026 00:19:28 +0800 Subject: [PATCH 2/6] chore(tests): rustfmt session resume integration test --- .../tests/session_resume_integration_test.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/openfang-kernel/tests/session_resume_integration_test.rs b/crates/openfang-kernel/tests/session_resume_integration_test.rs index 3379a103b..cddf82a16 100644 --- a/crates/openfang-kernel/tests/session_resume_integration_test.rs +++ b/crates/openfang-kernel/tests/session_resume_integration_test.rs @@ -10,11 +10,11 @@ use openfang_kernel::workflow::{ use openfang_kernel::OpenFangKernel; use openfang_memory::session::Session; use openfang_types::agent::{AgentId, AgentManifest, SessionId}; -use uuid::Uuid; use openfang_types::config::{DefaultModelConfig, KernelConfig}; use openfang_types::message::Message; use std::path::{Path, PathBuf}; use std::sync::Arc; +use uuid::Uuid; fn test_config(tmp: &tempfile::TempDir) -> KernelConfig { KernelConfig { @@ -69,7 +69,12 @@ fn only_markdown_file(dir: &Path) -> PathBuf { .map(|entry| entry.unwrap().path()) .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("md")) .collect(); - assert_eq!(entries.len(), 1, "expected one markdown file in {}", dir.display()); + assert_eq!( + entries.len(), + 1, + "expected one markdown file in {}", + dir.display() + ); entries[0].clone() } @@ -122,8 +127,10 @@ fn test_multi_session_e2e_session_summaries_stay_scoped() { .join(session_b.to_string()) .join("memory"); - let session_a_summary = std::fs::read_to_string(only_markdown_file(&session_a_memory_dir)).unwrap(); - let session_b_summary = std::fs::read_to_string(only_markdown_file(&session_b_memory_dir)).unwrap(); + let session_a_summary = + std::fs::read_to_string(only_markdown_file(&session_a_memory_dir)).unwrap(); + let session_b_summary = + std::fs::read_to_string(only_markdown_file(&session_b_memory_dir)).unwrap(); assert!(session_a_summary.contains("alpha confidential thread")); assert!(!session_a_summary.contains("bravo isolated topic")); From b64964bdbba9141b979642a75693f7fec89eb2f1 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Mar 2026 12:44:00 +0800 Subject: [PATCH 3/6] fix(multiagent): resolve pre-PR high-risk routing/session/migrate issues --- crates/openfang-api/src/routes.rs | 173 +++++++++++++++++++++--- crates/openfang-api/src/ws.rs | 58 +++++++- crates/openfang-kernel/src/kernel.rs | 111 +++++++++++++-- crates/openfang-kernel/src/workflow.rs | 87 +++++++++++- crates/openfang-migrate/src/openclaw.rs | 15 +- 5 files changed, 403 insertions(+), 41 deletions(-) diff --git a/crates/openfang-api/src/routes.rs b/crates/openfang-api/src/routes.rs index 7896f6a03..42461169d 100644 --- a/crates/openfang-api/src/routes.rs +++ b/crates/openfang-api/src/routes.rs @@ -242,20 +242,21 @@ pub fn resolve_attachments( pub fn inject_attachments_into_session( kernel: &OpenFangKernel, agent_id: AgentId, - session_id: Option, + resolved_session_id: SessionId, image_blocks: Vec, -) { +) -> Result<(), String> { use openfang_types::message::{Message, MessageContent, Role}; - let entry = match kernel.registry.get(agent_id) { - Some(e) => e, - None => return, - }; - - let resolved_session_id = session_id.unwrap_or(entry.session_id); - let mut session = match kernel.memory.get_session(resolved_session_id) { - Ok(Some(s)) => s, + Ok(Some(s)) => { + if s.agent_id != agent_id { + return Err(format!( + "Session {} does not belong to agent {}", + resolved_session_id, agent_id + )); + } + s + } _ => openfang_memory::session::Session { id: resolved_session_id, agent_id, @@ -270,12 +271,13 @@ pub fn inject_attachments_into_session( content: MessageContent::Blocks(image_blocks), }); - if let Err(e) = kernel.memory.save_session(&session) { - tracing::warn!(error = %e, "Failed to save session with image attachments"); - } + kernel + .memory + .save_session(&session) + .map_err(|e| format!("Failed to save session with image attachments: {e}")) } -fn parse_requested_session_id( +pub(crate) fn parse_requested_session_id( session_id: Option<&str>, ) -> Result, (StatusCode, Json)> { match session_id { @@ -290,6 +292,47 @@ fn parse_requested_session_id( } } +pub(crate) fn resolve_session_for_attachments( + kernel: &OpenFangKernel, + agent_id: AgentId, + requested_session_id: Option, +) -> Result)> { + let entry = match kernel.registry.get(agent_id) { + Some(entry) => entry, + None => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Agent not found"})), + )); + } + }; + + let resolved_session_id = requested_session_id.unwrap_or(entry.session_id); + if requested_session_id.is_none() { + return Ok(resolved_session_id); + } + + match kernel.memory.get_session(resolved_session_id) { + Ok(Some(session)) => { + if session.agent_id != agent_id { + return Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Session belongs to a different agent"})), + )); + } + Ok(resolved_session_id) + } + Ok(None) => Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Session not found"})), + )), + Err(e) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("Session lookup failed: {e}")})), + )), + } +} + /// POST /api/agents/:id/message — Send a message to an agent. pub async fn send_message( State(state): State>, @@ -332,12 +375,27 @@ pub async fn send_message( if !req.attachments.is_empty() { let image_blocks = resolve_attachments(&req.attachments); if !image_blocks.is_empty() { - inject_attachments_into_session( + let resolved_session_id = match resolve_session_for_attachments( &state.kernel, agent_id, requested_session_id, + ) { + Ok(session_id) => session_id, + Err(response) => return response, + }; + + if let Err(e) = inject_attachments_into_session( + &state.kernel, + agent_id, + resolved_session_id, image_blocks, - ); + ) { + tracing::warn!(agent_id = %agent_id, error = %e, "Failed to inject attachments into session"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "Failed to inject attachments"})), + ); + } } } @@ -10530,3 +10588,86 @@ pub async fn comms_task( ), } } + +#[cfg(test)] +mod tests { + use super::*; + use openfang_types::agent::AgentManifest; + use openfang_types::config::{DefaultModelConfig, KernelConfig}; + + const TEST_MANIFEST_TEMPLATE: &str = r#" +name = "{name}" +version = "0.1.0" +description = "routes unit test agent" +author = "test" +module = "builtin:chat" + +[model] +provider = "ollama" +model = "test-model" +system_prompt = "You are a test agent." +"#; + + fn boot_kernel() -> (OpenFangKernel, tempfile::TempDir) { + let tmp = tempfile::tempdir().unwrap(); + let config = KernelConfig { + home_dir: tmp.path().to_path_buf(), + data_dir: tmp.path().join("data"), + default_model: DefaultModelConfig { + provider: "ollama".to_string(), + model: "test-model".to_string(), + api_key_env: "OLLAMA_API_KEY".to_string(), + base_url: None, + }, + ..KernelConfig::default() + }; + let kernel = OpenFangKernel::boot_with_config(config).unwrap(); + (kernel, tmp) + } + + fn spawn_agent(kernel: &OpenFangKernel, name: &str) -> AgentId { + let manifest_toml = TEST_MANIFEST_TEMPLATE.replace("{name}", name); + let manifest: AgentManifest = toml::from_str(&manifest_toml).unwrap(); + kernel.spawn_agent(manifest).unwrap() + } + + #[test] + fn test_resolve_session_for_attachments_rejects_cross_agent_session() { + let (kernel, _tmp) = boot_kernel(); + let agent_a = spawn_agent(&kernel, "agent-a"); + let agent_b = spawn_agent(&kernel, "agent-b"); + let session_b = kernel.registry.get(agent_b).unwrap().session_id; + + let err = resolve_session_for_attachments(&kernel, agent_a, Some(session_b)).unwrap_err(); + assert_eq!(err.0, StatusCode::BAD_REQUEST); + assert_eq!(err.1 .0["error"], "Session belongs to a different agent"); + } + + #[test] + fn test_resolve_session_for_attachments_rejects_unknown_explicit_session() { + let (kernel, _tmp) = boot_kernel(); + let agent_a = spawn_agent(&kernel, "agent-a"); + let unknown = SessionId::new(); + + let err = resolve_session_for_attachments(&kernel, agent_a, Some(unknown)).unwrap_err(); + assert_eq!(err.0, StatusCode::BAD_REQUEST); + assert_eq!(err.1 .0["error"], "Session not found"); + } + + #[test] + fn test_inject_attachments_into_session_rejects_cross_agent_session() { + let (kernel, _tmp) = boot_kernel(); + let agent_a = spawn_agent(&kernel, "agent-a"); + let agent_b = spawn_agent(&kernel, "agent-b"); + let session_b = kernel.registry.get(agent_b).unwrap().session_id; + + let image = openfang_types::message::ContentBlock::Image { + media_type: "image/png".to_string(), + data: "dGVzdA==".to_string(), + }; + + let err = + inject_attachments_into_session(&kernel, agent_a, session_b, vec![image]).unwrap_err(); + assert!(err.contains("does not belong to agent")); + } +} diff --git a/crates/openfang-api/src/ws.rs b/crates/openfang-api/src/ws.rs index aebd1b49c..e3eefb599 100644 --- a/crates/openfang-api/src/ws.rs +++ b/crates/openfang-api/src/ws.rs @@ -427,10 +427,21 @@ async fn handle_text_message( } // Resolve file attachments into image content blocks - let requested_session_id = parsed["session_id"] - .as_str() - .and_then(|sid| sid.parse::().ok()) - .map(openfang_types::agent::SessionId); + let requested_session_id = + match crate::routes::parse_requested_session_id(parsed["session_id"].as_str()) { + Ok(session_id) => session_id, + Err((_status, body)) => { + let _ = send_json( + sender, + &serde_json::json!({ + "type": "error", + "content": body.0["error"].as_str().unwrap_or("Invalid session ID"), + }), + ) + .await; + return; + } + }; let mut has_images = false; if let Some(attachments) = parsed["attachments"].as_array() { @@ -441,13 +452,46 @@ async fn handle_text_message( if !refs.is_empty() { let image_blocks = crate::routes::resolve_attachments(&refs); if !image_blocks.is_empty() { + let resolved_session_id = + match crate::routes::resolve_session_for_attachments( + &state.kernel, + agent_id, + requested_session_id, + ) { + Ok(session_id) => session_id, + Err((_status, body)) => { + let _ = send_json( + sender, + &serde_json::json!({ + "type": "error", + "content": body.0["error"] + .as_str() + .unwrap_or("Invalid session for attachments"), + }), + ) + .await; + return; + } + }; + has_images = true; - crate::routes::inject_attachments_into_session( + if let Err(e) = crate::routes::inject_attachments_into_session( &state.kernel, agent_id, - requested_session_id, + resolved_session_id, image_blocks, - ); + ) { + tracing::warn!(agent_id = %agent_id, error = %e, "Failed to inject websocket attachments"); + let _ = send_json( + sender, + &serde_json::json!({ + "type": "error", + "content": "Failed to inject attachments", + }), + ) + .await; + return; + } } } } diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index 42bad1d21..c0b4bc351 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -13,7 +13,7 @@ use crate::supervisor::Supervisor; use crate::triggers::{TriggerEngine, TriggerId, TriggerPattern}; use crate::workflow::{ StepAgent, Workflow, WorkflowEngine, WorkflowId, WorkflowRouteRequest, WorkflowRunId, - WorkflowShadowComparison, + WorkflowShadowComparison, WorkflowTrafficPath, }; use openfang_memory::MemorySubstrate; @@ -1778,7 +1778,10 @@ impl OpenFangKernel { // Auto-compact if the session is large before running the loop if needs_compact { info!(agent_id = %agent_id, messages = session.messages.len(), "Auto-compacting session"); - match kernel_clone.compact_agent_session(agent_id).await { + match kernel_clone + .compact_agent_session_in_session(agent_id, resolved_session_id) + .await + { Ok(msg) => { info!(agent_id = %agent_id, "{msg}"); // Reload the session after compaction @@ -1903,7 +1906,10 @@ impl OpenFangKernel { let kc = kernel_clone.clone(); tokio::spawn(async move { info!(agent_id = %agent_id, estimated_tokens = estimated, "Post-loop compaction triggered"); - if let Err(e) = kc.compact_agent_session(agent_id).await { + if let Err(e) = kc + .compact_agent_session_in_session(agent_id, resolved_session_id) + .await + { warn!(agent_id = %agent_id, "Post-loop compaction failed: {e}"); } }); @@ -2868,6 +2874,19 @@ impl OpenFangKernel { /// Replaces the existing text-truncation compaction with an intelligent /// LLM-generated summary of older messages, keeping only recent messages. pub async fn compact_agent_session(&self, agent_id: AgentId) -> KernelResult { + let entry = self.registry.get(agent_id).ok_or_else(|| { + KernelError::OpenFang(OpenFangError::AgentNotFound(agent_id.to_string())) + })?; + self.compact_agent_session_in_session(agent_id, entry.session_id) + .await + } + + /// Compact a specific agent session by session id. + pub async fn compact_agent_session_in_session( + &self, + agent_id: AgentId, + session_id: SessionId, + ) -> KernelResult { use openfang_runtime::compactor::{compact_session, needs_compaction, CompactionConfig}; let entry = self.registry.get(agent_id).ok_or_else(|| { @@ -2876,10 +2895,19 @@ impl OpenFangKernel { let session = self .memory - .get_session(entry.session_id) + .get_session(session_id) .map_err(KernelError::OpenFang)? + .map(|session| { + if session.agent_id != agent_id { + return Err(KernelError::OpenFang(OpenFangError::Internal(format!( + "Session {session_id} belongs to a different agent" + )))); + } + Ok(session) + }) + .transpose()? .unwrap_or_else(|| openfang_memory::session::Session { - id: entry.session_id, + id: session_id, agent_id, messages: Vec::new(), context_window_tokens: 0, @@ -3503,11 +3531,29 @@ impl OpenFangKernel { request: WorkflowRouteRequest, input: String, ) -> KernelResult<(WorkflowRunId, String)> { - let workflow_id = self.route_workflow(&request).await.ok_or_else(|| { - KernelError::OpenFang(OpenFangError::Internal( - "No matching workflow route rule".to_string(), - )) - })?; + let workflow_id = self + .workflows + .route_workflow_for_primary_path(&request, WorkflowTrafficPath::Openfang) + .await + .ok_or_else(|| { + KernelError::OpenFang(OpenFangError::Internal( + "No matching OpenFang-primary workflow route rule".to_string(), + )) + })?; + let rollout = self + .workflows + .get_rollout_state(workflow_id) + .await + .ok_or_else(|| { + KernelError::OpenFang(OpenFangError::Internal( + "Workflow rollout state not found".to_string(), + )) + })?; + if rollout.primary_path != WorkflowTrafficPath::Openfang { + return Err(KernelError::OpenFang(OpenFangError::Internal( + "Workflow route is not promoted to OpenFang primary path".to_string(), + ))); + } self.run_workflow(workflow_id, input).await } @@ -5622,6 +5668,7 @@ impl openfang_wire::peer::PeerHandle for OpenFangKernel { #[cfg(test)] mod tests { use super::*; + use openfang_types::config::DefaultModelConfig; use std::collections::HashMap; #[test] @@ -5692,6 +5739,50 @@ mod tests { } } + fn boot_test_kernel() -> (OpenFangKernel, tempfile::TempDir) { + let tmp = tempfile::tempdir().unwrap(); + let config = KernelConfig { + home_dir: tmp.path().to_path_buf(), + data_dir: tmp.path().join("data"), + default_model: DefaultModelConfig { + provider: "ollama".to_string(), + model: "test-model".to_string(), + api_key_env: "OLLAMA_API_KEY".to_string(), + base_url: None, + }, + ..KernelConfig::default() + }; + let kernel = OpenFangKernel::boot_with_config(config).unwrap(); + (kernel, tmp) + } + + #[tokio::test] + async fn test_compact_agent_session_in_session_rejects_cross_agent_session() { + let (kernel, _tmp) = boot_test_kernel(); + let agent_a = kernel + .spawn_agent(test_manifest( + "agent-a", + "A routes test agent", + vec!["test".to_string()], + )) + .unwrap(); + let agent_b = kernel + .spawn_agent(test_manifest( + "agent-b", + "B routes test agent", + vec!["test".to_string()], + )) + .unwrap(); + let session_b = kernel.registry.get(agent_b).unwrap().session_id; + + let err = kernel + .compact_agent_session_in_session(agent_a, session_b) + .await + .unwrap_err(); + let err_text = err.to_string(); + assert!(err_text.contains("belongs to a different agent")); + } + #[test] fn test_send_to_agent_by_name_resolution() { // Test that name resolution works in the registry diff --git a/crates/openfang-kernel/src/workflow.rs b/crates/openfang-kernel/src/workflow.rs index 8a4843ec5..17811be7f 100644 --- a/crates/openfang-kernel/src/workflow.rs +++ b/crates/openfang-kernel/src/workflow.rs @@ -136,16 +136,16 @@ impl WorkflowRouteRule { fn score(&self) -> i32 { let mut score = self.priority.saturating_mul(100); if self.user_id.is_some() { - score += 8; + score = score.saturating_add(8); } if self.channel.is_some() { - score += 4; + score = score.saturating_add(4); } if self.task_type.is_some() { - score += 2; + score = score.saturating_add(2); } if self.risk_policy != WorkflowRiskPolicy::Any { - score += 1; + score = score.saturating_add(1); } score } @@ -635,6 +635,20 @@ impl WorkflowEngine { selected.map(|(workflow_id, _)| workflow_id) } + pub async fn route_workflow_for_primary_path( + &self, + request: &WorkflowRouteRequest, + required_path: WorkflowTrafficPath, + ) -> Option { + let workflow_id = self.route_workflow(request).await?; + let rollout = self.get_rollout_state(workflow_id).await?; + if rollout.primary_path == required_path { + Some(workflow_id) + } else { + None + } + } + fn resolve_step_name(workflow: &Workflow, step_name: &str) -> Option { workflow .steps @@ -4706,4 +4720,69 @@ Processed: Analyze this: raw data", Some(default_id) ); } + + #[test] + fn test_route_rule_score_saturates_without_overflow() { + let rule = WorkflowRouteRule { + workflow_id: WorkflowId::new(), + user_id: Some("u-1".to_string()), + channel: Some("feishu".to_string()), + task_type: Some("incident".to_string()), + risk_policy: WorkflowRiskPolicy::Max(RiskLevel::High), + priority: i32::MAX, + }; + assert_eq!(rule.score(), i32::MAX); + } + + #[tokio::test] + async fn test_route_workflow_for_primary_path_enforces_rollout_state() { + let engine = WorkflowEngine::new(); + let workflow = test_workflow(); + let workflow_id = workflow.id; + engine.register(workflow).await; + + engine + .set_route_rules(vec![WorkflowRouteRule { + workflow_id, + user_id: None, + channel: Some("feishu".to_string()), + task_type: Some("incident".to_string()), + risk_policy: WorkflowRiskPolicy::Any, + priority: 1, + }]) + .await; + + let request = WorkflowRouteRequest { + user_id: "u-1".to_string(), + channel: "feishu".to_string(), + task_type: "incident".to_string(), + risk_level: RiskLevel::Low, + }; + + // Default rollout state is production, so routed OpenFang traffic is blocked. + assert_eq!( + engine + .route_workflow_for_primary_path(&request, WorkflowTrafficPath::Openfang) + .await, + None + ); + + engine + .update_rollout_state( + workflow_id, + Some(WorkflowTrafficPath::Openfang), + Some(WorkflowTrafficPath::Production), + Some(true), + None, + ) + .await + .unwrap(); + + assert_eq!( + engine + .route_workflow_for_primary_path(&request, WorkflowTrafficPath::Openfang) + .await, + Some(workflow_id) + ); + } } diff --git a/crates/openfang-migrate/src/openclaw.rs b/crates/openfang-migrate/src/openclaw.rs index 83ccb1a4e..d2a0d4f78 100644 --- a/crates/openfang-migrate/src/openclaw.rs +++ b/crates/openfang-migrate/src/openclaw.rs @@ -912,8 +912,9 @@ fn tools_for_profile(profile: &str) -> Vec { /// Map OpenClaw provider name to OpenFang provider name. fn map_provider(openclaw_provider: &str) -> String { - let normalized = openclaw_provider.trim().to_lowercase().replace('-', "_"); - match normalized.as_str() { + let normalized = openclaw_provider.trim().to_lowercase(); + let alias_key = normalized.replace('-', "_"); + match alias_key.as_str() { "anthropic" | "claude" => "anthropic".to_string(), "openai" | "gpt" | "codex" | "openai_codex" => "openai".to_string(), "groq" => "groq".to_string(), @@ -937,7 +938,8 @@ fn map_provider(openclaw_provider: &str) -> String { "qianfan" | "baidu" => "qianfan".to_string(), "volcengine" | "doubao" => "volcengine".to_string(), "github_copilot" | "copilot" => "github-copilot".to_string(), - other => other.to_string(), + "claude_code" => "claude-code".to_string(), + _ => normalized, } } @@ -963,7 +965,8 @@ fn default_api_key_env(provider: &str) -> String { "qianfan" => "QIANFAN_API_KEY".to_string(), "volcengine" => "VOLCENGINE_API_KEY".to_string(), "github-copilot" => "GITHUB_TOKEN".to_string(), - "ollama" => String::new(), // Ollama doesn't need an API key + "claude-code" => String::new(), // Claude Code provider doesn't need API key env + "ollama" => String::new(), // Ollama doesn't need an API key other => format!("{}_API_KEY", other.to_uppercase()), } } @@ -4187,6 +4190,7 @@ mod tests { assert_eq!(default_api_key_env("copilot"), "GITHUB_TOKEN"); assert_eq!(default_api_key_env("github-copilot"), "GITHUB_TOKEN"); assert_eq!(default_api_key_env("github_copilot"), "GITHUB_TOKEN"); + assert_eq!(default_api_key_env("claude-code"), ""); } #[test] @@ -4385,10 +4389,13 @@ mod tests { assert_eq!(map_provider("gpt"), "openai"); assert_eq!(map_provider("groq"), "groq"); assert_eq!(map_provider("custom"), "custom"); + assert_eq!(map_provider("custom-provider"), "custom-provider"); assert_eq!(map_provider("google"), "google"); assert_eq!(map_provider("gemini"), "google"); assert_eq!(map_provider("xai"), "xai"); assert_eq!(map_provider("grok"), "xai"); + assert_eq!(map_provider("claude-code"), "claude-code"); + assert_eq!(map_provider("claude_code"), "claude-code"); } #[test] From 99d7d3bf9d187e3bb09f6ffa569f45abbda44b03 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Mar 2026 13:37:36 +0800 Subject: [PATCH 4/6] chore(governance): enforce pre-pr review gate workflow --- .github/pull_request_template.md | 35 ++++++++ .github/workflows/pre-pr-review-gate.yml | 34 ++++++++ .gitignore | 2 + CHANGELOG.md | 6 ++ CONTRIBUTING.md | 17 ++++ docs/README.md | 1 + docs/pr-quality-gates.md | 82 +++++++++++++++++++ scripts/ci/check_pr_review_gate.sh | 89 ++++++++++++++++++++ scripts/ci/configure_branch_protection.sh | 98 +++++++++++++++++++++++ 9 files changed, 364 insertions(+) create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/pre-pr-review-gate.yml create mode 100644 docs/pr-quality-gates.md create mode 100755 scripts/ci/check_pr_review_gate.sh create mode 100755 scripts/ci/configure_branch_protection.sh diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..804dfc18c --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,35 @@ +## Summary +- What changed: +- Why this change is needed: + +## Scope +- Type: feat / fix / refactor / docs / chore +- Main area(s) touched: +- Non-goals (what this PR intentionally does not solve): +- Slice (if part of a larger effort): + +## Validation +- `command` -> `result` +- `command` -> `result` + +## Comprehensive Pre-PR Review +- [ ] I ran the required pre-PR review gates locally. +- [ ] I listed concrete validation evidence (commands + results). +- [ ] I completed a comprehensive review and recorded findings severity. +- [ ] I documented rollback trigger and rollback steps. +- [ ] This PR is scoped to one concern (or one planned slice). + +## Findings +- High: +- Medium: +- Low: + +## Risks +- Behavior risk: +- Compatibility risk: +- Operational risk: + +## Rollback +- Trigger: +- Steps: +- Verification after rollback: diff --git a/.github/workflows/pre-pr-review-gate.yml b/.github/workflows/pre-pr-review-gate.yml new file mode 100644 index 000000000..91cfb347b --- /dev/null +++ b/.github/workflows/pre-pr-review-gate.yml @@ -0,0 +1,34 @@ +name: pre-pr-review-gate + +on: + pull_request: + branches: [main] + types: [opened, edited, synchronize, reopened, ready_for_review] + +permissions: + contents: read + pull-requests: read + +jobs: + pre-pr-review-gate: + name: pre-pr-review-gate + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Ensure codex runtime artifacts are not tracked + run: | + set -euo pipefail + if git ls-files | rg -q '^\.codex-tasks/'; then + echo "ERROR: .codex-tasks artifacts must not be committed." >&2 + git ls-files | rg '^\.codex-tasks/' >&2 + exit 1 + fi + + - name: Validate PR comprehensive review checklist + env: + PR_BODY: ${{ github.event.pull_request.body }} + run: | + set -euo pipefail + bash ./scripts/ci/check_pr_review_gate.sh diff --git a/.gitignore b/.gitignore index 78b7238c5..cd92ff533 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,8 @@ collector_knowledge_base.json predictions_database.json prediction_report_*.md BUILD_LOG.md +/.codex-tasks/ +/.longrun/ # OS .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 399f1cbaf..cb3afd176 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Shadow and rollback controls are operator-facing guardrails; keep `stable_path` anchored to the last production route until each upstream slice is reviewed and landed. - OpenClaw migration compatibility covers historical identity/provider/bindings variants, so representative production exports should still be revalidated before promotion. +### Operational Quality Gates +- Added CI workflow `.github/workflows/pre-pr-review-gate.yml` to enforce comprehensive pre-PR checklist structure on `pull_request -> main`. +- Added PR template `.github/pull_request_template.md` with mandatory sections: summary, scope, validation evidence, findings, risks, and rollback. +- Added branch-protection automation script `scripts/ci/configure_branch_protection.sh` to apply required checks/review rules for `main`. +- Added operator docs: `docs/pr-quality-gates.md`; linked from `docs/README.md` and `CONTRIBUTING.md`. + ## [0.1.0] - 2026-02-24 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1f3773977..0d667fa16 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,6 +12,7 @@ Thank you for your interest in contributing to OpenFang. This guide covers every - [How to Add a New Channel Adapter](#how-to-add-a-new-channel-adapter) - [How to Add a New Tool](#how-to-add-a-new-tool) - [Pull Request Process](#pull-request-process) +- [PR Quality Gates](#pr-quality-gates) - [Code of Conduct](#code-of-conduct) --- @@ -334,6 +335,22 @@ tools = ["my_tool"] 7. **CI must pass**: All automated checks must be green before merge. +8. **Review-first requirement**: Keep PR as Draft until comprehensive review findings are documented and blocking findings are resolved. + +### PR Quality Gates + +OpenFang uses a mandatory review-first workflow. Follow [`docs/pr-quality-gates.md`](docs/pr-quality-gates.md). + +Before changing a PR to Ready for review: + +1. Complete all required sections in `.github/pull_request_template.md`. +2. Check all required items under `## Comprehensive Pre-PR Review`. +3. Run local gate checks and include concrete validation evidence. +4. Resolve all High findings and re-run focused regressions. +5. Keep one concern per PR (or one planned slice). + +CI workflow `pre-pr-review-gate` validates the PR body structure and checklist state. + ### Commit Messages Use clear, imperative-mood messages: diff --git a/docs/README.md b/docs/README.md index 4ea86d4bf..e4765ae5c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -43,6 +43,7 @@ Welcome to the OpenFang documentation. OpenFang is the open-source Agent Operati | Guide | Description | |-------|-------------| | [Production Checklist](production-checklist.md) | Every step before tagging v0.1.0 -- signing keys, secrets, verification | +| [PR Quality Gates](pr-quality-gates.md) | Mandatory review-first PR workflow, slice rules, and branch protection baseline | ## Additional Resources diff --git a/docs/pr-quality-gates.md b/docs/pr-quality-gates.md new file mode 100644 index 000000000..9967b4cb4 --- /dev/null +++ b/docs/pr-quality-gates.md @@ -0,0 +1,82 @@ +# PR Quality Gates + +This document defines the fixed "review-first" PR workflow for OpenFang. + +## Goals + +- Prevent fast/partial reviews from entering `main`. +- Keep PRs small enough to review thoroughly. +- Ensure every review finding has a fix + regression validation loop. + +## Mandatory Flow + +1. Scope freeze +- One concern per PR (or one planned slice in a larger plan). +- State non-goals explicitly in the PR body. + +2. Local pre-PR gate +- Run required local checks before changing PR state to ready. +- Capture concrete command output evidence in the PR body. + +3. Comprehensive review (before ready) +- Review by severity first: High -> Medium -> Low. +- High issues must be fixed before `Ready for review`. +- Medium issues should be fixed unless there is a documented and accepted tradeoff. + +4. Fix + regression loop +- For each finding: patch -> focused regression -> update review notes. +- Re-run gate commands after fixes. + +5. Ready for review +- Only switch from Draft when all mandatory checklist items are checked and gate checks are green. + +## Required Template Sections + +The PR body must include: + +- `## Summary` +- `## Scope` +- `## Validation` +- `## Comprehensive Pre-PR Review` +- `## Findings` +- `## Risks` +- `## Rollback` + +The CI workflow `pre-pr-review-gate` enforces those sections and required checked items. + +## Small PR Slice Rules + +- Keep each PR to one theme. +- Prefer additive changes and isolated rollback. +- Include the focused tests/docs in the same slice. +- If a large effort is unavoidable, submit ordered slices (A/B/C...) and keep each independently mergeable. + +## Branch Protection Baseline + +Apply branch protection to your fork: + +```bash +scripts/ci/configure_branch_protection.sh NextDoorLaoHuang-HF/openfang main +``` + +For a different repository: + +```bash +scripts/ci/configure_branch_protection.sh / main +``` + +Optional: customize required checks: + +```bash +REQUIRED_CHECKS_JSON='["pre-pr-review-gate / pre-pr-review-gate","CI / Check / ubuntu-latest"]' \ + scripts/ci/configure_branch_protection.sh / main +``` + +## Ready-For-Review Gate + +Do not switch a PR to ready unless all are true: + +- Pre-PR gate commands passed. +- High findings are resolved. +- Findings/risk/rollback are documented in PR body. +- PR scope remains one concern per PR. diff --git a/scripts/ci/check_pr_review_gate.sh b/scripts/ci/check_pr_review_gate.sh new file mode 100755 index 000000000..9b8d984a8 --- /dev/null +++ b/scripts/ci/check_pr_review_gate.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: check_pr_review_gate.sh [--body-file ] + +Validate PR body contains required comprehensive pre-PR review sections and checked items. +By default, reads PR body from PR_BODY environment variable. +USAGE +} + +BODY_FILE="" +while [[ $# -gt 0 ]]; do + case "$1" in + --body-file) + [[ $# -ge 2 ]] || { echo "missing value for --body-file" >&2; exit 2; } + BODY_FILE="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +BODY="" +if [[ -n "$BODY_FILE" ]]; then + [[ -f "$BODY_FILE" ]] || { echo "body file not found: $BODY_FILE" >&2; exit 2; } + BODY="$(cat "$BODY_FILE")" +else + BODY="${PR_BODY:-}" +fi + +BODY="$(printf '%s' "$BODY" | tr -d '\r')" +if [[ -z "${BODY//[[:space:]]/}" ]]; then + echo "PR body is empty; cannot satisfy pre-PR review gate" >&2 + exit 1 +fi + +missing=0 + +require_section() { + local section="$1" + if ! printf '%s\n' "$BODY" | grep -Eiq "^##[[:space:]]+${section}[[:space:]]*$"; then + echo "missing required section: ## ${section}" >&2 + missing=1 + fi +} + +require_checked_item() { + local phrase="$1" + if ! printf '%s\n' "$BODY" | grep -Eiq "^- \[[xX]\][[:space:]].*${phrase}.*$"; then + echo "missing required checked item containing: ${phrase}" >&2 + missing=1 + fi + + if printf '%s\n' "$BODY" | grep -Eiq "^- \[[[:space:]]\][[:space:]].*${phrase}.*$"; then + echo "required item is present but unchecked: ${phrase}" >&2 + missing=1 + fi +} + +require_section "Summary" +require_section "Scope" +require_section "Validation" +require_section "Comprehensive Pre-PR Review" +require_section "Findings" +require_section "Risks" +require_section "Rollback" + +require_checked_item "required pre-PR review gates locally" +require_checked_item "concrete validation evidence" +require_checked_item "comprehensive review and recorded findings severity" +require_checked_item "documented rollback trigger and rollback steps" +require_checked_item "scoped to one concern" + +if [[ $missing -ne 0 ]]; then + echo "pre-PR review gate FAILED" >&2 + exit 1 +fi + +echo "pre-PR review gate passed" diff --git a/scripts/ci/configure_branch_protection.sh b/scripts/ci/configure_branch_protection.sh new file mode 100755 index 000000000..a776b195c --- /dev/null +++ b/scripts/ci/configure_branch_protection.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: configure_branch_protection.sh [branch] + +Configure GitHub branch protection with: +- required status checks +- at least one approving review +- stale review dismissal +- conversation resolution +- no force-push / no deletion + +Environment overrides: +- REQUIRED_CHECKS_JSON='["check-name-1","check-name-2"]' +- REQUIRED_APPROVALS=1 +USAGE +} + +if [[ $# -lt 1 || $# -gt 2 ]]; then + usage >&2 + exit 2 +fi + +REPO="$1" +BRANCH="${2:-main}" +REQUIRED_APPROVALS="${REQUIRED_APPROVALS:-1}" + +command -v gh >/dev/null 2>&1 || { echo "gh is required" >&2; exit 2; } +command -v python3 >/dev/null 2>&1 || { echo "python3 is required" >&2; exit 2; } + +DEFAULT_REQUIRED_CHECKS='["pre-pr-review-gate / pre-pr-review-gate"]' +CHECKS_JSON="${REQUIRED_CHECKS_JSON:-$DEFAULT_REQUIRED_CHECKS}" + +PAYLOAD="$(CHECKS_JSON="$CHECKS_JSON" REQUIRED_APPROVALS="$REQUIRED_APPROVALS" python3 - <<'PY' +import json +import os +import sys + +checks_raw = os.environ.get("CHECKS_JSON", "[]") +approvals_raw = os.environ.get("REQUIRED_APPROVALS", "1") + +try: + checks = json.loads(checks_raw) +except json.JSONDecodeError as exc: + print(f"invalid REQUIRED_CHECKS_JSON: {exc}", file=sys.stderr) + sys.exit(2) + +if not isinstance(checks, list) or not all(isinstance(x, str) and x.strip() for x in checks): + print("REQUIRED_CHECKS_JSON must be a JSON string array", file=sys.stderr) + sys.exit(2) + +try: + approvals = int(approvals_raw) +except ValueError: + print("REQUIRED_APPROVALS must be an integer", file=sys.stderr) + sys.exit(2) + +if approvals < 1: + print("REQUIRED_APPROVALS must be >= 1", file=sys.stderr) + sys.exit(2) + +payload = { + "required_status_checks": { + "strict": True, + "checks": [{"context": c} for c in checks], + }, + "enforce_admins": False, + "required_pull_request_reviews": { + "dismiss_stale_reviews": True, + "require_code_owner_reviews": False, + "required_approving_review_count": approvals, + "require_last_push_approval": False, + }, + "restrictions": None, + "required_linear_history": True, + "allow_force_pushes": False, + "allow_deletions": False, + "block_creations": False, + "required_conversation_resolution": True, + "lock_branch": False, + "allow_fork_syncing": True, +} + +print(json.dumps(payload)) +PY +)" + +printf 'Applying branch protection: repo=%s branch=%s\n' "$REPO" "$BRANCH" + +gh api \ + --method PUT \ + -H "Accept: application/vnd.github+json" \ + "/repos/${REPO}/branches/${BRANCH}/protection" \ + --input - <<<"$PAYLOAD" >/dev/null + +printf 'Branch protection applied successfully: %s/%s\n' "$REPO" "$BRANCH" From b8afe876742e49685edf41525d771cee1a4f9a1c Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Mar 2026 15:17:12 +0800 Subject: [PATCH 5/6] fix(governance): enforce ci required checks and clear clippy blockers --- CHANGELOG.md | 2 +- crates/openfang-cli/src/main.rs | 18 ++++------ crates/openfang-kernel/src/workflow.rs | 2 ++ .../tests/session_resume_integration_test.rs | 35 +++++++++++-------- docs/pr-quality-gates.md | 8 +++++ scripts/ci/configure_branch_protection.sh | 19 ++++++++-- 6 files changed, 56 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb3afd176..bcf1613bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Operational Quality Gates - Added CI workflow `.github/workflows/pre-pr-review-gate.yml` to enforce comprehensive pre-PR checklist structure on `pull_request -> main`. - Added PR template `.github/pull_request_template.md` with mandatory sections: summary, scope, validation evidence, findings, risks, and rollback. -- Added branch-protection automation script `scripts/ci/configure_branch_protection.sh` to apply required checks/review rules for `main`. +- Added branch-protection automation script `scripts/ci/configure_branch_protection.sh` to apply required checks/review rules for `main` (default checks include pre-PR gate plus CI check/test/clippy/format). - Added operator docs: `docs/pr-quality-gates.md`; linked from `docs/README.md` and `CONTRIBUTING.md`. ## [0.1.0] - 2026-02-24 diff --git a/crates/openfang-cli/src/main.rs b/crates/openfang-cli/src/main.rs index be5fe1f91..3f5f35512 100644 --- a/crates/openfang-cli/src/main.rs +++ b/crates/openfang-cli/src/main.rs @@ -2022,18 +2022,14 @@ fn cmd_doctor(json: bool, repair: bool) { ui::check_ok(".env file (permissions fixed to 0600)"); } repaired = true; - } else { - if !json { - ui::check_warn(&format!( - ".env file has loose permissions ({:o}), should be 0600", - mode - )); - } - } - } else { - if !json { - ui::check_ok(".env file"); + } else if !json { + ui::check_warn(&format!( + ".env file has loose permissions ({:o}), should be 0600", + mode + )); } + } else if !json { + ui::check_ok(".env file"); } } #[cfg(not(unix))] diff --git a/crates/openfang-kernel/src/workflow.rs b/crates/openfang-kernel/src/workflow.rs index 17811be7f..6da27781e 100644 --- a/crates/openfang-kernel/src/workflow.rs +++ b/crates/openfang-kernel/src/workflow.rs @@ -952,6 +952,8 @@ impl WorkflowEngine { run.recovery.variables = variables.clone(); } + // Keep call sites explicit while centralizing the mutation logic. + #[allow(clippy::too_many_arguments)] async fn persist_recovery_state( &self, run_id: WorkflowRunId, diff --git a/crates/openfang-kernel/tests/session_resume_integration_test.rs b/crates/openfang-kernel/tests/session_resume_integration_test.rs index cddf82a16..f50cc85c2 100644 --- a/crates/openfang-kernel/tests/session_resume_integration_test.rs +++ b/crates/openfang-kernel/tests/session_resume_integration_test.rs @@ -9,7 +9,7 @@ use openfang_kernel::workflow::{ }; use openfang_kernel::OpenFangKernel; use openfang_memory::session::Session; -use openfang_types::agent::{AgentId, AgentManifest, SessionId}; +use openfang_types::agent::{AgentId, AgentManifest, ManifestCapabilities, ModelConfig, SessionId}; use openfang_types::config::{DefaultModelConfig, KernelConfig}; use openfang_types::message::Message; use std::path::{Path, PathBuf}; @@ -31,19 +31,26 @@ fn test_config(tmp: &tempfile::TempDir) -> KernelConfig { } fn session_test_manifest(workspace: PathBuf) -> AgentManifest { - let mut manifest = AgentManifest::default(); - manifest.name = "session-e2e-agent".to_string(); - manifest.description = "Session isolation e2e test agent".to_string(); - manifest.author = "test".to_string(); - manifest.module = "builtin:chat".to_string(); - manifest.model.provider = "ollama".to_string(); - manifest.model.model = "test-model".to_string(); - manifest.model.api_key_env = Some("OLLAMA_API_KEY".to_string()); - manifest.model.system_prompt = "Test agent".to_string(); - manifest.capabilities.memory_read = vec!["*".to_string()]; - manifest.capabilities.memory_write = vec!["self.*".to_string()]; - manifest.workspace = Some(workspace); - manifest + AgentManifest { + name: "session-e2e-agent".to_string(), + description: "Session isolation e2e test agent".to_string(), + author: "test".to_string(), + module: "builtin:chat".to_string(), + model: ModelConfig { + provider: "ollama".to_string(), + model: "test-model".to_string(), + system_prompt: "Test agent".to_string(), + api_key_env: Some("OLLAMA_API_KEY".to_string()), + ..ModelConfig::default() + }, + capabilities: ManifestCapabilities { + memory_read: vec!["*".to_string()], + memory_write: vec!["self.*".to_string()], + ..ManifestCapabilities::default() + }, + workspace: Some(workspace), + ..AgentManifest::default() + } } fn write_session_messages( diff --git a/docs/pr-quality-gates.md b/docs/pr-quality-gates.md index 9967b4cb4..a6071673f 100644 --- a/docs/pr-quality-gates.md +++ b/docs/pr-quality-gates.md @@ -65,6 +65,14 @@ For a different repository: scripts/ci/configure_branch_protection.sh / main ``` +Default required checks applied by the script: + +- `pre-pr-review-gate / pre-pr-review-gate` +- `CI / Check / ubuntu-latest` +- `CI / Test / ubuntu-latest` +- `CI / Clippy` +- `CI / Format` + Optional: customize required checks: ```bash diff --git a/scripts/ci/configure_branch_protection.sh b/scripts/ci/configure_branch_protection.sh index a776b195c..e11ab0741 100755 --- a/scripts/ci/configure_branch_protection.sh +++ b/scripts/ci/configure_branch_protection.sh @@ -15,6 +15,13 @@ Configure GitHub branch protection with: Environment overrides: - REQUIRED_CHECKS_JSON='["check-name-1","check-name-2"]' - REQUIRED_APPROVALS=1 + +Default REQUIRED_CHECKS_JSON: +- pre-pr-review-gate / pre-pr-review-gate +- CI / Check / ubuntu-latest +- CI / Test / ubuntu-latest +- CI / Clippy +- CI / Format USAGE } @@ -30,7 +37,13 @@ REQUIRED_APPROVALS="${REQUIRED_APPROVALS:-1}" command -v gh >/dev/null 2>&1 || { echo "gh is required" >&2; exit 2; } command -v python3 >/dev/null 2>&1 || { echo "python3 is required" >&2; exit 2; } -DEFAULT_REQUIRED_CHECKS='["pre-pr-review-gate / pre-pr-review-gate"]' +DEFAULT_REQUIRED_CHECKS='[ + "pre-pr-review-gate / pre-pr-review-gate", + "CI / Check / ubuntu-latest", + "CI / Test / ubuntu-latest", + "CI / Clippy", + "CI / Format" +]' CHECKS_JSON="${REQUIRED_CHECKS_JSON:-$DEFAULT_REQUIRED_CHECKS}" PAYLOAD="$(CHECKS_JSON="$CHECKS_JSON" REQUIRED_APPROVALS="$REQUIRED_APPROVALS" python3 - <<'PY' @@ -64,7 +77,9 @@ if approvals < 1: payload = { "required_status_checks": { "strict": True, - "checks": [{"context": c} for c in checks], + # Use legacy `contexts` for portability across repos/forks where + # check-runs may not yet exist at configuration time. + "contexts": checks, }, "enforce_admins": False, "required_pull_request_reviews": { From 00fbc2c2f79b18480e41c0f6cf41f6fc45f23564 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Mar 2026 15:23:59 +0800 Subject: [PATCH 6/6] test(runtime): stabilize process manager per-agent-limit test --- crates/openfang-runtime/src/process_manager.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/openfang-runtime/src/process_manager.rs b/crates/openfang-runtime/src/process_manager.rs index d2e7f8ff6..bdf3663e1 100644 --- a/crates/openfang-runtime/src/process_manager.rs +++ b/crates/openfang-runtime/src/process_manager.rs @@ -324,7 +324,11 @@ mod tests { assert!(result.is_err()); assert!(result.unwrap_err().contains("max: 1")); - let _ = pm.kill(&id1).await; + // Avoid kill_process_tree() in this unit test because it may target the + // whole process group under some test runners. + if let Some((_, mut proc)) = pm.processes.remove(&id1) { + let _ = proc.child.kill().await; + } } #[tokio::test]