zeroclaw_runtime/tools/
sop_approve.rs1use 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
10pub 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 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 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 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 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 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 let stored = audit.get_run("nonexistent").await.unwrap();
289 assert!(stored.is_none(), "failed approve should not write audit");
290 }
291}