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