Skip to main content

luft_service/
phases.rs

1//! `service::phases` — build the human-readable phase progress view for the
2//! `luft phases` subcommand.
3//!
4//! Two data sources are supported:
5//!
6//! 1. **Meta (preferred)** — the `meta = {...}` table captured at run-start
7//!    and persisted in `RunCheckpoint::workflow_meta`. Provides phase
8//!    labels, dependencies, and agent counts without any event parsing.
9//! 2. **Events fallback** — when no meta is available (legacy scripts, runs
10//!    persisted before meta was introduced), derive phase structure from the
11//!    `PhaseStarted` events in `events.jsonl`.
12//!
13//! Agent rows come from two sources merged together:
14//! - **Completed agents**: `checkpoint.agent_results` (authoritative).
15//! - **Running agents**: events with `AgentStarted` but no paired `AgentDone`.
16
17use chrono::{DateTime, Utc};
18use luft_core::contract::event::AgentEvent;
19use luft_core::contract::ids::RunId;
20use luft_core::state::{CheckpointStatus, RunCheckpoint};
21use luft_planner::PlanMeta;
22use serde::Serialize;
23use std::collections::{HashMap, HashSet};
24
25/// Where a [`PhasesView`] came from.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
27#[serde(rename_all = "snake_case")]
28pub enum PhasesSource {
29    Meta,
30    EventsFallback,
31}
32
33/// Per-phase display status.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
35#[serde(rename_all = "snake_case")]
36pub enum PhaseStatus {
37    Pending,
38    Running,
39    Completed,
40    Failed,
41}
42
43impl PhaseStatus {
44    pub fn as_str(self) -> &'static str {
45        match self {
46            PhaseStatus::Pending => "pending",
47            PhaseStatus::Running => "running",
48            PhaseStatus::Completed => "completed",
49            PhaseStatus::Failed => "failed",
50        }
51    }
52
53    pub fn bracket(self) -> &'static str {
54        match self {
55            PhaseStatus::Pending => "[pending]",
56            PhaseStatus::Running => "[running]",
57            PhaseStatus::Completed => "[completed]",
58            PhaseStatus::Failed => "[failed]",
59        }
60    }
61}
62
63/// Top-level phases view: run header + phase tree.
64#[derive(Debug, Clone, Serialize)]
65pub struct PhasesView {
66    pub run: RunHeader,
67    pub source: PhasesSource,
68    pub phases: Vec<PhaseRow>,
69}
70
71/// Run-level summary info (the header line in CLI output).
72#[derive(Debug, Clone, Serialize)]
73pub struct RunHeader {
74    pub run_id: RunId,
75    pub task: String,
76    pub status: CheckpointStatus,
77    pub current_phase: u32,
78    pub total_phases: u32,
79    pub total_tokens: u64,
80    pub elapsed_secs: Option<f64>,
81    pub created_at: u64,
82}
83
84/// One phase row (with mounted agent sub-rows).
85#[derive(Debug, Clone, Serialize)]
86pub struct PhaseRow {
87    pub phase_id: u32,
88    pub label: String,
89    pub detail: Option<String>,
90    pub status: PhaseStatus,
91    pub planned: Option<usize>,
92    pub ok: usize,
93    pub failed: usize,
94    pub elapsed_secs: Option<f64>,
95    pub agents: Vec<AgentRow>,
96}
97
98/// Agent sub-row (mounted under a phase).
99#[derive(Debug, Clone, Serialize)]
100pub struct AgentRow {
101    pub short_id: String,
102    pub status: String,
103    pub tokens: Option<u64>,
104    pub findings: usize,
105    pub tool_count: Option<usize>,
106    pub last_message: Option<String>,
107}
108
109/// Build the phases view from a checkpoint + events.
110///
111/// Pure function: no I/O. Callers read files and pass the data in.
112pub fn build_phases_view(checkpoint: &RunCheckpoint, events: &[AgentEvent]) -> PhasesView {
113    let source = if checkpoint.workflow_meta.is_some() {
114        PhasesSource::Meta
115    } else {
116        PhasesSource::EventsFallback
117    };
118
119    let phases = match &checkpoint.workflow_meta {
120        Some(meta_json) => {
121            let meta: luft_planner::PlanMeta = serde_json::from_value(meta_json.clone())
122                .unwrap_or_else(|e| {
123                    tracing::warn!(error = %e, "failed to deserialize workflow_meta; falling back to events");
124                    luft_planner::PlanMeta::default()
125                });
126            build_from_meta(&meta, checkpoint, events)
127        }
128        None => build_from_events(checkpoint, events),
129    };
130
131    let total_phases = phases.len() as u32;
132    let elapsed_secs = compute_run_elapsed(events, checkpoint);
133
134    PhasesView {
135        run: RunHeader {
136            run_id: checkpoint.run_id,
137            task: checkpoint.task.clone(),
138            status: checkpoint.status.clone(),
139            current_phase: checkpoint.current_phase,
140            total_phases,
141            total_tokens: checkpoint.total_tokens,
142            elapsed_secs,
143            created_at: checkpoint.created_at,
144        },
145        source,
146        phases,
147    }
148}
149
150// ---------------------------------------------------------------------------
151// Phase list construction
152// ---------------------------------------------------------------------------
153
154fn build_from_meta(
155    meta: &PlanMeta,
156    checkpoint: &RunCheckpoint,
157    events: &[AgentEvent],
158) -> Vec<PhaseRow> {
159    let phase_started_ts = collect_phase_started_ts(events);
160    let phase_done_info = collect_phase_done_info(events);
161    let completed_map = build_completed_map(checkpoint);
162    let completed_agents = collect_completed_agents(checkpoint);
163    let running_agents = collect_running_agents(events, &checkpoint.agent_results);
164
165    meta.phases
166        .iter()
167        .enumerate()
168        .map(|(idx, mp)| {
169            let phase_id = (idx + 1) as u32;
170
171            let (ok, failed, status) = resolve_phase_status(phase_id, checkpoint, &completed_map);
172
173            let elapsed_secs = compute_phase_elapsed(phase_id, &phase_started_ts, &phase_done_info);
174
175            let agents = build_agent_rows(phase_id, &completed_agents, &running_agents);
176
177            PhaseRow {
178                phase_id,
179                label: mp.label.clone(),
180                detail: Some(mp.detail.clone()).filter(|d| !d.is_empty()),
181                status,
182                planned: if mp.agents > 0 { Some(mp.agents) } else { None },
183                ok,
184                failed,
185                elapsed_secs,
186                agents,
187            }
188        })
189        .collect()
190}
191
192fn build_from_events(checkpoint: &RunCheckpoint, events: &[AgentEvent]) -> Vec<PhaseRow> {
193    let phase_started_ts = collect_phase_started_ts(events);
194    let phase_done_info = collect_phase_done_info(events);
195    let completed_map = build_completed_map(checkpoint);
196    let completed_agents = collect_completed_agents(checkpoint);
197    let running_agents = collect_running_agents(events, &checkpoint.agent_results);
198
199    // Collect phase ids from events (PhaseStarted), deduped and sorted.
200    let mut phase_ids: Vec<(u32, String, usize)> = Vec::new();
201    let mut seen: HashSet<u32> = HashSet::new();
202    for e in events {
203        if let AgentEvent::PhaseStarted {
204            phase_id,
205            label,
206            planned,
207            ..
208        } = e
209        {
210            if seen.insert(*phase_id) {
211                phase_ids.push((*phase_id, label.clone(), *planned));
212            }
213        }
214    }
215
216    if phase_ids.is_empty() {
217        return vec![];
218    }
219
220    phase_ids
221        .iter()
222        .map(|(phase_id, label, planned)| {
223            let (ok, failed, status) = resolve_phase_status(*phase_id, checkpoint, &completed_map);
224            let elapsed_secs =
225                compute_phase_elapsed(*phase_id, &phase_started_ts, &phase_done_info);
226            let agents = build_agent_rows(*phase_id, &completed_agents, &running_agents);
227
228            PhaseRow {
229                phase_id: *phase_id,
230                label: label.clone(),
231                detail: None,
232                status,
233                planned: Some(*planned),
234                ok,
235                failed,
236                elapsed_secs,
237                agents,
238            }
239        })
240        .collect()
241}
242
243// ---------------------------------------------------------------------------
244// Status / ok / failed resolution
245// ---------------------------------------------------------------------------
246
247fn resolve_phase_status(
248    phase_id: u32,
249    checkpoint: &RunCheckpoint,
250    completed_map: &HashMap<u32, (usize, usize)>,
251) -> (usize, usize, PhaseStatus) {
252    if let Some((ok, failed)) = completed_map.get(&phase_id) {
253        let status = if *failed > 0 {
254            PhaseStatus::Failed
255        } else {
256            PhaseStatus::Completed
257        };
258        return (*ok, *failed, status);
259    }
260
261    // Not in completed_phases — infer from current_phase.
262    let status = if checkpoint.current_phase == 0 {
263        PhaseStatus::Pending
264    } else if phase_id < checkpoint.current_phase {
265        PhaseStatus::Completed
266    } else if phase_id == checkpoint.current_phase {
267        PhaseStatus::Running
268    } else {
269        PhaseStatus::Pending
270    };
271
272    (0, 0, status)
273}
274
275fn build_completed_map(checkpoint: &RunCheckpoint) -> HashMap<u32, (usize, usize)> {
276    checkpoint
277        .completed_phases
278        .iter()
279        .map(|s| (s.phase_id, (s.ok, s.failed)))
280        .collect()
281}
282
283// ---------------------------------------------------------------------------
284// Agent row construction
285// ---------------------------------------------------------------------------
286
287/// Collect agents from checkpoint.agent_results, grouped by phase_id.
288fn collect_completed_agents(checkpoint: &RunCheckpoint) -> HashMap<u32, Vec<AgentRow>> {
289    let mut map: HashMap<u32, Vec<AgentRow>> = HashMap::new();
290    for cache in checkpoint.agent_results.values() {
291        let row = AgentRow {
292            short_id: format!("{:.8}", cache.agent_id),
293            status: agent_status_str(&cache.status),
294            tokens: Some(cache.tokens),
295            findings: cache.findings.len(),
296            tool_count: None,
297            last_message: None,
298        };
299        map.entry(cache.phase_id).or_default().push(row);
300    }
301    map
302}
303
304/// Collect running agents: AgentStarted without a paired AgentDone.
305fn collect_running_agents(
306    events: &[AgentEvent],
307    completed: &HashMap<luft_core::contract::ids::AgentId, luft_core::state::AgentResultCache>,
308) -> HashMap<u32, Vec<AgentRow>> {
309    let mut done_agents: HashSet<luft_core::contract::ids::AgentId> = HashSet::new();
310    for e in events {
311        if let AgentEvent::AgentDone { agent_id, .. } = e {
312            done_agents.insert(*agent_id);
313        }
314    }
315
316    let mut map: HashMap<u32, Vec<AgentRow>> = HashMap::new();
317    for e in events {
318        if let AgentEvent::AgentStarted {
319            phase_id, agent_id, ..
320        } = e
321        {
322            if completed.contains_key(agent_id) || done_agents.contains(agent_id) {
323                continue;
324            }
325            let row = AgentRow {
326                short_id: format!("{:.8}", agent_id),
327                status: "running".to_string(),
328                tokens: None,
329                findings: 0,
330                tool_count: None,
331                last_message: None,
332            };
333            map.entry(*phase_id).or_default().push(row);
334        }
335    }
336    map
337}
338
339fn build_agent_rows(
340    phase_id: u32,
341    completed: &HashMap<u32, Vec<AgentRow>>,
342    running: &HashMap<u32, Vec<AgentRow>>,
343) -> Vec<AgentRow> {
344    let mut agents = Vec::new();
345    if let Some(c) = completed.get(&phase_id) {
346        agents.extend(c.iter().cloned());
347    }
348    if let Some(r) = running.get(&phase_id) {
349        agents.extend(r.iter().cloned());
350    }
351    agents
352}
353
354fn agent_status_str(status: &str) -> String {
355    match status {
356        "ok" | "Ok" | "OK" => "completed".to_string(),
357        "error" | "Error" => "failed".to_string(),
358        "cancelled" | "Cancelled" => "cancelled".to_string(),
359        "timed_out" | "TimedOut" | "timedout" => "timed_out".to_string(),
360        other => other.to_lowercase(),
361    }
362}
363
364// ---------------------------------------------------------------------------
365// Timing
366// ---------------------------------------------------------------------------
367
368fn collect_phase_started_ts(events: &[AgentEvent]) -> HashMap<u32, DateTime<Utc>> {
369    let mut map = HashMap::new();
370    for e in events {
371        if let AgentEvent::PhaseStarted { phase_id, ts, .. } = e {
372            map.entry(*phase_id).or_insert(*ts);
373        }
374    }
375    map
376}
377
378#[derive(Debug)]
379struct PhaseDoneInfo {
380    ts: DateTime<Utc>,
381    #[allow(dead_code)]
382    ok: usize,
383    #[allow(dead_code)]
384    failed: usize,
385}
386
387fn collect_phase_done_info(events: &[AgentEvent]) -> HashMap<u32, PhaseDoneInfo> {
388    let mut map = HashMap::new();
389    for e in events {
390        if let AgentEvent::PhaseDone {
391            phase_id,
392            ts,
393            ok,
394            failed,
395            ..
396        } = e
397        {
398            map.insert(
399                *phase_id,
400                PhaseDoneInfo {
401                    ts: *ts,
402                    ok: *ok,
403                    failed: *failed,
404                },
405            );
406        }
407    }
408    map
409}
410
411fn compute_phase_elapsed(
412    phase_id: u32,
413    started: &HashMap<u32, DateTime<Utc>>,
414    done: &HashMap<u32, PhaseDoneInfo>,
415) -> Option<f64> {
416    let start = started.get(&phase_id)?;
417    let end = done.get(&phase_id)?;
418    let dur = end.ts.signed_duration_since(*start);
419    let secs = dur.num_milliseconds() as f64 / 1000.0;
420    if secs >= 0.0 {
421        Some(secs)
422    } else {
423        None
424    }
425}
426
427fn compute_run_elapsed(events: &[AgentEvent], checkpoint: &RunCheckpoint) -> Option<f64> {
428    let run_started = events.iter().find_map(|e| {
429        if let AgentEvent::RunStarted { ts, .. } = e {
430            Some(*ts)
431        } else {
432            None
433        }
434    });
435
436    let run_done = events.iter().find_map(|e| {
437        if let AgentEvent::RunDone { ts, .. } = e {
438            Some(*ts)
439        } else {
440            None
441        }
442    });
443
444    match (run_started, run_done) {
445        (Some(start), Some(end)) => {
446            let secs = end.signed_duration_since(start).num_milliseconds() as f64 / 1000.0;
447            if secs >= 0.0 {
448                Some(secs)
449            } else {
450                None
451            }
452        }
453        (Some(start), None) => {
454            // Run still in progress — use now.
455            let secs = Utc::now().signed_duration_since(start).num_milliseconds() as f64 / 1000.0;
456            if secs >= 0.0 {
457                Some(secs)
458            } else {
459                None
460            }
461        }
462        (None, _) => {
463            // No RunStarted event — fallback to checkpoint timestamps.
464            if checkpoint.created_at > 0 && checkpoint.updated_at > checkpoint.created_at {
465                Some((checkpoint.updated_at - checkpoint.created_at) as f64)
466            } else {
467                None
468            }
469        }
470    }
471}
472
473// ---------------------------------------------------------------------------
474// Tests
475// ---------------------------------------------------------------------------
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480    use chrono::TimeZone;
481    use luft_core::contract::ids::{AgentId, PhaseId, RunId, TokenUsage};
482    use luft_core::state::{AgentResultCache, PhaseSummary};
483    use luft_planner::{MetaPhase, PlanMeta};
484    use std::collections::HashMap;
485
486    fn ts(secs: i64) -> DateTime<Utc> {
487        Utc.timestamp_opt(secs, 0).unwrap()
488    }
489
490    fn cp(meta: Option<PlanMeta>, current_phase: u32) -> RunCheckpoint {
491        RunCheckpoint {
492            run_id: RunId::now_v7(),
493            task: "test task".into(),
494            status: CheckpointStatus::Running,
495            current_phase,
496            completed_phases: vec![],
497            agent_results: HashMap::new(),
498            findings: vec![],
499            total_tokens: 0,
500            created_at: 1000,
501            updated_at: 2000,
502            completed_spans: vec![],
503            workflow_meta: meta.map(|m| serde_json::to_value(m).unwrap()),
504            started_agent_ids: vec![],
505        }
506    }
507
508    fn cp_with_agents(
509        meta: Option<PlanMeta>,
510        current_phase: u32,
511        agents: Vec<(AgentId, PhaseId, &str, u64, usize)>,
512    ) -> RunCheckpoint {
513        let mut checkpoint = cp(meta, current_phase);
514        for (agent_id, phase_id, status, tokens, findings_count) in agents {
515            checkpoint.agent_results.insert(
516                agent_id,
517                AgentResultCache {
518                    agent_id,
519                    phase_id,
520                    status: status.to_string(),
521                    output: serde_json::Value::Null,
522                    findings: (0..findings_count)
523                        .map(|i| luft_core::contract::finding::Finding {
524                            kind: "test".into(),
525                            severity: luft_core::contract::finding::Severity::Info,
526                            title: format!("finding {}", i),
527                            detail: String::new(),
528                            location: None,
529                            evidence: vec![],
530                            data: serde_json::Value::Null,
531                        })
532                        .collect(),
533                    tokens,
534                    completed_at: 0,
535                    cache_key_hash: None,
536                    description: None,
537                    role: None,
538                },
539            );
540            checkpoint.total_tokens += tokens;
541        }
542        checkpoint
543    }
544
545    // ── Meta path tests ──────────────────────────────────────────
546
547    #[test]
548    fn meta_pending_when_no_events() {
549        let meta = PlanMeta {
550            phases: vec![MetaPhase {
551                label: "x".into(),
552                detail: "do x".into(),
553                agents: 2,
554                ..Default::default()
555            }],
556            reasoning: "r".into(),
557        };
558        let checkpoint = cp(Some(meta), 0);
559        let view = build_phases_view(&checkpoint, &[]);
560        assert_eq!(view.source, PhasesSource::Meta);
561        assert_eq!(view.phases.len(), 1);
562        assert_eq!(view.phases[0].status, PhaseStatus::Pending);
563        assert!(view.phases[0].agents.is_empty());
564    }
565
566    #[test]
567    fn meta_running_current_phase() {
568        let meta = PlanMeta {
569            phases: vec![
570                MetaPhase {
571                    label: "a".into(),
572                    detail: "1".into(),
573                    agents: 1,
574                    ..Default::default()
575                },
576                MetaPhase {
577                    label: "b".into(),
578                    detail: "2".into(),
579                    agents: 1,
580                    ..Default::default()
581                },
582                MetaPhase {
583                    label: "c".into(),
584                    detail: "3".into(),
585                    agents: 1,
586                    ..Default::default()
587                },
588            ],
589            reasoning: String::new(),
590        };
591        let checkpoint = cp(Some(meta), 2);
592        let view = build_phases_view(&checkpoint, &[]);
593        assert_eq!(view.phases[0].status, PhaseStatus::Completed);
594        assert_eq!(view.phases[1].status, PhaseStatus::Running);
595        assert_eq!(view.phases[2].status, PhaseStatus::Pending);
596    }
597
598    #[test]
599    fn meta_with_completed_agents() {
600        let meta = PlanMeta {
601            phases: vec![MetaPhase {
602                label: "gather".into(),
603                detail: "collect".into(),
604                agents: 2,
605                ..Default::default()
606            }],
607            reasoning: String::new(),
608        };
609        let a1 = AgentId::now_v7();
610        let a2 = AgentId::now_v7();
611        let checkpoint = cp_with_agents(
612            Some(meta),
613            1,
614            vec![(a1, 1, "ok", 120, 0), (a2, 1, "ok", 80, 1)],
615        );
616        let view = build_phases_view(&checkpoint, &[]);
617        assert_eq!(view.phases[0].agents.len(), 2);
618        let tokens: std::collections::HashSet<_> =
619            view.phases[0].agents.iter().map(|a| a.tokens).collect();
620        assert!(tokens.contains(&Some(120)));
621        assert!(tokens.contains(&Some(80)));
622        let findings_total: usize = view.phases[0].agents.iter().map(|a| a.findings).sum();
623        assert_eq!(findings_total, 1);
624    }
625
626    #[test]
627    fn meta_with_running_agent_from_events() {
628        let meta = PlanMeta {
629            phases: vec![MetaPhase {
630                label: "analyze".into(),
631                detail: "analyze".into(),
632                agents: 2,
633                ..Default::default()
634            }],
635            reasoning: String::new(),
636        };
637        let a1 = AgentId::now_v7();
638        let running_agent = AgentId::now_v7();
639        let checkpoint = cp_with_agents(Some(meta), 1, vec![(a1, 1, "ok", 640, 2)]);
640        let events = vec![AgentEvent::AgentStarted {
641            run_id: checkpoint.run_id,
642            phase_id: 1,
643            agent_id: running_agent,
644            prompt_preview: "working".into(),
645            model: None,
646            description: None,
647            role: None,
648            name: None,
649            agent_seq: 0,
650            ts: Default::default(),
651        }];
652        let view = build_phases_view(&checkpoint, &events);
653        assert_eq!(view.phases[0].agents.len(), 2);
654        // First agent is completed (from checkpoint)
655        assert_eq!(view.phases[0].agents[0].status, "completed");
656        assert_eq!(view.phases[0].agents[0].tokens, Some(640));
657        // Second agent is running (from events)
658        assert_eq!(view.phases[0].agents[1].status, "running");
659        assert_eq!(view.phases[0].agents[1].tokens, None);
660        assert_eq!(view.phases[0].agents[1].tool_count, None);
661    }
662
663    #[test]
664    fn meta_phase_elapsed_from_ts() {
665        let meta = PlanMeta {
666            phases: vec![MetaPhase {
667                label: "p".into(),
668                detail: "d".into(),
669                agents: 1,
670                ..Default::default()
671            }],
672            reasoning: String::new(),
673        };
674        let checkpoint = cp(Some(meta), 1);
675        let events = vec![
676            AgentEvent::PhaseStarted {
677                run_id: checkpoint.run_id,
678                phase_id: 1,
679                label: "p".into(),
680                planned: 1,
681                parent_span_id: None,
682                description: None,
683                role: None,
684                ts: ts(100),
685            },
686            AgentEvent::PhaseDone {
687                run_id: checkpoint.run_id,
688                phase_id: 1,
689                ok: 1,
690                failed: 0,
691                ts: ts(103),
692            },
693        ];
694        let view = build_phases_view(&checkpoint, &events);
695        assert_eq!(view.phases[0].elapsed_secs, Some(3.0));
696    }
697
698    #[test]
699    fn meta_phase_elapsed_none_when_ts_missing() {
700        let meta = PlanMeta {
701            phases: vec![MetaPhase {
702                label: "p".into(),
703                detail: "d".into(),
704                agents: 1,
705                ..Default::default()
706            }],
707            reasoning: String::new(),
708        };
709        let checkpoint = cp(Some(meta), 1);
710        // No events at all → elapsed None
711        let view = build_phases_view(&checkpoint, &[]);
712        assert_eq!(view.phases[0].elapsed_secs, None);
713    }
714
715    #[test]
716    fn meta_failed_phase_status() {
717        let meta = PlanMeta {
718            phases: vec![MetaPhase {
719                label: "p".into(),
720                detail: "d".into(),
721                agents: 1,
722                ..Default::default()
723            }],
724            reasoning: String::new(),
725        };
726        let mut checkpoint = cp(Some(meta), 1);
727        checkpoint.completed_phases = vec![PhaseSummary {
728            phase_id: 1,
729            label: "p".into(),
730            planned: 1,
731            ok: 0,
732            failed: 2,
733            description: None,
734            role: None,
735        }];
736        let view = build_phases_view(&checkpoint, &[]);
737        assert_eq!(view.phases[0].status, PhaseStatus::Failed);
738        assert_eq!(view.phases[0].ok, 0);
739        assert_eq!(view.phases[0].failed, 2);
740    }
741
742    // ── Events fallback tests ────────────────────────────────────
743
744    #[test]
745    fn fallback_events_reconstructs_phases() {
746        let checkpoint = cp(None, 1);
747        let events = vec![
748            AgentEvent::PhaseStarted {
749                run_id: checkpoint.run_id,
750                phase_id: 1,
751                label: "discover".into(),
752                planned: 2,
753                parent_span_id: None,
754                description: None,
755                role: None,
756                ts: ts(100),
757            },
758            AgentEvent::PhaseStarted {
759                run_id: checkpoint.run_id,
760                phase_id: 2,
761                label: "report".into(),
762                planned: 1,
763                parent_span_id: None,
764                description: None,
765                role: None,
766                ts: ts(200),
767            },
768        ];
769        let view = build_phases_view(&checkpoint, &events);
770        assert_eq!(view.source, PhasesSource::EventsFallback);
771        assert_eq!(view.phases.len(), 2);
772        assert_eq!(view.phases[0].label, "discover");
773        assert_eq!(view.phases[1].label, "report");
774        assert!(view.phases[0].detail.is_none());
775    }
776
777    #[test]
778    fn fallback_no_events_empty_phases() {
779        let checkpoint = cp(None, 0);
780        let view = build_phases_view(&checkpoint, &[]);
781        assert_eq!(view.source, PhasesSource::EventsFallback);
782        assert!(view.phases.is_empty());
783    }
784
785    // ── Run header tests ────────────────────────────────────────
786
787    #[test]
788    fn header_fields_populated() {
789        let meta = PlanMeta {
790            phases: vec![MetaPhase {
791                label: "x".into(),
792                detail: "y".into(),
793                agents: 1,
794                ..Default::default()
795            }],
796            reasoning: String::new(),
797        };
798        let checkpoint = cp(Some(meta), 1);
799        let view = build_phases_view(&checkpoint, &[]);
800        assert_eq!(view.run.task, "test task");
801        assert_eq!(view.run.current_phase, 1);
802        assert_eq!(view.run.total_phases, 1);
803    }
804
805    #[test]
806    fn header_elapsed_from_run_events() {
807        let checkpoint = cp(None, 1);
808        let events = vec![
809            AgentEvent::RunStarted {
810                run_id: checkpoint.run_id,
811                task: "t".into(),
812                ts: ts(100),
813            },
814            AgentEvent::RunDone {
815                run_id: checkpoint.run_id,
816                status: luft_core::contract::event::RunStatus::Completed,
817                total_tokens: TokenUsage::default(),
818                report: serde_json::Value::Null,
819                ts: ts(142),
820            },
821        ];
822        let view = build_phases_view(&checkpoint, &events);
823        assert_eq!(view.run.elapsed_secs, Some(42.0));
824    }
825
826    #[test]
827    fn header_elapsed_fallback_to_checkpoint() {
828        let checkpoint = cp(None, 1);
829        let view = build_phases_view(&checkpoint, &[]);
830        // created_at=1000, updated_at=2000 → 1000 secs
831        assert_eq!(view.run.elapsed_secs, Some(1000.0));
832    }
833
834    // ── Utility tests ───────────────────────────────────────────
835
836    #[test]
837    fn agent_status_str_variants() {
838        assert_eq!(agent_status_str("ok"), "completed");
839        assert_eq!(agent_status_str("error"), "failed");
840        assert_eq!(agent_status_str("cancelled"), "cancelled");
841        assert_eq!(agent_status_str("timed_out"), "timed_out");
842    }
843
844    #[test]
845    fn phase_status_bracket() {
846        assert_eq!(PhaseStatus::Pending.bracket(), "[pending]");
847        assert_eq!(PhaseStatus::Running.bracket(), "[running]");
848        assert_eq!(PhaseStatus::Completed.bracket(), "[completed]");
849        assert_eq!(PhaseStatus::Failed.bracket(), "[failed]");
850    }
851
852    #[test]
853    fn pending_phase_has_no_agents() {
854        let meta = PlanMeta {
855            phases: vec![
856                MetaPhase {
857                    label: "a".into(),
858                    detail: "1".into(),
859                    agents: 1,
860                    ..Default::default()
861                },
862                MetaPhase {
863                    label: "b".into(),
864                    detail: "2".into(),
865                    agents: 1,
866                    ..Default::default()
867                },
868            ],
869            reasoning: String::new(),
870        };
871        let checkpoint = cp(Some(meta), 0);
872        let view = build_phases_view(&checkpoint, &[]);
873        // Both pending, no agents
874        for phase in &view.phases {
875            assert_eq!(phase.status, PhaseStatus::Pending);
876            assert!(phase.agents.is_empty());
877        }
878    }
879}