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 pub eval_def_hash: crate::types::DefinitionHash,
77 pub fingerprint: Option<crate::types::Fingerprint>,
79 pub fingerprints: BTreeMap<crate::config::ArtifactName, Option<crate::types::Fingerprint>>,
81 pub key: Option<crate::types::ReuseKey>,
83 #[serde(skip_serializing_if = "Option::is_none")]
84 pub last: Option<LastRequest>,
85 #[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
102pub 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 ¶llelism,
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 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 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 _ 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}