Skip to main content

zeroclaw_runtime/sop/
types.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::fmt;
4use std::path::PathBuf;
5
6// ── Priority ────────────────────────────────────────────────────
7
8/// SOP priority level, used for execution mode resolution and scheduling.
9#[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// ── Execution Mode ──────────────────────────────────────────────
31
32/// How much autonomy the agent has when executing an SOP.
33#[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    /// Execute all steps without human approval.
38    Auto,
39    /// Request approval before starting, then execute all steps.
40    #[default]
41    Supervised,
42    /// Request approval before each step.
43    StepByStep,
44    /// Critical/High → Auto, Normal/Low → Supervised.
45    PriorityBased,
46    /// Execute steps sequentially without LLM round-trips.
47    /// Step outputs are piped as inputs to the next step.
48    /// Checkpoint steps pause for human approval.
49    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// ── Trigger ─────────────────────────────────────────────────────
65
66/// What event can activate an SOP.
67#[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// ── Step kind ────────────────────────────────────────────────────
103
104/// The kind of a workflow step.
105#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "snake_case")]
107pub enum SopStepKind {
108    /// Normal step — executed by the agent (or deterministic handler).
109    #[default]
110    Execute,
111    /// Checkpoint step — pauses execution and waits for human approval.
112    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// ── Typed step parameters ────────────────────────────────────────
125
126/// JSON Schema fragment for validating step input/output data.
127///
128/// Stored as a raw `serde_json::Value` so callers can validate without
129/// pulling in a full JSON Schema library.
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct StepSchema {
132    /// JSON Schema object describing expected input shape.
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub input: Option<serde_json::Value>,
135    /// JSON Schema object describing expected output shape.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub output: Option<serde_json::Value>,
138}
139
140// ── Step ────────────────────────────────────────────────────────
141
142/// A single step in an SOP procedure, parsed from SOP.md.
143#[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    /// Step kind: `execute` (default) or `checkpoint`.
153    #[serde(default)]
154    pub kind: SopStepKind,
155    /// Typed input/output schemas for deterministic data flow validation.
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub schema: Option<StepSchema>,
158}
159
160// ── SOP ─────────────────────────────────────────────────────────
161
162/// A complete Standard Operating Procedure definition.
163#[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    /// When true, sets execution_mode to Deterministic.
179    /// Steps execute sequentially without LLM round-trips.
180    #[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// ── TOML manifest (internal parse target) ───────────────────────
193
194/// Top-level SOP.toml structure.
195#[derive(Debug, Clone, Deserialize)]
196pub struct SopManifest {
197    pub sop: SopMeta,
198    #[serde(default)]
199    pub triggers: Vec<SopTrigger>,
200}
201
202/// The `[sop]` table in SOP.toml.
203#[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    /// Opt-in deterministic execution (no LLM round-trips between steps).
218    #[serde(default)]
219    pub deterministic: bool,
220}
221
222fn default_sop_version() -> String {
223    "0.1.0".to_string()
224}
225
226// ── Event ────────────────────────────────────────────────────────
227
228/// The source type of an incoming event that may trigger an SOP.
229#[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/// An incoming event that may trigger one or more SOPs.
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct SopEvent {
254    pub source: SopTriggerSource,
255    /// Topic, path, or signal identifier (depends on source type).
256    #[serde(default)]
257    pub topic: Option<String>,
258    /// Raw payload (JSON string, sensor reading, etc.).
259    #[serde(default)]
260    pub payload: Option<String>,
261    /// When the event occurred (ISO-8601).
262    pub timestamp: String,
263}
264
265// ── Run state ────────────────────────────────────────────────────
266
267/// Status of an SOP execution run.
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
269#[serde(rename_all = "snake_case")]
270pub enum SopRunStatus {
271    Pending,
272    Running,
273    WaitingApproval,
274    /// Paused at a checkpoint in a deterministic workflow.
275    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/// Result status of a single step execution.
296#[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/// Result of executing a single SOP step.
315#[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/// A full SOP execution run (from trigger to completion).
325#[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    /// ISO-8601 timestamp when the run entered WaitingApproval (for timeout tracking).
337    #[serde(default)]
338    pub waiting_since: Option<String>,
339    /// Number of LLM calls saved by deterministic execution in this run.
340    #[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// ── Deterministic workflow state (persistence + resume) ──────────
354
355/// Persisted state for a deterministic workflow run, enabling resume
356/// after interruption. Serialized to a JSON file alongside the SOP.
357#[derive(Debug, Clone, Serialize, Deserialize)]
358pub struct DeterministicRunState {
359    /// Identifier of this run.
360    pub run_id: String,
361    /// SOP name this state belongs to.
362    pub sop_name: String,
363    /// Last successfully completed step number (0 = none completed).
364    pub last_completed_step: u32,
365    /// Total steps in the workflow.
366    pub total_steps: u32,
367    /// Output of each completed step, keyed by step number.
368    pub step_outputs: HashMap<u32, serde_json::Value>,
369    /// ISO-8601 timestamp when this state was last persisted.
370    pub persisted_at: String,
371    /// Number of LLM calls that were saved by deterministic execution.
372    pub llm_calls_saved: u64,
373    /// Whether the run is paused at a checkpoint awaiting approval.
374    pub paused_at_checkpoint: bool,
375}
376
377// ── Cost savings metric ──────────────────────────────────────────
378
379/// Tracks how many LLM round-trips were saved by deterministic execution.
380#[derive(Debug, Clone, Default, Serialize, Deserialize)]
381pub struct DeterministicSavings {
382    /// Total LLM calls saved across all deterministic runs.
383    pub total_llm_calls_saved: u64,
384    /// Total deterministic runs completed.
385    pub total_runs: u64,
386}
387
388/// What the engine instructs the caller to do next after a state transition.
389#[derive(Debug, Clone)]
390pub enum SopRunAction {
391    /// Inject this step into the agent for execution.
392    ExecuteStep {
393        run_id: String,
394        step: SopStep,
395        context: String,
396    },
397    /// Pause and wait for operator approval before executing this step.
398    WaitApproval {
399        run_id: String,
400        step: SopStep,
401        context: String,
402    },
403    /// Execute a step deterministically (no LLM). The `input` is the piped
404    /// output from the previous step (or trigger payload for step 1).
405    DeterministicStep {
406        run_id: String,
407        step: SopStep,
408        input: serde_json::Value,
409    },
410    /// Deterministic workflow hit a checkpoint — pause for human approval.
411    /// Workflow state has been persisted so it can resume after approval.
412    CheckpointWait {
413        run_id: String,
414        step: SopStep,
415        state_file: PathBuf,
416    },
417    /// The SOP run completed successfully.
418    Completed { run_id: String, sop_name: String },
419    /// The SOP run failed.
420    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}