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        }];
651        let view = build_phases_view(&checkpoint, &events);
652        assert_eq!(view.phases[0].agents.len(), 2);
653        // First agent is completed (from checkpoint)
654        assert_eq!(view.phases[0].agents[0].status, "completed");
655        assert_eq!(view.phases[0].agents[0].tokens, Some(640));
656        // Second agent is running (from events)
657        assert_eq!(view.phases[0].agents[1].status, "running");
658        assert_eq!(view.phases[0].agents[1].tokens, None);
659        assert_eq!(view.phases[0].agents[1].tool_count, None);
660    }
661
662    #[test]
663    fn meta_phase_elapsed_from_ts() {
664        let meta = PlanMeta {
665            phases: vec![MetaPhase {
666                label: "p".into(),
667                detail: "d".into(),
668                agents: 1,
669                ..Default::default()
670            }],
671            reasoning: String::new(),
672        };
673        let checkpoint = cp(Some(meta), 1);
674        let events = vec![
675            AgentEvent::PhaseStarted {
676                run_id: checkpoint.run_id,
677                phase_id: 1,
678                label: "p".into(),
679                planned: 1,
680                parent_span_id: None,
681                description: None,
682                role: None,
683                ts: ts(100),
684            },
685            AgentEvent::PhaseDone {
686                run_id: checkpoint.run_id,
687                phase_id: 1,
688                ok: 1,
689                failed: 0,
690                ts: ts(103),
691            },
692        ];
693        let view = build_phases_view(&checkpoint, &events);
694        assert_eq!(view.phases[0].elapsed_secs, Some(3.0));
695    }
696
697    #[test]
698    fn meta_phase_elapsed_none_when_ts_missing() {
699        let meta = PlanMeta {
700            phases: vec![MetaPhase {
701                label: "p".into(),
702                detail: "d".into(),
703                agents: 1,
704                ..Default::default()
705            }],
706            reasoning: String::new(),
707        };
708        let checkpoint = cp(Some(meta), 1);
709        // No events at all → elapsed None
710        let view = build_phases_view(&checkpoint, &[]);
711        assert_eq!(view.phases[0].elapsed_secs, None);
712    }
713
714    #[test]
715    fn meta_failed_phase_status() {
716        let meta = PlanMeta {
717            phases: vec![MetaPhase {
718                label: "p".into(),
719                detail: "d".into(),
720                agents: 1,
721                ..Default::default()
722            }],
723            reasoning: String::new(),
724        };
725        let mut checkpoint = cp(Some(meta), 1);
726        checkpoint.completed_phases = vec![PhaseSummary {
727            phase_id: 1,
728            label: "p".into(),
729            planned: 1,
730            ok: 0,
731            failed: 2,
732            description: None,
733            role: None,
734        }];
735        let view = build_phases_view(&checkpoint, &[]);
736        assert_eq!(view.phases[0].status, PhaseStatus::Failed);
737        assert_eq!(view.phases[0].ok, 0);
738        assert_eq!(view.phases[0].failed, 2);
739    }
740
741    // ── Events fallback tests ────────────────────────────────────
742
743    #[test]
744    fn fallback_events_reconstructs_phases() {
745        let checkpoint = cp(None, 1);
746        let events = vec![
747            AgentEvent::PhaseStarted {
748                run_id: checkpoint.run_id,
749                phase_id: 1,
750                label: "discover".into(),
751                planned: 2,
752                parent_span_id: None,
753                description: None,
754                role: None,
755                ts: ts(100),
756            },
757            AgentEvent::PhaseStarted {
758                run_id: checkpoint.run_id,
759                phase_id: 2,
760                label: "report".into(),
761                planned: 1,
762                parent_span_id: None,
763                description: None,
764                role: None,
765                ts: ts(200),
766            },
767        ];
768        let view = build_phases_view(&checkpoint, &events);
769        assert_eq!(view.source, PhasesSource::EventsFallback);
770        assert_eq!(view.phases.len(), 2);
771        assert_eq!(view.phases[0].label, "discover");
772        assert_eq!(view.phases[1].label, "report");
773        assert!(view.phases[0].detail.is_none());
774    }
775
776    #[test]
777    fn fallback_no_events_empty_phases() {
778        let checkpoint = cp(None, 0);
779        let view = build_phases_view(&checkpoint, &[]);
780        assert_eq!(view.source, PhasesSource::EventsFallback);
781        assert!(view.phases.is_empty());
782    }
783
784    // ── Run header tests ────────────────────────────────────────
785
786    #[test]
787    fn header_fields_populated() {
788        let meta = PlanMeta {
789            phases: vec![MetaPhase {
790                label: "x".into(),
791                detail: "y".into(),
792                agents: 1,
793                ..Default::default()
794            }],
795            reasoning: String::new(),
796        };
797        let checkpoint = cp(Some(meta), 1);
798        let view = build_phases_view(&checkpoint, &[]);
799        assert_eq!(view.run.task, "test task");
800        assert_eq!(view.run.current_phase, 1);
801        assert_eq!(view.run.total_phases, 1);
802    }
803
804    #[test]
805    fn header_elapsed_from_run_events() {
806        let checkpoint = cp(None, 1);
807        let events = vec![
808            AgentEvent::RunStarted {
809                run_id: checkpoint.run_id,
810                task: "t".into(),
811                ts: ts(100),
812            },
813            AgentEvent::RunDone {
814                run_id: checkpoint.run_id,
815                status: luft_core::contract::event::RunStatus::Completed,
816                total_tokens: TokenUsage::default(),
817                report: serde_json::Value::Null,
818                ts: ts(142),
819            },
820        ];
821        let view = build_phases_view(&checkpoint, &events);
822        assert_eq!(view.run.elapsed_secs, Some(42.0));
823    }
824
825    #[test]
826    fn header_elapsed_fallback_to_checkpoint() {
827        let checkpoint = cp(None, 1);
828        let view = build_phases_view(&checkpoint, &[]);
829        // created_at=1000, updated_at=2000 → 1000 secs
830        assert_eq!(view.run.elapsed_secs, Some(1000.0));
831    }
832
833    // ── Utility tests ───────────────────────────────────────────
834
835    #[test]
836    fn agent_status_str_variants() {
837        assert_eq!(agent_status_str("ok"), "completed");
838        assert_eq!(agent_status_str("error"), "failed");
839        assert_eq!(agent_status_str("cancelled"), "cancelled");
840        assert_eq!(agent_status_str("timed_out"), "timed_out");
841    }
842
843    #[test]
844    fn phase_status_bracket() {
845        assert_eq!(PhaseStatus::Pending.bracket(), "[pending]");
846        assert_eq!(PhaseStatus::Running.bracket(), "[running]");
847        assert_eq!(PhaseStatus::Completed.bracket(), "[completed]");
848        assert_eq!(PhaseStatus::Failed.bracket(), "[failed]");
849    }
850
851    #[test]
852    fn pending_phase_has_no_agents() {
853        let meta = PlanMeta {
854            phases: vec![
855                MetaPhase {
856                    label: "a".into(),
857                    detail: "1".into(),
858                    agents: 1,
859                    ..Default::default()
860                },
861                MetaPhase {
862                    label: "b".into(),
863                    detail: "2".into(),
864                    agents: 1,
865                    ..Default::default()
866                },
867            ],
868            reasoning: String::new(),
869        };
870        let checkpoint = cp(Some(meta), 0);
871        let view = build_phases_view(&checkpoint, &[]);
872        // Both pending, no agents
873        for phase in &view.phases {
874            assert_eq!(phase.status, PhaseStatus::Pending);
875            assert!(phase.agents.is_empty());
876        }
877    }
878}