1use std::{
2 collections::{BTreeMap, BTreeSet},
3 path::Path,
4 sync::Arc,
5};
6
7use tokio_util::sync::CancellationToken;
8
9use crate::{
10 broker::{self, now},
11 cache,
12 config::{ProfileKind, read_workspace_config},
13 graph::{EvalStatus, Evidence, Graph},
14 project::selection::{ProfileSelection, Selection, select_profiles},
15 remote::Session,
16 store::{self, ExecutionOptions, Receipts, Request, Run, RunView},
17 workspace,
18};
19
20#[derive(Debug, Clone, Default)]
21pub struct VerifyOptions {
22 pub modes: crate::runs::RunModes,
23 pub jobs: Option<usize>,
25 pub fingerprint_jobs: Option<usize>,
28 pub max_executions: Option<u64>,
29 pub reuse_only: BTreeSet<ProfileKind>,
32 pub profile: Option<ProfileSelection>,
33 pub recursive: bool,
34 pub force: bool,
36 pub comment: Option<crate::types::RunComment>,
38}
39
40pub async fn verify(
42 repo: &Path,
43 state_dir: Option<&Path>,
44 selection: &Selection,
45 options: &VerifyOptions,
46 cancellation: CancellationToken,
47) -> Result<RunView, String> {
48 super::fingerprint_parallelism(options)?;
49 let context = crate::runs::Context::discover(repo, state_dir)?;
50 let observation = crate::runs::start(&context, selection, options, None).await?;
51 crate::runs::wait(&context, &observation.run, None, cancellation).await?;
52 crate::runs::view(&context, &observation.run).await
53}
54pub(crate) async fn reserve(
56 repo: &Path,
57 state_dir: Option<&Path>,
58 selection: &Selection,
59 options: &VerifyOptions,
60 request_key: Option<&crate::types::RequestKey>,
61) -> Result<(crate::types::RunId, bool), String> {
62 if options.jobs == Some(0) {
63 return Err("jobs must be at least 1.".into());
64 }
65 let parallelism = super::fingerprint_parallelism(options)?;
66 let config = read_workspace_config(repo).map_err(|e| e.to_string())?;
67 let config = Arc::new(select_profiles(
68 config,
69 selection,
70 options.profile.as_ref(),
71 options.recursive,
72 )?);
73 Graph::new(&config).map_err(|e| e.to_string())?;
74 let selected = selection.resolve(&config)?;
75 let selected_ids: BTreeSet<_> = selected.evals.iter().map(|eval| &eval.id).collect();
76 let evals = selection.included_evals(&config, options.recursive)?;
77 let definitions =
78 store::definitions::Definitions::from_view(&crate::query::graph(&config, selection)?)
79 .map_err(|error| error.to_string())?;
80 let state = store::state_dir(state_dir)?;
81 let _limits = crate::limits::Limits::read(&state)?;
82 let receipts = Receipts::open(&state, &config.root).await?;
83 let result = async {
84 let runs = workspace::prepare_directory(&state.join("runs"), &config.root)
85 .map_err(|e| e.to_string())?;
86 let directory =
87 crate::platform::private_tempdir_in("run-", &runs).map_err(|e| e.to_string())?;
88 let id = directory
89 .path()
90 .file_name()
91 .unwrap()
92 .to_str()
93 .expect("generated run directory names are ASCII")
94 .to_owned();
95 let _ = directory.keep();
96 let run = Run {
97 modes: options.modes.clone(),
98 control: Some(crate::runs::Control::starting()?),
99 request_key: request_key.cloned(),
100 comment: options.comment.clone(),
101 id: id.parse()?,
102 repo_path: config.root.clone(),
103 repository: crate::repository::identify(&config.root),
104 state_dir: state,
105 state: crate::store::RunState::Running,
106 created_at: now(),
107 selection: selection.clone(),
108 profile: options.profile.clone(),
109 definitions,
110 jobs: options.jobs,
111 fingerprint_jobs: Some(parallelism.limit()),
112 max_executions: options.max_executions,
113 executions_started: 0,
114 wait_timeout_ms: None,
115 wait_timed_out: false,
116 reuse_only: options.reuse_only.clone(),
117 recursive: options.recursive,
118 force: options.force,
119 validation: Default::default(),
120 stopped_backends: Vec::new(),
121 evidence: BTreeMap::new(),
122 };
123 let requests: Vec<_> = evals
124 .iter()
125 .enumerate()
126 .map(|(ordinal, eval)| Request {
127 state: crate::store::RequestState::pending(
128 crate::types::RequestStatus::Queued,
129 None,
130 )
131 .expect("queued state"),
132 id: format!("{}-{}", run.id, ordinal + 1)
133 .parse()
134 .expect("generated request id is a safe segment"),
135 run_id: run.id.clone(),
136 eval_id: eval.id.clone(),
137 target: eval.target.clone(),
138 title: eval.declaration.title.clone(),
139 profile: (eval.declaration.profile()).into(),
140 requested_profile: (eval.declaration.profile()).into(),
141 eval_def_hash: cache::eval_definition_hash(&eval.declaration),
142 options: ExecutionOptions {
143 mutex: eval.declaration.mutex.clone(),
144 ..ExecutionOptions::new(eval.declaration.profile(), eval.variant.as_ref())
145 },
146 execution_id: None,
147 provenance: None,
148 usage: None,
149 reused_usage: None,
150 joined: false,
151 producer: None,
152 reviewer: None,
153 origin: None,
154 session_id: None,
155 session: None,
156 human_definition: None,
157 payload: eval.declaration.payload().into(),
158 references: eval.references.clone(),
159 deps: eval.deps.clone(),
160 force: options.force && selected_ids.contains(&eval.id),
161 fingerprint: None,
162 key: None,
163 fingerprints: Default::default(),
164 created_at: run.created_at,
165 started_at: None,
166 cwd: config.root.join(config.artifacts[&eval.target].folder()),
167 run_dir: None,
168 argv: None,
169 child: None,
170 blocked_reason: None,
171 queue: None,
172 blocked_by: Vec::new(),
173 })
174 .collect();
175 let id = receipts.reserve(&run, &requests).await?;
176 let accepted = id == run.id;
177 if !accepted {
178 let _ = std::fs::remove_dir(run.state_dir.join("runs").join(run.id.as_str()));
179 }
180 Ok((id, accepted))
181 }
182 .await;
183 let closed = receipts.close().await;
184 result.and_then(|reserved| closed.map(|()| reserved))
185}
186#[derive(Debug, thiserror::Error)]
188pub(crate) enum Error {
189 #[error("INPUT_CHANGED: {0}")]
190 InputChanged(String),
191 #[error("{0}")]
192 Preparation(String),
193}
194impl Error {
195 pub(crate) fn code(&self) -> crate::types::FailureCode {
196 match self {
197 Self::InputChanged(_) => crate::types::FailureCode::InputChanged,
198 Self::Preparation(_) => crate::types::FailureCode::PreparationFailed,
199 }
200 }
201}
202impl From<String> for Error {
203 fn from(message: String) -> Self {
204 Self::Preparation(message)
205 }
206}
207impl From<&str> for Error {
208 fn from(message: &str) -> Self {
209 Self::Preparation(message.into())
210 }
211}
212
213pub(crate) async fn execute(
215 mut run: Run,
216 receipts: &Receipts,
217 cancellation: CancellationToken,
218) -> Result<RunView, Error> {
219 let selection = &run.selection.clone();
220 let options = &VerifyOptions {
221 modes: run.modes.clone(),
222 jobs: run.jobs,
223 fingerprint_jobs: run.fingerprint_jobs,
224 max_executions: run.max_executions,
225 reuse_only: run.reuse_only.clone(),
226 profile: run.profile.clone(),
227 recursive: run.recursive,
228 force: run.force,
229 comment: run.comment.clone(),
230 };
231 let repo = &run.repo_path;
232 let state_dir = Some(run.state_dir.as_path());
233 if options.jobs == Some(0) {
234 return Err("jobs must be at least 1.".into());
235 }
236 let parallelism = super::fingerprint_parallelism(options)?;
237 let config = crate::config::read_workspace_declarations(repo)
238 .map_err(|e| Error::InputChanged(e.to_string()))?;
239 let config = Arc::new(
240 select_profiles(
241 config,
242 selection,
243 options.profile.as_ref(),
244 options.recursive,
245 )
246 .map_err(Error::InputChanged)?,
247 );
248 let graph = Graph::new(&config).map_err(|e| Error::InputChanged(e.to_string()))?;
249 let selected = selection
250 .resolve(&config)
251 .map_err(|e| Error::InputChanged(e.to_string()))?;
252 let required: BTreeSet<_> = graph
253 .dependency_closure(&selected.roots)
254 .map_err(|e| e.to_string())?
255 .into_iter()
256 .collect();
257 let evals = selection.included_evals(&config, options.recursive)?;
258 let definitions =
259 store::definitions::Definitions::from_view(&crate::query::graph(&config, selection)?)
260 .map_err(|error| error.to_string())?;
261 let state = store::state_dir(state_dir)?;
262 let limits = crate::limits::Limits::read(&state)?;
263 let runs = workspace::prepare_directory(&state.join("runs"), &config.root)
264 .map_err(|e| e.to_string())?;
265 if serde_json::to_string(&definitions).map_err(|e| e.to_string())?
266 != serde_json::to_string(&run.definitions).map_err(|e| e.to_string())?
267 {
268 return Err(Error::InputChanged(
269 "declarations changed after acceptance.".into(),
270 ));
271 }
272 let fingerprints = cache::prepare(
273 &config,
274 cache::fingerprint_targets(&config, &required),
275 &runs,
276 ¶llelism,
277 cancellation.clone(),
278 )
279 .await?;
280 if cancellation.is_cancelled() {
281 return Err("Project preparation was cancelled.".into());
282 }
283 let keys = cache::eval_keys(&config, &fingerprints);
284 let remote = Session::open(Some(&state), Some(&config.root))?;
288 if remote.is_some()
290 && let Some(variable) = crate::llm::active_test_endpoint()
291 {
292 return Err(format!(
293 "{variable} points Agent reviews at a local test endpoint; set ARTIFACTIZE_REMOTE=off so their results stay out of the review store."
294 ).into());
295 }
296 if let Some(remote) = &remote
297 && !options.force
298 {
299 remote
300 .refresh(
301 receipts,
302 keys.values().map(|key| key.value.clone()).collect(),
303 )
304 .await?;
305 }
306 unchanged_declarations(repo, selection, options, &run.definitions)?;
307 let mut requests: Vec<_> = store::read_run(&state, &run.id).await?.requests;
308 for request in &mut requests {
309 request.fingerprint = fingerprints
310 .get(&request.target)
311 .map(|fingerprint| fingerprint.value.clone());
312 request.key = keys.get(&request.eval_id).map(|key| key.value.clone());
313 request.fingerprints = keys
314 .get(&request.eval_id)
315 .map(|key| key.fingerprints.clone())
316 .unwrap_or_default();
317 receipts.save_request(request).await?;
318 }
319 run.control.as_mut().expect("owned Run").activity = crate::runs::Activity::Executing;
320 receipts.save_run(&run).await?;
321 let mut evidence = match broker::schedule(
322 config.clone(),
323 &graph,
324 &fingerprints,
325 &keys,
326 ¶llelism,
327 &limits,
328 &mut run,
329 &mut requests,
330 receipts,
331 remote.clone(),
332 cancellation.clone(),
333 )
334 .await
335 {
336 Ok(evidence) => evidence,
337 Err(error) => {
338 return Err(error.into());
339 }
340 };
341 let evaluation = graph.evaluate_for_run(&evidence, run.modes.speculate);
342 for request in &mut requests {
343 let withdrawn = request.status().is_human_wait() && cancellation.is_cancelled();
345 if (evidence.contains_key(request.eval_id.as_str()) && !withdrawn)
346 || (request.status().is_human_wait() && !withdrawn)
347 {
348 continue;
349 }
350 let eval = &evaluation.evals[request.eval_id.as_str()];
351 if request.status() == crate::types::RequestStatus::Blocked && !cancellation.is_cancelled()
352 {
353 if eval.status == EvalStatus::Blocked {
355 request.blocked_by = eval.blocked_by.iter().copied().map(Into::into).collect();
356 request.blocked_reason = Some(gate_reason(eval));
357 }
358 continue;
359 }
360 if cancellation.is_cancelled() {
361 request.state = crate::store::RequestState::failed(
362 "Run was cancelled.".into(),
363 Some(crate::types::FailureCode::Cancelled),
364 now(),
365 );
366 evidence.insert(request.eval_id.clone(), Evidence::OperationalError);
367 } else if request.status() == crate::types::RequestStatus::BudgetExhausted
368 && eval.can_execute()
369 {
370 continue;
371 } else {
372 request.state =
373 crate::store::RequestState::pending(status(eval.status), request.completed_at())
374 .expect("non-executed request state");
375 request.queue = None;
376 request.blocked_reason = Some(gate_reason(eval));
377 }
378 }
379 let evaluation = graph.evaluate_for_run(&evidence, run.modes.speculate);
380 let required_evals: Vec<_> = evaluation
381 .evals
382 .iter()
383 .filter(|(id, _)| required.contains(&graph.eval_target(id).unwrap()))
384 .collect();
385 let satisfied = required
386 .iter()
387 .all(|id| evaluation.artifacts[id.as_str()].satisfied);
388 let budget_exhausted = requests
389 .iter()
390 .any(|request| request.status() == crate::types::RequestStatus::BudgetExhausted);
391 let not_reused = evidence
393 .values()
394 .filter(|evidence| matches!(evidence, Evidence::Stale))
395 .count();
396 let at = now();
397 run.state = if cancellation.is_cancelled() {
398 crate::store::RunState::Error {
399 at,
400 error: Some("Run was cancelled.".into()),
401 }
402 } else if budget_exhausted {
403 crate::store::RunState::Incomplete {
404 at,
405 error: Some(broker::budget_reason(&run)),
406 }
407 } else if not_reused > 0 {
408 crate::store::RunState::Incomplete {
409 at,
410 error: Some(format!(
411 "{not_reused} eval{} had no result to reuse and {} not executed (--reuse-only).",
412 if not_reused == 1 { "" } else { "s" },
413 if not_reused == 1 { "was" } else { "were" }
414 )),
415 }
416 } else if required_evals
417 .iter()
418 .any(|(_, c)| c.status == EvalStatus::Error)
419 {
420 crate::store::RunState::Error { at, error: None }
421 } else if required_evals
422 .iter()
423 .any(|(_, c)| c.status == EvalStatus::Red)
424 {
425 crate::store::RunState::Red { at }
426 } else if required_evals
427 .iter()
428 .any(|(_, eval)| eval.status == EvalStatus::Deferred)
429 && required_evals
430 .iter()
431 .all(|(_, eval)| eval.status.passes_gate())
432 && required.iter().all(|id| {
433 graph.artifacts()[id.as_str()].basis || !graph.artifacts()[id.as_str()].evals.is_empty()
434 })
435 {
436 crate::store::RunState::Yellow { at }
437 } else if satisfied {
438 crate::store::RunState::Green { at }
439 } else {
440 crate::store::RunState::Incomplete { at, error: None }
441 };
442 if cancellation.is_cancelled() {
443 run.control.as_mut().expect("owned Run").failure_code =
444 Some(crate::types::FailureCode::Cancelled);
445 }
446 use crate::config::Field;
447 run.validation = store::Validation(Some(store::ValidationSnapshot {
448 selection: Field::Value(store::definitions::SavedSelection {
449 known: run.selection.clone(),
450 extra: Default::default(),
451 }),
452 recursive: Field::Value(run.recursive),
453 force: Field::Value(run.force),
454 selected_eval_ids: Field::Value(
455 selected.evals.iter().map(|eval| eval.id.clone()).collect(),
456 ),
457 included_eval_ids: Field::Value(evals.iter().map(|eval| eval.id.clone()).collect()),
458 satisfied: Field::Value(satisfied && !cancellation.is_cancelled()),
459 obligations: Field::Value(
460 evaluation
461 .obligations
462 .iter()
463 .filter(|id| required.contains(*id))
464 .map(|id| (*id).clone())
465 .collect(),
466 ),
467 artifacts: Field::Value(
468 required
469 .iter()
470 .map(|id| {
471 let artifact = &evaluation.artifacts[id.as_str()];
472 let (fingerprint_kind, fingerprint) = fingerprints.get(id).map_or(
473 (Field::Missing, Field::Missing),
474 |fingerprint| {
475 (
476 config.artifacts[*id]
477 .fingerprint
478 .as_ref()
479 .map(crate::config::Fingerprint::kind)
480 .into(),
481 Field::Value(fingerprint.value.clone()),
482 )
483 },
484 );
485 store::ArtifactValidation {
486 id: (*id).clone(),
487 status: Field::Value(artifact.status.into()),
488 passed: Field::Value(artifact.passed as u64),
489 total: Field::Value(artifact.total as u64),
490 satisfied: Field::Value(artifact.satisfied),
491 fingerprint_kind,
492 fingerprint,
493 extra: Default::default(),
494 }
495 })
496 .collect(),
497 ),
498 evals: Field::Value(
499 required_evals
500 .iter()
501 .map(|(id, eval)| store::EvalValidation {
502 id: (*id).clone(),
503 status: Field::Value(status(eval.status)),
504 blocked_by: Field::Value(
505 eval.blocked_by.iter().copied().map(Into::into).collect(),
506 ),
507 extra: Default::default(),
508 })
509 .collect(),
510 ),
511 extra: Default::default(),
512 }));
513 if cancellation.is_cancelled() {
514 receipts
515 .retire_worker(&run.id, crate::types::FailureCode::Cancelled)
516 .await?;
517 }
518 if run.modes.speculate
519 && let Some(remote) = remote
520 {
521 run.control.as_mut().expect("owned Run").activity = crate::runs::Activity::Publishing;
524 let mut publishing = run.clone();
525 publishing.state = crate::store::RunState::Running;
526 receipts.save_run(&publishing).await?;
527 for request in &requests {
528 remote.publish_request(receipts, request).await?;
529 }
530 }
531 if let Err(error) = store::history::collect(&run.state_dir).await {
534 use std::io::Write;
535 let _ = writeln!(std::io::stderr().lock(), "Cache GC failed: {error}");
536 }
537 receipts.finish(&run, &requests).await?;
538 let state = run.state_dir.clone();
540 let bounds = limits.agent_sessions();
541 let collected = crate::task::joined(
542 tokio::task::spawn_blocking(move || crate::agent::session::collect(&state, bounds, false))
543 .await,
544 )
545 .map_err(|e| e.to_string())
546 .and_then(|collected| collected);
547 if let Err(error) = collected {
548 use std::io::Write;
549 let _ = writeln!(
550 std::io::stderr().lock(),
551 "Agent session collection failed: {error}"
552 );
553 }
554 store::read_run(&run.state_dir, &run.id)
555 .await
556 .map_err(Into::into)
557}
558
559pub(crate) fn status(status: EvalStatus) -> crate::types::RequestStatus {
560 match status {
561 EvalStatus::Deferred => crate::types::RequestStatus::Deferred,
562 EvalStatus::Provisional => crate::types::RequestStatus::Provisional,
563 EvalStatus::Green => crate::types::RequestStatus::Green,
564 EvalStatus::Red => crate::types::RequestStatus::Red,
565 EvalStatus::Error => crate::types::RequestStatus::Error,
566 EvalStatus::Stale => crate::types::RequestStatus::Stale,
567 EvalStatus::Unreviewed => crate::types::RequestStatus::Unreviewed,
568 EvalStatus::Wait => crate::types::RequestStatus::WaitDependency,
569 EvalStatus::Blocked => crate::types::RequestStatus::Blocked,
570 }
571}
572
573pub(crate) fn gate_reason(eval: &crate::graph::EvalEvaluation<'_>) -> String {
575 eval.blocked_reason().unwrap_or_else(|| {
576 format!(
577 "Waiting for current GREEN dependency evidence: {}",
578 eval.unmet_gates
579 .iter()
580 .map(ToString::to_string)
581 .collect::<Vec<_>>()
582 .join(", ")
583 )
584 })
585}
586
587fn unchanged_declarations(
590 repo: &Path,
591 selection: &Selection,
592 options: &VerifyOptions,
593 saved: &store::definitions::Definitions,
594) -> Result<(), Error> {
595 let check = || -> Result<(), String> {
596 let config = crate::config::read_workspace_declarations(repo).map_err(|e| e.to_string())?;
597 let config = select_profiles(
598 config,
599 selection,
600 options.profile.as_ref(),
601 options.recursive,
602 )?;
603 let current =
604 store::definitions::Definitions::from_view(&crate::query::graph(&config, selection)?)
605 .map_err(|e| e.to_string())?;
606 if serde_json::to_string(¤t).map_err(|e| e.to_string())?
607 != serde_json::to_string(saved).map_err(|e| e.to_string())?
608 {
609 return Err("declarations changed after acceptance.".into());
610 }
611 Ok(())
612 };
613 check().map_err(Error::InputChanged)
614}
615
616#[cfg(test)]
617mod tests {
618 use super::Error;
619 use crate::types::FailureCode;
620
621 #[test]
622 fn failure_identity_comes_from_the_variant_not_owner_diagnostic_text() {
623 let preparation = Error::Preparation("INPUT_CHANGED: text from a command".into());
624 assert_eq!(preparation.code(), FailureCode::PreparationFailed);
625 let changed = Error::InputChanged("declarations changed after acceptance.".into());
626 assert_eq!(changed.code(), FailureCode::InputChanged);
627 assert_eq!(
628 changed.to_string(),
629 "INPUT_CHANGED: declarations changed after acceptance."
630 );
631 }
632}