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
10pub 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 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 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 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 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 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 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 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 let run = test_run();
193 logger.log_run_start(&run).await.unwrap();
194
195 let step = test_step_result(1);
197 logger.log_step_result(&run.run_id, &step).await.unwrap();
198
199 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 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 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}