Skip to main content

artifactize_core/project/
verify.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    path::Path,
4    sync::Arc,
5    time::Duration,
6};
7
8use serde_json::json;
9use tokio_util::sync::CancellationToken;
10
11use crate::{
12    broker::{self, now},
13    cache,
14    config::{DependencyGates, ProfileKind, read_workspace_config},
15    graph::{EvalStatus, Evidence, Graph},
16    project::selection::{ProfileSelection, Selection, select_profiles},
17    remote::Session,
18    store::{self, ExecutionOptions, Receipts, Request, Run, RunView},
19    workspace,
20};
21
22/// Give Human sign-off ten minutes by default, then return without cancelling
23/// pending requests; later verify/run queries can still observe their settlement.
24pub const DEFAULT_HUMAN_WAIT: Duration = Duration::from_secs(600);
25/// Four concurrent evals keep ordinary machines responsive while overlapping review I/O;
26/// shared by API/CLI defaults and legacy Run decoding, and overridable with --jobs.
27pub const DEFAULT_JOBS: usize = 4;
28
29#[derive(Debug, Clone)]
30pub struct VerifyOptions {
31    pub jobs: usize,
32    /// Fingerprints computed at once, in preparation and end-of-review rechecks; `None` uses
33    /// the available CPUs.
34    pub fingerprint_jobs: Option<usize>,
35    /// Print `Run: RUN_ID` to stderr as soon as the Run is saved, before any eval runs.
36    pub announce_run: bool,
37    pub max_executions: Option<u64>,
38    /// How long this Run waits for Human submissions; the timeout never cancels a review.
39    pub wait_timeout: Duration,
40    /// Kinds whose evals only reuse a result, from the cache or the review store; one with
41    /// nothing to reuse is not executed, and the Run ends INCOMPLETE.
42    pub reuse_only: BTreeSet<ProfileKind>,
43    pub profile: Option<ProfileSelection>,
44    pub recursive: bool,
45    /// Force only explicitly selected Evals, never recursive dependencies.
46    pub force: bool,
47    /// None uses root reviewPolicy; Some(false) explicitly enforces GREEN gates.
48    pub ignore_gates: Option<bool>,
49}
50
51impl Default for VerifyOptions {
52    fn default() -> Self {
53        Self {
54            jobs: DEFAULT_JOBS,
55            fingerprint_jobs: None,
56            announce_run: false,
57            max_executions: None,
58            wait_timeout: DEFAULT_HUMAN_WAIT,
59            reuse_only: BTreeSet::new(),
60            profile: None,
61            recursive: false,
62            force: false,
63            ignore_gates: None,
64        }
65    }
66}
67
68/// Executes READY evals in the foreground, reusing completed results by fingerprint.
69pub async fn verify(
70    repo: &Path,
71    state_dir: Option<&Path>,
72    selection: &Selection,
73    options: &VerifyOptions,
74    cancellation: CancellationToken,
75) -> Result<RunView, String> {
76    if options.jobs == 0 {
77        return Err("jobs must be at least 1.".into());
78    }
79    let parallelism = super::fingerprint_parallelism(options)?;
80    let wait_timeout_ms = u32::try_from(options.wait_timeout.as_millis())
81        .ok()
82        .filter(|ms| (1..=crate::config::validation::MAX_TIMEOUT_MS).contains(&u64::from(*ms)))
83        .ok_or("wait timeout must be between 1 and 2147483647 ms.")?;
84    let config = read_workspace_config(repo).map_err(|e| e.to_string())?;
85    let config = Arc::new(select_profiles(
86        config,
87        selection,
88        options.profile.as_ref(),
89        options.recursive,
90    )?);
91    let ignore_gates = options.ignore_gates.unwrap_or_else(|| {
92        config
93            .artifacts
94            .values()
95            .find(|artifact| artifact.path.as_os_str().is_empty())
96            .and_then(|artifact| artifact.review_policy.as_ref())
97            .is_some_and(|policy| matches!(policy.dependency_gates, Some(DependencyGates::Ignore)))
98    });
99    let graph = Graph::new(&config).map_err(|e| e.to_string())?;
100    let selected = selection.resolve(&config)?;
101    let selected_ids: BTreeSet<_> = selected.evals.iter().map(|eval| &eval.id).collect();
102    let required: BTreeSet<_> = graph
103        .dependency_closure(&selected.roots)
104        .map_err(|e| e.to_string())?
105        .into_iter()
106        .collect();
107    let evals = selection.included_evals(&config, options.recursive)?;
108    let definitions =
109        store::definitions::Definitions::from_view(&crate::query::graph(&config, selection)?)
110            .map_err(|error| error.to_string())?;
111    let state = store::state_dir(state_dir)?;
112    let limits = crate::limits::Limits::read(&state)?;
113    let receipts = Receipts::open(&state, &config.root).await?;
114    let runs = workspace::prepare_directory(&state.join("runs"), &config.root)
115        .map_err(|e| e.to_string())?;
116    let fingerprints = cache::prepare(
117        &config,
118        cache::fingerprint_targets(&config, &required),
119        &runs,
120        &parallelism,
121        cancellation.clone(),
122    )
123    .await?;
124    if cancellation.is_cancelled() {
125        return Err("Project preparation was cancelled.".into());
126    }
127    let keys = cache::eval_keys(&config, &fingerprints);
128    // The store is asked once for every key, and a record that completed after the local
129    // latest is mirrored into the local history before the Run claims anything. A forced Run
130    // reads nothing from the store, but publishes its results like any other.
131    let remote = Session::open(Some(&state), Some(&config.root))?;
132    // Results from a fake provider must never reach the shared review store.
133    if remote.is_some()
134        && let Some(variable) = crate::llm::active_test_endpoint()
135    {
136        return Err(format!(
137            "{variable} points Agent reviews at a local test endpoint; set ARTIFACTIZE_REMOTE=off so their results stay out of the review store."
138        ));
139    }
140    if let Some(remote) = &remote
141        && !options.force
142    {
143        remote
144            .refresh(
145                &receipts,
146                keys.values().map(|key| key.value.clone()).collect(),
147            )
148            .await?;
149    }
150    let directory = tempfile::Builder::new()
151        .prefix("run-")
152        .tempdir_in(runs)
153        .map_err(|e| e.to_string())?;
154    let id = directory
155        .path()
156        .file_name()
157        .unwrap()
158        .to_str()
159        .expect("generated run directory names are ASCII")
160        .to_owned();
161    let _ = directory.keep();
162    let mut run = Run {
163        id: id.parse()?,
164        repo_path: config.root.clone(),
165        repository: crate::repository::identify(&config.root),
166        state_dir: state,
167        state: crate::store::RunState::Running,
168        created_at: now(),
169        selection: selection.clone(),
170        profile: options.profile.clone(),
171        definitions,
172        jobs: options.jobs,
173        fingerprint_jobs: Some(parallelism.limit()),
174        max_executions: options.max_executions,
175        executions_started: 0,
176        wait_timeout_ms: Some(Duration::from_millis(u64::from(wait_timeout_ms))),
177        wait_timed_out: false,
178        reuse_only: options.reuse_only.clone(),
179        recursive: options.recursive,
180        force: options.force,
181        ignore_gates,
182        validation: Default::default(),
183        stopped_backends: Vec::new(),
184        evidence: BTreeMap::new(),
185    };
186    let mut requests: Vec<_> = evals
187        .iter()
188        .enumerate()
189        .map(|(ordinal, eval)| Request {
190            state: crate::store::RequestState::pending(crate::types::RequestStatus::Queued, None)
191                .expect("queued state"),
192            id: format!("{}-{}", run.id, ordinal + 1)
193                .parse()
194                .expect("generated request id is a safe segment"),
195            run_id: run.id.clone(),
196            eval_id: eval.id.clone(),
197            target: eval.target.clone(),
198            title: eval.declaration.title.clone(),
199            profile: (eval.declaration.profile()).into(),
200            requested_profile: (eval.declaration.profile()).into(),
201            eval_def_hash: cache::eval_definition_hash(&eval.declaration),
202            options: ExecutionOptions::new(eval.declaration.profile(), eval.variant.as_ref()),
203            execution_id: None,
204            provenance: None,
205            usage: None,
206            reused_usage: None,
207            joined: false,
208            producer: None,
209            reviewer: None,
210            origin: None,
211            session_id: None,
212            session: None,
213            human_definition: None,
214            payload: eval.declaration.payload().into(),
215            references: eval.references.clone(),
216            deps: eval.deps.clone(),
217            force: options.force
218                && selected_ids.contains(&eval.id)
219                && !matches!(
220                    eval.declaration.profile(),
221                    crate::config::Profile::Dependency { .. }
222                ),
223            fingerprint: fingerprints
224                .get(&eval.target)
225                .map(|fingerprint| fingerprint.value.clone()),
226            key: keys.get(&eval.id).map(|key| key.value.clone()),
227            fingerprints: keys
228                .get(&eval.id)
229                .map(|key| key.fingerprints.clone())
230                .unwrap_or_default(),
231            created_at: run.created_at,
232            started_at: None,
233            cwd: config.root.join(config.artifacts[&eval.target].folder()),
234            run_dir: None,
235            argv: None,
236            child: None,
237            blocked_reason: None,
238            queue: None,
239            blocked_by: Vec::new(),
240        })
241        .collect();
242    receipts.create_run(&run, &requests).await?;
243    if options.announce_run {
244        use std::io::Write;
245        let _ = writeln!(
246            std::io::stderr().lock(),
247            "Started {} (follow: artifactize monitor)",
248            run.id
249        );
250    }
251    let mut evidence = match broker::schedule(
252        config.clone(),
253        &graph,
254        &fingerprints,
255        &keys,
256        &parallelism,
257        &limits,
258        &mut run,
259        &mut requests,
260        &receipts,
261        remote,
262        cancellation.clone(),
263    )
264    .await
265    {
266        Ok(evidence) => evidence,
267        Err(error) => {
268            // A failing Run, for example on a rejected remote token, does not stay RUNNING.
269            run.state = crate::store::RunState::Error {
270                at: now(),
271                error: Some(error.clone()),
272            };
273            let _ = receipts.save_run(&run).await;
274            return Err(error);
275        }
276    };
277    let evaluation = graph.evaluate_with_policy(&evidence, ignore_gates);
278    for request in &mut requests {
279        if request.profile.kind() == ProfileKind::Dependency {
280            if request.status() == crate::types::RequestStatus::Error {
281                continue;
282            }
283            broker::derive(request, &evaluation.evals[request.eval_id.as_str()], now());
284            continue;
285        }
286        if evidence.contains_key(request.eval_id.as_str())
287            || request.status() == crate::types::RequestStatus::WaitingHuman
288        {
289            continue;
290        }
291        let eval = &evaluation.evals[request.eval_id.as_str()];
292        if cancellation.is_cancelled() {
293            request.state = crate::store::RequestState::failed(
294                "Run was cancelled.".into(),
295                Some(crate::types::FailureCode::Cancelled),
296                now(),
297            );
298            evidence.insert(request.eval_id.clone(), Evidence::OperationalError);
299        } else if request.status() == crate::types::RequestStatus::BudgetExhausted
300            && eval.can_execute()
301        {
302            continue;
303        } else {
304            request.state =
305                crate::store::RequestState::pending(status(eval.status), request.completed_at())
306                    .expect("non-executed request state");
307            request.queue = None;
308            request.blocked_reason = Some(format!(
309                "{}: {}",
310                if eval.status == EvalStatus::Blocked {
311                    "Dependency verdict RED"
312                } else {
313                    "Waiting for current GREEN dependency evidence"
314                },
315                eval.unmet_gates
316                    .iter()
317                    .map(ToString::to_string)
318                    .collect::<Vec<_>>()
319                    .join(", ")
320            ));
321        }
322    }
323    let evaluation = graph.evaluate_with_policy(&evidence, ignore_gates);
324    let required_evals: Vec<_> = evaluation
325        .evals
326        .iter()
327        .filter(|(id, _)| required.contains(&graph.eval_target(id).unwrap()))
328        .collect();
329    let satisfied = required
330        .iter()
331        .all(|id| evaluation.artifacts[id.as_str()].satisfied);
332    let budget_exhausted = requests
333        .iter()
334        .any(|request| request.status() == crate::types::RequestStatus::BudgetExhausted);
335    // Only a --reuse-only eval that had nothing to reuse leaves stale evidence in a Run.
336    let not_reused = evidence
337        .values()
338        .filter(|evidence| matches!(evidence, Evidence::Stale))
339        .count();
340    let at = now();
341    run.state = if cancellation.is_cancelled() {
342        crate::store::RunState::Error {
343            at,
344            error: Some("Run was cancelled.".into()),
345        }
346    } else if run.wait_timed_out {
347        crate::store::RunState::Incomplete {
348            at,
349            error: Some(
350                "Human wait timed out; pending requests remain available for submission.".into(),
351            ),
352        }
353    } else if budget_exhausted {
354        crate::store::RunState::Incomplete {
355            at,
356            error: Some(broker::budget_reason(&run)),
357        }
358    } else if not_reused > 0 {
359        crate::store::RunState::Incomplete {
360            at,
361            error: Some(format!(
362                "{not_reused} eval{} had no result to reuse and {} not executed (--reuse-only).",
363                if not_reused == 1 { "" } else { "s" },
364                if not_reused == 1 { "was" } else { "were" }
365            )),
366        }
367    } else if required_evals
368        .iter()
369        .any(|(_, c)| c.status == EvalStatus::Error)
370    {
371        crate::store::RunState::Error { at, error: None }
372    } else if required_evals
373        .iter()
374        .any(|(_, c)| c.status == EvalStatus::Red)
375    {
376        crate::store::RunState::Red { at }
377    } else if satisfied {
378        crate::store::RunState::Green { at }
379    } else {
380        crate::store::RunState::Incomplete { at, error: None }
381    };
382    run.validation = serde_json::from_value(json!({
383        "selection":run.selection,
384        "recursive":run.recursive,
385        "force":run.force,
386        "ignoreGates":run.ignore_gates,
387        "selectedEvalIds":selected.evals.iter().map(|eval| &eval.id).collect::<Vec<_>>(),
388        "includedEvalIds":evals.iter().map(|eval| &eval.id).collect::<Vec<_>>(),
389        "satisfied":satisfied && !cancellation.is_cancelled(),
390        "obligations":evaluation
391            .obligations
392            .iter()
393            .filter(|id| required.contains(*id))
394            .collect::<Vec<_>>(),
395        "artifacts":required
396            .iter()
397            .map(|id| {
398                let a = &evaluation.artifacts[id.as_str()];
399                let mut artifact = json!({
400                    "id":id,
401                    "status":format!("{:?}", a.status).to_uppercase(),
402                    "passed":a.passed,
403                    "total":a.total,
404                    "satisfied":a.satisfied,
405                });
406                if let Some(fingerprint) = fingerprints.get(id) {
407                    artifact["fingerprintKind"] = json!(if fingerprint.manifest.is_some() {
408                        "artifactsum"
409                    } else {
410                        "script"
411                    });
412                    artifact["fingerprint"] = json!(fingerprint.value);
413                }
414                artifact
415            })
416            .collect::<Vec<_>>(),
417        "evals":required_evals
418            .iter()
419            .map(|(id, eval)| {
420                json!({
421                    "id":id,
422                    "status":status(eval.status),
423                    "blockedBy":eval.blocked_by,
424                })
425            })
426            .collect::<Vec<_>>(),
427    }))
428    .expect("generated validation snapshot");
429    receipts.finish(&run, &requests).await?;
430    // Saved Agent conversations past their size bound are collected; a failure only warns.
431    let state = run.state_dir.clone();
432    let bounds = limits.agent_sessions();
433    let collected = crate::task::joined(
434        tokio::task::spawn_blocking(move || crate::agent::session::collect(&state, bounds, false))
435            .await,
436    )
437    .map_err(|e| e.to_string())
438    .and_then(|collected| collected);
439    if let Err(error) = collected {
440        use std::io::Write;
441        let _ = writeln!(
442            std::io::stderr().lock(),
443            "Agent session collection failed: {error}"
444        );
445    }
446    store::read_run(&run.state_dir, &run.id).await
447}
448
449pub(crate) fn status(status: EvalStatus) -> crate::types::RequestStatus {
450    match status {
451        EvalStatus::Green => crate::types::RequestStatus::Green,
452        EvalStatus::Red => crate::types::RequestStatus::Red,
453        EvalStatus::Error => crate::types::RequestStatus::Error,
454        EvalStatus::Stale => crate::types::RequestStatus::Stale,
455        EvalStatus::Unreviewed => crate::types::RequestStatus::Unreviewed,
456        EvalStatus::Wait => crate::types::RequestStatus::WaitDependency,
457        EvalStatus::Blocked => crate::types::RequestStatus::Blocked,
458    }
459}