construct/tools/
sop_execute.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::{SopEvent, SopRunAction, SopTriggerSource};
9use crate::sop::{SopAuditLogger, SopEngine};
10
11pub struct SopExecuteTool {
13 engine: Arc<Mutex<SopEngine>>,
14 audit: Option<Arc<SopAuditLogger>>,
15}
16
17impl SopExecuteTool {
18 pub fn new(engine: Arc<Mutex<SopEngine>>) -> Self {
19 Self {
20 engine,
21 audit: None,
22 }
23 }
24
25 pub fn with_audit(mut self, audit: Arc<SopAuditLogger>) -> Self {
26 self.audit = Some(audit);
27 self
28 }
29}
30
31#[async_trait]
32impl Tool for SopExecuteTool {
33 fn name(&self) -> &str {
34 "sop_execute"
35 }
36
37 fn description(&self) -> &str {
38 "Manually trigger a Standard Operating Procedure (SOP) by name. Returns the run ID and first step instruction. Use sop_list to see available SOPs."
39 }
40
41 fn parameters_schema(&self) -> serde_json::Value {
42 json!({
43 "type": "object",
44 "properties": {
45 "name": {
46 "type": "string",
47 "description": "Name of the SOP to execute"
48 },
49 "payload": {
50 "type": "string",
51 "description": "Optional trigger payload (JSON string)"
52 }
53 },
54 "required": ["name"]
55 })
56 }
57
58 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
59 let sop_name = args
60 .get("name")
61 .and_then(|v| v.as_str())
62 .ok_or_else(|| anyhow::anyhow!("Missing 'name' parameter"))?;
63
64 let payload = args
65 .get("payload")
66 .and_then(|v| v.as_str())
67 .map(String::from);
68
69 let event = SopEvent {
70 source: SopTriggerSource::Manual,
71 topic: None,
72 payload,
73 timestamp: now_iso8601(),
74 };
75
76 let (action, run_snapshot) = {
78 let mut engine = self
79 .engine
80 .lock()
81 .map_err(|e| anyhow::anyhow!("Engine lock poisoned: {e}"))?;
82
83 match engine.start_run(sop_name, event) {
84 Ok(action) => {
85 let run_id = action_run_id(&action);
86 let snapshot = run_id.and_then(|id| engine.get_run(id).cloned());
87 (Ok(action), snapshot)
88 }
89 Err(e) => (Err(e), None),
90 }
91 };
92
93 if let Some(ref audit) = self.audit {
95 if let Some(ref run) = run_snapshot {
96 if let Err(e) = audit.log_run_start(run).await {
97 warn!("SOP audit log_run_start failed: {e}");
98 }
99 }
100 }
101
102 match action {
103 Ok(action) => {
104 let output = match action {
105 SopRunAction::ExecuteStep {
106 run_id, context, ..
107 } => {
108 format!("SOP run started: {run_id}\n\n{context}")
109 }
110 SopRunAction::WaitApproval {
111 run_id, context, ..
112 } => {
113 format!("SOP run started: {run_id} (waiting for approval)\n\n{context}")
114 }
115 SopRunAction::Completed { run_id, sop_name } => {
116 format!("SOP '{sop_name}' run {run_id} completed immediately (no steps).")
117 }
118 SopRunAction::Failed { run_id, reason, .. } => {
119 format!("SOP run {run_id} failed: {reason}")
120 }
121 SopRunAction::DeterministicStep { run_id, step, .. } => {
122 format!(
123 "SOP run started (deterministic): {run_id}\nFirst step: {}",
124 step.title
125 )
126 }
127 SopRunAction::CheckpointWait { run_id, step, .. } => {
128 format!(
129 "SOP run started: {run_id} (paused at checkpoint: {})",
130 step.title
131 )
132 }
133 };
134 Ok(ToolResult {
135 success: true,
136 output,
137 error: None,
138 })
139 }
140 Err(e) => Ok(ToolResult {
141 success: false,
142 output: String::new(),
143 error: Some(format!("Failed to start SOP: {e}")),
144 }),
145 }
146 }
147}
148
149fn action_run_id(action: &SopRunAction) -> Option<&str> {
151 match action {
152 SopRunAction::ExecuteStep { run_id, .. }
153 | SopRunAction::WaitApproval { run_id, .. }
154 | SopRunAction::Completed { run_id, .. }
155 | SopRunAction::Failed { run_id, .. }
156 | SopRunAction::DeterministicStep { run_id, .. }
157 | SopRunAction::CheckpointWait { run_id, .. } => Some(run_id),
158 }
159}
160
161use crate::sop::engine::now_iso8601;
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166 use crate::config::SopConfig;
167 use crate::sop::engine::SopEngine;
168 use crate::sop::types::*;
169
170 fn test_sop(name: &str, mode: SopExecutionMode) -> Sop {
171 Sop {
172 name: name.into(),
173 description: format!("Test SOP: {name}"),
174 version: "1.0.0".into(),
175 priority: SopPriority::Normal,
176 execution_mode: mode,
177 triggers: vec![SopTrigger::Manual],
178 steps: vec![
179 SopStep {
180 number: 1,
181 title: "Step one".into(),
182 body: "Do step one".into(),
183 suggested_tools: vec!["shell".into()],
184 requires_confirmation: false,
185 kind: SopStepKind::default(),
186 schema: None,
187 },
188 SopStep {
189 number: 2,
190 title: "Step two".into(),
191 body: "Do step two".into(),
192 suggested_tools: vec![],
193 requires_confirmation: false,
194 kind: SopStepKind::default(),
195 schema: None,
196 },
197 ],
198 cooldown_secs: 0,
199 max_concurrent: 1,
200 location: None,
201 deterministic: false,
202 }
203 }
204
205 fn engine_with_sops(sops: Vec<Sop>) -> Arc<Mutex<SopEngine>> {
206 let mut engine = SopEngine::new(SopConfig::default());
207 engine.set_sops_for_test(sops);
208 Arc::new(Mutex::new(engine))
209 }
210
211 #[tokio::test]
212 async fn execute_auto_sop() {
213 let engine = engine_with_sops(vec![test_sop("test-sop", SopExecutionMode::Auto)]);
214 let tool = SopExecuteTool::new(engine);
215 let result = tool.execute(json!({"name": "test-sop"})).await.unwrap();
216 assert!(result.success);
217 assert!(result.output.contains("run-"));
218 assert!(result.output.contains("Step one"));
219 }
220
221 #[tokio::test]
222 async fn execute_supervised_sop() {
223 let engine = engine_with_sops(vec![test_sop("test-sop", SopExecutionMode::Supervised)]);
224 let tool = SopExecuteTool::new(engine);
225 let result = tool.execute(json!({"name": "test-sop"})).await.unwrap();
226 assert!(result.success);
227 assert!(result.output.contains("waiting for approval"));
228 }
229
230 #[tokio::test]
231 async fn execute_unknown_sop() {
232 let engine = engine_with_sops(vec![]);
233 let tool = SopExecuteTool::new(engine);
234 let result = tool.execute(json!({"name": "nonexistent"})).await.unwrap();
235 assert!(!result.success);
236 assert!(result.error.unwrap().contains("Failed to start SOP"));
237 }
238
239 #[tokio::test]
240 async fn execute_missing_name() {
241 let engine = engine_with_sops(vec![]);
242 let tool = SopExecuteTool::new(engine);
243 let result = tool.execute(json!({})).await;
244 assert!(result.is_err());
245 }
246
247 #[tokio::test]
248 async fn execute_with_payload() {
249 let engine = engine_with_sops(vec![test_sop("test-sop", SopExecutionMode::Auto)]);
250 let tool = SopExecuteTool::new(engine);
251 let result = tool
252 .execute(json!({"name": "test-sop", "payload": "{\"value\": 87.3}"}))
253 .await
254 .unwrap();
255 assert!(result.success);
256 assert!(result.output.contains("87.3"));
257 }
258
259 #[test]
260 fn name_and_schema() {
261 let engine = engine_with_sops(vec![]);
262 let tool = SopExecuteTool::new(engine);
263 assert_eq!(tool.name(), "sop_execute");
264 assert!(tool.parameters_schema()["required"].is_array());
265 }
266}