Skip to main content

construct/tools/
sop_approve.rs

1use 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
11/// Approve a pending SOP step that is waiting for operator approval.
12pub 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        // Lock engine, approve, snapshot run for audit, then drop lock
68        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        // Audit logging (engine lock dropped, safe to await)
84        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        // Metrics collector (independent of audit)
93        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        // Start run — Supervised mode → WaitApproval
166        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        // Verify approval audit entry was written (stored under sop_approval_ key)
225        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        // No audit entry for failed approval
256        let stored = audit.get_run("nonexistent").await.unwrap();
257        assert!(stored.is_none(), "failed approve should not write audit");
258    }
259}