huskies: merge 835
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
//! Pipeline rendering: builds the full status text from pipeline items.
|
||||
|
||||
use crate::agents::{AgentPool, AgentStatus};
|
||||
use crate::config::ProjectConfig;
|
||||
use crate::pipeline_state::{PipelineItem, Stage};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// 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<u32> {
|
||||
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<u32>.
|
||||
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 { .. }) => 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().unwrap_or_default();
|
||||
let active_map: HashMap<String, &crate::agents::AgentInfo> = active_agents
|
||||
.iter()
|
||||
.filter(|a| matches!(a.status, AgentStatus::Running | AgentStatus::Pending))
|
||||
.map(|a| (a.story_id.clone(), a))
|
||||
.collect();
|
||||
|
||||
// Read token usage once for all stories to avoid repeated file I/O.
|
||||
let cost_by_story: HashMap<String, f64> = crate::agents::token_usage::read_all(project_root)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.fold(HashMap::new(), |mut map, r| {
|
||||
*map.entry(r.story_id).or_insert(0.0) += r.usage.total_cost_usd;
|
||||
map
|
||||
});
|
||||
|
||||
let config = ProjectConfig::load(project_root).ok();
|
||||
|
||||
// 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<String> = agents
|
||||
.list_running_merges()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.collect();
|
||||
let merge_failures: HashMap<String, String> = items
|
||||
.iter()
|
||||
.filter(|i| matches!(i.stage, Stage::Merge { .. }))
|
||||
.filter_map(|i| {
|
||||
let content = crate::db::read_content(&i.story_id.0)?;
|
||||
let meta = crate::io::story_metadata::parse_front_matter(&content).ok()?;
|
||||
let mf = meta.merge_failure?;
|
||||
Some((i.story_id.0.clone(), mf))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut out = String::from("**Pipeline Status**\n\n");
|
||||
|
||||
// Active pipeline stages to display (Archived is handled separately below).
|
||||
type StagePredicate = fn(&Stage) -> bool;
|
||||
let stage_filters: &[(&str, StagePredicate)] = &[
|
||||
("Backlog", |s| matches!(s, Stage::Backlog)),
|
||||
("In Progress", |s| matches!(s, Stage::Coding)),
|
||||
("QA", |s| matches!(s, Stage::Qa)),
|
||||
("Merge", |s| matches!(s, Stage::Merge { .. })),
|
||||
("Done", |s| matches!(s, Stage::Done { .. })),
|
||||
];
|
||||
|
||||
for (label, filter) in stage_filters {
|
||||
let mut stage_items: Vec<&PipelineItem> =
|
||||
items.iter().filter(|i| filter(&i.stage)).collect();
|
||||
stage_items.sort_by(|a, b| a.story_id.0.cmp(&b.story_id.0));
|
||||
let count = stage_items.len();
|
||||
out.push_str(&format!("**{label}** ({count})\n"));
|
||||
if stage_items.is_empty() {
|
||||
out.push_str(" *(none)*\n");
|
||||
} else {
|
||||
for item in &stage_items {
|
||||
out.push_str(&render_item_line(
|
||||
item,
|
||||
items,
|
||||
&active_map,
|
||||
&cost_by_story,
|
||||
&config,
|
||||
&running_merges,
|
||||
&merge_failures,
|
||||
));
|
||||
}
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
// Blocked items: Archived { reason: Blocked } shown with 🔴 indicator.
|
||||
let mut blocked_items: Vec<&PipelineItem> =
|
||||
items.iter().filter(|i| i.stage.is_blocked()).collect();
|
||||
blocked_items.sort_by(|a, b| a.story_id.0.cmp(&b.story_id.0));
|
||||
if !blocked_items.is_empty() {
|
||||
out.push_str(&format!("**Blocked** ({})\n", blocked_items.len()));
|
||||
for item in &blocked_items {
|
||||
out.push_str(&render_item_line(
|
||||
item,
|
||||
items,
|
||||
&active_map,
|
||||
&cost_by_story,
|
||||
&config,
|
||||
&running_merges,
|
||||
&merge_failures,
|
||||
));
|
||||
}
|
||||
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<String> = active_agents
|
||||
.iter()
|
||||
.filter(|a| matches!(a.status, AgentStatus::Running | AgentStatus::Pending))
|
||||
.map(|a| a.agent_name.clone())
|
||||
.collect();
|
||||
|
||||
let free: Vec<String> = 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
|
||||
}
|
||||
|
||||
/// Render a single status line for one pipeline item.
|
||||
fn render_item_line(
|
||||
item: &PipelineItem,
|
||||
all_items: &[PipelineItem],
|
||||
active_map: &HashMap<String, &crate::agents::AgentInfo>,
|
||||
cost_by_story: &HashMap<String, f64>,
|
||||
config: &Option<ProjectConfig>,
|
||||
running_merges: &HashSet<String>,
|
||||
merge_failures: &HashMap<String, String>,
|
||||
) -> String {
|
||||
let story_id = &item.story_id.0;
|
||||
let name_opt = if item.name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(item.name.as_str())
|
||||
};
|
||||
let frozen = crate::io::story_metadata::is_story_frozen_in_store(story_id);
|
||||
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<String> = unmet.iter().map(|n| n.to_string()).collect();
|
||||
format!(" *(waiting on: {})*", nums.join(", "))
|
||||
};
|
||||
|
||||
// Merge-stage items get a dedicated breakdown indicator instead of the
|
||||
// generic traffic-light dot.
|
||||
if matches!(item.stage, Stage::Merge { .. }) {
|
||||
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 = item.stage.is_blocked();
|
||||
let throttled = agent.map(|a| a.throttled).unwrap_or(false);
|
||||
let dot = super::traffic_light_dot(blocked, throttled, agent.is_some());
|
||||
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_deref())
|
||||
.unwrap_or("?");
|
||||
format!(
|
||||
" {dot}{display}{cost_suffix}{dep_suffix} — {} ({model_str})\n",
|
||||
agent.agent_name
|
||||
)
|
||||
} else {
|
||||
format!(" {dot}{display}{cost_suffix}{dep_suffix}\n")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user