Skip to main content

machi_workflow/
validate.rs

1//! Dry-run validation of workflow scripts (meta + stub host path).
2//!
3//! Aligns with Grok Build's `validate_script` contract: extract meta, then run
4//! the script against a **probe host** that never calls models. Failures from
5//! compile/runtime or hard script errors surface as [`ValidationError`].
6
7use std::thread;
8
9use tokio::sync::mpsc;
10use tokio_util::sync::CancellationToken;
11
12use crate::host::{AgentResult, BudgetState, HostError, WorkflowHostRequest};
13use crate::journal::Journal;
14use crate::meta::{MetaError, extract_meta};
15use crate::run::WorkflowOutcome;
16use crate::{DEFAULT_AGENT_BUDGET, WorkflowRunParams, run_workflow};
17
18/// Successful dry-run report.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct ValidationReport {
21    /// Workflow name from meta.
22    pub name: String,
23    /// Number of declared phases.
24    pub phases: usize,
25    /// Whether the terminal outcome was considered successful for authoring.
26    pub outcome_ok: bool,
27    /// Short human summary (truncated).
28    pub outcome_summary: String,
29}
30
31/// Validation failures.
32#[derive(Debug, thiserror::Error)]
33pub enum ValidationError {
34    /// Meta extraction failed.
35    #[error("meta: {0}")]
36    Meta(#[from] MetaError),
37    /// Dry-run failed.
38    #[error("dry-run: {0}")]
39    Run(String),
40}
41
42/// Default `args` used when the author does not supply probe input.
43#[must_use]
44pub fn default_probe_args() -> serde_json::Value {
45    serde_json::json!({
46        "objective": "stub objective",
47        "query": "stub query",
48        "breadth": 2,
49        "target": "stub target",
50        "skeptic_count": 1,
51        "max_verify_attempts": 1,
52        "baseline_commit": "",
53        "test_command": "cargo test",
54        "diff_summary": "stub diff",
55        "since_commit": "abc123",
56    })
57}
58
59/// Validate a script with the default agent budget.
60///
61/// # Errors
62///
63/// Meta or dry-run failures.
64pub fn validate_script(
65    script: &str,
66    args: Option<serde_json::Value>,
67) -> Result<ValidationReport, ValidationError> {
68    validate_script_with_agent_budget(script, args, DEFAULT_AGENT_BUDGET)
69}
70
71/// Validate with an explicit agent-call budget for the probe host.
72///
73/// # Errors
74///
75/// Meta or dry-run failures.
76pub fn validate_script_with_agent_budget(
77    script: &str,
78    args: Option<serde_json::Value>,
79    agent_budget: u64,
80) -> Result<ValidationReport, ValidationError> {
81    let meta = extract_meta(script)?;
82
83    let (host_tx, host_rx) = mpsc::unbounded_channel();
84    let host = thread::spawn(move || probe_host_loop(host_rx, agent_budget));
85
86    let outcome = run_workflow(WorkflowRunParams {
87        script: script.to_owned(),
88        args: args.unwrap_or_else(default_probe_args),
89        journal: Journal::new(None),
90        host_tx,
91        cancel: CancellationToken::new(),
92        max_ops: 10_000_000,
93    });
94    // Dropping the sender ends the probe loop; join for hygiene.
95    let _ = host.join();
96
97    let (outcome_ok, outcome_summary) = summarize_outcome(&outcome);
98    if !outcome_ok {
99        return Err(ValidationError::Run(outcome_summary));
100    }
101
102    Ok(ValidationReport {
103        name: meta.name,
104        phases: meta.phases.len(),
105        outcome_ok,
106        outcome_summary,
107    })
108}
109
110fn summarize_outcome(outcome: &WorkflowOutcome) -> (bool, String) {
111    match outcome {
112        WorkflowOutcome::Completed { result } => (
113            true,
114            format!("completed: {}", truncate(&result.to_string())),
115        ),
116        WorkflowOutcome::Paused { kind, message } => (
117            true,
118            format!("paused ({}): {}", kind.as_str(), truncate(message)),
119        ),
120        WorkflowOutcome::Failed { error } => (false, format!("failed: {error}")),
121        WorkflowOutcome::BudgetExceeded { message } => {
122            (false, format!("budget: {}", truncate(message)))
123        }
124        WorkflowOutcome::Cancelled => (false, "cancelled".into()),
125    }
126}
127
128fn truncate(s: &str) -> String {
129    const MAX: usize = 200;
130    if s.chars().count() > MAX {
131        let head: String = s.chars().take(MAX).collect();
132        format!("{head}…")
133    } else {
134        s.to_owned()
135    }
136}
137
138/// Blocking probe host: answers every host RPC without side effects or models.
139fn probe_host_loop(mut rx: mpsc::UnboundedReceiver<WorkflowHostRequest>, agent_budget: u64) {
140    let mut agent_calls = 0u64;
141    while let Some(req) = rx.blocking_recv() {
142        match req {
143            WorkflowHostRequest::ReserveAgentCalls { count, reply } => {
144                let requested = agent_calls.saturating_add(count);
145                if requested > agent_budget {
146                    let _ = reply.send(Err(HostError::AgentCallQuotaExceeded {
147                        requested,
148                        maximum: agent_budget,
149                    }));
150                } else {
151                    agent_calls = requested;
152                    let _ = reply.send(Ok(()));
153                }
154            }
155            WorkflowHostRequest::ReleaseAgentCalls { count, reply } => {
156                agent_calls = agent_calls.saturating_sub(count);
157                let _ = reply.send(Ok(()));
158            }
159            WorkflowHostRequest::SpawnAgent { reply, .. } => {
160                let _ = reply.send(Ok(AgentResult {
161                    agent_id: "probe".into(),
162                    success: true,
163                    output: serde_json::json!({
164                        "stub": true,
165                        "achieved": true,
166                        "text": "probe agent output",
167                    }),
168                    cancelled: false,
169                    tokens_used: 1,
170                    duration_ms: 1,
171                }));
172            }
173            WorkflowHostRequest::BudgetQuery { reply } => {
174                let _ = reply.send(Ok(BudgetState {
175                    total: Some(agent_budget),
176                    spent: agent_calls,
177                    reserved: 0,
178                    remaining: Some(agent_budget.saturating_sub(agent_calls)),
179                }));
180            }
181            WorkflowHostRequest::RenderTemplate { name, reply, .. } => {
182                let _ = reply.send(Ok(format!("probe-template:{name}")));
183            }
184            WorkflowHostRequest::WriteScratchFile { name, reply, .. } => {
185                let _ = reply.send(Ok(format!("scratch/{name}")));
186            }
187            WorkflowHostRequest::ReadScratchFile { name, reply, .. } => {
188                let _ = reply.send(Ok(format!("probe-content:{name}")));
189            }
190            WorkflowHostRequest::GitDiffSince { reply, .. } => {
191                let _ = reply.send(Ok(String::new()));
192            }
193            WorkflowHostRequest::Phase { .. }
194            | WorkflowHostRequest::Log { .. }
195            | WorkflowHostRequest::Telemetry { .. } => {}
196        }
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn valid_script_passes() {
206        let report = validate_script(
207            r#"
208            let meta = #{ name: "t", description: "d" };
209            let r = agent("work");
210            complete(r.output);
211            "#,
212            None,
213        )
214        .expect("validate");
215        assert_eq!(report.name, "t");
216        assert!(report.outcome_ok);
217    }
218
219    #[test]
220    fn missing_meta_fails() {
221        let err = validate_script("let x = 1;", None).expect_err("meta");
222        assert!(matches!(err, ValidationError::Meta(_)));
223    }
224
225    #[test]
226    fn probe_args_nonempty() {
227        let args = default_probe_args();
228        assert!(
229            !args
230                .get("objective")
231                .and_then(|v| v.as_str())
232                .unwrap_or("")
233                .is_empty()
234        );
235        assert!(
236            args.get("breadth")
237                .and_then(serde_json::Value::as_u64)
238                .unwrap_or(0)
239                >= 2
240        );
241    }
242
243    #[test]
244    fn parallel_probe_path() {
245        let report = validate_script(
246            r#"
247            let meta = #{ name: "p", description: "parallel probe" };
248            let rs = parallel([
249                #{ prompt: "a", label: "a" },
250                #{ prompt: "b", label: "b" },
251            ]);
252            complete(#{ n: rs.len() });
253            "#,
254            None,
255        )
256        .expect("parallel validate");
257        assert!(report.outcome_ok);
258    }
259}