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