Skip to main content

artifactize_core/project/
verify.rs

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    /// Optional per-Run concurrency cap; absent admits every READY eval.
24    pub jobs: Option<usize>,
25    /// Fingerprints computed at once, in preparation and end-of-review rechecks; `None` uses
26    /// the available CPUs.
27    pub fingerprint_jobs: Option<usize>,
28    pub max_executions: Option<u64>,
29    /// Kinds whose evals only reuse a result, from the cache or the review store; one with
30    /// nothing to reuse is not executed, and the Run ends INCOMPLETE.
31    pub reuse_only: BTreeSet<ProfileKind>,
32    pub profile: Option<ProfileSelection>,
33    pub recursive: bool,
34    /// Force only explicitly selected Evals, never recursive dependencies.
35    pub force: bool,
36    /// What the requester is working on, shown with the Run; it changes nothing it does.
37    pub comment: Option<crate::types::RunComment>,
38}
39
40/// Submit to artifactize and observe settlement. Cancelling this future/token only stops watching.
41pub 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}
54/// Validate declarations and reserve initial requests; fingerprints and remote I/O follow admission.
55pub(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/// Keep the failure kind typed through execution; prefixes belong only to display.
187#[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
213/// Execute exactly the admitted Run. Never reserve a second Run or substitute changed declarations.
214pub(crate) async fn execute(
215    mut run: Run,
216    receipts: &Receipts,
217    cancellation: CancellationToken,
218) -> Result<(), 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        &parallelism,
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    // The store is asked once for every key, and a record that completed after the local
285    // latest is mirrored into the local history before the Run claims anything. A forced Run
286    // reads nothing from the store, but publishes its results like any other.
287    let remote = Session::open(Some(&state), Some(&config.root))?;
288    // Results from a fake provider must never reach the shared review store.
289    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 = store::read_run_requests_only(&state, &run.id).await?;
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        &parallelism,
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        // A cancelled Run withdraws its waiting sign-offs too, deferred ones included.
344        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            // A block saved mid-Run named the causes known then; the settled Run names all.
354            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    // Only a --reuse-only eval that had nothing to reuse leaves stale evidence in a Run.
392    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        // A blocked result cannot be retracted from a team store. Publish survivors only
522        // after scheduling/withdrawal finishes, while the owned Run still owns failures.
523        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    // The reuse history is collected once per Run, before it ends so that whoever waits for
532    // the end also sees a failure, which only warns.
533    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    // Saved Agent conversations past their size bound are collected; a failure only warns.
539    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    Ok(())
555}
556
557pub(crate) fn status(status: EvalStatus) -> crate::types::RequestStatus {
558    match status {
559        EvalStatus::Deferred => crate::types::RequestStatus::Deferred,
560        EvalStatus::Provisional => crate::types::RequestStatus::Provisional,
561        EvalStatus::Green => crate::types::RequestStatus::Green,
562        EvalStatus::Red => crate::types::RequestStatus::Red,
563        EvalStatus::Error => crate::types::RequestStatus::Error,
564        EvalStatus::Stale => crate::types::RequestStatus::Stale,
565        EvalStatus::Unreviewed => crate::types::RequestStatus::Unreviewed,
566        EvalStatus::Wait => crate::types::RequestStatus::WaitDependency,
567        EvalStatus::Blocked => crate::types::RequestStatus::Blocked,
568    }
569}
570
571/// The same gate explanation is saved during scheduling and at Run settlement.
572pub(crate) fn gate_reason(eval: &crate::graph::EvalEvaluation<'_>) -> String {
573    eval.blocked_reason().unwrap_or_else(|| {
574        format!(
575            "Waiting for current GREEN dependency evidence: {}",
576            eval.unmet_gates
577                .iter()
578                .map(ToString::to_string)
579                .collect::<Vec<_>>()
580                .join(", ")
581        )
582    })
583}
584
585/// Fingerprints and remote I/O can take time; check the accepted declarations again at the
586/// execution boundary instead of silently applying a different live selection or profile.
587fn unchanged_declarations(
588    repo: &Path,
589    selection: &Selection,
590    options: &VerifyOptions,
591    saved: &store::definitions::Definitions,
592) -> Result<(), Error> {
593    let check = || -> Result<(), String> {
594        let config = crate::config::read_workspace_declarations(repo).map_err(|e| e.to_string())?;
595        let config = select_profiles(
596            config,
597            selection,
598            options.profile.as_ref(),
599            options.recursive,
600        )?;
601        let current =
602            store::definitions::Definitions::from_view(&crate::query::graph(&config, selection)?)
603                .map_err(|e| e.to_string())?;
604        if serde_json::to_string(&current).map_err(|e| e.to_string())?
605            != serde_json::to_string(saved).map_err(|e| e.to_string())?
606        {
607            return Err("declarations changed after acceptance.".into());
608        }
609        Ok(())
610    };
611    check().map_err(Error::InputChanged)
612}
613
614#[cfg(test)]
615mod tests {
616    use super::Error;
617    use crate::types::FailureCode;
618
619    #[test]
620    fn failure_identity_comes_from_the_variant_not_owner_diagnostic_text() {
621        let preparation = Error::Preparation("INPUT_CHANGED: text from a command".into());
622        assert_eq!(preparation.code(), FailureCode::PreparationFailed);
623        let changed = Error::InputChanged("declarations changed after acceptance.".into());
624        assert_eq!(changed.code(), FailureCode::InputChanged);
625        assert_eq!(
626            changed.to_string(),
627            "INPUT_CHANGED: declarations changed after acceptance."
628        );
629    }
630}