1#![allow(dead_code)]
7
8use std::collections::HashMap;
9use std::time::{SystemTime, UNIX_EPOCH};
10
11#[derive(Debug, Clone, PartialEq, Eq, Hash)]
17pub enum AuditEventType {
18 WorkflowSubmitted,
20 StateChanged,
22 TaskStateChanged,
24 ParameterUpdated,
26 WorkflowCancelled,
28 ErrorOccurred,
30 WorkflowCompleted,
32 RetryAttempted,
34}
35
36impl AuditEventType {
37 #[must_use]
39 pub fn is_state_change(&self) -> bool {
40 matches!(
41 self,
42 Self::StateChanged
43 | Self::TaskStateChanged
44 | Self::WorkflowCancelled
45 | Self::WorkflowCompleted
46 )
47 }
48
49 #[must_use]
51 pub fn as_str(&self) -> &'static str {
52 match self {
53 Self::WorkflowSubmitted => "workflow_submitted",
54 Self::StateChanged => "state_changed",
55 Self::TaskStateChanged => "task_state_changed",
56 Self::ParameterUpdated => "parameter_updated",
57 Self::WorkflowCancelled => "workflow_cancelled",
58 Self::ErrorOccurred => "error_occurred",
59 Self::WorkflowCompleted => "workflow_completed",
60 Self::RetryAttempted => "retry_attempted",
61 }
62 }
63}
64
65#[derive(Debug, Clone)]
71pub struct WorkflowAuditEntry {
72 pub run_id: String,
74 pub event_type: AuditEventType,
76 pub timestamp_secs: u64,
78 pub actor: Option<String>,
80 pub context: Option<String>,
82}
83
84impl WorkflowAuditEntry {
85 #[must_use]
87 pub fn new(run_id: impl Into<String>, event_type: AuditEventType) -> Self {
88 let timestamp_secs = SystemTime::now()
89 .duration_since(UNIX_EPOCH)
90 .map(|d| d.as_secs())
91 .unwrap_or(0);
92 Self {
93 run_id: run_id.into(),
94 event_type,
95 timestamp_secs,
96 actor: None,
97 context: None,
98 }
99 }
100
101 #[must_use]
103 pub fn with_timestamp(
104 run_id: impl Into<String>,
105 event_type: AuditEventType,
106 timestamp_secs: u64,
107 ) -> Self {
108 Self {
109 run_id: run_id.into(),
110 event_type,
111 timestamp_secs,
112 actor: None,
113 context: None,
114 }
115 }
116
117 #[must_use]
119 pub fn with_actor(mut self, actor: impl Into<String>) -> Self {
120 self.actor = Some(actor.into());
121 self
122 }
123
124 #[must_use]
126 pub fn with_context(mut self, ctx: impl Into<String>) -> Self {
127 self.context = Some(ctx.into());
128 self
129 }
130
131 #[must_use]
133 pub fn description(&self) -> String {
134 format!(
135 "[{}] run={} event={}{}",
136 self.timestamp_secs,
137 self.run_id,
138 self.event_type.as_str(),
139 self.actor
140 .as_ref()
141 .map(|a| format!(" actor={a}"))
142 .unwrap_or_default(),
143 )
144 }
145}
146
147#[derive(Debug, Default)]
153pub struct WorkflowAudit {
154 index: HashMap<String, Vec<usize>>,
156 all: Vec<WorkflowAuditEntry>,
158}
159
160impl WorkflowAudit {
161 #[must_use]
163 pub fn new() -> Self {
164 Self::default()
165 }
166
167 pub fn record(&mut self, entry: WorkflowAuditEntry) {
169 let idx = self.all.len();
170 self.index
171 .entry(entry.run_id.clone())
172 .or_default()
173 .push(idx);
174 self.all.push(entry);
175 }
176
177 #[must_use]
179 pub fn entries_for_run(&self, run_id: &str) -> Vec<&WorkflowAuditEntry> {
180 self.index
181 .get(run_id)
182 .map(|idxs| idxs.iter().map(|&i| &self.all[i]).collect())
183 .unwrap_or_default()
184 }
185
186 #[must_use]
188 pub fn recent(&self, n: usize) -> Vec<&WorkflowAuditEntry> {
189 self.all.iter().rev().take(n).collect()
190 }
191
192 #[must_use]
194 pub fn total_entries(&self) -> usize {
195 self.all.len()
196 }
197
198 #[must_use]
200 pub fn all_entries(&self) -> &[WorkflowAuditEntry] {
201 &self.all
202 }
203
204 #[must_use]
206 pub fn by_event_type(&self, et: &AuditEventType) -> Vec<&WorkflowAuditEntry> {
207 self.all.iter().filter(|e| &e.event_type == et).collect()
208 }
209}
210
211#[cfg(test)]
216mod tests {
217 use super::*;
218
219 #[test]
220 fn test_event_type_is_state_change_true() {
221 assert!(AuditEventType::StateChanged.is_state_change());
222 assert!(AuditEventType::TaskStateChanged.is_state_change());
223 assert!(AuditEventType::WorkflowCancelled.is_state_change());
224 assert!(AuditEventType::WorkflowCompleted.is_state_change());
225 }
226
227 #[test]
228 fn test_event_type_is_state_change_false() {
229 assert!(!AuditEventType::WorkflowSubmitted.is_state_change());
230 assert!(!AuditEventType::ParameterUpdated.is_state_change());
231 assert!(!AuditEventType::ErrorOccurred.is_state_change());
232 assert!(!AuditEventType::RetryAttempted.is_state_change());
233 }
234
235 #[test]
236 fn test_event_type_as_str() {
237 assert_eq!(
238 AuditEventType::WorkflowSubmitted.as_str(),
239 "workflow_submitted"
240 );
241 assert_eq!(AuditEventType::StateChanged.as_str(), "state_changed");
242 assert_eq!(AuditEventType::ErrorOccurred.as_str(), "error_occurred");
243 }
244
245 #[test]
246 fn test_entry_description_no_actor() {
247 let e = WorkflowAuditEntry::with_timestamp("run1", AuditEventType::StateChanged, 1000);
248 let desc = e.description();
249 assert!(desc.contains("run1"));
250 assert!(desc.contains("state_changed"));
251 assert!(desc.contains("1000"));
252 }
253
254 #[test]
255 fn test_entry_description_with_actor() {
256 let e = WorkflowAuditEntry::with_timestamp("run2", AuditEventType::StateChanged, 2000)
257 .with_actor("operator");
258 let desc = e.description();
259 assert!(desc.contains("actor=operator"));
260 }
261
262 #[test]
263 fn test_entry_with_context() {
264 let e = WorkflowAuditEntry::new("r", AuditEventType::ParameterUpdated)
265 .with_context(r#"{"key":"val"}"#);
266 assert!(e.context.is_some());
267 }
268
269 #[test]
270 fn test_audit_record_and_total() {
271 let mut audit = WorkflowAudit::new();
272 audit.record(WorkflowAuditEntry::new(
273 "r1",
274 AuditEventType::WorkflowSubmitted,
275 ));
276 audit.record(WorkflowAuditEntry::new("r1", AuditEventType::StateChanged));
277 audit.record(WorkflowAuditEntry::new(
278 "r2",
279 AuditEventType::WorkflowSubmitted,
280 ));
281 assert_eq!(audit.total_entries(), 3);
282 }
283
284 #[test]
285 fn test_entries_for_run() {
286 let mut audit = WorkflowAudit::new();
287 audit.record(WorkflowAuditEntry::new(
288 "run1",
289 AuditEventType::WorkflowSubmitted,
290 ));
291 audit.record(WorkflowAuditEntry::new(
292 "run2",
293 AuditEventType::WorkflowSubmitted,
294 ));
295 audit.record(WorkflowAuditEntry::new(
296 "run1",
297 AuditEventType::WorkflowCompleted,
298 ));
299 let entries = audit.entries_for_run("run1");
300 assert_eq!(entries.len(), 2);
301 }
302
303 #[test]
304 fn test_entries_for_unknown_run() {
305 let audit = WorkflowAudit::new();
306 assert!(audit.entries_for_run("ghost").is_empty());
307 }
308
309 #[test]
310 fn test_recent_entries() {
311 let mut audit = WorkflowAudit::new();
312 for i in 0..5u64 {
313 audit.record(WorkflowAuditEntry::with_timestamp(
314 format!("run{i}"),
315 AuditEventType::WorkflowSubmitted,
316 i,
317 ));
318 }
319 let recent = audit.recent(3);
320 assert_eq!(recent.len(), 3);
321 assert_eq!(recent[0].timestamp_secs, 4);
323 }
324
325 #[test]
326 fn test_by_event_type() {
327 let mut audit = WorkflowAudit::new();
328 audit.record(WorkflowAuditEntry::new("r", AuditEventType::ErrorOccurred));
329 audit.record(WorkflowAuditEntry::new("r", AuditEventType::StateChanged));
330 audit.record(WorkflowAuditEntry::new("r", AuditEventType::ErrorOccurred));
331 let errors = audit.by_event_type(&AuditEventType::ErrorOccurred);
332 assert_eq!(errors.len(), 2);
333 }
334
335 #[test]
336 fn test_all_entries_insertion_order() {
337 let mut audit = WorkflowAudit::new();
338 audit.record(WorkflowAuditEntry::with_timestamp(
339 "r",
340 AuditEventType::WorkflowSubmitted,
341 1,
342 ));
343 audit.record(WorkflowAuditEntry::with_timestamp(
344 "r",
345 AuditEventType::WorkflowCompleted,
346 2,
347 ));
348 let all = audit.all_entries();
349 assert_eq!(all[0].timestamp_secs, 1);
350 assert_eq!(all[1].timestamp_secs, 2);
351 }
352}