Skip to main content

zeroclaw_runtime/tools/
sop_status.rs

1use std::fmt::Write;
2use std::sync::{Arc, Mutex};
3
4use async_trait::async_trait;
5use serde_json::json;
6
7use crate::sop::{SopEngine, SopMetricsCollector};
8use zeroclaw_api::tool::{Tool, ToolResult};
9
10/// Query SOP execution status — active runs, finished runs, or a specific run by ID.
11pub struct SopStatusTool {
12    engine: Arc<Mutex<SopEngine>>,
13    collector: Option<Arc<SopMetricsCollector>>,
14}
15
16impl SopStatusTool {
17    pub fn new(engine: Arc<Mutex<SopEngine>>) -> Self {
18        Self {
19            engine,
20            collector: None,
21        }
22    }
23
24    pub fn with_collector(mut self, collector: Arc<SopMetricsCollector>) -> Self {
25        self.collector = Some(collector);
26        self
27    }
28
29    fn append_gate_status(&self, output: &mut String, include_gate_status: bool) {
30        if include_gate_status {
31            let _ = writeln!(
32                output,
33                "\nGate Status: not available (gate evaluation not supported)"
34            );
35        }
36    }
37}
38
39#[async_trait]
40impl Tool for SopStatusTool {
41    fn name(&self) -> &str {
42        "sop_status"
43    }
44
45    fn description(&self) -> &str {
46        "Query SOP execution status. Provide run_id for a specific run, or sop_name to list runs for that SOP. With no arguments, shows all active runs."
47    }
48
49    fn parameters_schema(&self) -> serde_json::Value {
50        json!({
51            "type": "object",
52            "properties": {
53                "run_id": {
54                    "type": "string",
55                    "description": "Specific run ID to query"
56                },
57                "sop_name": {
58                    "type": "string",
59                    "description": "SOP name to list runs for"
60                },
61                "include_metrics": {
62                    "type": "boolean",
63                    "description": "Include aggregated SOP metrics (completion rate, deviation rate, intervention counts, windowed variants)"
64                },
65                "include_gate_status": {
66                    "type": "boolean",
67                    "description": "Include trust phase and gate evaluation status"
68                }
69            }
70        })
71    }
72
73    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
74        let run_id = args.get("run_id").and_then(|v| v.as_str());
75        let sop_name = args.get("sop_name").and_then(|v| v.as_str());
76        let include_metrics = args
77            .get("include_metrics")
78            .and_then(|v| v.as_bool())
79            .unwrap_or(false);
80        let include_gate_status = args
81            .get("include_gate_status")
82            .and_then(|v| v.as_bool())
83            .unwrap_or(false);
84
85        let engine = self.engine.lock().map_err(|e| {
86            ::zeroclaw_log::record!(
87                ERROR,
88                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
89                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
90                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
91                "SOP engine lock poisoned"
92            );
93
94            anyhow::Error::msg(format!("Engine lock poisoned: {e}"))
95        })?;
96
97        // Query specific run
98        if let Some(run_id) = run_id {
99            return match engine.get_run(run_id) {
100                Some(run) => {
101                    let mut output = format!(
102                        "Run: {}\nSOP: {}\nStatus: {}\nStep: {} of {}\nStarted: {}\n",
103                        run.run_id,
104                        run.sop_name,
105                        run.status,
106                        run.current_step,
107                        run.total_steps,
108                        run.started_at,
109                    );
110                    if let Some(ref completed) = run.completed_at {
111                        let _ = writeln!(output, "Completed: {completed}");
112                    }
113                    if !run.step_results.is_empty() {
114                        let _ = writeln!(output, "\nStep results:");
115                        for step in &run.step_results {
116                            let _ = writeln!(
117                                output,
118                                "  Step {}: {} — {}",
119                                step.step_number, step.status, step.output
120                            );
121                        }
122                    }
123                    self.append_gate_status(&mut output, include_gate_status);
124                    Ok(ToolResult {
125                        success: true,
126                        output,
127                        error: None,
128                    })
129                }
130                None => Ok(ToolResult {
131                    success: true,
132                    output: format!("No run found with ID '{run_id}'."),
133                    error: None,
134                }),
135            };
136        }
137
138        // List runs for a specific SOP or all active runs
139        let mut output = String::new();
140
141        // Active runs
142        let active: Vec<_> = engine
143            .active_runs()
144            .values()
145            .filter(|r| sop_name.is_none_or(|name| r.sop_name == name))
146            .collect();
147
148        if active.is_empty() {
149            let scope = sop_name.map_or(String::new(), |n| format!(" for '{n}'"));
150            let _ = writeln!(output, "No active runs{scope}.");
151        } else {
152            let _ = writeln!(output, "Active runs ({}):", active.len());
153            for run in &active {
154                let _ = writeln!(
155                    output,
156                    "  {} — {} [{}] step {}/{}",
157                    run.run_id, run.sop_name, run.status, run.current_step, run.total_steps
158                );
159            }
160        }
161
162        // Finished runs
163        let finished = engine.finished_runs(sop_name);
164        if !finished.is_empty() {
165            let _ = writeln!(output, "\nFinished runs ({}):", finished.len());
166            for run in finished.iter().rev().take(10) {
167                let _ = writeln!(
168                    output,
169                    "  {} — {} [{}] ({})",
170                    run.run_id,
171                    run.sop_name,
172                    run.status,
173                    run.completed_at.as_deref().unwrap_or("?")
174                );
175            }
176        }
177
178        // Metrics summary (when requested and collector is available)
179        if include_metrics {
180            if let Some(ref collector) = self.collector {
181                let prefix = sop_name.map_or("sop".to_string(), |n| format!("sop.{n}"));
182                let _ = writeln!(output, "\nMetrics ({prefix}):");
183                for suffix in METRIC_SUFFIXES {
184                    let key = format!("{prefix}.{suffix}");
185                    if let Some(val) = collector.get_metric_value(&key) {
186                        let _ = writeln!(output, "  {suffix}: {}", format_metric_value(&val));
187                    }
188                }
189            } else {
190                let _ = writeln!(
191                    output,
192                    "\nMetrics: not available (collector not configured)"
193                );
194            }
195        }
196
197        self.append_gate_status(&mut output, include_gate_status);
198
199        Ok(ToolResult {
200            success: true,
201            output,
202            error: None,
203        })
204    }
205}
206
207/// Metric suffixes rendered in status output.
208const METRIC_SUFFIXES: &[&str] = &[
209    "runs_completed",
210    "runs_failed",
211    "runs_cancelled",
212    "completion_rate",
213    "deviation_rate",
214    "protocol_adherence_rate",
215    "human_intervention_count",
216    "human_intervention_rate",
217    "timeout_auto_approvals",
218    "timeout_approval_rate",
219    "completion_rate_7d",
220    "deviation_rate_7d",
221    "completion_rate_30d",
222    "deviation_rate_30d",
223];
224
225fn format_metric_value(val: &serde_json::Value) -> String {
226    match val {
227        serde_json::Value::Number(n) => {
228            if let Some(u) = n.as_u64() {
229                format!("{u}")
230            } else if let Some(f) = n.as_f64() {
231                if f.fract() == 0.0 {
232                    format!("{f:.0}")
233                } else {
234                    format!("{f:.4}")
235                }
236            } else {
237                n.to_string()
238            }
239        }
240        other => other.to_string(),
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use crate::sop::engine::SopEngine;
248    use crate::sop::types::*;
249    use zeroclaw_config::schema::SopConfig;
250
251    fn test_sop(name: &str) -> Sop {
252        Sop {
253            name: name.into(),
254            description: format!("Test SOP: {name}"),
255            version: "1.0.0".into(),
256            priority: SopPriority::Normal,
257            execution_mode: SopExecutionMode::Auto,
258            triggers: vec![SopTrigger::Manual],
259            steps: vec![SopStep {
260                number: 1,
261                title: "Step one".into(),
262                body: "Do it".into(),
263                suggested_tools: vec![],
264                requires_confirmation: false,
265                kind: SopStepKind::default(),
266                schema: None,
267            }],
268            cooldown_secs: 0,
269            max_concurrent: 2,
270            location: None,
271            deterministic: false,
272        }
273    }
274
275    fn engine_with_sops(sops: Vec<Sop>) -> Arc<Mutex<SopEngine>> {
276        let mut engine = SopEngine::new(SopConfig::default());
277        engine.set_sops_for_test(sops);
278        Arc::new(Mutex::new(engine))
279    }
280
281    fn manual_event() -> SopEvent {
282        SopEvent {
283            source: SopTriggerSource::Manual,
284            topic: None,
285            payload: None,
286            timestamp: "2026-02-19T12:00:00Z".into(),
287        }
288    }
289
290    #[tokio::test]
291    async fn status_no_runs() {
292        let engine = engine_with_sops(vec![test_sop("s1")]);
293        let tool = SopStatusTool::new(engine);
294        let result = tool.execute(json!({})).await.unwrap();
295        assert!(result.success);
296        assert!(result.output.contains("No active runs"));
297    }
298
299    #[tokio::test]
300    async fn status_with_active_run() {
301        let engine = engine_with_sops(vec![test_sop("s1")]);
302        let run_id = {
303            let mut e = engine.lock().unwrap();
304            e.start_run("s1", manual_event()).unwrap();
305            e.active_runs().keys().next().unwrap().clone()
306        };
307        let tool = SopStatusTool::new(engine);
308        let result = tool.execute(json!({})).await.unwrap();
309        assert!(result.success);
310        assert!(result.output.contains("Active runs (1)"));
311        assert!(result.output.contains(&run_id));
312    }
313
314    #[tokio::test]
315    async fn status_specific_run() {
316        let engine = engine_with_sops(vec![test_sop("s1")]);
317        let run_id = {
318            let mut e = engine.lock().unwrap();
319            e.start_run("s1", manual_event()).unwrap();
320            e.active_runs().keys().next().unwrap().clone()
321        };
322        let tool = SopStatusTool::new(engine);
323        let result = tool.execute(json!({"run_id": run_id})).await.unwrap();
324        assert!(result.success);
325        assert!(result.output.contains(&format!("Run: {run_id}")));
326        assert!(result.output.contains("Status: running"));
327    }
328
329    #[tokio::test]
330    async fn status_unknown_run() {
331        let engine = engine_with_sops(vec![]);
332        let tool = SopStatusTool::new(engine);
333        let result = tool
334            .execute(json!({"run_id": "nonexistent"}))
335            .await
336            .unwrap();
337        assert!(result.success);
338        assert!(result.output.contains("No run found"));
339    }
340
341    #[tokio::test]
342    async fn status_filter_by_sop_name() {
343        let engine = engine_with_sops(vec![test_sop("s1"), test_sop("s2")]);
344        {
345            let mut e = engine.lock().unwrap();
346            e.start_run("s1", manual_event()).unwrap();
347            e.start_run("s2", manual_event()).unwrap();
348        }
349        let tool = SopStatusTool::new(engine);
350        let result = tool.execute(json!({"sop_name": "s1"})).await.unwrap();
351        assert!(result.success);
352        assert!(result.output.contains("s1"));
353        // s2's run shouldn't show
354        assert!(!result.output.contains(" s2 "));
355    }
356
357    #[test]
358    fn name_and_schema() {
359        let engine = engine_with_sops(vec![]);
360        let tool = SopStatusTool::new(engine);
361        assert_eq!(tool.name(), "sop_status");
362        let schema = tool.parameters_schema();
363        assert!(schema["properties"]["run_id"].is_object());
364        assert!(schema["properties"]["sop_name"].is_object());
365        assert!(schema["properties"]["include_metrics"].is_object());
366    }
367
368    #[tokio::test]
369    async fn status_with_metrics_global() {
370        let engine = engine_with_sops(vec![test_sop("s1")]);
371        let collector = Arc::new(SopMetricsCollector::new());
372        // Record a completed run in the collector
373        let run = SopRun {
374            run_id: "r1".into(),
375            sop_name: "s1".into(),
376            trigger_event: manual_event(),
377            status: SopRunStatus::Completed,
378            current_step: 1,
379            total_steps: 1,
380            started_at: "2026-02-19T12:00:00Z".into(),
381            completed_at: Some("2026-02-19T12:05:00Z".into()),
382            step_results: vec![SopStepResult {
383                step_number: 1,
384                status: SopStepStatus::Completed,
385                output: "done".into(),
386                started_at: "2026-02-19T12:00:00Z".into(),
387                completed_at: Some("2026-02-19T12:01:00Z".into()),
388            }],
389            waiting_since: None,
390            llm_calls_saved: 0,
391        };
392        collector.record_run_complete(&run);
393
394        let tool = SopStatusTool::new(engine).with_collector(collector);
395        let result = tool
396            .execute(json!({"include_metrics": true}))
397            .await
398            .unwrap();
399        assert!(result.success);
400        assert!(result.output.contains("Metrics (sop):"));
401        assert!(result.output.contains("runs_completed: 1"));
402        assert!(result.output.contains("completion_rate: 1"));
403    }
404
405    #[tokio::test]
406    async fn status_with_metrics_per_sop() {
407        let engine = engine_with_sops(vec![test_sop("s1")]);
408        let collector = Arc::new(SopMetricsCollector::new());
409        let run = SopRun {
410            run_id: "r1".into(),
411            sop_name: "s1".into(),
412            trigger_event: manual_event(),
413            status: SopRunStatus::Failed,
414            current_step: 1,
415            total_steps: 2,
416            started_at: "2026-02-19T12:00:00Z".into(),
417            completed_at: Some("2026-02-19T12:05:00Z".into()),
418            step_results: vec![SopStepResult {
419                step_number: 1,
420                status: SopStepStatus::Failed,
421                output: "fail".into(),
422                started_at: "2026-02-19T12:00:00Z".into(),
423                completed_at: Some("2026-02-19T12:01:00Z".into()),
424            }],
425            waiting_since: None,
426            llm_calls_saved: 0,
427        };
428        collector.record_run_complete(&run);
429
430        let tool = SopStatusTool::new(engine).with_collector(collector);
431        let result = tool
432            .execute(json!({"sop_name": "s1", "include_metrics": true}))
433            .await
434            .unwrap();
435        assert!(result.success);
436        assert!(result.output.contains("Metrics (sop.s1):"));
437        assert!(result.output.contains("runs_failed: 1"));
438        assert!(result.output.contains("completion_rate: 0"));
439    }
440
441    #[tokio::test]
442    async fn status_metrics_without_collector() {
443        let engine = engine_with_sops(vec![]);
444        let tool = SopStatusTool::new(engine);
445        let result = tool
446            .execute(json!({"include_metrics": true}))
447            .await
448            .unwrap();
449        assert!(result.success);
450        assert!(result.output.contains("not available"));
451    }
452
453    #[tokio::test]
454    async fn status_metrics_not_shown_by_default() {
455        let engine = engine_with_sops(vec![test_sop("s1")]);
456        let collector = Arc::new(SopMetricsCollector::new());
457        let tool = SopStatusTool::new(engine).with_collector(collector);
458        let result = tool.execute(json!({})).await.unwrap();
459        assert!(result.success);
460        assert!(!result.output.contains("Metrics"));
461    }
462}