1use std::{
2 collections::{BTreeMap, BTreeSet},
3 path::Path,
4 sync::Arc,
5 time::Duration,
6};
7
8use serde_json::json;
9use tokio_util::sync::CancellationToken;
10
11use crate::{
12 broker::{self, now},
13 cache,
14 config::{DependencyGates, ProfileKind, read_workspace_config},
15 graph::{EvalStatus, Evidence, Graph},
16 project::selection::{ProfileSelection, Selection, select_profiles},
17 remote::Session,
18 store::{self, ExecutionOptions, Receipts, Request, Run, RunView},
19 workspace,
20};
21
22pub const DEFAULT_HUMAN_WAIT: Duration = Duration::from_secs(600);
25pub const DEFAULT_JOBS: usize = 4;
28
29#[derive(Debug, Clone)]
30pub struct VerifyOptions {
31 pub jobs: usize,
32 pub fingerprint_jobs: Option<usize>,
35 pub announce_run: bool,
37 pub max_executions: Option<u64>,
38 pub wait_timeout: Duration,
40 pub reuse_only: BTreeSet<ProfileKind>,
43 pub profile: Option<ProfileSelection>,
44 pub recursive: bool,
45 pub force: bool,
47 pub ignore_gates: Option<bool>,
49}
50
51impl Default for VerifyOptions {
52 fn default() -> Self {
53 Self {
54 jobs: DEFAULT_JOBS,
55 fingerprint_jobs: None,
56 announce_run: false,
57 max_executions: None,
58 wait_timeout: DEFAULT_HUMAN_WAIT,
59 reuse_only: BTreeSet::new(),
60 profile: None,
61 recursive: false,
62 force: false,
63 ignore_gates: None,
64 }
65 }
66}
67
68pub async fn verify(
70 repo: &Path,
71 state_dir: Option<&Path>,
72 selection: &Selection,
73 options: &VerifyOptions,
74 cancellation: CancellationToken,
75) -> Result<RunView, String> {
76 if options.jobs == 0 {
77 return Err("jobs must be at least 1.".into());
78 }
79 let parallelism = super::fingerprint_parallelism(options)?;
80 let wait_timeout_ms = u32::try_from(options.wait_timeout.as_millis())
81 .ok()
82 .filter(|ms| (1..=crate::config::validation::MAX_TIMEOUT_MS).contains(&u64::from(*ms)))
83 .ok_or("wait timeout must be between 1 and 2147483647 ms.")?;
84 let config = read_workspace_config(repo).map_err(|e| e.to_string())?;
85 let config = Arc::new(select_profiles(
86 config,
87 selection,
88 options.profile.as_ref(),
89 options.recursive,
90 )?);
91 let ignore_gates = options.ignore_gates.unwrap_or_else(|| {
92 config
93 .artifacts
94 .values()
95 .find(|artifact| artifact.path.as_os_str().is_empty())
96 .and_then(|artifact| artifact.review_policy.as_ref())
97 .is_some_and(|policy| matches!(policy.dependency_gates, Some(DependencyGates::Ignore)))
98 });
99 let graph = Graph::new(&config).map_err(|e| e.to_string())?;
100 let selected = selection.resolve(&config)?;
101 let selected_ids: BTreeSet<_> = selected.evals.iter().map(|eval| &eval.id).collect();
102 let required: BTreeSet<_> = graph
103 .dependency_closure(&selected.roots)
104 .map_err(|e| e.to_string())?
105 .into_iter()
106 .collect();
107 let evals = selection.included_evals(&config, options.recursive)?;
108 let definitions =
109 store::definitions::Definitions::from_view(&crate::query::graph(&config, selection)?)
110 .map_err(|error| error.to_string())?;
111 let state = store::state_dir(state_dir)?;
112 let limits = crate::limits::Limits::read(&state)?;
113 let receipts = Receipts::open(&state, &config.root).await?;
114 let runs = workspace::prepare_directory(&state.join("runs"), &config.root)
115 .map_err(|e| e.to_string())?;
116 let fingerprints = cache::prepare(
117 &config,
118 cache::fingerprint_targets(&config, &required),
119 &runs,
120 ¶llelism,
121 cancellation.clone(),
122 )
123 .await?;
124 if cancellation.is_cancelled() {
125 return Err("Project preparation was cancelled.".into());
126 }
127 let keys = cache::eval_keys(&config, &fingerprints);
128 let remote = Session::open(Some(&state), Some(&config.root))?;
132 if remote.is_some()
134 && let Some(variable) = crate::llm::active_test_endpoint()
135 {
136 return Err(format!(
137 "{variable} points Agent reviews at a local test endpoint; set ARTIFACTIZE_REMOTE=off so their results stay out of the review store."
138 ));
139 }
140 if let Some(remote) = &remote
141 && !options.force
142 {
143 remote
144 .refresh(
145 &receipts,
146 keys.values().map(|key| key.value.clone()).collect(),
147 )
148 .await?;
149 }
150 let directory = tempfile::Builder::new()
151 .prefix("run-")
152 .tempdir_in(runs)
153 .map_err(|e| e.to_string())?;
154 let id = directory
155 .path()
156 .file_name()
157 .unwrap()
158 .to_str()
159 .expect("generated run directory names are ASCII")
160 .to_owned();
161 let _ = directory.keep();
162 let mut run = Run {
163 id: id.parse()?,
164 repo_path: config.root.clone(),
165 repository: crate::repository::identify(&config.root),
166 state_dir: state,
167 state: crate::store::RunState::Running,
168 created_at: now(),
169 selection: selection.clone(),
170 profile: options.profile.clone(),
171 definitions,
172 jobs: options.jobs,
173 fingerprint_jobs: Some(parallelism.limit()),
174 max_executions: options.max_executions,
175 executions_started: 0,
176 wait_timeout_ms: Some(Duration::from_millis(u64::from(wait_timeout_ms))),
177 wait_timed_out: false,
178 reuse_only: options.reuse_only.clone(),
179 recursive: options.recursive,
180 force: options.force,
181 ignore_gates,
182 validation: Default::default(),
183 stopped_backends: Vec::new(),
184 evidence: BTreeMap::new(),
185 };
186 let mut requests: Vec<_> = evals
187 .iter()
188 .enumerate()
189 .map(|(ordinal, eval)| Request {
190 state: crate::store::RequestState::pending(crate::types::RequestStatus::Queued, None)
191 .expect("queued state"),
192 id: format!("{}-{}", run.id, ordinal + 1)
193 .parse()
194 .expect("generated request id is a safe segment"),
195 run_id: run.id.clone(),
196 eval_id: eval.id.clone(),
197 target: eval.target.clone(),
198 title: eval.declaration.title.clone(),
199 profile: (eval.declaration.profile()).into(),
200 requested_profile: (eval.declaration.profile()).into(),
201 eval_def_hash: cache::eval_definition_hash(&eval.declaration),
202 options: ExecutionOptions::new(eval.declaration.profile(), eval.variant.as_ref()),
203 execution_id: None,
204 provenance: None,
205 usage: None,
206 reused_usage: None,
207 joined: false,
208 producer: None,
209 reviewer: None,
210 origin: None,
211 session_id: None,
212 session: None,
213 human_definition: None,
214 payload: eval.declaration.payload().into(),
215 references: eval.references.clone(),
216 deps: eval.deps.clone(),
217 force: options.force
218 && selected_ids.contains(&eval.id)
219 && !matches!(
220 eval.declaration.profile(),
221 crate::config::Profile::Dependency { .. }
222 ),
223 fingerprint: fingerprints
224 .get(&eval.target)
225 .map(|fingerprint| fingerprint.value.clone()),
226 key: keys.get(&eval.id).map(|key| key.value.clone()),
227 fingerprints: keys
228 .get(&eval.id)
229 .map(|key| key.fingerprints.clone())
230 .unwrap_or_default(),
231 created_at: run.created_at,
232 started_at: None,
233 cwd: config.root.join(config.artifacts[&eval.target].folder()),
234 run_dir: None,
235 argv: None,
236 child: None,
237 blocked_reason: None,
238 queue: None,
239 blocked_by: Vec::new(),
240 })
241 .collect();
242 receipts.create_run(&run, &requests).await?;
243 if options.announce_run {
244 use std::io::Write;
245 let _ = writeln!(
246 std::io::stderr().lock(),
247 "Started {} (follow: artifactize monitor)",
248 run.id
249 );
250 }
251 let mut evidence = match broker::schedule(
252 config.clone(),
253 &graph,
254 &fingerprints,
255 &keys,
256 ¶llelism,
257 &limits,
258 &mut run,
259 &mut requests,
260 &receipts,
261 remote,
262 cancellation.clone(),
263 )
264 .await
265 {
266 Ok(evidence) => evidence,
267 Err(error) => {
268 run.state = crate::store::RunState::Error {
270 at: now(),
271 error: Some(error.clone()),
272 };
273 let _ = receipts.save_run(&run).await;
274 return Err(error);
275 }
276 };
277 let evaluation = graph.evaluate_with_policy(&evidence, ignore_gates);
278 for request in &mut requests {
279 if request.profile.kind() == ProfileKind::Dependency {
280 if request.status() == crate::types::RequestStatus::Error {
281 continue;
282 }
283 broker::derive(request, &evaluation.evals[request.eval_id.as_str()], now());
284 continue;
285 }
286 if evidence.contains_key(request.eval_id.as_str())
287 || request.status() == crate::types::RequestStatus::WaitingHuman
288 {
289 continue;
290 }
291 let eval = &evaluation.evals[request.eval_id.as_str()];
292 if cancellation.is_cancelled() {
293 request.state = crate::store::RequestState::failed(
294 "Run was cancelled.".into(),
295 Some(crate::types::FailureCode::Cancelled),
296 now(),
297 );
298 evidence.insert(request.eval_id.clone(), Evidence::OperationalError);
299 } else if request.status() == crate::types::RequestStatus::BudgetExhausted
300 && eval.can_execute()
301 {
302 continue;
303 } else {
304 request.state =
305 crate::store::RequestState::pending(status(eval.status), request.completed_at())
306 .expect("non-executed request state");
307 request.queue = None;
308 request.blocked_reason = Some(format!(
309 "{}: {}",
310 if eval.status == EvalStatus::Blocked {
311 "Dependency verdict RED"
312 } else {
313 "Waiting for current GREEN dependency evidence"
314 },
315 eval.unmet_gates
316 .iter()
317 .map(ToString::to_string)
318 .collect::<Vec<_>>()
319 .join(", ")
320 ));
321 }
322 }
323 let evaluation = graph.evaluate_with_policy(&evidence, ignore_gates);
324 let required_evals: Vec<_> = evaluation
325 .evals
326 .iter()
327 .filter(|(id, _)| required.contains(&graph.eval_target(id).unwrap()))
328 .collect();
329 let satisfied = required
330 .iter()
331 .all(|id| evaluation.artifacts[id.as_str()].satisfied);
332 let budget_exhausted = requests
333 .iter()
334 .any(|request| request.status() == crate::types::RequestStatus::BudgetExhausted);
335 let not_reused = evidence
337 .values()
338 .filter(|evidence| matches!(evidence, Evidence::Stale))
339 .count();
340 let at = now();
341 run.state = if cancellation.is_cancelled() {
342 crate::store::RunState::Error {
343 at,
344 error: Some("Run was cancelled.".into()),
345 }
346 } else if run.wait_timed_out {
347 crate::store::RunState::Incomplete {
348 at,
349 error: Some(
350 "Human wait timed out; pending requests remain available for submission.".into(),
351 ),
352 }
353 } else if budget_exhausted {
354 crate::store::RunState::Incomplete {
355 at,
356 error: Some(broker::budget_reason(&run)),
357 }
358 } else if not_reused > 0 {
359 crate::store::RunState::Incomplete {
360 at,
361 error: Some(format!(
362 "{not_reused} eval{} had no result to reuse and {} not executed (--reuse-only).",
363 if not_reused == 1 { "" } else { "s" },
364 if not_reused == 1 { "was" } else { "were" }
365 )),
366 }
367 } else if required_evals
368 .iter()
369 .any(|(_, c)| c.status == EvalStatus::Error)
370 {
371 crate::store::RunState::Error { at, error: None }
372 } else if required_evals
373 .iter()
374 .any(|(_, c)| c.status == EvalStatus::Red)
375 {
376 crate::store::RunState::Red { at }
377 } else if satisfied {
378 crate::store::RunState::Green { at }
379 } else {
380 crate::store::RunState::Incomplete { at, error: None }
381 };
382 run.validation = serde_json::from_value(json!({
383 "selection":run.selection,
384 "recursive":run.recursive,
385 "force":run.force,
386 "ignoreGates":run.ignore_gates,
387 "selectedEvalIds":selected.evals.iter().map(|eval| &eval.id).collect::<Vec<_>>(),
388 "includedEvalIds":evals.iter().map(|eval| &eval.id).collect::<Vec<_>>(),
389 "satisfied":satisfied && !cancellation.is_cancelled(),
390 "obligations":evaluation
391 .obligations
392 .iter()
393 .filter(|id| required.contains(*id))
394 .collect::<Vec<_>>(),
395 "artifacts":required
396 .iter()
397 .map(|id| {
398 let a = &evaluation.artifacts[id.as_str()];
399 let mut artifact = json!({
400 "id":id,
401 "status":format!("{:?}", a.status).to_uppercase(),
402 "passed":a.passed,
403 "total":a.total,
404 "satisfied":a.satisfied,
405 });
406 if let Some(fingerprint) = fingerprints.get(id) {
407 artifact["fingerprintKind"] = json!(if fingerprint.manifest.is_some() {
408 "artifactsum"
409 } else {
410 "script"
411 });
412 artifact["fingerprint"] = json!(fingerprint.value);
413 }
414 artifact
415 })
416 .collect::<Vec<_>>(),
417 "evals":required_evals
418 .iter()
419 .map(|(id, eval)| {
420 json!({
421 "id":id,
422 "status":status(eval.status),
423 "blockedBy":eval.blocked_by,
424 })
425 })
426 .collect::<Vec<_>>(),
427 }))
428 .expect("generated validation snapshot");
429 receipts.finish(&run, &requests).await?;
430 let state = run.state_dir.clone();
432 let bounds = limits.agent_sessions();
433 let collected = crate::task::joined(
434 tokio::task::spawn_blocking(move || crate::agent::session::collect(&state, bounds, false))
435 .await,
436 )
437 .map_err(|e| e.to_string())
438 .and_then(|collected| collected);
439 if let Err(error) = collected {
440 use std::io::Write;
441 let _ = writeln!(
442 std::io::stderr().lock(),
443 "Agent session collection failed: {error}"
444 );
445 }
446 store::read_run(&run.state_dir, &run.id).await
447}
448
449pub(crate) fn status(status: EvalStatus) -> crate::types::RequestStatus {
450 match status {
451 EvalStatus::Green => crate::types::RequestStatus::Green,
452 EvalStatus::Red => crate::types::RequestStatus::Red,
453 EvalStatus::Error => crate::types::RequestStatus::Error,
454 EvalStatus::Stale => crate::types::RequestStatus::Stale,
455 EvalStatus::Unreviewed => crate::types::RequestStatus::Unreviewed,
456 EvalStatus::Wait => crate::types::RequestStatus::WaitDependency,
457 EvalStatus::Blocked => crate::types::RequestStatus::Blocked,
458 }
459}