1use crate::run::{E2eStatus, GateStatus, Liveness, RunState};
4use serde::Serialize;
5
6#[derive(Debug, Serialize)]
7pub struct Progress {
9 pub activity: String,
11 pub phases: Vec<Phase>,
13 pub rounds: Vec<ReviewProgress>,
15}
16
17#[derive(Debug, Serialize)]
18pub struct Phase {
20 pub key: &'static str,
22 pub label: &'static str,
24 pub state: &'static str,
26 pub detail: String,
28}
29
30#[derive(Debug, Serialize)]
32pub struct ReviewProgress {
33 pub round: usize,
35 pub detail: String,
37}
38
39fn phase(node: &str) -> &'static str {
40 match node {
41 "prep" => "prep",
42 "advise" | "advise-synthesis" => "advice",
43 "implement" | "implementing" => "implement",
44 "judge" | "judging" | "deliberate" | "deliberating" | "vote" | "voting" => "selection",
45 "review" | "reviewing" | "fix" | "verify" => "review",
46 "gate" | "gating" | "gate-fix" => "gate",
47 "land" | "landing" | "merge" => "land",
48 _ => "other",
49 }
50}
51
52fn implementation_results(s: &RunState) -> Option<String> {
53 let n = s
54 .candidates
55 .iter()
56 .filter(|c| c.duration_ms > 0 || c.failed.is_some() || c.verified_noop.is_some())
57 .count();
58 (n > 0).then(|| format!("{n} implementation results recorded"))
59}
60
61pub fn activity(s: &RunState, live: Liveness, waiting: bool) -> String {
63 if waiting {
64 "Waiting for your answer".to_owned()
65 } else if let Some(w) = &s.resource_wait {
66 format!("Waiting for {}", w.kind)
67 } else if let Some(b) = &s.resource_block {
68 format!("Held · {}", b.description())
69 } else if s.parked {
70 "Parked · work preserved".to_owned()
71 } else if s.status.done() {
72 s.status.as_str().replace('_', " ")
73 } else if live == Liveness::Dead {
74 "Driver exited · work preserved".to_owned()
75 } else if live == Liveness::Unknown {
76 format!("{} · driver unconfirmed", s.status.as_str())
77 } else if !s.active.is_empty() {
78 format!(
79 "{} · {}",
80 s.status.as_str(),
81 s.active.keys().cloned().collect::<Vec<_>>().join(", ")
82 )
83 } else {
84 format!("{} · no active seat recorded", s.status.as_str())
85 }
86}
87
88pub fn build(s: &RunState, live: Liveness, waiting: bool) -> Progress {
90 let stopped =
91 s.status.done() || s.parked || s.resource_block.is_some() || live == Liveness::Dead;
92 let activity = activity(s, live, waiting);
93 let review_only = crate::graph::is_review_run(s);
94 let halted = matches!(
95 s.status,
96 crate::run::RunStatus::Blocked
97 | crate::run::RunStatus::Failed
98 | crate::run::RunStatus::Stalled
99 );
100 let current = if halted {
101 s.resource_block
102 .as_ref()
103 .map(|b| phase(&b.node))
104 .or_else(|| s.events.last().map(|e| phase(&e.node)))
105 .unwrap_or("other")
106 } else if s.status == crate::run::RunStatus::Prep
107 && s.active.values().any(|a| phase(&a.node) == "advice")
108 {
109 "advice"
110 } else {
111 phase(s.status.as_str())
112 };
113 let mut phases = Vec::new();
114 for (key, label, applicable, evidence) in [
115 (
116 "prep",
117 "Prepare",
118 true,
119 s.prep_failure
120 .as_ref()
121 .map(|_| "Preparation failed".to_owned())
122 .or_else(|| {
123 (s.prep_complete == Some(true)).then(|| "Preparation complete".to_owned())
124 }),
125 ),
126 (
127 "advice",
128 "Design advice",
129 !review_only && s.config.graph.advise,
130 s.advice
131 .as_ref()
132 .map(|a| format!("{} advisor records", a.records.len())),
133 ),
134 (
135 "implement",
136 "Parallel implementation",
137 !review_only,
138 implementation_results(s),
139 ),
140 (
141 "selection",
142 "Selection",
143 !review_only && s.config.graph.implementers > 1 && !s.judge_skipped,
144 s.tally.as_ref().map(|_| "Decision recorded".to_owned()),
145 ),
146 (
147 "review",
148 "Review & verification",
149 true,
150 (!s.reviews.is_empty())
151 .then(|| format!("{} recorded rounds · see results below", s.reviews.len())),
152 ),
153 (
154 "gate",
155 "Final gate",
156 true,
157 match s.gate_status() {
158 GateStatus::NotRun => None,
159 GateStatus::NotConfigured => Some("No commands configured".to_owned()),
160 GateStatus::Passed => Some("Passed on recorded head".to_owned()),
161 GateStatus::Failed => Some("Gate failed".to_owned()),
162 },
163 ),
164 (
165 "land",
166 "Publish & land",
167 true,
168 if s.status == crate::run::RunStatus::Merged {
169 Some("Merge confirmed".to_owned())
170 } else {
171 s.merge
172 .as_ref()
173 .map(|_| "Publication outcome recorded · see Landing".to_owned())
174 },
175 ),
176 ] {
177 let active: Vec<_> = s
178 .active
179 .iter()
180 .filter(|(_, a)| phase(&a.node) == key)
181 .map(|(k, _)| k.as_str())
182 .collect();
183 let here = (!s.status.done() || halted) && (!active.is_empty() || current == key);
184 let state = if !applicable && !here && evidence.is_none() {
185 "skipped"
186 } else if here && (waiting || s.resource_wait.is_some() || stopped) {
187 "paused"
188 } else if here && live == Liveness::Unknown {
189 "unconfirmed"
190 } else if here {
191 "current"
192 } else if (key == "prep" && s.prep_failure.is_some())
193 || (key == "gate" && s.gate_status() == GateStatus::Failed)
194 {
195 "failed"
196 } else if (key == "prep" && s.prep_complete == Some(true))
197 || (key == "selection" && s.tally.is_some())
198 || (key == "gate" && s.gate_status() == GateStatus::Passed)
199 || (key == "land" && s.status == crate::run::RunStatus::Merged)
200 {
201 "complete"
202 } else if evidence.is_some() {
203 "recorded"
204 } else if s.status.done()
205 || [
206 "prep",
207 "advice",
208 "implement",
209 "selection",
210 "review",
211 "gate",
212 "land",
213 ]
214 .iter()
215 .position(|p| *p == key)
216 .zip(
217 [
218 "prep",
219 "advice",
220 "implement",
221 "selection",
222 "review",
223 "gate",
224 "land",
225 ]
226 .iter()
227 .position(|p| *p == current),
228 )
229 .is_some_and(|(step, now)| step < now)
230 {
231 "unrecorded"
232 } else {
233 "upcoming"
234 };
235 let detail = if !active.is_empty() {
236 active.join(" · ")
237 } else {
238 evidence.unwrap_or_else(|| {
239 match state {
240 "skipped" => "Not applicable to this run",
241 "unknown" | "unrecorded" => "No result recorded",
242 "current" => "Current phase",
243 "unconfirmed" => "Driver unconfirmed",
244 "paused" => "Stopped here",
245 _ => "No result yet",
246 }
247 .to_owned()
248 })
249 };
250 phases.push(Phase {
251 key,
252 label,
253 state,
254 detail,
255 });
256 }
257 if s.active.values().any(|a| phase(&a.node) == "other") {
258 phases.push(Phase {
259 key: "other",
260 label: "Other activity",
261 state: if stopped || waiting {
262 "paused"
263 } else if live == Liveness::Unknown {
264 "unconfirmed"
265 } else {
266 "current"
267 },
268 detail: s
269 .active
270 .iter()
271 .filter(|(_, a)| phase(&a.node) == "other")
272 .map(|(k, a)| format!("{k} · {}", a.node))
273 .collect::<Vec<_>>()
274 .join(", "),
275 });
276 }
277 let rounds = s
278 .reviews
279 .iter()
280 .map(|r| {
281 let verification = match r.e2e_status() {
282 E2eStatus::Passed => "verification passed on recorded head",
283 E2eStatus::Failed => "verification failed on recorded head",
284 E2eStatus::Deferred => "verification deferred",
285 E2eStatus::NotConfigured => "no verification commands configured",
286 E2eStatus::ResourceBlocked => "verification resource blocked",
287 };
288 let panel = if r.expected > 0 {
289 format!("{}/{} reviewers answered", r.answered, r.expected)
290 } else {
291 format!(
292 "{} reviewer records · panel completeness unknown",
293 r.reviews.len()
294 )
295 };
296 ReviewProgress {
297 round: r.round,
298 detail: format!(
299 "{panel} · {verification}{}",
300 if r.fix.is_some() {
301 " · fix recorded"
302 } else {
303 ""
304 }
305 ),
306 }
307 })
308 .collect();
309 Progress {
310 activity,
311 phases,
312 rounds,
313 }
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use crate::{config::Config, run::RunStatus};
320
321 fn run() -> RunState {
322 RunState::new(
323 "repo".into(),
324 "main".into(),
325 "abc".into(),
326 "task".into(),
327 Config::default(),
328 )
329 }
330
331 #[test]
332 fn progress_does_not_infer_previous_success_from_phase_order() {
333 let mut s = run();
334 s.status = RunStatus::Gating;
335 let p = build(&s, Liveness::Live, false);
336 assert_eq!(
337 p.phases.iter().find(|p| p.key == "gate").unwrap().state,
338 "current"
339 );
340 assert!(!p.phases.iter().any(|p| p.state == "recorded"));
341 assert_eq!(
342 p.phases
343 .iter()
344 .find(|p| p.key == "implement")
345 .unwrap()
346 .state,
347 "unrecorded"
348 );
349 assert_eq!(
350 p.phases.iter().find(|p| p.key == "land").unwrap().state,
351 "upcoming"
352 );
353 s.status = RunStatus::Merged;
354 assert_eq!(
355 build(&s, Liveness::Dead, false)
356 .phases
357 .iter()
358 .find(|p| p.key == "implement")
359 .unwrap()
360 .state,
361 "unrecorded"
362 );
363 assert!(
364 !build(&s, Liveness::Dead, false)
365 .phases
366 .iter()
367 .any(|p| matches!(p.state, "current" | "recorded"))
368 );
369 }
370
371 #[test]
372 fn progress_parallel_seats_waits_exit_and_unknown_remain_distinct() {
373 let mut s = run();
374 s.status = RunStatus::Implementing;
375 for key in ["implement-A", "implement-B"] {
376 s.seat_started("implement", key, std::time::Duration::from_secs(600), 0);
377 }
378 let p = build(&s, Liveness::Live, false);
379 assert!(p.activity.contains("implement-A, implement-B"));
380 assert!(
381 p.phases
382 .iter()
383 .find(|p| p.key == "implement")
384 .unwrap()
385 .detail
386 .contains("implement-B")
387 );
388 assert_eq!(
389 build(&s, Liveness::Live, true).activity,
390 "Waiting for your answer"
391 );
392 assert!(
393 build(&s, Liveness::Unknown, false)
394 .activity
395 .contains("unconfirmed")
396 );
397 let dead = build(&s, Liveness::Dead, false);
398 assert!(dead.activity.contains("Driver exited"));
399 assert_eq!(
400 dead.phases
401 .iter()
402 .find(|p| p.key == "implement")
403 .unwrap()
404 .state,
405 "paused"
406 );
407 s.active.clear();
408 s.seat_started(
409 "new-node",
410 "new-seat",
411 std::time::Duration::from_secs(60),
412 0,
413 );
414 assert_eq!(
415 build(&s, Liveness::Live, false).phases.last().unwrap().key,
416 "other"
417 );
418 assert_eq!(
419 build(&s, Liveness::Live, false)
420 .phases
421 .iter()
422 .find(|p| p.key == "implement")
423 .unwrap()
424 .state,
425 "current"
426 );
427 }
428
429 #[test]
430 fn progress_resource_wait_and_stopped_panel_are_not_active_work() {
431 let mut s = run();
432 s.status = RunStatus::Reviewing;
433 s.resource_wait = Some(crate::run::ResourceWait {
434 node: "verify".into(),
435 kind: "build cache".into(),
436 holder: "another run".into(),
437 since: jiff::Timestamp::now(),
438 limit_secs: 60,
439 });
440 let p = build(&s, Liveness::Live, false);
441 assert_eq!(p.activity, "Waiting for build cache");
442 assert_eq!(
443 p.phases.iter().find(|p| p.key == "review").unwrap().state,
444 "paused"
445 );
446 s.resource_wait = None;
447 s.status = RunStatus::Stalled;
448 s.event("review", "Panel stopped");
449 let p = build(&s, Liveness::Dead, false);
450 assert_eq!(p.activity, "stalled");
451 assert_eq!(
452 p.phases.iter().find(|p| p.key == "review").unwrap().state,
453 "paused"
454 );
455 s.status = RunStatus::Reviewing;
456 assert_eq!(
457 build(&s, Liveness::Unknown, false)
458 .phases
459 .iter()
460 .find(|p| p.key == "review")
461 .unwrap()
462 .state,
463 "unconfirmed"
464 );
465 }
466
467 #[test]
468 fn progress_review_only_skips_competition_and_preserves_gate_failure() {
469 let mut s = run();
470 s.reviewed_commits = Some(vec![]);
471 s.status = RunStatus::Reviewing;
472 let p = build(&s, Liveness::Live, false);
473 assert_eq!(
474 p.phases
475 .iter()
476 .find(|p| p.key == "implement")
477 .unwrap()
478 .state,
479 "skipped"
480 );
481 assert_eq!(
482 p.phases
483 .iter()
484 .find(|p| p.key == "selection")
485 .unwrap()
486 .state,
487 "skipped"
488 );
489 assert!(
490 p.phases
491 .iter()
492 .find(|p| p.key == "review")
493 .unwrap()
494 .detail
495 .contains("Current")
496 );
497 s.status = RunStatus::Blocked;
498 s.gate_ran = true;
499 s.gate.push(crate::run::CommandOutcome {
500 command: "check".into(),
501 code: Some(1),
502 output_tail: String::new(),
503 duration_ms: 1,
504 resource_blocked: false,
505 });
506 let p = build(&s, Liveness::Dead, false);
507 assert_eq!(p.activity, "blocked");
508 assert_eq!(
509 p.phases.iter().find(|p| p.key == "gate").unwrap().detail,
510 "Gate failed"
511 );
512 }
513}