From b053f14d58cd6c9658ea289f14f15d8e3ca1a9b8 Mon Sep 17 00:00:00 2001 From: dave Date: Fri, 15 May 2026 01:32:34 +0000 Subject: [PATCH] huskies: merge 1085 --- frontend/src/api/client/types.ts | 27 +++ frontend/src/api/gateway.ts | 28 +++ frontend/src/components/GatewayPanel.test.tsx | 51 ++++- frontend/src/components/GatewayPanel.tsx | 178 +++++++++++------- server/src/chat/commands/status/render.rs | 116 ++++++------ .../src/http/mcp/story_tools/story/query.rs | 12 +- server/src/http/workflow/pipeline.rs | 8 + server/src/pipeline_state/mod.rs | 4 +- server/src/pipeline_state/types.rs | 138 ++++++++++++++ server/src/service/ws/message/convert.rs | 8 + server/src/service/ws/message/response.rs | 2 + 11 files changed, 440 insertions(+), 132 deletions(-) diff --git a/frontend/src/api/client/types.ts b/frontend/src/api/client/types.ts index e7486e53..279b26aa 100644 --- a/frontend/src/api/client/types.ts +++ b/frontend/src/api/client/types.ts @@ -50,6 +50,29 @@ export interface AgentAssignment { status: string; } +/** Display column for a work item — derived server-side from `Stage::pipeline()` (story 1085). */ +export type Pipeline = + | "backlog" + | "coding" + | "qa" + | "merge" + | "done" + | "closed" + | "archived"; + +/** Badge/indicator for a work item — derived server-side from `Stage::status()` (story 1085). */ +export type Status = + | "active" + | "frozen" + | "review-hold" + | "blocked" + | "merge-failure" + | "merge-failure-final" + | "abandoned" + | "superseded" + | "rejected" + | "done"; + /** A single item in any pipeline stage (backlog, current, QA, merge, or done). */ export interface PipelineStageItem { story_id: string; @@ -57,6 +80,10 @@ export interface PipelineStageItem { error: string | null; merge_failure: string | null; agent: AgentAssignment | null; + /** Display column (story 1085); falls back to the bucket name on legacy servers. */ + pipeline?: Pipeline; + /** Display badge (story 1085); falls back to derived `blocked`/`frozen` on legacy servers. */ + status?: Status; review_hold: boolean | null; qa: string | null; depends_on: number[] | null; diff --git a/frontend/src/api/gateway.ts b/frontend/src/api/gateway.ts index e0e01111..96049ac2 100644 --- a/frontend/src/api/gateway.ts +++ b/frontend/src/api/gateway.ts @@ -24,10 +24,38 @@ export interface GatewayInfo { projects: GatewayProject[]; } +/** Display column for a work item — derived server-side from `Stage::pipeline()` (story 1085). */ +export type Pipeline = + | "backlog" + | "coding" + | "qa" + | "merge" + | "done" + | "closed" + | "archived"; + +/** Badge/indicator for a work item — derived server-side from `Stage::status()` (story 1085). */ +export type Status = + | "active" + | "frozen" + | "review-hold" + | "blocked" + | "merge-failure" + | "merge-failure-final" + | "abandoned" + | "superseded" + | "rejected" + | "done"; + export interface PipelineItem { story_id: string; name: string; + /** Legacy stage string (kept for back-compat); prefer `pipeline` + `status`. */ stage: string; + /** Display column (story 1085). Optional until all servers are upgraded. */ + pipeline?: Pipeline; + /** Display badge (story 1085). Optional until all servers are upgraded. */ + status?: Status; agent?: { agent_name: string; model: string; status: string } | null; blocked?: boolean; retry_count?: number; diff --git a/frontend/src/components/GatewayPanel.test.tsx b/frontend/src/components/GatewayPanel.test.tsx index 01c8eff1..74112485 100644 --- a/frontend/src/components/GatewayPanel.test.tsx +++ b/frontend/src/components/GatewayPanel.test.tsx @@ -69,29 +69,34 @@ describe("StoryRow", () => { expect(screen.getByText("awaiting-slot (#2)")).toBeInTheDocument(); }); - // AC2: failure kind labels derived from merge_failure string - it("shows ConflictDetected for merge_failure with conflict text", () => { + // Story 1085: failure kind no longer derived from substring. Items in + // the merge_failure / merge_failure_final status get a generic FAILED badge; + // the kind detail is exposed via the typed `status` field for callers that + // need it (instead of being squeezed into the badge text). + it("shows ✕ FAILED badge for merge-failure status", () => { const item: PipelineItem = { story_id: "73_story_conflict", name: "Conflict Story", stage: "merge", - blocked: true, + pipeline: "merge", + status: "merge-failure", merge_failure: "Merge conflict: conflicts detected", }; render(); - expect(screen.getByText("ConflictDetected")).toBeInTheDocument(); + expect(screen.getByText("✕ FAILED")).toBeInTheDocument(); }); - it("shows GatesFailed for merge_failure with quality gates text", () => { + it("shows ⛔ FAILED (FINAL) badge for merge-failure-final status", () => { const item: PipelineItem = { story_id: "74_story_gates", name: "Gates Failed Story", stage: "merge", - blocked: true, + pipeline: "merge", + status: "merge-failure-final", merge_failure: "Quality gates failed: cargo test failed", }; render(); - expect(screen.getByText("GatesFailed")).toBeInTheDocument(); + expect(screen.getByText("⛔ FAILED (FINAL)")).toBeInTheDocument(); }); it("shows RECOVERING badge for merge_failure item with running mergemaster", () => { @@ -163,4 +168,36 @@ describe("StoryRow", () => { render(); expect(screen.getByText("⊘ BLOCKED")).toBeInTheDocument(); }); + + // Story 1085 AC 4 — Frozen items remain visible in their underlying column + // with a frozen indicator. The server hands us `pipeline: "coding"` for a + // frozen-while-coding story and the badge is decorated separately. + it("shows ❄ FROZEN badge for a frozen item (column stays as underlying pipeline)", () => { + const item: PipelineItem = { + story_id: "70_story_frozen_coding", + name: "Paused Coding Story", + stage: "current", + pipeline: "coding", + status: "frozen", + }; + render(); + expect(screen.getByText("❄ FROZEN")).toBeInTheDocument(); + }); + + // Story 1085 AC 4 (subsumes 1052) — Done items must never get a + // MergeFailure indicator, even if a stale `merge_failure` string is present. + it("done items render Done badge, never MergeFailure", () => { + const item: PipelineItem = { + story_id: "71_story_done", + name: "Completed Story", + stage: "done", + pipeline: "done", + status: "done", + merge_failure: "ignored stale string", + }; + render(); + expect(screen.getByText("Done")).toBeInTheDocument(); + expect(screen.queryByText("✕ FAILED")).not.toBeInTheDocument(); + expect(screen.queryByText(/FAILED/)).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/GatewayPanel.tsx b/frontend/src/components/GatewayPanel.tsx index 38fbda58..646e9b8a 100644 --- a/frontend/src/components/GatewayPanel.tsx +++ b/frontend/src/components/GatewayPanel.tsx @@ -14,9 +14,42 @@ import { type JoinedAgent, type GatewayProject, type AllProjectsPipeline, + type Pipeline, type PipelineItem, + type Status, } from "../api/gateway"; +/// Resolve an item's pipeline column. Servers running the new (story 1085) +/// backend send `pipeline`; older servers only send `stage` so we fall back to +/// mapping the bucket name onto the new column vocabulary. +function itemPipeline(item: PipelineItem): Pipeline { + if (item.pipeline) return item.pipeline; + switch (item.stage) { + case "current": + return "coding"; + case "qa": + return "qa"; + case "merge": + return "merge"; + case "done": + return "done"; + case "archived": + return "archived"; + default: + return "backlog"; + } +} + +/// Resolve an item's badge. Falls back to `merge_failure`/`blocked` on +/// legacy servers that don't yet emit `status`. +function itemStatus(item: PipelineItem): Status { + if (item.status) return item.status; + if (item.merge_failure) return "merge-failure"; + if (item.blocked) return "blocked"; + if (item.stage === "done") return "done"; + return "active"; +} + const { useCallback, useEffect, useRef, useState } = React; /// Seconds of silence before an agent is considered disconnected. @@ -48,72 +81,86 @@ const STATUS_LABELS: Record = { disconnected: "Disconnected", }; -const STAGE_COLORS: Record = { +const PIPELINE_COLORS: Record = { backlog: "#8b949e", - current: "#3fb950", + coding: "#3fb950", qa: "#d2a679", merge: "#79c0ff", done: "#6e7681", + closed: "#6e7681", archived: "#6e7681", }; -const STAGE_LABELS: Record = { +const PIPELINE_LABELS: Record = { backlog: "Backlog", - current: "In Progress", + coding: "In Progress", qa: "QA", merge: "Merging", done: "Done", + closed: "Closed", archived: "Archived", }; -/// Derive a short label from a merge failure string based on the failure kind. -function mergeFailureKindLabel(failure: string): string { - if (failure.includes("Merge conflict") || failure.includes("CONFLICT")) { - return "ConflictDetected"; - } - if (failure.includes("Quality gates failed") || failure.includes("gates failed")) { - return "GatesFailed"; - } - if (failure.includes("no code changes") || failure.includes("empty diff")) { - return "EmptyDiff"; - } - if (failure.includes("No commits")) { - return "NoCommits"; - } - return "✕ FAILED"; -} - /// A single story row inside a project pipeline card. -/** Render one story row in a gateway-aggregate panel: `# ` with stage badge. */ +/** Render one story row in a gateway-aggregate panel: `# ` with status badge. */ export function StoryRow({ item, mergeQueuePos }: { item: PipelineItem; mergeQueuePos?: number }) { - const isStuck = item.merge_failure != null || item.blocked; - const isMergeActive = item.stage === "merge" && !isStuck && item.agent?.status === "running"; + const pipeline = itemPipeline(item); + const status = itemStatus(item); + const agentStatus = item.agent?.status; let color: string; let label: string; + let frozenPrefix = ""; - if (isMergeActive) { - color = "#58a6ff"; - label = "▶ MERGING"; - } else if (isStuck) { - const agentStatus = item.agent?.status; + // Frozen items keep their underlying pipeline column but get a ❄️ badge. + // (AC 4 — story 1085, subsumes the freeze-hides-item bug.) + if (status === "frozen") { + color = "#79c0ff"; + label = "❄ FROZEN"; + frozenPrefix = "❄ "; + } else if (status === "merge-failure" || status === "merge-failure-final") { + // Done items never reach this branch — `Stage::status()` returns + // `Status::Done` for done items (AC 4). if (agentStatus === "running") { color = "#e3b341"; label = "⟳ RECOVERING"; } else if (agentStatus === "pending") { color = "#e3b341"; label = "⏳ QUEUED"; - } else if (item.merge_failure != null) { + } else { color = "#f85149"; - label = mergeFailureKindLabel(item.merge_failure); + label = status === "merge-failure-final" ? "⛔ FAILED (FINAL)" : "✕ FAILED"; + } + } else if (status === "blocked") { + if (agentStatus === "running") { + color = "#e3b341"; + label = "⟳ RECOVERING"; + } else if (agentStatus === "pending") { + color = "#e3b341"; + label = "⏳ QUEUED"; } else { color = "#f85149"; label = "⊘ BLOCKED"; } - } else if (item.stage === "merge" && item.agent?.status === "pending") { + } else if (status === "review-hold") { + color = "#d2a679"; + label = "REVIEW HOLD"; + } else if (status === "abandoned") { + color = "#6e7681"; + label = "ABANDONED"; + } else if (status === "superseded") { + color = "#6e7681"; + label = "SUPERSEDED"; + } else if (status === "rejected") { + color = "#f85149"; + label = "REJECTED"; + } else if (pipeline === "merge" && agentStatus === "running") { + color = "#58a6ff"; + label = "▶ MERGING"; + } else if (pipeline === "merge" && agentStatus === "pending") { color = "#e3b341"; label = "⏳ QUEUED"; - } else if (item.stage === "merge") { + } else if (pipeline === "merge") { color = "#6e7681"; if (mergeQueuePos === 1) { label = "NEXT IN QUEUE"; @@ -123,10 +170,11 @@ export function StoryRow({ item, mergeQueuePos }: { item: PipelineItem; mergeQue label = "awaiting-slot"; } } else { - color = STAGE_COLORS[item.stage] ?? "#8b949e"; - label = STAGE_LABELS[item.stage] ?? item.stage; + color = PIPELINE_COLORS[pipeline] ?? "#8b949e"; + label = PIPELINE_LABELS[pipeline] ?? pipeline; } + const isMergeActive = pipeline === "merge" && status === "active" && agentStatus === "running"; const idNum = item.story_id.match(/^(\d+)/)?.[1]; return ( @@ -158,7 +206,7 @@ export function StoryRow({ item, mergeQueuePos }: { item: PipelineItem; mergeQue {idNum && #{idNum}{" "}} - {item.name} + {frozenPrefix}{item.name} ); @@ -388,6 +436,8 @@ function aggregateItems( story_id: b.story_id, name: b.name, stage: "backlog", + pipeline: "backlog" as Pipeline, + status: "active" as Status, })), }; } @@ -395,14 +445,14 @@ function aggregateItems( return { project, items: (status.active ?? []).filter( - (i) => i.stage !== "done", + (i) => itemPipeline(i) !== "done", ), }; } if (tab === "done") { return { project, - items: (status.active ?? []).filter((i) => i.stage === "done"), + items: (status.active ?? []).filter((i) => itemPipeline(i) === "done"), }; } // archived @@ -419,12 +469,12 @@ function tabCount(pipeline: AllProjectsPipeline, tab: TabKey): number { if (tab === "in-progress") { return ( sum + - (status.active ?? []).filter((i) => i.stage !== "done").length + (status.active ?? []).filter((i) => itemPipeline(i) !== "done").length ); } if (tab === "done") { return ( - sum + (status.active ?? []).filter((i) => i.stage === "done").length + sum + (status.active ?? []).filter((i) => itemPipeline(i) === "done").length ); } return sum + (status.archived ?? []).length; @@ -518,13 +568,16 @@ function ProjectStoryRow({ ); } -const IN_PROGRESS_STAGE_LABELS: Record = { - current: "Coding", +const IN_PROGRESS_PIPELINE_LABELS: Record<"coding" | "qa" | "merge", string> = { + coding: "Coding", qa: "QA", merge: "Merging", }; -/// In Progress tab content — items grouped by stage (coding / qa / merging). +/// In Progress tab content — items grouped by their `pipeline` column. +/// +/// Frozen items appear in the column corresponding to their underlying +/// `Stage::resume_to` (server-side), so they always show up in-place. function InProgressTabContent({ groups, }: { @@ -535,25 +588,22 @@ function InProgressTabContent({ ); const multiProject = new Set(allItems.map((x) => x.project)).size > 1; - const byStage = { - current: allItems.filter((x) => x.item.stage === "current"), - qa: allItems.filter((x) => x.item.stage === "qa"), - merge: allItems.filter((x) => x.item.stage === "merge"), + const byPipeline = { + coding: allItems.filter((x) => itemPipeline(x.item) === "coding"), + qa: allItems.filter((x) => itemPipeline(x.item) === "qa"), + merge: allItems.filter((x) => itemPipeline(x.item) === "merge"), }; - const stages = (["current", "qa", "merge"] as const).filter( - (s) => byStage[s].length > 0, + const pipelines = (["coding", "qa", "merge"] as const).filter( + (p) => byPipeline[p].length > 0, ); - // Compute queue position among clean awaiting merge items (Stage::Merge, no failure, no running agent). + // Compute queue position among "clean" awaiting-merge items: pipeline=merge, + // status=active, and no agent currently running. const mergeQueuePosMap = new Map(); let queuePos = 0; - for (const { project, item } of byStage.merge) { - if ( - !item.blocked && - !item.merge_failure && - item.agent?.status !== "running" - ) { + for (const { project, item } of byPipeline.merge) { + if (itemStatus(item) === "active" && item.agent?.status !== "running") { queuePos += 1; mergeQueuePosMap.set(`${project}:${item.story_id}`, queuePos); } @@ -569,33 +619,33 @@ function InProgressTabContent({ return (
- {stages.map((stage) => ( -
+ {pipelines.map((p) => ( +
- {IN_PROGRESS_STAGE_LABELS[stage]}{" "} + {IN_PROGRESS_PIPELINE_LABELS[p]}{" "} - ({byStage[stage].length}) + ({byPipeline[p].length})
- {byStage[stage].map(({ project, item }) => ( + {byPipeline[p].map(({ project, item }) => ( Option<&'static str> { - match s { - Stage::Upcoming | Stage::Backlog => Some("Backlog"), - Stage::Coding { .. } - | Stage::Blocked { .. } - | Stage::Archived { - reason: ArchiveReason::Blocked { .. }, - .. - } => Some("In Progress"), - Stage::Qa | Stage::ReviewHold { .. } => Some("QA"), - Stage::Merge { .. } | Stage::MergeFailure { .. } | Stage::MergeFailureFinal { .. } => { - Some("Merge") - } - Stage::Done { .. } => Some("Done"), - Stage::Frozen { resume_to } => display_section(resume_to), - Stage::Abandoned { .. } | Stage::Superseded { .. } | Stage::Rejected { .. } => { - Some("Closed") - } - Stage::Archived { .. } => None, // Completed/MergeFailed/ReviewHeld stay hidden + // 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. @@ -114,10 +107,10 @@ pub(crate) fn build_status_from_items( let config = ProjectConfig::load(project_root).ok(); - // Pre-fetch working tree state for all Coding-stage items whose worktrees exist. + // Pre-fetch working tree state for all Coding-column items whose worktrees exist. let dirty_files_by_story: HashMap = items .iter() - .filter(|i| matches!(i.stage, Stage::Coding { .. })) + .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() { @@ -137,10 +130,13 @@ pub(crate) fn build_status_from_items( .into_iter() .collect(); // Merge-failure detail now lives on the typed MergeJob CRDT entry - // (story 929 — CRDT is the sole source of metadata). + // (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| matches!(i.stage, Stage::Merge { .. })) + .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?; @@ -260,8 +256,10 @@ fn render_item_line( } else { Some(item.name.as_str()) }; - // Use the typed CRDT stage as the sole source of truth (story 945). - let frozen = matches!(item.stage, Stage::Frozen { .. }); + // 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 @@ -282,41 +280,52 @@ fn render_item_line( format!(" *(waiting on: {})*", nums.join(", ")) }; - // Closed-stage items (abandoned / superseded / rejected) each get a + // Closed-pipeline items (abandoned / superseded / rejected) each get a // distinct indicator and optionally display their metadata. - match &item.stage { - Stage::Abandoned { .. } => { + match status { + Status::Abandoned => { return format!(" \u{1F5D1}\u{FE0F} {display}{cost_suffix}\n"); // 🗑️ } - Stage::Superseded { superseded_by, .. } => { + 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 {}\n", // 🔀 - superseded_by.0 + " \u{1F500} {display}{cost_suffix} — superseded by {superseded_by}\n", // 🔀 ); } - Stage::Rejected { reason, .. } => { + 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-stage items get dedicated breakdown indicators instead of the + // Merge-column items get dedicated breakdown indicators instead of the // generic traffic-light dot. MergeFailure / MergeFailureFinal items - // now also appear in the Merge section (in-place) so they are handled - // here alongside normal Merge items. - if matches!( - item.stage, - Stage::Merge { .. } | Stage::MergeFailure { .. } | Stage::MergeFailureFinal { .. } - ) { - match &item.stage { + // 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 ⛔. - Stage::MergeFailureFinal { kind } => { + 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. - Stage::MergeFailure { kind, .. } => { + 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" @@ -353,16 +362,7 @@ fn render_item_line( } } - let blocked = matches!( - item.stage, - Stage::Blocked { .. } - | Stage::MergeFailure { .. } - | Stage::MergeFailureFinal { .. } - | Stage::Archived { - reason: ArchiveReason::Blocked { .. }, - .. - } - ); + let blocked = status == Status::Blocked; // Blocked items with a recovery agent get differentiated indicators. if blocked { return match agent.map(|a| &a.status) { diff --git a/server/src/http/mcp/story_tools/story/query.rs b/server/src/http/mcp/story_tools/story/query.rs index a70ec725..800201fe 100644 --- a/server/src/http/mcp/story_tools/story/query.rs +++ b/server/src/http/mcp/story_tools/story/query.rs @@ -51,6 +51,8 @@ pub(crate) fn tool_get_pipeline_status(ctx: &AppContext) -> Result Result = state .archived .iter() - .map(|s| json!({ "story_id": s.story_id, "name": slim_name(&s.name), "stage": "archived" })) + .map(|s| { + json!({ + "story_id": s.story_id, + "name": slim_name(&s.name), + "stage": "archived", + "pipeline": s.pipeline.as_str(), + "status": s.status.as_str(), + }) + }) .collect(); serde_json::to_string_pretty(&json!({ diff --git a/server/src/http/workflow/pipeline.rs b/server/src/http/workflow/pipeline.rs index eba5c2b2..c65891a9 100644 --- a/server/src/http/workflow/pipeline.rs +++ b/server/src/http/workflow/pipeline.rs @@ -24,6 +24,10 @@ pub struct UpcomingStory { pub merge_failure: Option, /// Active agent working on this item, if any. pub agent: Option, + /// Display column (story 1085) — derived from `Stage::pipeline()`. + pub pipeline: crate::pipeline_state::Pipeline, + /// Display badge/indicator (story 1085) — derived from `Stage::status()`. + pub status: crate::pipeline_state::Status, /// True when the item is held in QA for human review. #[serde(skip_serializing_if = "Option::is_none")] pub review_hold: Option, @@ -142,6 +146,8 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result { error: None, merge_failure, agent, + pipeline: item.stage.pipeline(), + status: item.stage.status(), review_hold, qa, retry_count: if item.retry_count() > 0 { @@ -278,6 +284,8 @@ pub fn load_upcoming_stories(_ctx: &AppContext) -> Result, St error: None, merge_failure: None, agent: None, + pipeline: item.stage.pipeline(), + status: item.stage.status(), review_hold: None, qa: None, retry_count: if item_retry_count > 0 { diff --git a/server/src/pipeline_state/mod.rs b/server/src/pipeline_state/mod.rs index 8c1f03c6..9d7b4ac1 100644 --- a/server/src/pipeline_state/mod.rs +++ b/server/src/pipeline_state/mod.rs @@ -41,8 +41,8 @@ mod tests; #[allow(unused_imports)] pub use types::{ AgentClaim, AgentName, ArchiveReason, BranchName, ExecutionState, GitSha, MergeFailureKind, - NodePubkey, PipelineItem, PlanState, Stage, StoryId, TransitionError, stage_dir_name, - stage_label, + NodePubkey, Pipeline, PipelineItem, PlanState, Stage, Status, StoryId, TransitionError, + stage_dir_name, stage_label, }; #[allow(unused_imports)] diff --git a/server/src/pipeline_state/types.rs b/server/src/pipeline_state/types.rs index cf1354dd..2c3fdf7a 100644 --- a/server/src/pipeline_state/types.rs +++ b/server/src/pipeline_state/types.rs @@ -429,6 +429,144 @@ impl Stage { } } +// ── Display split (story 1085): Pipeline column + Status badge ───────────── + +/// Column placement for a work item in the UI/chat status display. +/// +/// Derived from [`Stage`] via [`Stage::pipeline`]. Display callers route items +/// to columns by this enum instead of pattern-matching `Stage` variants, so +/// new badges (e.g. `Frozen`, `Blocked`) do not produce new columns. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Pipeline { + /// Items in `Upcoming` or `Backlog` stages. + Backlog, + /// Items being coded (or blocked while in the coding lane). + Coding, + /// Items in QA or `ReviewHold`. + Qa, + /// Items in `Merge`, `MergeFailure`, or `MergeFailureFinal`. + Merge, + /// Items in `Done`. + Done, + /// Abandoned, superseded, or rejected items. + Closed, + /// Items swept into `Archived`. + Archived, +} + +impl Pipeline { + /// Stable wire-format identifier (kebab-case). + pub fn as_str(&self) -> &'static str { + match self { + Pipeline::Backlog => "backlog", + Pipeline::Coding => "coding", + Pipeline::Qa => "qa", + Pipeline::Merge => "merge", + Pipeline::Done => "done", + Pipeline::Closed => "closed", + Pipeline::Archived => "archived", + } + } +} + +/// Badge/indicator for a work item, orthogonal to its [`Pipeline`] column. +/// +/// Derived from [`Stage`] via [`Stage::status`]. A `Frozen` story stays in +/// its underlying `Pipeline` column (e.g. `Coding`) and is decorated with +/// `Status::Frozen` for the display. `Status::Done` is reserved for items in +/// the `Done` column and is never produced for items still in flight, so a +/// done item never carries a `MergeFailure*` badge (story 1052). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", tag = "kind")] +pub enum Status { + /// No special badge — normal in-progress item. + Active, + /// Item is paused (`Stage::Frozen`). + Frozen, + /// Item is held for human review (`Stage::ReviewHold`). + ReviewHold, + /// Item is blocked (`Stage::Blocked` or legacy `Archived(Blocked)`). + Blocked, + /// Merge failed; mergemaster may still be recovering. + MergeFailure, + /// Merge failed beyond automatic recovery. + MergeFailureFinal, + /// User abandoned the item. + Abandoned, + /// Item was superseded by another work item. + Superseded, + /// Item was permanently rejected. + Rejected, + /// Item completed successfully. + Done, +} + +impl Status { + /// Stable wire-format identifier (kebab-case). + pub fn as_str(&self) -> &'static str { + match self { + Status::Active => "active", + Status::Frozen => "frozen", + Status::ReviewHold => "review-hold", + Status::Blocked => "blocked", + Status::MergeFailure => "merge-failure", + Status::MergeFailureFinal => "merge-failure-final", + Status::Abandoned => "abandoned", + Status::Superseded => "superseded", + Status::Rejected => "rejected", + Status::Done => "done", + } + } +} + +impl Stage { + /// Display column for this stage. `Frozen { resume_to }` recurses so a + /// paused story keeps its underlying column. + pub fn pipeline(&self) -> Pipeline { + match self { + Stage::Upcoming | Stage::Backlog => Pipeline::Backlog, + Stage::Coding { .. } | Stage::Blocked { .. } => Pipeline::Coding, + Stage::Qa | Stage::ReviewHold { .. } => Pipeline::Qa, + Stage::Merge { .. } | Stage::MergeFailure { .. } | Stage::MergeFailureFinal { .. } => { + Pipeline::Merge + } + Stage::Frozen { resume_to } => resume_to.pipeline(), + Stage::Done { .. } => Pipeline::Done, + Stage::Abandoned { .. } | Stage::Superseded { .. } | Stage::Rejected { .. } => { + Pipeline::Closed + } + Stage::Archived { + reason: ArchiveReason::Blocked { .. }, + .. + } => Pipeline::Coding, + Stage::Archived { .. } => Pipeline::Archived, + } + } + + /// Display badge for this stage. `Frozen { resume_to }` returns + /// `Status::Frozen` regardless of the inner stage; callers wanting the + /// underlying badge inspect `resume_to` directly. + pub fn status(&self) -> Status { + match self { + Stage::Frozen { .. } => Status::Frozen, + Stage::ReviewHold { .. } => Status::ReviewHold, + Stage::Blocked { .. } + | Stage::Archived { + reason: ArchiveReason::Blocked { .. }, + .. + } => Status::Blocked, + Stage::MergeFailure { .. } => Status::MergeFailure, + Stage::MergeFailureFinal { .. } => Status::MergeFailureFinal, + Stage::Abandoned { .. } => Status::Abandoned, + Stage::Superseded { .. } => Status::Superseded, + Stage::Rejected { .. } => Status::Rejected, + Stage::Done { .. } => Status::Done, + _ => Status::Active, + } + } +} + // ── Per-node execution state ──────────────────────────────────────────────── /// Per-node execution tracking, stored in the CRDT under each node's pubkey. diff --git a/server/src/service/ws/message/convert.rs b/server/src/service/ws/message/convert.rs index 02b53c6a..6330435a 100644 --- a/server/src/service/ws/message/convert.rs +++ b/server/src/service/ws/message/convert.rs @@ -212,6 +212,8 @@ mod tests { error: None, merge_failure: None, agent: None, + pipeline: crate::pipeline_state::Pipeline::Backlog, + status: crate::pipeline_state::Status::Active, review_hold: None, qa: None, retry_count: None, @@ -226,6 +228,8 @@ mod tests { error: None, merge_failure: None, agent: None, + pipeline: crate::pipeline_state::Pipeline::Coding, + status: crate::pipeline_state::Status::Active, review_hold: None, qa: None, retry_count: None, @@ -242,6 +246,8 @@ mod tests { error: None, merge_failure: None, agent: None, + pipeline: crate::pipeline_state::Pipeline::Done, + status: crate::pipeline_state::Status::Done, review_hold: None, qa: None, retry_count: None, @@ -303,6 +309,8 @@ mod tests { model: Some(crate::agents::AgentModel::Sonnet), status: crate::agents::AgentStatus::Running, }), + pipeline: crate::pipeline_state::Pipeline::Coding, + status: crate::pipeline_state::Status::Active, review_hold: None, qa: None, retry_count: None, diff --git a/server/src/service/ws/message/response.rs b/server/src/service/ws/message/response.rs index 751df126..1003d687 100644 --- a/server/src/service/ws/message/response.rs +++ b/server/src/service/ws/message/response.rs @@ -205,6 +205,8 @@ mod tests { error: None, merge_failure: None, agent: None, + pipeline: crate::pipeline_state::Pipeline::Backlog, + status: crate::pipeline_state::Status::Active, review_hold: None, qa: None, retry_count: None,