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