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    /// Existing-branch review; retained candidates are not this run's implementation.
10    pub review_only: bool,
11    /// Current activity, including waits and driver exit.
12    pub activity: String,
13    /// Ordered phases; ordering implies no success.
14    pub phases: Vec<Phase>,
15    /// Recorded rounds only; no guessed in-flight round number.
16    pub rounds: Vec<ReviewProgress>,
17}
18
19#[derive(Debug, Serialize)]
20/// A stage with its own evidence, independent of adjacent stages.
21pub struct Phase {
22    /// Stable DOM identity.
23    pub key: &'static str,
24    /// Human readable stage name.
25    pub label: &'static str,
26    /// Display state: current, paused, complete, failed, recorded, skipped, unrecorded, unknown or upcoming.
27    pub state: &'static str,
28    /// Recorded results or active seat names.
29    pub detail: String,
30}
31
32/// A recorded review attempt, including verification and panel completeness.
33#[derive(Debug, Serialize)]
34pub struct ReviewProgress {
35    /// The persisted round number.
36    pub round: usize,
37    /// Historical result; does not claim verification of a later head.
38    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
63/// Compact activity shared by run cards and task navigation.
64pub 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
90/// Derive progress without mutating persisted state or interpreting event prose.
91pub 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        // Historical implementation results must not become this review's work.
477        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}