//! Pipeline rendering: builds the full status text from pipeline items. use crate::agents::{AgentPool, AgentStatus}; use crate::config::ProjectConfig; use crate::pipeline_state::{ArchiveReason, Pipeline, PipelineItem, Stage, Status}; use std::collections::{HashMap, HashSet}; /// Map a stage to its display section label, or `None` to skip it entirely. /// /// This routes through [`Stage::pipeline`] so chat output and the web UI use /// the same column derivation. Frozen stories appear in their underlying /// `resume_to` column (handled inside `Stage::pipeline`) and items in /// `Stage::Archived` (with non-Blocked reasons) stay hidden. pub(crate) fn display_section(s: &Stage) -> Option<&'static str> { // Archived items with non-Blocked reasons are hidden from chat output. if matches!(s, Stage::Archived { reason, .. } if !matches!(reason, ArchiveReason::Blocked { .. })) { return None; } Some(match s.pipeline() { Pipeline::Backlog => "Backlog", Pipeline::Coding => "In Progress", Pipeline::Qa => "QA", Pipeline::Merge => "Merge", Pipeline::Done => "Done", Pipeline::Closed => "Closed", Pipeline::Archived => return None, }) } /// Check which dependency numbers from `item.depends_on` are unmet. /// /// A dependency is considered met if the dep is in `Done` or `Archived` stage /// in `all_items`. If the dep is not found in `all_items` at all (e.g. it was /// archived before the CRDT migration and has no row), it is treated as met. pub(crate) fn unmet_deps_from_items(item: &PipelineItem, all_items: &[PipelineItem]) -> Vec { item.depends_on .iter() .filter_map(|dep_id| { // dep_id.0 is the raw number string (e.g. "999") as projected // from PipelineItemView.depends_on: Vec. let dep_num: u32 = dep_id.0.parse().ok()?; // Find the dep by matching the numeric prefix of its story_id. let dep = all_items.iter().find(|i| { i.story_id.0 == dep_id.0 || i.story_id.0.split('_').next() == Some(dep_id.0.as_str()) }); match dep { Some(d) if matches!( d.stage, Stage::Done { .. } | Stage::Archived { .. } | Stage::Abandoned { .. } | Stage::Superseded { .. } | Stage::Rejected { .. } ) => { None } Some(_) => Some(dep_num), // Found but not done = unmet None => None, // Not in CRDT; treat as met } }) .collect() } /// Extract the first non-empty line from `text`, truncated to `max_len` chars. pub(crate) fn first_non_empty_snippet(text: &str, max_len: usize) -> String { let line = text.lines().find(|l| !l.trim().is_empty()).unwrap_or(""); let mut chars = line.chars(); let truncated: String = chars.by_ref().take(max_len).collect(); if chars.next().is_some() { format!("{truncated}…") } else { truncated } } /// Build the full pipeline status text formatted for Matrix (markdown). pub(crate) fn build_pipeline_status(project_root: &std::path::Path, agents: &AgentPool) -> String { let items = crate::pipeline_state::read_all_typed(); build_status_from_items(project_root, agents, &items) } /// Inner implementation that accepts pre-loaded items for testability. pub(crate) fn build_status_from_items( project_root: &std::path::Path, agents: &AgentPool, items: &[PipelineItem], ) -> String { // Build a map from story_id → active AgentInfo for quick lookup. let active_agents = agents.list_agents_nonblocking(); let active_map: HashMap = active_agents .iter() .filter(|a| matches!(a.status, AgentStatus::Running | AgentStatus::Pending)) .map(|a| (a.story_id.clone(), a)) .collect(); // Build a per-story cost map from the in-memory rollup register. // Only completed stories have entries; in-progress stories show no cost. let cost_by_story: HashMap = crate::service::agents::cost_rollup::all_rollups(project_root) .into_iter() .map(|r| (r.story_id, r.total_cost_usd)) .collect(); let config = ProjectConfig::load(project_root).ok(); // Pre-fetch working tree state for all Coding-column items whose worktrees exist. let dirty_files_by_story: HashMap = items .iter() .filter(|i| i.stage.pipeline() == Pipeline::Coding && i.stage.status() == Status::Active) .filter_map(|i| { let wt = crate::worktree::worktree_path(project_root, &i.story_id.0); if wt.is_dir() { let info = crate::service::git_ops::io::read_dirty_files_sync(&wt); Some((i.story_id.0.clone(), info)) } else { None } }) .collect(); // Pre-fetch merge-specific state: deterministic merges in flight and // any merge_failure text persisted to the story's front matter. let running_merges: HashSet = agents .list_running_merges() .unwrap_or_default() .into_iter() .collect(); // Merge-failure detail now lives on the typed MergeJob CRDT entry // (story 929 — CRDT is the sole source of metadata). Only items in the // Merge column with an Active status (i.e. `Stage::Merge { .. }`) need a // pre-fetched failure snippet; MergeFailure(Final) items render their // own snippet from the typed kind. let merge_failures: HashMap = items .iter() .filter(|i| i.stage.pipeline() == Pipeline::Merge && i.stage.status() == Status::Active) .filter_map(|i| { let job = crate::crdt_state::read_merge_job(&i.story_id.0)?; let err = job.error?; Some((i.story_id.0.clone(), err)) }) .collect(); let mut out = String::from("**Pipeline Status**\n\n"); // Render each display section in order. Blocked items appear in-place // under their stage section (determined by `display_section`); there is // no separate "Blocked" section. Frozen items appear under the section // their `resume_to` stage maps to. let sections = ["Backlog", "In Progress", "QA", "Merge", "Done", "Closed"]; for label in sections { let mut section_items: Vec<&PipelineItem> = items .iter() .filter(|i| display_section(&i.stage) == Some(label)) .collect(); section_items.sort_by(|a, b| a.story_id.0.cmp(&b.story_id.0)); let count = section_items.len(); out.push_str(&format!("**{label}** ({count})\n")); if section_items.is_empty() { out.push_str(" *(none)*\n"); } else { let ctx = ItemRenderCtx { active_map: &active_map, cost_by_story: &cost_by_story, config: &config, running_merges: &running_merges, merge_failures: &merge_failures, dirty_files_by_story: &dirty_files_by_story, }; for item in §ion_items { out.push_str(&render_item_line(item, items, &ctx)); } } out.push('\n'); } // Free agents: configured agents not currently running or pending. out.push_str("**Free Agents**\n"); if let Some(cfg) = &config { let busy_names: HashSet = active_agents .iter() .filter(|a| matches!(a.status, AgentStatus::Running | AgentStatus::Pending)) .map(|a| a.agent_name.clone()) .collect(); let free: Vec = cfg .agent .iter() .filter(|a| !busy_names.contains(&a.name)) .map(|a| match &a.model { Some(m) => format!("{} ({})", a.name, m), None => a.name.clone(), }) .collect(); if free.is_empty() { out.push_str(" *(none — all agents busy)*\n"); } else { for name in &free { out.push_str(&format!(" • {name}\n")); } } } else { out.push_str(" *(no agent config found)*\n"); } out } /// Return an inline working-tree suffix for a story with uncommitted changes. /// /// Returns an empty string when the working tree is clean. The suffix is /// appended directly to the coder line, e.g. `, Working tree: 3 modified (uncommitted)`. /// File paths are not listed here; use `status N` (triage) for the per-file breakdown. fn working_tree_suffix(info: &crate::service::git_ops::DirtyFiles) -> String { if info.is_clean() { return String::new(); } let summary = match (info.modified, info.new) { (m, 0) => format!("{m} modified"), (0, n) => format!("{n} new"), (m, n) => format!("{m} modified, {n} new"), }; format!(", Working tree: {summary} (uncommitted)") } /// Shared lookup tables passed to [`render_item_line`] to keep the argument count manageable. struct ItemRenderCtx<'a> { active_map: &'a HashMap, cost_by_story: &'a HashMap, config: &'a Option, running_merges: &'a HashSet, merge_failures: &'a HashMap, dirty_files_by_story: &'a HashMap, } /// Render a single status line for one pipeline item. fn render_item_line( item: &PipelineItem, all_items: &[PipelineItem], ctx: &ItemRenderCtx<'_>, ) -> String { let active_map = ctx.active_map; let cost_by_story = ctx.cost_by_story; let config = ctx.config; let running_merges = ctx.running_merges; let merge_failures = ctx.merge_failures; let dirty_files_by_story = ctx.dirty_files_by_story; let story_id = &item.story_id.0; let name_opt = if item.name.is_empty() { None } else { Some(item.name.as_str()) }; // Use the new Pipeline + Status helpers (story 1085). let pipeline = item.stage.pipeline(); let status = item.stage.status(); let frozen = status == Status::Frozen; let base_label = super::story_short_label(story_id, name_opt); let display = if frozen { format!("\u{2744}\u{FE0F} {base_label}") // ❄️ prefix } else { base_label }; let cost_suffix = cost_by_story .get(story_id) .filter(|&&c| c > 0.0) .map(|c| format!(" — ${c:.2}")) .unwrap_or_default(); let agent = active_map.get(story_id); let unmet = unmet_deps_from_items(item, all_items); let dep_suffix = if unmet.is_empty() { String::new() } else { let nums: Vec = unmet.iter().map(|n| n.to_string()).collect(); format!(" *(waiting on: {})*", nums.join(", ")) }; // Closed-pipeline items (abandoned / superseded / rejected) each get a // distinct indicator and optionally display their metadata. match status { Status::Abandoned => { return format!(" \u{1F5D1}\u{FE0F} {display}{cost_suffix}\n"); // 🗑️ } Status::Superseded => { let superseded_by = match &item.stage { Stage::Superseded { superseded_by, .. } => superseded_by.0.as_str(), _ => "", }; return format!( " \u{1F500} {display}{cost_suffix} — superseded by {superseded_by}\n", // 🔀 ); } Status::Rejected => { let reason = match &item.stage { Stage::Rejected { reason, .. } => reason.as_str(), _ => "", }; let snippet = first_non_empty_snippet(reason, 120); return format!(" \u{1F6AB} {display}{cost_suffix} — {snippet}\n"); // 🚫 } _ => {} } // Merge-column items get dedicated breakdown indicators instead of the // generic traffic-light dot. MergeFailure / MergeFailureFinal items // appear in the Merge column (in-place) and are handled by the same arm. if pipeline == Pipeline::Merge { match status { // MergeFailureFinal: mergemaster already tried and gave up — always ⛔. Status::MergeFailureFinal => { let kind = match &item.stage { Stage::MergeFailureFinal { kind } => kind, _ => unreachable!(), }; let snippet = first_non_empty_snippet(&kind.display_reason(), 120); return format!(" \u{26D4} {display}{cost_suffix}{dep_suffix} — {snippet}\n"); } // MergeFailure: a recovery agent may be running or queued. Status::MergeFailure => { let kind = match &item.stage { Stage::MergeFailure { kind, .. } => kind, _ => unreachable!(), }; return match agent.map(|a| &a.status) { Some(AgentStatus::Running) => format!( " \u{1F916} {display}{cost_suffix}{dep_suffix} — mergemaster running\n" ), Some(AgentStatus::Pending) => format!( " \u{23F3} {display}{cost_suffix}{dep_suffix} — mergemaster queued\n" ), _ => { let snippet = first_non_empty_snippet(&kind.display_reason(), 120); format!(" \u{26D4} {display}{cost_suffix}{dep_suffix} — {snippet}\n") } }; } _ => {} } let in_det_merge = running_merges.contains(story_id); let merge_failure = merge_failures.get(story_id); if in_det_merge { // A fresh deterministic merge is in progress — always prefer 🔄, // even when a previous attempt recorded a merge_failure. return format!( " \u{1F504} {display}{cost_suffix}{dep_suffix} — deterministic-merge running\n" ); } else if agent.is_some() { return format!( " \u{1F916} {display}{cost_suffix}{dep_suffix} — mergemaster running\n" ); } else if let Some(mf) = merge_failure { let snippet = first_non_empty_snippet(mf, 120); return format!(" \u{26D4} {display}{cost_suffix}{dep_suffix} — {snippet}\n"); } else { return format!(" \u{23F3} {display}{cost_suffix}{dep_suffix}\n"); } } let blocked = status == Status::Blocked; // Blocked items with a recovery agent get differentiated indicators. if blocked { return match agent.map(|a| &a.status) { Some(AgentStatus::Running) => { format!(" \u{1F916} {display}{cost_suffix}{dep_suffix} — recovery coder running\n") } Some(AgentStatus::Pending) => { format!(" \u{23F3} {display}{cost_suffix}{dep_suffix} — recovery coder queued\n") } _ => format!(" \u{1F534} {display}{cost_suffix}{dep_suffix}\n"), }; } let throttled = agent .and_then(|a| a.throttled) .is_some_and(|until| until > chrono::Utc::now()); let dot = super::traffic_light_dot(blocked, throttled, agent.is_some()); let wt_suffix = dirty_files_by_story .get(story_id) .map(working_tree_suffix) .unwrap_or_default(); if let Some(agent) = agent { let model_str = config .as_ref() .and_then(|cfg| cfg.find_agent(&agent.agent_name)) .and_then(|ac| ac.model.as_ref().map(|m| m.as_str())) .unwrap_or("?"); format!( " {dot}{display}{cost_suffix}{dep_suffix} — {} ({model_str}){wt_suffix}\n", agent.agent_name ) } else { format!(" {dot}{display}{cost_suffix}{dep_suffix}{wt_suffix}\n") } }