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