Skip to main content

zeroclaw_runtime/sop/
audit.rs

1use std::sync::Arc;
2
3use anyhow::Result;
4
5use super::types::{SopRun, SopStepResult};
6use zeroclaw_memory::traits::{Memory, MemoryCategory};
7
8const SOP_CATEGORY: &str = "sop";
9
10/// Persists SOP execution runs and step results to the Memory backend.
11///
12/// Storage keys:
13/// - `sop_run_{run_id}` — full `SopRun` JSON (created on start, updated on complete)
14/// - `sop_step_{run_id}_{step_number}` — `SopStepResult` JSON (one per step)
15pub struct SopAuditLogger {
16    memory: Arc<dyn Memory>,
17}
18
19impl SopAuditLogger {
20    pub fn new(memory: Arc<dyn Memory>) -> Self {
21        Self { memory }
22    }
23
24    /// Log the start of a new SOP run.
25    pub async fn log_run_start(&self, run: &SopRun) -> Result<()> {
26        let key = run_key(&run.run_id);
27        let content = serde_json::to_string_pretty(run)?;
28        self.memory.store(&key, &content, category(), None).await?;
29        ::zeroclaw_log::record!(
30            INFO,
31            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
32            &format!(
33                "SOP audit: run {} started for '{}'",
34                run.run_id, run.sop_name
35            )
36        );
37        Ok(())
38    }
39
40    /// Log a step result.
41    pub async fn log_step_result(&self, run_id: &str, result: &SopStepResult) -> Result<()> {
42        let key = step_key(run_id, result.step_number);
43        let content = serde_json::to_string_pretty(result)?;
44        self.memory.store(&key, &content, category(), None).await?;
45        Ok(())
46    }
47
48    /// Log run completion (updates the run record with final state).
49    pub async fn log_run_complete(&self, run: &SopRun) -> Result<()> {
50        let key = run_key(&run.run_id);
51        let content = serde_json::to_string_pretty(run)?;
52        self.memory.store(&key, &content, category(), None).await?;
53        ::zeroclaw_log::record!(
54            INFO,
55            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
56            &format!(
57                "SOP audit: run {} finished with status {}",
58                run.run_id, run.status
59            )
60        );
61        Ok(())
62    }
63
64    /// Log an operator approval event for a specific step.
65    pub async fn log_approval(&self, run: &SopRun, step_number: u32) -> Result<()> {
66        let key = format!("sop_approval_{}_{step_number}", run.run_id);
67        let content = serde_json::to_string_pretty(run)?;
68        self.memory.store(&key, &content, category(), None).await?;
69        ::zeroclaw_log::record!(
70            INFO,
71            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
72            &format!(
73                "SOP audit: run {} step {step_number} approved by operator",
74                run.run_id
75            )
76        );
77        Ok(())
78    }
79
80    /// Log a timeout-based auto-approval event for a specific step.
81    pub async fn log_timeout_auto_approve(&self, run: &SopRun, step_number: u32) -> Result<()> {
82        let key = format!("sop_timeout_approve_{}_{step_number}", run.run_id);
83        let content = serde_json::to_string_pretty(run)?;
84        self.memory.store(&key, &content, category(), None).await?;
85        ::zeroclaw_log::record!(
86            INFO,
87            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
88            &format!(
89                "SOP audit: run {} step {step_number} auto-approved after timeout",
90                run.run_id
91            )
92        );
93        Ok(())
94    }
95
96    /// Retrieve a stored run by ID (if it exists in memory).
97    pub async fn get_run(&self, run_id: &str) -> Result<Option<SopRun>> {
98        let key = run_key(run_id);
99        match self.memory.get(&key).await? {
100            Some(entry) => {
101                let run: SopRun = serde_json::from_str(&entry.content).map_err(|e| {
102                    ::zeroclaw_log::record!(
103                        WARN,
104                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
105                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
106                            .with_attrs(
107                                ::serde_json::json!({"error": format!("{}", e), "run_id": run_id})
108                            ),
109                        "SOP audit: failed to parse run "
110                    );
111                    e
112                })?;
113                Ok(Some(run))
114            }
115            None => Ok(None),
116        }
117    }
118
119    /// List all stored SOP run keys.
120    pub async fn list_runs(&self) -> Result<Vec<String>> {
121        let entries = self.memory.list(Some(&category()), None).await?;
122        let run_keys: Vec<String> = entries
123            .into_iter()
124            .filter(|e| e.key.starts_with("sop_run_"))
125            .map(|e| e.key)
126            .collect();
127        Ok(run_keys)
128    }
129}
130
131fn run_key(run_id: &str) -> String {
132    format!("sop_run_{run_id}")
133}
134
135fn step_key(run_id: &str, step_number: u32) -> String {
136    format!("sop_step_{run_id}_{step_number}")
137}
138
139fn category() -> MemoryCategory {
140    MemoryCategory::Custom(SOP_CATEGORY.into())
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use crate::sop::types::{SopEvent, SopRunStatus, SopStepStatus, SopTriggerSource};
147
148    fn test_run() -> SopRun {
149        SopRun {
150            run_id: "run-test-001".into(),
151            sop_name: "test-sop".into(),
152            trigger_event: SopEvent {
153                source: SopTriggerSource::Manual,
154                topic: None,
155                payload: None,
156                timestamp: "2026-02-19T12:00:00Z".into(),
157            },
158            status: SopRunStatus::Running,
159            current_step: 1,
160            total_steps: 3,
161            started_at: "2026-02-19T12:00:00Z".into(),
162            completed_at: None,
163            step_results: Vec::new(),
164            waiting_since: None,
165            llm_calls_saved: 0,
166        }
167    }
168
169    fn test_step_result(n: u32) -> SopStepResult {
170        SopStepResult {
171            step_number: n,
172            status: SopStepStatus::Completed,
173            output: format!("Step {n} completed"),
174            started_at: "2026-02-19T12:00:00Z".into(),
175            completed_at: Some("2026-02-19T12:00:05Z".into()),
176        }
177    }
178
179    #[tokio::test]
180    async fn audit_roundtrip() {
181        let mem_cfg = zeroclaw_config::schema::MemoryConfig {
182            backend: "sqlite".into(),
183            ..zeroclaw_config::schema::MemoryConfig::default()
184        };
185        let tmp = tempfile::tempdir().unwrap();
186        let memory: Arc<dyn Memory> =
187            Arc::from(zeroclaw_memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
188
189        let logger = SopAuditLogger::new(memory);
190
191        // Log run start
192        let run = test_run();
193        logger.log_run_start(&run).await.unwrap();
194
195        // Log step result
196        let step = test_step_result(1);
197        logger.log_step_result(&run.run_id, &step).await.unwrap();
198
199        // Log run complete
200        let mut completed_run = run.clone();
201        completed_run.status = SopRunStatus::Completed;
202        completed_run.completed_at = Some("2026-02-19T12:05:00Z".into());
203        completed_run.step_results = vec![step];
204        logger.log_run_complete(&completed_run).await.unwrap();
205
206        // Retrieve
207        let retrieved = logger.get_run("run-test-001").await.unwrap().unwrap();
208        assert_eq!(retrieved.run_id, "run-test-001");
209        assert_eq!(retrieved.status, SopRunStatus::Completed);
210        assert_eq!(retrieved.step_results.len(), 1);
211
212        // List runs
213        let keys = logger.list_runs().await.unwrap();
214        assert!(keys.contains(&"sop_run_run-test-001".to_string()));
215    }
216
217    #[tokio::test]
218    async fn log_approval_persists_entry() {
219        let mem_cfg = zeroclaw_config::schema::MemoryConfig {
220            backend: "sqlite".into(),
221            ..zeroclaw_config::schema::MemoryConfig::default()
222        };
223        let tmp = tempfile::tempdir().unwrap();
224        let memory: Arc<dyn Memory> =
225            Arc::from(zeroclaw_memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
226
227        let logger = SopAuditLogger::new(memory.clone());
228        let run = test_run();
229        logger.log_approval(&run, 1).await.unwrap();
230
231        let entries = memory.list(Some(&category()), None).await.unwrap();
232        let approval_keys: Vec<_> = entries
233            .iter()
234            .filter(|e| e.key.starts_with("sop_approval_"))
235            .collect();
236        assert_eq!(approval_keys.len(), 1);
237        assert!(approval_keys[0].key.contains("run-test-001"));
238    }
239
240    #[tokio::test]
241    async fn log_timeout_auto_approve_persists_entry() {
242        let mem_cfg = zeroclaw_config::schema::MemoryConfig {
243            backend: "sqlite".into(),
244            ..zeroclaw_config::schema::MemoryConfig::default()
245        };
246        let tmp = tempfile::tempdir().unwrap();
247        let memory: Arc<dyn Memory> =
248            Arc::from(zeroclaw_memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
249
250        let logger = SopAuditLogger::new(memory.clone());
251        let run = test_run();
252        logger.log_timeout_auto_approve(&run, 1).await.unwrap();
253
254        let entries = memory.list(Some(&category()), None).await.unwrap();
255        let timeout_keys: Vec<_> = entries
256            .iter()
257            .filter(|e| e.key.starts_with("sop_timeout_approve_"))
258            .collect();
259        assert_eq!(timeout_keys.len(), 1);
260        assert!(timeout_keys[0].key.contains("run-test-001"));
261    }
262
263    #[tokio::test]
264    async fn get_nonexistent_run_returns_none() {
265        let mem_cfg = zeroclaw_config::schema::MemoryConfig {
266            backend: "sqlite".into(),
267            ..zeroclaw_config::schema::MemoryConfig::default()
268        };
269        let tmp = tempfile::tempdir().unwrap();
270        let memory: Arc<dyn Memory> =
271            Arc::from(zeroclaw_memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
272
273        let logger = SopAuditLogger::new(memory);
274        let result = logger.get_run("nonexistent").await.unwrap();
275        assert!(result.is_none());
276    }
277}