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