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 {
140                    mutex: eval.declaration.mutex.clone(),
141                    ..ExecutionOptions::new(eval.declaration.profile(), eval.variant.as_ref())
142                },
143                execution_id: None,
144                provenance: None,
145                usage: None,
146                reused_usage: None,
147                joined: false,
148                producer: None,
149                reviewer: None,
150                origin: None,
151                session_id: None,
152                session: None,
153                human_definition: None,
154                payload: eval.declaration.payload().into(),
155                references: eval.references.clone(),
156                deps: eval.deps.clone(),
157                force: options.force && selected_ids.contains(&eval.id),
158                fingerprint: None,
159                key: None,
160                fingerprints: Default::default(),
161                created_at: run.created_at,
162                started_at: None,
163                cwd: config.root.join(config.artifacts[&eval.target].folder()),
164                run_dir: None,
165                argv: None,
166                child: None,
167                blocked_reason: None,
168                queue: None,
169                blocked_by: Vec::new(),
170            })
171            .collect();
172        let id = receipts.reserve(&run, &requests).await?;
173        let accepted = id == run.id;
174        if !accepted {
175            let _ = std::fs::remove_dir(run.state_dir.join("runs").join(run.id.as_str()));
176        }
177        Ok((id, accepted))
178    }
179    .await;
180    let closed = receipts.close().await;
181    result.and_then(|reserved| closed.map(|()| reserved))
182}
183/// Keep the failure kind typed through execution; prefixes belong only to display.
184#[derive(Debug, thiserror::Error)]
185pub(crate) enum Error {
186    #[error("INPUT_CHANGED: {0}")]
187    InputChanged(String),
188    #[error("{0}")]
189    Preparation(String),
190}
191impl Error {
192    pub(crate) fn code(&self) -> crate::types::FailureCode {
193        match self {
194            Self::InputChanged(_) => crate::types::FailureCode::InputChanged,
195            Self::Preparation(_) => crate::types::FailureCode::PreparationFailed,
196        }
197    }
198}
199impl From<String> for Error {
200    fn from(message: String) -> Self {
201        Self::Preparation(message)
202    }
203}
204impl From<&str> for Error {
205    fn from(message: &str) -> Self {
206        Self::Preparation(message.into())
207    }
208}
209
210/// Execute exactly the admitted Run. Never reserve a second Run or substitute changed declarations.
211pub(crate) async fn execute(
212    mut run: Run,
213    receipts: &Receipts,
214    cancellation: CancellationToken,
215) -> Result<RunView, Error> {
216    let selection = &run.selection.clone();
217    let options = &VerifyOptions {
218        modes: run.modes.clone(),
219        jobs: run.jobs,
220        fingerprint_jobs: run.fingerprint_jobs,
221        max_executions: run.max_executions,
222        reuse_only: run.reuse_only.clone(),
223        profile: run.profile.clone(),
224        recursive: run.recursive,
225        force: run.force,
226    };
227    let repo = &run.repo_path;
228    let state_dir = Some(run.state_dir.as_path());
229    if options.jobs == Some(0) {
230        return Err("jobs must be at least 1.".into());
231    }
232    let parallelism = super::fingerprint_parallelism(options)?;
233    let config = crate::config::read_workspace_declarations(repo)
234        .map_err(|e| Error::InputChanged(e.to_string()))?;
235    let config = Arc::new(
236        select_profiles(
237            config,
238            selection,
239            options.profile.as_ref(),
240            options.recursive,
241        )
242        .map_err(Error::InputChanged)?,
243    );
244    let graph = Graph::new(&config).map_err(|e| Error::InputChanged(e.to_string()))?;
245    let selected = selection
246        .resolve(&config)
247        .map_err(|e| Error::InputChanged(e.to_string()))?;
248    let required: BTreeSet<_> = graph
249        .dependency_closure(&selected.roots)
250        .map_err(|e| e.to_string())?
251        .into_iter()
252        .collect();
253    let evals = selection.included_evals(&config, options.recursive)?;
254    let definitions =
255        store::definitions::Definitions::from_view(&crate::query::graph(&config, selection)?)
256            .map_err(|error| error.to_string())?;
257    let state = store::state_dir(state_dir)?;
258    let limits = crate::limits::Limits::read(&state)?;
259    let runs = workspace::prepare_directory(&state.join("runs"), &config.root)
260        .map_err(|e| e.to_string())?;
261    if serde_json::to_string(&definitions).map_err(|e| e.to_string())?
262        != serde_json::to_string(&run.definitions).map_err(|e| e.to_string())?
263    {
264        return Err(Error::InputChanged(
265            "declarations changed after acceptance.".into(),
266        ));
267    }
268    let fingerprints = cache::prepare(
269        &config,
270        cache::fingerprint_targets(&config, &required),
271        &runs,
272        &parallelism,
273        cancellation.clone(),
274    )
275    .await?;
276    if cancellation.is_cancelled() {
277        return Err("Project preparation was cancelled.".into());
278    }
279    let keys = cache::eval_keys(&config, &fingerprints);
280    // The store is asked once for every key, and a record that completed after the local
281    // latest is mirrored into the local history before the Run claims anything. A forced Run
282    // reads nothing from the store, but publishes its results like any other.
283    let remote = Session::open(Some(&state), Some(&config.root))?;
284    // Results from a fake provider must never reach the shared review store.
285    if remote.is_some()
286        && let Some(variable) = crate::llm::active_test_endpoint()
287    {
288        return Err(format!(
289            "{variable} points Agent reviews at a local test endpoint; set ARTIFACTIZE_REMOTE=off so their results stay out of the review store."
290        ).into());
291    }
292    if let Some(remote) = &remote
293        && !options.force
294    {
295        remote
296            .refresh(
297                receipts,
298                keys.values().map(|key| key.value.clone()).collect(),
299            )
300            .await?;
301    }
302    unchanged_declarations(repo, selection, options, &run.definitions)?;
303    let mut requests: Vec<_> = store::read_run(&state, &run.id).await?.requests;
304    for request in &mut requests {
305        request.fingerprint = fingerprints
306            .get(&request.target)
307            .map(|fingerprint| fingerprint.value.clone());
308        request.key = keys.get(&request.eval_id).map(|key| key.value.clone());
309        request.fingerprints = keys
310            .get(&request.eval_id)
311            .map(|key| key.fingerprints.clone())
312            .unwrap_or_default();
313        receipts.save_request(request).await?;
314    }
315    run.control.as_mut().expect("owned Run").activity = crate::runs::Activity::Executing;
316    receipts.save_run(&run).await?;
317    let mut evidence = match broker::schedule(
318        config.clone(),
319        &graph,
320        &fingerprints,
321        &keys,
322        &parallelism,
323        &limits,
324        &mut run,
325        &mut requests,
326        receipts,
327        remote.clone(),
328        cancellation.clone(),
329    )
330    .await
331    {
332        Ok(evidence) => evidence,
333        Err(error) => {
334            return Err(error.into());
335        }
336    };
337    let evaluation = graph.evaluate_for_run(&evidence, run.modes.speculate);
338    for request in &mut requests {
339        // A cancelled Run withdraws its waiting sign-offs too, deferred ones included.
340        let withdrawn = request.status().is_human_wait() && cancellation.is_cancelled();
341        if (evidence.contains_key(request.eval_id.as_str()) && !withdrawn)
342            || (request.status().is_human_wait() && !withdrawn)
343        {
344            continue;
345        }
346        let eval = &evaluation.evals[request.eval_id.as_str()];
347        if request.status() == crate::types::RequestStatus::Blocked && !cancellation.is_cancelled()
348        {
349            // A block saved mid-Run named the causes known then; the settled Run names all.
350            if eval.status == EvalStatus::Blocked {
351                request.blocked_by = eval.blocked_by.iter().copied().map(Into::into).collect();
352                request.blocked_reason = Some(gate_reason(eval));
353            }
354            continue;
355        }
356        if cancellation.is_cancelled() {
357            request.state = crate::store::RequestState::failed(
358                "Run was cancelled.".into(),
359                Some(crate::types::FailureCode::Cancelled),
360                now(),
361            );
362            evidence.insert(request.eval_id.clone(), Evidence::OperationalError);
363        } else if request.status() == crate::types::RequestStatus::BudgetExhausted
364            && eval.can_execute()
365        {
366            continue;
367        } else {
368            request.state =
369                crate::store::RequestState::pending(status(eval.status), request.completed_at())
370                    .expect("non-executed request state");
371            request.queue = None;
372            request.blocked_reason = Some(gate_reason(eval));
373        }
374    }
375    let evaluation = graph.evaluate_for_run(&evidence, run.modes.speculate);
376    let required_evals: Vec<_> = evaluation
377        .evals
378        .iter()
379        .filter(|(id, _)| required.contains(&graph.eval_target(id).unwrap()))
380        .collect();
381    let satisfied = required
382        .iter()
383        .all(|id| evaluation.artifacts[id.as_str()].satisfied);
384    let budget_exhausted = requests
385        .iter()
386        .any(|request| request.status() == crate::types::RequestStatus::BudgetExhausted);
387    // Only a --reuse-only eval that had nothing to reuse leaves stale evidence in a Run.
388    let not_reused = evidence
389        .values()
390        .filter(|evidence| matches!(evidence, Evidence::Stale))
391        .count();
392    let at = now();
393    run.state = if cancellation.is_cancelled() {
394        crate::store::RunState::Error {
395            at,
396            error: Some("Run was cancelled.".into()),
397        }
398    } else if budget_exhausted {
399        crate::store::RunState::Incomplete {
400            at,
401            error: Some(broker::budget_reason(&run)),
402        }
403    } else if not_reused > 0 {
404        crate::store::RunState::Incomplete {
405            at,
406            error: Some(format!(
407                "{not_reused} eval{} had no result to reuse and {} not executed (--reuse-only).",
408                if not_reused == 1 { "" } else { "s" },
409                if not_reused == 1 { "was" } else { "were" }
410            )),
411        }
412    } else if required_evals
413        .iter()
414        .any(|(_, c)| c.status == EvalStatus::Error)
415    {
416        crate::store::RunState::Error { at, error: None }
417    } else if required_evals
418        .iter()
419        .any(|(_, c)| c.status == EvalStatus::Red)
420    {
421        crate::store::RunState::Red { at }
422    } else if required_evals
423        .iter()
424        .any(|(_, eval)| eval.status == EvalStatus::Deferred)
425        && required_evals
426            .iter()
427            .all(|(_, eval)| eval.status.passes_gate())
428        && required.iter().all(|id| {
429            graph.artifacts()[id.as_str()].basis || !graph.artifacts()[id.as_str()].evals.is_empty()
430        })
431    {
432        crate::store::RunState::SignoffsPending { at }
433    } else if satisfied {
434        crate::store::RunState::Green { at }
435    } else {
436        crate::store::RunState::Incomplete { at, error: None }
437    };
438    if cancellation.is_cancelled() {
439        run.control.as_mut().expect("owned Run").failure_code =
440            Some(crate::types::FailureCode::Cancelled);
441    }
442    use crate::config::Field;
443    run.validation = store::Validation(Some(store::ValidationSnapshot {
444        selection: Field::Value(store::definitions::SavedSelection {
445            known: run.selection.clone(),
446            extra: Default::default(),
447        }),
448        recursive: Field::Value(run.recursive),
449        force: Field::Value(run.force),
450        selected_eval_ids: Field::Value(
451            selected.evals.iter().map(|eval| eval.id.clone()).collect(),
452        ),
453        included_eval_ids: Field::Value(evals.iter().map(|eval| eval.id.clone()).collect()),
454        satisfied: Field::Value(satisfied && !cancellation.is_cancelled()),
455        obligations: Field::Value(
456            evaluation
457                .obligations
458                .iter()
459                .filter(|id| required.contains(*id))
460                .map(|id| (*id).clone())
461                .collect(),
462        ),
463        artifacts: Field::Value(
464            required
465                .iter()
466                .map(|id| {
467                    let artifact = &evaluation.artifacts[id.as_str()];
468                    let (fingerprint_kind, fingerprint) = fingerprints.get(id).map_or(
469                        (Field::Missing, Field::Missing),
470                        |fingerprint| {
471                            (
472                                config.artifacts[*id]
473                                    .fingerprint
474                                    .as_ref()
475                                    .map(crate::config::Fingerprint::kind)
476                                    .into(),
477                                Field::Value(fingerprint.value.clone()),
478                            )
479                        },
480                    );
481                    store::ArtifactValidation {
482                        id: (*id).clone(),
483                        status: Field::Value(artifact.status.into()),
484                        passed: Field::Value(artifact.passed as u64),
485                        total: Field::Value(artifact.total as u64),
486                        satisfied: Field::Value(artifact.satisfied),
487                        fingerprint_kind,
488                        fingerprint,
489                        extra: Default::default(),
490                    }
491                })
492                .collect(),
493        ),
494        evals: Field::Value(
495            required_evals
496                .iter()
497                .map(|(id, eval)| store::EvalValidation {
498                    id: (*id).clone(),
499                    status: Field::Value(status(eval.status)),
500                    blocked_by: Field::Value(
501                        eval.blocked_by.iter().copied().map(Into::into).collect(),
502                    ),
503                    extra: Default::default(),
504                })
505                .collect(),
506        ),
507        extra: Default::default(),
508    }));
509    if cancellation.is_cancelled() {
510        receipts
511            .retire_worker(&run.id, crate::types::FailureCode::Cancelled)
512            .await?;
513    }
514    if run.modes.speculate
515        && let Some(remote) = remote
516    {
517        // A blocked result cannot be retracted from a team store. Publish survivors only
518        // after scheduling/withdrawal finishes, while the owned Run still owns failures.
519        run.control.as_mut().expect("owned Run").activity = crate::runs::Activity::Publishing;
520        let mut publishing = run.clone();
521        publishing.state = crate::store::RunState::Running;
522        receipts.save_run(&publishing).await?;
523        for request in &requests {
524            remote.publish_request(receipts, request).await?;
525        }
526    }
527    receipts.finish(&run, &requests).await?;
528    // Saved Agent conversations past their size bound are collected; a failure only warns.
529    let state = run.state_dir.clone();
530    let bounds = limits.agent_sessions();
531    let collected = crate::task::joined(
532        tokio::task::spawn_blocking(move || crate::agent::session::collect(&state, bounds, false))
533            .await,
534    )
535    .map_err(|e| e.to_string())
536    .and_then(|collected| collected);
537    if let Err(error) = collected {
538        use std::io::Write;
539        let _ = writeln!(
540            std::io::stderr().lock(),
541            "Agent session collection failed: {error}"
542        );
543    }
544    store::read_run(&run.state_dir, &run.id)
545        .await
546        .map_err(Into::into)
547}
548
549pub(crate) fn status(status: EvalStatus) -> crate::types::RequestStatus {
550    match status {
551        EvalStatus::Deferred => crate::types::RequestStatus::Deferred,
552        EvalStatus::Provisional => crate::types::RequestStatus::Provisional,
553        EvalStatus::Green => crate::types::RequestStatus::Green,
554        EvalStatus::Red => crate::types::RequestStatus::Red,
555        EvalStatus::Error => crate::types::RequestStatus::Error,
556        EvalStatus::Stale => crate::types::RequestStatus::Stale,
557        EvalStatus::Unreviewed => crate::types::RequestStatus::Unreviewed,
558        EvalStatus::Wait => crate::types::RequestStatus::WaitDependency,
559        EvalStatus::Blocked => crate::types::RequestStatus::Blocked,
560    }
561}
562
563/// The same gate explanation is saved during scheduling and at Run settlement.
564pub(crate) fn gate_reason(eval: &crate::graph::EvalEvaluation<'_>) -> String {
565    eval.blocked_reason().unwrap_or_else(|| {
566        format!(
567            "Waiting for current GREEN dependency evidence: {}",
568            eval.unmet_gates
569                .iter()
570                .map(ToString::to_string)
571                .collect::<Vec<_>>()
572                .join(", ")
573        )
574    })
575}
576
577/// Fingerprints and remote I/O can take time; check the accepted declarations again at the
578/// execution boundary instead of silently applying a different live selection or profile.
579fn unchanged_declarations(
580    repo: &Path,
581    selection: &Selection,
582    options: &VerifyOptions,
583    saved: &store::definitions::Definitions,
584) -> Result<(), Error> {
585    let check = || -> Result<(), String> {
586        let config = crate::config::read_workspace_declarations(repo).map_err(|e| e.to_string())?;
587        let config = select_profiles(
588            config,
589            selection,
590            options.profile.as_ref(),
591            options.recursive,
592        )?;
593        let current =
594            store::definitions::Definitions::from_view(&crate::query::graph(&config, selection)?)
595                .map_err(|e| e.to_string())?;
596        if serde_json::to_string(&current).map_err(|e| e.to_string())?
597            != serde_json::to_string(saved).map_err(|e| e.to_string())?
598        {
599            return Err("declarations changed after acceptance.".into());
600        }
601        Ok(())
602    };
603    check().map_err(Error::InputChanged)
604}
605
606#[cfg(test)]
607mod tests {
608    use super::Error;
609    use crate::types::FailureCode;
610
611    #[test]
612    fn failure_identity_comes_from_the_variant_not_owner_diagnostic_text() {
613        let preparation = Error::Preparation("INPUT_CHANGED: text from a command".into());
614        assert_eq!(preparation.code(), FailureCode::PreparationFailed);
615        let changed = Error::InputChanged("declarations changed after acceptance.".into());
616        assert_eq!(changed.code(), FailureCode::InputChanged);
617        assert_eq!(
618            changed.to_string(),
619            "INPUT_CHANGED: declarations changed after acceptance."
620        );
621    }
622}