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