Skip to main content

artifactize_core/project/
status.rs

1mod states;
2pub use states::{ArtifactCondition, EvalCondition, VerifyAction};
3#[cfg(test)]
4mod tests;
5
6use std::{
7    collections::{BTreeMap, BTreeSet},
8    path::Path,
9};
10
11use serde::Serialize;
12use tokio_util::sync::CancellationToken;
13
14use crate::{
15    cache,
16    config::{DependencyGates, Profile, read_workspace_config},
17    graph::{Evidence, Graph, Readiness},
18    project::{VerifyOptions, selection::select_profiles},
19    remote::Session,
20    store::{self, Claim, LastRequest},
21};
22
23use super::selection::Selection;
24
25#[derive(Debug, Serialize)]
26#[serde(rename_all = "camelCase")]
27pub struct StatusView {
28    pub selection: Selection,
29    pub recursive: bool,
30    pub force: bool,
31    pub ignore_gates: bool,
32    pub selected_eval_ids: Vec<crate::config::EvalId>,
33    pub included_eval_ids: Vec<crate::config::EvalId>,
34    pub satisfied: bool,
35    pub obligations: Vec<crate::config::ArtifactName>,
36    pub artifacts: Vec<ArtifactState>,
37    pub evals: Vec<EvalState>,
38    pub counts: Counts,
39    #[serde(skip_serializing_if = "Vec::is_empty")]
40    pub unreadable: Vec<store::Unreadable>,
41}
42
43#[derive(Debug, Serialize)]
44#[serde(rename_all = "camelCase")]
45pub struct ArtifactState {
46    pub id: crate::config::ArtifactName,
47    pub kind: crate::config::ArtifactKind,
48    #[serde(with = "crate::platform::path_serde")]
49    pub path: std::path::PathBuf,
50    pub tags: Vec<String>,
51    pub state: ArtifactCondition,
52    pub reason: String,
53    pub eval_ids: Vec<crate::config::EvalId>,
54    pub passed: usize,
55    pub total: usize,
56    pub satisfied: bool,
57    pub obligations: Vec<crate::config::ArtifactName>,
58}
59
60#[derive(Debug, Serialize)]
61#[serde(rename_all = "camelCase")]
62pub struct EvalState {
63    pub id: crate::config::EvalId,
64    pub target: crate::config::ArtifactName,
65    pub title: String,
66    pub profile: Profile,
67    pub selected: bool,
68    pub included: bool,
69    pub force: bool,
70    pub state: EvalCondition,
71    pub action: VerifyAction,
72    pub reason: String,
73    pub blocked_by: Vec<crate::store::Blocker>,
74    pub obligations: Vec<crate::config::ArtifactName>,
75    /// The Eval definition hash: the eval strategy the key covers.
76    pub eval_def_hash: crate::types::DefinitionHash,
77    /// The target's current fingerprint; null without one.
78    pub fingerprint: Option<crate::types::Fingerprint>,
79    /// Each Artifact the eval depends on, with its current fingerprint or null without one.
80    pub fingerprints: BTreeMap<crate::config::ArtifactName, Option<crate::types::Fingerprint>>,
81    /// The reuse key composed of the hash and the fingerprints; null when one is missing.
82    pub key: Option<crate::types::ReuseKey>,
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub last: Option<LastRequest>,
85    /// Why the key no longer matches the newest cached result for this Eval definition: the
86    /// target's changed files and the dependency Artifacts whose fingerprints changed.
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub changes: Option<cache::Changes>,
89}
90
91#[derive(Debug, Default, Serialize)]
92pub struct Counts {
93    pub artifacts: BTreeMap<ArtifactCondition, usize>,
94    pub evals: BTreeMap<EvalCondition, usize>,
95    pub execute: usize,
96    pub derive: usize,
97    pub reuse: usize,
98    pub wait: usize,
99    pub blocked: usize,
100}
101
102/// Prepare current fingerprints without executing evals or changing saved evidence.
103pub async fn status(
104    repo: &Path,
105    state_dir: Option<&Path>,
106    selection: &Selection,
107    options: &VerifyOptions,
108    cancellation: CancellationToken,
109) -> Result<StatusView, String> {
110    let parallelism = super::fingerprint_parallelism(options)?;
111    let config = read_workspace_config(repo).map_err(|error| error.to_string())?;
112    let config = select_profiles(
113        config,
114        selection,
115        options.profile.as_ref(),
116        options.recursive,
117    )?;
118    let ignore_gates = options.ignore_gates.unwrap_or_else(|| {
119        config
120            .artifacts
121            .values()
122            .find(|artifact| artifact.path.as_os_str().is_empty())
123            .and_then(|artifact| artifact.review_policy.as_ref())
124            .is_some_and(|policy| matches!(policy.dependency_gates, Some(DependencyGates::Ignore)))
125    });
126    let graph = Graph::new(&config).map_err(|error| error.to_string())?;
127    let selected = selection.resolve(&config)?;
128    let required: BTreeSet<_> = graph
129        .dependency_closure(&selected.roots)
130        .map_err(|error| error.to_string())?
131        .into_iter()
132        .collect();
133    let selected_eval_ids: Vec<_> = selected.evals.iter().map(|eval| eval.id.clone()).collect();
134    let included_eval_ids: Vec<_> = selection
135        .included_evals(&config, options.recursive)?
136        .iter()
137        .map(|eval| eval.id.clone())
138        .collect();
139    let state = store::state_dir(state_dir)?;
140    let mut latest = store::read_latest_requests(&state, &config.root).await?;
141    let mut evidence: BTreeMap<_, _> = latest
142        .keys()
143        .map(|id| (id.clone(), Evidence::Stale))
144        .collect();
145    if options.force {
146        evidence.extend(
147            selected_eval_ids
148                .iter()
149                .map(|id| (id.clone(), Evidence::Stale)),
150        );
151    }
152    let selected_ids: BTreeSet<_> = selected_eval_ids.iter().collect();
153    let included_ids: BTreeSet<_> = included_eval_ids.iter().collect();
154    let fingerprints = cache::prepare(
155        &config,
156        cache::fingerprint_targets(&config, &required),
157        &state,
158        &parallelism,
159        cancellation.clone(),
160    )
161    .await?;
162    let all_keys = cache::eval_keys(&config, &fingerprints);
163    let keys: BTreeMap<_, _> = all_keys
164        .iter()
165        .filter(|(id, _)| !(options.force && selected_ids.contains(*id)))
166        .map(|(id, key)| (*id, key))
167        .collect();
168    let mut cached = store::read_keyed_executions(
169        &state,
170        &keys
171            .values()
172            .map(|key| key.value.clone())
173            .collect::<BTreeSet<_>>()
174            .into_iter()
175            .collect::<Vec<_>>(),
176    )
177    .await?;
178    // Read-only: a store record that completed after the local latest counts as reuse without
179    // being mirrored, as verify would take it before claiming. --force reads nothing from the
180    // store, so its prediction has none.
181    if !options.force
182        && let Some(remote) = Session::open(Some(&state), Some(&config.root))?
183    {
184        let all: Vec<_> = keys
185            .values()
186            .map(|key| key.value.clone())
187            .collect::<BTreeSet<_>>()
188            .into_iter()
189            .collect();
190        for execution in remote.lookup(&all).await? {
191            let key = execution.key.clone().expect("remote key");
192            let newer = match cached.get(&key) {
193                Some(Claim::Reuse(local)) => execution.completed_after(local),
194                _ => true,
195            };
196            if newer {
197                cached.insert(key, Claim::Reuse(Box::new(execution)));
198            }
199        }
200    }
201    let claim = |id: &crate::types::EvalId| keys.get(id).and_then(|key| cached.get(&key.value));
202    // Explain changed keys against the newest cached result for the same Eval definition.
203    let stale: Vec<_> = keys
204        .iter()
205        .filter(|(id, _)| !matches!(claim(id), Some(Claim::Reuse(_))))
206        .map(|(id, key)| ((*id).clone(), key.eval_def_hash.clone()))
207        .collect();
208    let previous = store::read_latest_cached(&state, &stale).await?;
209    for eval in &config.evals {
210        if let Some(Claim::Reuse(execution)) = claim(&eval.id) {
211            evidence.insert(
212                eval.id.clone(),
213                Evidence::Current(execution.verdict().expect("completed cache entry")),
214            );
215        }
216    }
217    if cancellation.is_cancelled() {
218        return Err("Project preparation was cancelled.".into());
219    }
220    let evaluation = graph.evaluate_with_policy(&evidence, ignore_gates);
221    let obligations: Vec<_> = evaluation
222        .obligations
223        .iter()
224        .filter(|id| required.contains(*id))
225        .map(|id| (*id).clone())
226        .collect();
227    let mut counts = Counts::default();
228    let mut artifacts = Vec::new();
229    for id in &required {
230        let artifact = &evaluation.artifacts[id.as_str()];
231        let status = ArtifactCondition::from(artifact.status);
232        let own = &graph.artifacts()[id.as_str()];
233        let unmet = graph
234            .dependency_closure(&[*id])
235            .map_err(|error| error.to_string())?
236            .into_iter()
237            .filter(|id| !evaluation.artifacts[id.as_str()].own_satisfied)
238            .map(|id| (*id).clone())
239            .collect::<Vec<_>>();
240        let reason = if artifact.satisfied {
241            "All required obligations are satisfied.".into()
242        } else if own.evals.is_empty() && !own.basis {
243            "No Evals are declared and this Artifact is not a basis.".into()
244        } else {
245            format!(
246                "Unmet obligations: {}",
247                unmet
248                    .iter()
249                    .map(ToString::to_string)
250                    .collect::<Vec<_>>()
251                    .join(", ")
252            )
253        };
254        *counts.artifacts.entry(status).or_default() += 1;
255        artifacts.push(ArtifactState {
256            kind: config.artifacts[*id].kind,
257            path: config.artifacts[*id].path.clone(),
258            id: (*id).clone(),
259            tags: config.artifacts[*id].tags.clone(),
260            state: status,
261            reason,
262            eval_ids: own.evals.iter().map(|id| (*id).clone()).collect(),
263            passed: artifact.passed,
264            total: artifact.total,
265            satisfied: artifact.satisfied,
266            obligations: unmet,
267        });
268    }
269    let obligations_by_artifact: BTreeMap<_, _> = artifacts
270        .iter()
271        .map(|artifact| (artifact.id.as_str(), &artifact.obligations))
272        .collect();
273    let mut evals = Vec::new();
274    for eval in config
275        .evals
276        .iter()
277        .filter(|eval| required.contains(&eval.target))
278    {
279        let current = &evaluation.evals[eval.id.as_str()];
280        let selected = selected_ids.contains(&eval.id);
281        let included = included_ids.contains(&eval.id);
282        let derived = matches!(eval.declaration.profile(), Profile::Dependency { .. });
283        let force = options.force && selected && !derived;
284        let (action, reason) = match current.readiness {
285            _ if derived => (
286                VerifyAction::Derive,
287                if current.blocked_by.is_empty() {
288                    "Derived from current GREEN dependency evidence; no execution or reuse.".into()
289                } else {
290                    format!(
291                        "Derived dependency verdict: waiting for current GREEN evidence from {}.",
292                        current
293                            .blocked_by
294                            .iter()
295                            .map(ToString::to_string)
296                            .collect::<Vec<_>>()
297                            .join(", ")
298                    )
299                },
300            ),
301            // verify takes cached results before gates resolve, even behind RED; the state keeps
302            // the gate.
303            _ if matches!(current.evidence, Some(Evidence::Current(_))) => (
304                VerifyAction::Reuse,
305                format!(
306                    "The current eval and fingerprints have a completed {}{}{}",
307                    match claim(&eval.id) {
308                        Some(Claim::Reuse(execution)) if execution.origin.is_some() => format!(
309                            "result in the remote review store, from {}",
310                            execution
311                                .producer
312                                .as_ref()
313                                .map_or("an unknown producer", |producer| producer.name.as_str())
314                        ),
315                        _ => "cached result".into(),
316                    },
317                    match claim(&eval.id) {
318                        Some(Claim::Reuse(execution))
319                            if execution.profile
320                                != crate::config::StoredProfile::from(
321                                    eval.declaration.profile()
322                                ) =>
323                            format!(
324                                " produced by profile {}",
325                                crate::query::profile_name(&execution.profile, &execution.options)
326                            ),
327                        _ => String::new(),
328                    },
329                    match current.readiness {
330                        Readiness::Ready => ".".into(),
331                        Readiness::Wait => format!(
332                            "; its gates still wait for: {}",
333                            current
334                                .unmet_gates
335                                .iter()
336                                .map(ToString::to_string)
337                                .collect::<Vec<_>>()
338                                .join(", ")
339                        ),
340                        Readiness::Blocked => format!(
341                            "; its gates are blocked by RED: {}",
342                            current
343                                .unmet_gates
344                                .iter()
345                                .map(ToString::to_string)
346                                .collect::<Vec<_>>()
347                                .join(", ")
348                        ),
349                    }
350                ),
351            ),
352            Readiness::Blocked => (
353                VerifyAction::Blocked,
354                format!(
355                    "Dependency verdict RED: {}",
356                    current
357                        .unmet_gates
358                        .iter()
359                        .map(ToString::to_string)
360                        .collect::<Vec<_>>()
361                        .join(", ")
362                ),
363            ),
364            Readiness::Wait => (
365                VerifyAction::Wait,
366                format!(
367                    "Waiting for current GREEN dependency evidence: {}",
368                    current
369                        .unmet_gates
370                        .iter()
371                        .map(ToString::to_string)
372                        .collect::<Vec<_>>()
373                        .join(", ")
374                ),
375            ),
376            Readiness::Ready
377                if !force
378                    && matches!(claim(&eval.id), Some(Claim::Wait(_) | Claim::WaitHuman(_))) =>
379            {
380                (
381                    VerifyAction::Wait,
382                    "The current eval and fingerprints have a live execution.".into(),
383                )
384            }
385            Readiness::Ready => match eval.declaration.profile() {
386                Profile::Dependency { .. } => unreachable!("derived above"),
387                Profile::Human { .. } => (
388                    VerifyAction::Execute,
389                    "Record a request awaiting a Human claim and submission.".into(),
390                ),
391                Profile::Runtime { .. } | Profile::Agent { .. } => (
392                    VerifyAction::Execute,
393                    if force {
394                        "An explicitly forced Eval requires a new execution.".into()
395                    } else {
396                        match cache::eval_key(&config, eval, &fingerprints) {
397                            Ok(_) => {
398                                "The current eval and fingerprints have no completed cached result."
399                                    .into()
400                            }
401                            Err(cache::Unkeyed::Derived) => unreachable!("derived above"),
402                            Err(cache::Unkeyed::Target) => {
403                                "The Artifact declares fingerprint: false; saved noncached results satisfy only their own Run."
404                                    .into()
405                            }
406                            Err(cache::Unkeyed::Dependency(id)) => format!(
407                                "Dependency {id} declares fingerprint: false, so this eval has no reuse key; saved noncached results satisfy only their own Run."
408                            ),
409                        }
410                    },
411                ),
412            },
413        };
414        let status = if !force && matches!(claim(&eval.id), Some(Claim::WaitHuman(_))) {
415            EvalCondition::WaitingHuman
416        } else {
417            EvalCondition::from(current.status)
418        };
419        *counts.evals.entry(status).or_default() += 1;
420        if included {
421            counts.action(action);
422        }
423        evals.push(EvalState {
424            id: eval.id.clone(),
425            target: eval.target.clone(),
426            title: eval.declaration.title.clone(),
427            profile: eval.declaration.profile().clone(),
428            selected,
429            included,
430            force,
431            state: status,
432            action,
433            reason,
434            blocked_by: current.blocked_by.iter().copied().map(Into::into).collect(),
435            obligations: obligations_by_artifact[eval.target.as_str()].clone(),
436            eval_def_hash: cache::eval_definition_hash(&eval.declaration),
437            fingerprint: fingerprints
438                .get(&eval.target)
439                .map(|fingerprint| fingerprint.value.clone()),
440            fingerprints: cache::dependencies(&config, eval)
441                .into_iter()
442                .map(|id| {
443                    (
444                        config.artifacts[id].name.clone(),
445                        fingerprints
446                            .get(id)
447                            .map(|fingerprint| fingerprint.value.clone()),
448                    )
449                })
450                .collect(),
451            key: all_keys.get(&eval.id).map(|key| key.value.clone()),
452            last: latest.remove(eval.id.as_str()),
453            changes: keys.get(&eval.id).and_then(|key| {
454                previous
455                    .get(&(eval.id.clone(), key.eval_def_hash.clone()))
456                    .map(|execution| {
457                        cache::changes(
458                            execution,
459                            &eval.target,
460                            key,
461                            fingerprints[&eval.target].manifest.as_ref(),
462                        )
463                    })
464            }),
465        });
466    }
467    let unreadable = store::read_unreadable(&state, Some(&config.root)).await?;
468    Ok(StatusView {
469        unreadable,
470        selection: selection.clone(),
471        recursive: options.recursive,
472        force: options.force,
473        ignore_gates,
474        selected_eval_ids,
475        included_eval_ids,
476        satisfied: obligations.is_empty(),
477        obligations,
478        artifacts,
479        evals,
480        counts,
481    })
482}
483
484impl Counts {
485    fn action(&mut self, action: VerifyAction) {
486        match action {
487            VerifyAction::Execute => self.execute += 1,
488            VerifyAction::Derive => self.derive += 1,
489            VerifyAction::Reuse => self.reuse += 1,
490            VerifyAction::Wait => self.wait += 1,
491            VerifyAction::Blocked => self.blocked += 1,
492        }
493    }
494}