Skip to main content

zeroclaw_runtime/tools/
sop_approve.rs

1use std::sync::{Arc, Mutex};
2
3use async_trait::async_trait;
4use serde_json::json;
5
6use crate::sop::types::SopRunAction;
7use crate::sop::{SopAuditLogger, SopEngine, SopMetricsCollector};
8use zeroclaw_api::tool::{Tool, ToolResult};
9
10/// Approve a pending SOP step that is waiting for operator approval.
11pub struct SopApproveTool {
12    engine: Arc<Mutex<SopEngine>>,
13    audit: Option<Arc<SopAuditLogger>>,
14    collector: Option<Arc<SopMetricsCollector>>,
15}
16
17impl SopApproveTool {
18    pub fn new(engine: Arc<Mutex<SopEngine>>) -> Self {
19        Self {
20            engine,
21            audit: None,
22            collector: None,
23        }
24    }
25
26    pub fn with_audit(mut self, audit: Arc<SopAuditLogger>) -> Self {
27        self.audit = Some(audit);
28        self
29    }
30
31    pub fn with_collector(mut self, collector: Arc<SopMetricsCollector>) -> Self {
32        self.collector = Some(collector);
33        self
34    }
35}
36
37#[async_trait]
38impl Tool for SopApproveTool {
39    fn name(&self) -> &str {
40        "sop_approve"
41    }
42
43    fn description(&self) -> &str {
44        "Approve a pending SOP step that is waiting for operator approval. Returns the step instruction to execute. Use sop_status to see which runs are waiting."
45    }
46
47    fn parameters_schema(&self) -> serde_json::Value {
48        json!({
49            "type": "object",
50            "properties": {
51                "run_id": {
52                    "type": "string",
53                    "description": "The run ID to approve"
54                }
55            },
56            "required": ["run_id"]
57        })
58    }
59
60    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
61        let run_id = args.get("run_id").and_then(|v| v.as_str()).ok_or_else(|| {
62            ::zeroclaw_log::record!(
63                WARN,
64                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
65                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
66                    .with_attrs(::serde_json::json!({"param": "run_id"})),
67                "tool argument validation failed"
68            );
69
70            anyhow::Error::msg("Missing 'run_id' parameter")
71        })?;
72
73        // Lock engine, approve, snapshot run for audit, then drop lock
74        let (result, run_snapshot) = {
75            let mut engine = self.engine.lock().map_err(|e| {
76                ::zeroclaw_log::record!(
77                    ERROR,
78                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
79                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
80                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
81                    "SOP engine lock poisoned"
82                );
83
84                anyhow::Error::msg(format!("Engine lock poisoned: {e}"))
85            })?;
86
87            match engine.approve_step(run_id) {
88                Ok(action) => {
89                    let snapshot = engine.get_run(run_id).cloned();
90                    (Ok(action), snapshot)
91                }
92                Err(e) => (Err(e), None),
93            }
94        };
95
96        // Audit logging (engine lock dropped, safe to await)
97        if let Some(ref audit) = self.audit
98            && let Some(ref run) = run_snapshot
99            && let Err(e) = audit.log_approval(run, run.current_step).await
100        {
101            ::zeroclaw_log::record!(
102                WARN,
103                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
104                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
105                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
106                "SOP audit log after approve failed"
107            );
108        }
109
110        // Metrics collector (independent of audit)
111        if let Some(ref collector) = self.collector
112            && let Some(ref run) = run_snapshot
113        {
114            collector.record_approval(&run.sop_name, &run.run_id);
115        }
116
117        match result {
118            Ok(action) => {
119                let output = match action {
120                    SopRunAction::ExecuteStep {
121                        run_id, context, ..
122                    } => {
123                        format!("Approved. Proceeding with run {run_id}.\n\n{context}")
124                    }
125                    other => format!("Approved. Action: {other:?}"),
126                };
127                Ok(ToolResult {
128                    success: true,
129                    output,
130                    error: None,
131                })
132            }
133            Err(e) => Ok(ToolResult {
134                success: false,
135                output: String::new(),
136                error: Some(format!("Approval failed: {e}")),
137            }),
138        }
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::sop::engine::SopEngine;
146    use crate::sop::types::*;
147    use zeroclaw_config::schema::SopConfig;
148    use zeroclaw_memory::Memory;
149
150    fn test_sop() -> Sop {
151        Sop {
152            name: "test-sop".into(),
153            description: "Test SOP".into(),
154            version: "1.0.0".into(),
155            priority: SopPriority::Normal,
156            execution_mode: SopExecutionMode::Supervised,
157            triggers: vec![SopTrigger::Manual],
158            steps: vec![SopStep {
159                number: 1,
160                title: "Step one".into(),
161                body: "Do it".into(),
162                suggested_tools: vec![],
163                requires_confirmation: false,
164                kind: SopStepKind::default(),
165                schema: None,
166            }],
167            cooldown_secs: 0,
168            max_concurrent: 1,
169            location: None,
170            deterministic: false,
171        }
172    }
173
174    fn engine_with_run() -> (Arc<Mutex<SopEngine>>, String) {
175        let mut engine = SopEngine::new(SopConfig::default());
176        engine.set_sops_for_test(vec![test_sop()]);
177        let event = SopEvent {
178            source: SopTriggerSource::Manual,
179            topic: None,
180            payload: None,
181            timestamp: "2026-02-19T12:00:00Z".into(),
182        };
183        // Start run — Supervised mode → WaitApproval
184        engine.start_run("test-sop", event).unwrap();
185        let run_id = engine
186            .active_runs()
187            .keys()
188            .next()
189            .expect("expected active run")
190            .clone();
191        (Arc::new(Mutex::new(engine)), run_id)
192    }
193
194    #[tokio::test]
195    async fn approve_waiting_run() {
196        let (engine, run_id) = engine_with_run();
197        let tool = SopApproveTool::new(engine);
198        let result = tool.execute(json!({"run_id": run_id})).await.unwrap();
199        assert!(result.success);
200        assert!(result.output.contains("Approved"));
201        assert!(result.output.contains("Step one"));
202    }
203
204    #[tokio::test]
205    async fn approve_nonexistent_run() {
206        let engine = Arc::new(Mutex::new(SopEngine::new(SopConfig::default())));
207        let tool = SopApproveTool::new(engine);
208        let result = tool
209            .execute(json!({"run_id": "nonexistent"}))
210            .await
211            .unwrap();
212        assert!(!result.success);
213        assert!(result.error.unwrap().contains("Approval failed"));
214    }
215
216    #[tokio::test]
217    async fn approve_missing_run_id() {
218        let engine = Arc::new(Mutex::new(SopEngine::new(SopConfig::default())));
219        let tool = SopApproveTool::new(engine);
220        let result = tool.execute(json!({})).await;
221        assert!(result.is_err());
222    }
223
224    #[test]
225    fn name_and_schema() {
226        let engine = Arc::new(Mutex::new(SopEngine::new(SopConfig::default())));
227        let tool = SopApproveTool::new(engine);
228        assert_eq!(tool.name(), "sop_approve");
229        assert!(tool.parameters_schema()["required"].is_array());
230    }
231
232    #[tokio::test]
233    async fn approve_writes_audit() {
234        let (engine, run_id) = engine_with_run();
235        let tmp = tempfile::tempdir().unwrap();
236        let mem_cfg = zeroclaw_config::schema::MemoryConfig {
237            backend: "sqlite".into(),
238            ..zeroclaw_config::schema::MemoryConfig::default()
239        };
240        let memory: Arc<dyn Memory> =
241            Arc::from(zeroclaw_memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
242        let audit = Arc::new(SopAuditLogger::new(memory.clone()));
243
244        let tool = SopApproveTool::new(engine).with_audit(audit.clone());
245        let result = tool.execute(json!({"run_id": &run_id})).await.unwrap();
246        assert!(result.success);
247
248        // Verify approval audit entry was written (stored under sop_approval_ key)
249        let entries = memory
250            .list(
251                Some(&zeroclaw_memory::traits::MemoryCategory::Custom(
252                    "sop".into(),
253                )),
254                None,
255            )
256            .await
257            .unwrap();
258        let approval_keys: Vec<_> = entries
259            .iter()
260            .filter(|e| e.key.starts_with("sop_approval_"))
261            .collect();
262        assert!(
263            !approval_keys.is_empty(),
264            "approval audit should be written on approve"
265        );
266    }
267
268    #[tokio::test]
269    async fn approve_failure_does_not_write_audit() {
270        let engine = Arc::new(Mutex::new(SopEngine::new(SopConfig::default())));
271        let tmp = tempfile::tempdir().unwrap();
272        let mem_cfg = zeroclaw_config::schema::MemoryConfig {
273            backend: "sqlite".into(),
274            ..zeroclaw_config::schema::MemoryConfig::default()
275        };
276        let memory: Arc<dyn Memory> =
277            Arc::from(zeroclaw_memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
278        let audit = Arc::new(SopAuditLogger::new(memory.clone()));
279
280        let tool = SopApproveTool::new(engine).with_audit(audit.clone());
281        let result = tool
282            .execute(json!({"run_id": "nonexistent"}))
283            .await
284            .unwrap();
285        assert!(!result.success);
286
287        // No audit entry for failed approval
288        let stored = audit.get_run("nonexistent").await.unwrap();
289        assert!(stored.is_none(), "failed approve should not write audit");
290    }
291}