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
10pub 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 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 let mut output = String::new();
140
141 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 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 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
207const 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 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 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}