1use 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#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct ValidationReport {
21 pub name: String,
23 pub phases: usize,
25 pub outcome_ok: bool,
27 pub outcome_summary: String,
29}
30
31#[derive(Debug, thiserror::Error)]
33pub enum ValidationError {
34 #[error("meta: {0}")]
36 Meta(#[from] MetaError),
37 #[error("dry-run: {0}")]
39 Run(String),
40}
41
42#[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
59pub 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
71pub 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 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
138fn 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}