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