Skip to main content

zeroclaw_runtime/sop/
mod.rs

1pub mod audit;
2pub mod condition;
3pub mod dispatch;
4pub mod engine;
5pub mod metrics;
6pub mod types;
7
8pub use audit::SopAuditLogger;
9pub use engine::SopEngine;
10pub use metrics::SopMetricsCollector;
11#[allow(unused_imports)]
12pub use types::{
13    DeterministicRunState, DeterministicSavings, Sop, SopEvent, SopExecutionMode, SopPriority,
14    SopRun, SopRunAction, SopRunStatus, SopStep, SopStepKind, SopStepResult, SopStepStatus,
15    SopTrigger, SopTriggerSource, StepSchema,
16};
17
18use anyhow::Result;
19use std::path::{Path, PathBuf};
20
21use types::{SopManifest, SopMeta};
22
23/// Parse an execution mode string into `SopExecutionMode`, falling back to
24/// `Supervised` for unknown values.
25pub fn parse_execution_mode(s: &str) -> SopExecutionMode {
26    match s.trim().to_lowercase().as_str() {
27        "auto" => SopExecutionMode::Auto,
28        "step_by_step" => SopExecutionMode::StepByStep,
29        "priority_based" => SopExecutionMode::PriorityBased,
30        "deterministic" => SopExecutionMode::Deterministic,
31        // "supervised" and any unknown value
32        _ => SopExecutionMode::Supervised,
33    }
34}
35
36// ── SOP directory helpers ───────────────────────────────────────
37
38/// Return the default SOPs directory: `<workspace>/sops`.
39fn sops_dir(workspace_dir: &Path) -> PathBuf {
40    workspace_dir.join("sops")
41}
42
43/// Resolve the SOPs directory from config, falling back to workspace default.
44pub fn resolve_sops_dir(workspace_dir: &Path, config_dir: Option<&str>) -> PathBuf {
45    match config_dir {
46        Some(dir) if !dir.is_empty() => {
47            let expanded = shellexpand::tilde(dir);
48            PathBuf::from(expanded.as_ref())
49        }
50        _ => sops_dir(workspace_dir),
51    }
52}
53
54// ── SOP loading ─────────────────────────────────────────────────
55
56/// Load all SOPs from the configured directory.
57pub fn load_sops(
58    workspace_dir: &Path,
59    config_dir: Option<&str>,
60    default_execution_mode: SopExecutionMode,
61) -> Vec<Sop> {
62    let dir = resolve_sops_dir(workspace_dir, config_dir);
63    load_sops_from_directory(&dir, default_execution_mode)
64}
65
66/// Load SOPs from a specific directory. Each subdirectory may contain
67/// `SOP.toml` (metadata + triggers) and `SOP.md` (procedure steps).
68pub fn load_sops_from_directory(
69    sops_dir: &Path,
70    default_execution_mode: SopExecutionMode,
71) -> Vec<Sop> {
72    if !sops_dir.exists() {
73        return Vec::new();
74    }
75
76    let mut sops = Vec::new();
77
78    let Ok(entries) = std::fs::read_dir(sops_dir) else {
79        return sops;
80    };
81
82    for entry in entries.flatten() {
83        let path = entry.path();
84        if !path.is_dir() {
85            continue;
86        }
87
88        let toml_path = path.join("SOP.toml");
89        if !toml_path.exists() {
90            continue;
91        }
92
93        match load_sop(&path, default_execution_mode) {
94            Ok(sop) => sops.push(sop),
95            Err(e) => {
96                ::zeroclaw_log::record!(
97                    WARN,
98                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
99                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
100                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
101                    &format!("Failed to load SOP from {}", path.display().to_string())
102                );
103            }
104        }
105    }
106
107    sops.sort_by(|a, b| a.name.cmp(&b.name));
108    sops
109}
110
111/// Load a single SOP from a directory containing SOP.toml and optionally SOP.md.
112fn load_sop(sop_dir: &Path, default_execution_mode: SopExecutionMode) -> Result<Sop> {
113    let toml_path = sop_dir.join("SOP.toml");
114    let toml_content = std::fs::read_to_string(&toml_path)?;
115    let manifest: SopManifest = toml::from_str(&toml_content)?;
116
117    let md_path = sop_dir.join("SOP.md");
118    let steps = if md_path.exists() {
119        let md_content = std::fs::read_to_string(&md_path)?;
120        parse_steps(&md_content)
121    } else {
122        Vec::new()
123    };
124
125    let SopMeta {
126        name,
127        description,
128        version,
129        priority,
130        execution_mode,
131        cooldown_secs,
132        max_concurrent,
133        deterministic,
134    } = manifest.sop;
135
136    // When deterministic=true, override execution_mode to Deterministic
137    let effective_mode = if deterministic {
138        SopExecutionMode::Deterministic
139    } else {
140        execution_mode.unwrap_or(default_execution_mode)
141    };
142
143    Ok(Sop {
144        name,
145        description,
146        version,
147        priority,
148        execution_mode: effective_mode,
149        triggers: manifest.triggers,
150        steps,
151        cooldown_secs,
152        max_concurrent,
153        location: Some(sop_dir.to_path_buf()),
154        deterministic,
155    })
156}
157
158// ── Markdown step parser ────────────────────────────────────────
159
160/// Parse procedure steps from SOP.md content.
161///
162/// Expects a `## Steps` heading followed by numbered items (`1.`, `2.`, …).
163/// Each item's first bold text (`**...**`) is the step title; the rest is body.
164/// Sub-bullets `- tools:` and `- requires_confirmation: true` are parsed.
165pub fn parse_steps(md: &str) -> Vec<SopStep> {
166    let mut steps = Vec::new();
167    let mut in_steps_section = false;
168    let mut current_number: Option<u32> = None;
169    let mut current_title = String::new();
170    let mut current_body = String::new();
171    let mut current_tools: Vec<String> = Vec::new();
172    let mut current_requires_confirmation = false;
173    let mut current_kind = SopStepKind::Execute;
174
175    for line in md.lines() {
176        let trimmed = line.trim();
177
178        // Detect ## Steps heading
179        if trimmed.starts_with("## ") {
180            if trimmed.eq_ignore_ascii_case("## steps") || trimmed.eq_ignore_ascii_case("## Steps")
181            {
182                in_steps_section = true;
183                continue;
184            }
185            // Any other ## heading ends the steps section
186            if in_steps_section {
187                // Flush pending step
188                flush_step(
189                    &mut steps,
190                    &mut current_number,
191                    &mut current_title,
192                    &mut current_body,
193                    &mut current_tools,
194                    &mut current_requires_confirmation,
195                    &mut current_kind,
196                );
197                in_steps_section = false;
198            }
199            continue;
200        }
201
202        if !in_steps_section {
203            continue;
204        }
205
206        // Check for numbered item: `1.`, `2.`, etc.
207        if let Some(rest) = parse_numbered_item(trimmed) {
208            // Flush previous step
209            flush_step(
210                &mut steps,
211                &mut current_number,
212                &mut current_title,
213                &mut current_body,
214                &mut current_tools,
215                &mut current_requires_confirmation,
216                &mut current_kind,
217            );
218
219            let step_num = u32::try_from(steps.len())
220                .unwrap_or(u32::MAX)
221                .saturating_add(1);
222            current_number = Some(step_num);
223
224            // Extract title from bold text: **title** — body
225            if let Some((title, body)) = extract_bold_title(rest) {
226                current_title = title;
227                current_body = body;
228            } else {
229                current_title = rest.to_string();
230                current_body = String::new();
231            }
232            current_tools = Vec::new();
233            current_requires_confirmation = false;
234            continue;
235        }
236
237        // Sub-bullet parsing (only when inside a step)
238        if current_number.is_some() && trimmed.starts_with("- ") {
239            let bullet = trimmed.trim_start_matches("- ").trim();
240            if let Some(tools_str) = bullet.strip_prefix("tools:") {
241                current_tools = tools_str
242                    .split(',')
243                    .map(|t| t.trim().to_string())
244                    .filter(|t| !t.is_empty())
245                    .collect();
246            } else if bullet.starts_with("requires_confirmation:") {
247                if let Some(val) = bullet.strip_prefix("requires_confirmation:") {
248                    current_requires_confirmation = val.trim().eq_ignore_ascii_case("true");
249                }
250            } else if bullet.starts_with("kind:") {
251                if let Some(val) = bullet.strip_prefix("kind:") {
252                    let val = val.trim();
253                    if val.eq_ignore_ascii_case("checkpoint") {
254                        current_kind = SopStepKind::Checkpoint;
255                    } else {
256                        current_kind = SopStepKind::Execute;
257                    }
258                }
259            } else {
260                // Continuation body line
261                if !current_body.is_empty() {
262                    current_body.push('\n');
263                }
264                current_body.push_str(trimmed);
265            }
266            continue;
267        }
268
269        // Continuation line for step body
270        if current_number.is_some() && !trimmed.is_empty() {
271            if !current_body.is_empty() {
272                current_body.push('\n');
273            }
274            current_body.push_str(trimmed);
275        }
276    }
277
278    // Flush final step
279    flush_step(
280        &mut steps,
281        &mut current_number,
282        &mut current_title,
283        &mut current_body,
284        &mut current_tools,
285        &mut current_requires_confirmation,
286        &mut current_kind,
287    );
288
289    steps
290}
291
292/// Flush accumulated step state into the steps vector.
293fn flush_step(
294    steps: &mut Vec<SopStep>,
295    number: &mut Option<u32>,
296    title: &mut String,
297    body: &mut String,
298    tools: &mut Vec<String>,
299    requires_confirmation: &mut bool,
300    kind: &mut SopStepKind,
301) {
302    if let Some(n) = number.take() {
303        steps.push(SopStep {
304            number: n,
305            title: std::mem::take(title),
306            body: body.trim().to_string(),
307            suggested_tools: std::mem::take(tools),
308            requires_confirmation: *requires_confirmation,
309            kind: *kind,
310            schema: None,
311        });
312        *body = String::new();
313        *requires_confirmation = false;
314        *kind = SopStepKind::Execute;
315    }
316}
317
318/// Try to parse `N. rest` from a line, returning `rest` if successful.
319fn parse_numbered_item(line: &str) -> Option<&str> {
320    let dot_pos = line.find(". ")?;
321    let prefix = &line[..dot_pos];
322    if prefix.chars().all(|c| c.is_ascii_digit()) && !prefix.is_empty() {
323        Some(line[dot_pos + 2..].trim())
324    } else {
325        None
326    }
327}
328
329/// Extract `**title**` from the beginning of text, returning (title, rest).
330pub fn extract_bold_title(text: &str) -> Option<(String, String)> {
331    let start = text.find("**")?;
332    let after_start = start + 2;
333    let end = text[after_start..].find("**")?;
334    let title = text[after_start..after_start + end].to_string();
335
336    // Rest is everything after the closing ** and any separator (— or -)
337    let rest_start = after_start + end + 2;
338    let rest = text[rest_start..].trim();
339    let rest = rest
340        .strip_prefix("—")
341        .or_else(|| rest.strip_prefix("–"))
342        .or_else(|| rest.strip_prefix("-"))
343        .unwrap_or(rest)
344        .trim();
345
346    Some((title, rest.to_string()))
347}
348
349// ── Validation ──────────────────────────────────────────────────
350
351/// Validate a loaded SOP and return a list of warnings.
352pub fn validate_sop(sop: &Sop) -> Vec<String> {
353    let mut warnings = Vec::new();
354
355    if sop.name.is_empty() {
356        warnings.push("SOP name is empty".into());
357    }
358    if sop.description.is_empty() {
359        warnings.push("SOP description is empty".into());
360    }
361    if sop.triggers.is_empty() {
362        warnings.push("SOP has no triggers defined".into());
363    }
364    if sop.steps.is_empty() {
365        warnings.push("SOP has no steps (missing or empty SOP.md)".into());
366    }
367
368    // Check step numbering continuity
369    for (i, step) in sop.steps.iter().enumerate() {
370        let expected = u32::try_from(i).unwrap_or(u32::MAX).saturating_add(1);
371        if step.number != expected {
372            warnings.push(format!(
373                "Step numbering gap: expected {expected}, got {}",
374                step.number
375            ));
376        }
377        if step.title.is_empty() {
378            warnings.push(format!("Step {} has an empty title", step.number));
379        }
380    }
381
382    warnings
383}