1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::fmt;
4use std::path::PathBuf;
5
6#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
10#[serde(rename_all = "lowercase")]
11pub enum SopPriority {
12 Low,
13 #[default]
14 Normal,
15 High,
16 Critical,
17}
18
19impl fmt::Display for SopPriority {
20 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
21 match self {
22 Self::Low => write!(f, "low"),
23 Self::Normal => write!(f, "normal"),
24 Self::High => write!(f, "high"),
25 Self::Critical => write!(f, "critical"),
26 }
27 }
28}
29
30#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
34#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
35#[serde(rename_all = "snake_case")]
36pub enum SopExecutionMode {
37 Auto,
39 #[default]
41 Supervised,
42 StepByStep,
44 PriorityBased,
46 Deterministic,
50}
51
52impl fmt::Display for SopExecutionMode {
53 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54 match self {
55 Self::Auto => write!(f, "auto"),
56 Self::Supervised => write!(f, "supervised"),
57 Self::StepByStep => write!(f, "step_by_step"),
58 Self::PriorityBased => write!(f, "priority_based"),
59 Self::Deterministic => write!(f, "deterministic"),
60 }
61 }
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(tag = "type", rename_all = "lowercase")]
69pub enum SopTrigger {
70 Mqtt {
71 topic: String,
72 #[serde(default)]
73 condition: Option<String>,
74 },
75 Webhook {
76 path: String,
77 },
78 Cron {
79 expression: String,
80 },
81 Peripheral {
82 board: String,
83 signal: String,
84 #[serde(default)]
85 condition: Option<String>,
86 },
87 Manual,
88}
89
90impl fmt::Display for SopTrigger {
91 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92 match self {
93 Self::Mqtt { topic, .. } => write!(f, "mqtt:{topic}"),
94 Self::Webhook { path } => write!(f, "webhook:{path}"),
95 Self::Cron { expression } => write!(f, "cron:{expression}"),
96 Self::Peripheral { board, signal, .. } => write!(f, "peripheral:{board}/{signal}"),
97 Self::Manual => write!(f, "manual"),
98 }
99 }
100}
101
102#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "snake_case")]
107pub enum SopStepKind {
108 #[default]
110 Execute,
111 Checkpoint,
113}
114
115impl fmt::Display for SopStepKind {
116 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117 match self {
118 Self::Execute => write!(f, "execute"),
119 Self::Checkpoint => write!(f, "checkpoint"),
120 }
121 }
122}
123
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct StepSchema {
132 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub input: Option<serde_json::Value>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub output: Option<serde_json::Value>,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144pub struct SopStep {
145 pub number: u32,
146 pub title: String,
147 pub body: String,
148 #[serde(default)]
149 pub suggested_tools: Vec<String>,
150 #[serde(default)]
151 pub requires_confirmation: bool,
152 #[serde(default)]
154 pub kind: SopStepKind,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub schema: Option<StepSchema>,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct Sop {
165 pub name: String,
166 pub description: String,
167 pub version: String,
168 pub priority: SopPriority,
169 pub execution_mode: SopExecutionMode,
170 pub triggers: Vec<SopTrigger>,
171 pub steps: Vec<SopStep>,
172 #[serde(default = "default_cooldown_secs")]
173 pub cooldown_secs: u64,
174 #[serde(default = "default_max_concurrent")]
175 pub max_concurrent: u32,
176 #[serde(skip)]
177 pub location: Option<PathBuf>,
178 #[serde(default)]
181 pub deterministic: bool,
182}
183
184fn default_cooldown_secs() -> u64 {
185 0
186}
187
188fn default_max_concurrent() -> u32 {
189 1
190}
191
192#[derive(Debug, Clone, Deserialize)]
196pub struct SopManifest {
197 pub sop: SopMeta,
198 #[serde(default)]
199 pub triggers: Vec<SopTrigger>,
200}
201
202#[derive(Debug, Clone, Deserialize)]
204pub struct SopMeta {
205 pub name: String,
206 pub description: String,
207 #[serde(default = "default_sop_version")]
208 pub version: String,
209 #[serde(default)]
210 pub priority: SopPriority,
211 #[serde(default)]
212 pub execution_mode: Option<SopExecutionMode>,
213 #[serde(default = "default_cooldown_secs")]
214 pub cooldown_secs: u64,
215 #[serde(default = "default_max_concurrent")]
216 pub max_concurrent: u32,
217 #[serde(default)]
219 pub deterministic: bool,
220}
221
222fn default_sop_version() -> String {
223 "0.1.0".to_string()
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
230#[serde(rename_all = "lowercase")]
231pub enum SopTriggerSource {
232 Mqtt,
233 Webhook,
234 Cron,
235 Peripheral,
236 Manual,
237}
238
239impl fmt::Display for SopTriggerSource {
240 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
241 match self {
242 Self::Mqtt => write!(f, "mqtt"),
243 Self::Webhook => write!(f, "webhook"),
244 Self::Cron => write!(f, "cron"),
245 Self::Peripheral => write!(f, "peripheral"),
246 Self::Manual => write!(f, "manual"),
247 }
248 }
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct SopEvent {
254 pub source: SopTriggerSource,
255 #[serde(default)]
257 pub topic: Option<String>,
258 #[serde(default)]
260 pub payload: Option<String>,
261 pub timestamp: String,
263}
264
265#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
269#[serde(rename_all = "snake_case")]
270pub enum SopRunStatus {
271 Pending,
272 Running,
273 WaitingApproval,
274 PausedCheckpoint,
276 Completed,
277 Failed,
278 Cancelled,
279}
280
281impl fmt::Display for SopRunStatus {
282 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
283 match self {
284 Self::Pending => write!(f, "pending"),
285 Self::Running => write!(f, "running"),
286 Self::WaitingApproval => write!(f, "waiting_approval"),
287 Self::PausedCheckpoint => write!(f, "paused_checkpoint"),
288 Self::Completed => write!(f, "completed"),
289 Self::Failed => write!(f, "failed"),
290 Self::Cancelled => write!(f, "cancelled"),
291 }
292 }
293}
294
295#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
297#[serde(rename_all = "snake_case")]
298pub enum SopStepStatus {
299 Completed,
300 Failed,
301 Skipped,
302}
303
304impl fmt::Display for SopStepStatus {
305 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
306 match self {
307 Self::Completed => write!(f, "completed"),
308 Self::Failed => write!(f, "failed"),
309 Self::Skipped => write!(f, "skipped"),
310 }
311 }
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct SopStepResult {
317 pub step_number: u32,
318 pub status: SopStepStatus,
319 pub output: String,
320 pub started_at: String,
321 pub completed_at: Option<String>,
322}
323
324#[derive(Debug, Clone, Serialize, Deserialize)]
326pub struct SopRun {
327 pub run_id: String,
328 pub sop_name: String,
329 pub trigger_event: SopEvent,
330 pub status: SopRunStatus,
331 pub current_step: u32,
332 pub total_steps: u32,
333 pub started_at: String,
334 pub completed_at: Option<String>,
335 pub step_results: Vec<SopStepResult>,
336 #[serde(default)]
338 pub waiting_since: Option<String>,
339 #[serde(default)]
341 pub llm_calls_saved: u64,
342}
343
344impl ::zeroclaw_api::attribution::Attributable for SopRun {
345 fn role(&self) -> ::zeroclaw_api::attribution::Role {
346 ::zeroclaw_api::attribution::Role::Sop
347 }
348 fn alias(&self) -> &str {
349 &self.sop_name
350 }
351}
352
353#[derive(Debug, Clone, Serialize, Deserialize)]
358pub struct DeterministicRunState {
359 pub run_id: String,
361 pub sop_name: String,
363 pub last_completed_step: u32,
365 pub total_steps: u32,
367 pub step_outputs: HashMap<u32, serde_json::Value>,
369 pub persisted_at: String,
371 pub llm_calls_saved: u64,
373 pub paused_at_checkpoint: bool,
375}
376
377#[derive(Debug, Clone, Default, Serialize, Deserialize)]
381pub struct DeterministicSavings {
382 pub total_llm_calls_saved: u64,
384 pub total_runs: u64,
386}
387
388#[derive(Debug, Clone)]
390pub enum SopRunAction {
391 ExecuteStep {
393 run_id: String,
394 step: SopStep,
395 context: String,
396 },
397 WaitApproval {
399 run_id: String,
400 step: SopStep,
401 context: String,
402 },
403 DeterministicStep {
406 run_id: String,
407 step: SopStep,
408 input: serde_json::Value,
409 },
410 CheckpointWait {
413 run_id: String,
414 step: SopStep,
415 state_file: PathBuf,
416 },
417 Completed { run_id: String, sop_name: String },
419 Failed {
421 run_id: String,
422 sop_name: String,
423 reason: String,
424 },
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430
431 #[test]
432 fn priority_display() {
433 assert_eq!(SopPriority::Critical.to_string(), "critical");
434 assert_eq!(SopPriority::Low.to_string(), "low");
435 }
436
437 #[test]
438 fn execution_mode_display() {
439 assert_eq!(SopExecutionMode::Auto.to_string(), "auto");
440 assert_eq!(
441 SopExecutionMode::PriorityBased.to_string(),
442 "priority_based"
443 );
444 }
445
446 #[test]
447 fn trigger_display() {
448 let mqtt = SopTrigger::Mqtt {
449 topic: "sensors/temp".into(),
450 condition: Some("$.value > 85".into()),
451 };
452 assert_eq!(mqtt.to_string(), "mqtt:sensors/temp");
453
454 let manual = SopTrigger::Manual;
455 assert_eq!(manual.to_string(), "manual");
456 }
457
458 #[test]
459 fn priority_serde_roundtrip() {
460 let json = serde_json::to_string(&SopPriority::Critical).unwrap();
461 assert_eq!(json, "\"critical\"");
462 let parsed: SopPriority = serde_json::from_str(&json).unwrap();
463 assert_eq!(parsed, SopPriority::Critical);
464 }
465
466 #[test]
467 fn execution_mode_serde_roundtrip() {
468 let json = serde_json::to_string(&SopExecutionMode::PriorityBased).unwrap();
469 assert_eq!(json, "\"priority_based\"");
470 let parsed: SopExecutionMode = serde_json::from_str(&json).unwrap();
471 assert_eq!(parsed, SopExecutionMode::PriorityBased);
472 }
473
474 #[test]
475 fn trigger_toml_roundtrip() {
476 let toml_str = r#"
477type = "mqtt"
478topic = "facility/pump/pressure"
479condition = "$.value > 85"
480"#;
481 let trigger: SopTrigger = toml::from_str(toml_str).unwrap();
482 assert!(
483 matches!(trigger, SopTrigger::Mqtt { ref topic, .. } if topic == "facility/pump/pressure")
484 );
485 }
486
487 #[test]
488 fn trigger_manual_toml() {
489 let toml_str = r#"type = "manual""#;
490 let trigger: SopTrigger = toml::from_str(toml_str).unwrap();
491 assert_eq!(trigger, SopTrigger::Manual);
492 }
493
494 #[test]
495 fn run_status_display() {
496 assert_eq!(
497 SopRunStatus::WaitingApproval.to_string(),
498 "waiting_approval"
499 );
500 }
501
502 #[test]
503 fn step_kind_display() {
504 assert_eq!(SopStepKind::Execute.to_string(), "execute");
505 assert_eq!(SopStepKind::Checkpoint.to_string(), "checkpoint");
506 }
507
508 #[test]
509 fn step_kind_serde_roundtrip() {
510 let json = serde_json::to_string(&SopStepKind::Checkpoint).unwrap();
511 assert_eq!(json, "\"checkpoint\"");
512 let parsed: SopStepKind = serde_json::from_str(&json).unwrap();
513 assert_eq!(parsed, SopStepKind::Checkpoint);
514 }
515
516 #[test]
517 fn execution_mode_deterministic_roundtrip() {
518 let json = serde_json::to_string(&SopExecutionMode::Deterministic).unwrap();
519 assert_eq!(json, "\"deterministic\"");
520 let parsed: SopExecutionMode = serde_json::from_str(&json).unwrap();
521 assert_eq!(parsed, SopExecutionMode::Deterministic);
522 }
523
524 #[test]
525 fn deterministic_run_state_serde() {
526 let state = DeterministicRunState {
527 run_id: "det-001".into(),
528 sop_name: "test-sop".into(),
529 last_completed_step: 2,
530 total_steps: 5,
531 step_outputs: {
532 let mut m = std::collections::HashMap::new();
533 m.insert(1, serde_json::json!({"result": "ok"}));
534 m.insert(2, serde_json::json!("step2_done"));
535 m
536 },
537 persisted_at: "2026-03-01T00:00:00Z".into(),
538 llm_calls_saved: 2,
539 paused_at_checkpoint: true,
540 };
541 let json = serde_json::to_string(&state).unwrap();
542 let parsed: DeterministicRunState = serde_json::from_str(&json).unwrap();
543 assert_eq!(parsed.run_id, "det-001");
544 assert_eq!(parsed.last_completed_step, 2);
545 assert_eq!(parsed.llm_calls_saved, 2);
546 assert!(parsed.paused_at_checkpoint);
547 assert_eq!(parsed.step_outputs.len(), 2);
548 }
549
550 #[test]
551 fn run_status_paused_checkpoint_display() {
552 assert_eq!(
553 SopRunStatus::PausedCheckpoint.to_string(),
554 "paused_checkpoint"
555 );
556 }
557
558 #[test]
559 fn step_defaults() {
560 let step: SopStep =
561 serde_json::from_str(r#"{"number": 1, "title": "Check", "body": "Verify readings"}"#)
562 .unwrap();
563 assert!(step.suggested_tools.is_empty());
564 assert!(!step.requires_confirmation);
565 }
566
567 #[test]
568 fn manifest_parse() {
569 let toml_str = r#"
570[sop]
571name = "test-sop"
572description = "A test SOP"
573
574[[triggers]]
575type = "manual"
576
577[[triggers]]
578type = "webhook"
579path = "/sop/test"
580"#;
581 let manifest: SopManifest = toml::from_str(toml_str).unwrap();
582 assert_eq!(manifest.sop.name, "test-sop");
583 assert_eq!(manifest.triggers.len(), 2);
584 assert_eq!(manifest.sop.priority, SopPriority::Normal);
585 assert_eq!(manifest.sop.execution_mode, None);
586 }
587
588 #[test]
589 fn trigger_source_display() {
590 assert_eq!(SopTriggerSource::Mqtt.to_string(), "mqtt");
591 assert_eq!(SopTriggerSource::Manual.to_string(), "manual");
592 }
593
594 #[test]
595 fn step_status_display() {
596 assert_eq!(SopStepStatus::Completed.to_string(), "completed");
597 assert_eq!(SopStepStatus::Failed.to_string(), "failed");
598 assert_eq!(SopStepStatus::Skipped.to_string(), "skipped");
599 }
600
601 #[test]
602 fn sop_event_serde_roundtrip() {
603 let event = SopEvent {
604 source: SopTriggerSource::Mqtt,
605 topic: Some("sensors/pressure".into()),
606 payload: Some(r#"{"value": 87.3}"#.into()),
607 timestamp: "2026-02-19T12:00:00Z".into(),
608 };
609 let json = serde_json::to_string(&event).unwrap();
610 let parsed: SopEvent = serde_json::from_str(&json).unwrap();
611 assert_eq!(parsed.source, SopTriggerSource::Mqtt);
612 assert_eq!(parsed.topic.as_deref(), Some("sensors/pressure"));
613 }
614
615 #[test]
616 fn sop_run_serde_roundtrip() {
617 let run = SopRun {
618 run_id: "run-001".into(),
619 sop_name: "test-sop".into(),
620 trigger_event: SopEvent {
621 source: SopTriggerSource::Manual,
622 topic: None,
623 payload: None,
624 timestamp: "2026-02-19T12:00:00Z".into(),
625 },
626 status: SopRunStatus::Running,
627 current_step: 2,
628 total_steps: 5,
629 started_at: "2026-02-19T12:00:00Z".into(),
630 completed_at: None,
631 step_results: vec![SopStepResult {
632 step_number: 1,
633 status: SopStepStatus::Completed,
634 output: "Step 1 done".into(),
635 started_at: "2026-02-19T12:00:00Z".into(),
636 completed_at: Some("2026-02-19T12:00:05Z".into()),
637 }],
638 waiting_since: None,
639 llm_calls_saved: 0,
640 };
641 let json = serde_json::to_string(&run).unwrap();
642 let parsed: SopRun = serde_json::from_str(&json).unwrap();
643 assert_eq!(parsed.run_id, "run-001");
644 assert_eq!(parsed.status, SopRunStatus::Running);
645 assert_eq!(parsed.step_results.len(), 1);
646 assert_eq!(parsed.step_results[0].status, SopStepStatus::Completed);
647 }
648}