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}
37
38/// Submit to artifactize and observe settlement. Cancelling this future/token only stops watching.
39pub 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}
52/// Validate declarations and reserve initial requests; fingerprints and remote I/O follow admission.
53pub(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/// Keep the failure kind typed through execution; prefixes belong only to display.
186#[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
212/// Execute exactly the admitted Run. Never reserve a second Run or substitute changed declarations.
213pub(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        &parallelism,
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    // The store is asked once for every key, and a record that completed after the local
283    // latest is mirrored into the local history before the Run claims anything. A forced Run
284    // reads nothing from the store, but publishes its results like any other.
285    let remote = Session::open(Some(&state), Some(&config.root))?;
286    // Results from a fake provider must never reach the shared review store.
287    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        &parallelism,
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    // Only a --reuse-only eval that had nothing to reuse leaves stale evidence in a Run.
390    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        // A blocked result cannot be retracted from a team store. Publish survivors only
520        // after scheduling/withdrawal finishes, while the owned Run still owns failures.
521        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    // Saved Agent conversations past their size bound are collected; a failure only warns.
531    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
565/// The same gate explanation is saved during scheduling and at Run settlement.
566pub(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
582/// Fingerprints and remote I/O can take time; check the accepted declarations again at the
583/// execution boundary instead of silently applying a different live selection or profile.
584fn 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(&current).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}