Skip to main content

magi/
run_progress.rs

1//! Display-only progress from structured records. Phase order is never evidence
2//! that an earlier phase succeeded. This view cannot authorize an operation.
3use crate::run::{E2eStatus, GateStatus, Liveness, RunState};
4use serde::Serialize;
5
6#[derive(Debug, Serialize)]
7/// Shared presentation for list cards and run details.
8pub struct Progress {
9    /// Current activity, including waits and driver exit.
10    pub activity: String,
11    /// Ordered phases; ordering implies no success.
12    pub phases: Vec<Phase>,
13    /// Recorded rounds only; no guessed in-flight round number.
14    pub rounds: Vec<ReviewProgress>,
15}
16
17#[derive(Debug, Serialize)]
18/// A stage with its own evidence, independent of adjacent stages.
19pub struct Phase {
20    /// Stable DOM identity.
21    pub key: &'static str,
22    /// Human readable stage name.
23    pub label: &'static str,
24    /// Display state: current, paused, complete, failed, recorded, skipped, unrecorded, unknown or upcoming.
25    pub state: &'static str,
26    /// Recorded results or active seat names.
27    pub detail: String,
28}
29
30/// A recorded review attempt, including verification and panel completeness.
31#[derive(Debug, Serialize)]
32pub struct ReviewProgress {
33    /// The persisted round number.
34    pub round: usize,
35    /// Historical result; does not claim verification of a later head.
36    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
61/// Compact activity shared by run cards and task navigation.
62pub 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
88/// Derive progress without mutating persisted state or interpreting event prose.
89pub 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}