huskies: merge 1085
This commit is contained in:
@@ -50,6 +50,29 @@ export interface AgentAssignment {
|
|||||||
status: string;
|
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). */
|
/** A single item in any pipeline stage (backlog, current, QA, merge, or done). */
|
||||||
export interface PipelineStageItem {
|
export interface PipelineStageItem {
|
||||||
story_id: string;
|
story_id: string;
|
||||||
@@ -57,6 +80,10 @@ export interface PipelineStageItem {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
merge_failure: string | null;
|
merge_failure: string | null;
|
||||||
agent: AgentAssignment | 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;
|
review_hold: boolean | null;
|
||||||
qa: string | null;
|
qa: string | null;
|
||||||
depends_on: number[] | null;
|
depends_on: number[] | null;
|
||||||
|
|||||||
@@ -24,10 +24,38 @@ export interface GatewayInfo {
|
|||||||
projects: GatewayProject[];
|
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 {
|
export interface PipelineItem {
|
||||||
story_id: string;
|
story_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
/** Legacy stage string (kept for back-compat); prefer `pipeline` + `status`. */
|
||||||
stage: string;
|
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;
|
agent?: { agent_name: string; model: string; status: string } | null;
|
||||||
blocked?: boolean;
|
blocked?: boolean;
|
||||||
retry_count?: number;
|
retry_count?: number;
|
||||||
|
|||||||
@@ -69,29 +69,34 @@ describe("StoryRow", () => {
|
|||||||
expect(screen.getByText("awaiting-slot (#2)")).toBeInTheDocument();
|
expect(screen.getByText("awaiting-slot (#2)")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
// AC2: failure kind labels derived from merge_failure string
|
// Story 1085: failure kind no longer derived from substring. Items in
|
||||||
it("shows ConflictDetected for merge_failure with conflict text", () => {
|
// 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 = {
|
const item: PipelineItem = {
|
||||||
story_id: "73_story_conflict",
|
story_id: "73_story_conflict",
|
||||||
name: "Conflict Story",
|
name: "Conflict Story",
|
||||||
stage: "merge",
|
stage: "merge",
|
||||||
blocked: true,
|
pipeline: "merge",
|
||||||
|
status: "merge-failure",
|
||||||
merge_failure: "Merge conflict: conflicts detected",
|
merge_failure: "Merge conflict: conflicts detected",
|
||||||
};
|
};
|
||||||
render(<StoryRow item={item} />);
|
render(<StoryRow item={item} />);
|
||||||
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 = {
|
const item: PipelineItem = {
|
||||||
story_id: "74_story_gates",
|
story_id: "74_story_gates",
|
||||||
name: "Gates Failed Story",
|
name: "Gates Failed Story",
|
||||||
stage: "merge",
|
stage: "merge",
|
||||||
blocked: true,
|
pipeline: "merge",
|
||||||
|
status: "merge-failure-final",
|
||||||
merge_failure: "Quality gates failed: cargo test failed",
|
merge_failure: "Quality gates failed: cargo test failed",
|
||||||
};
|
};
|
||||||
render(<StoryRow item={item} />);
|
render(<StoryRow item={item} />);
|
||||||
expect(screen.getByText("GatesFailed")).toBeInTheDocument();
|
expect(screen.getByText("⛔ FAILED (FINAL)")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows RECOVERING badge for merge_failure item with running mergemaster", () => {
|
it("shows RECOVERING badge for merge_failure item with running mergemaster", () => {
|
||||||
@@ -163,4 +168,36 @@ describe("StoryRow", () => {
|
|||||||
render(<StoryRow item={item} />);
|
render(<StoryRow item={item} />);
|
||||||
expect(screen.getByText("⊘ BLOCKED")).toBeInTheDocument();
|
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(<StoryRow item={item} />);
|
||||||
|
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(<StoryRow item={item} />);
|
||||||
|
expect(screen.getByText("Done")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("✕ FAILED")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/FAILED/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,9 +14,42 @@ import {
|
|||||||
type JoinedAgent,
|
type JoinedAgent,
|
||||||
type GatewayProject,
|
type GatewayProject,
|
||||||
type AllProjectsPipeline,
|
type AllProjectsPipeline,
|
||||||
|
type Pipeline,
|
||||||
type PipelineItem,
|
type PipelineItem,
|
||||||
|
type Status,
|
||||||
} from "../api/gateway";
|
} 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;
|
const { useCallback, useEffect, useRef, useState } = React;
|
||||||
|
|
||||||
/// Seconds of silence before an agent is considered disconnected.
|
/// Seconds of silence before an agent is considered disconnected.
|
||||||
@@ -48,72 +81,86 @@ const STATUS_LABELS: Record<AgentStatus, string> = {
|
|||||||
disconnected: "Disconnected",
|
disconnected: "Disconnected",
|
||||||
};
|
};
|
||||||
|
|
||||||
const STAGE_COLORS: Record<string, string> = {
|
const PIPELINE_COLORS: Record<Pipeline, string> = {
|
||||||
backlog: "#8b949e",
|
backlog: "#8b949e",
|
||||||
current: "#3fb950",
|
coding: "#3fb950",
|
||||||
qa: "#d2a679",
|
qa: "#d2a679",
|
||||||
merge: "#79c0ff",
|
merge: "#79c0ff",
|
||||||
done: "#6e7681",
|
done: "#6e7681",
|
||||||
|
closed: "#6e7681",
|
||||||
archived: "#6e7681",
|
archived: "#6e7681",
|
||||||
};
|
};
|
||||||
|
|
||||||
const STAGE_LABELS: Record<string, string> = {
|
const PIPELINE_LABELS: Record<Pipeline, string> = {
|
||||||
backlog: "Backlog",
|
backlog: "Backlog",
|
||||||
current: "In Progress",
|
coding: "In Progress",
|
||||||
qa: "QA",
|
qa: "QA",
|
||||||
merge: "Merging",
|
merge: "Merging",
|
||||||
done: "Done",
|
done: "Done",
|
||||||
|
closed: "Closed",
|
||||||
archived: "Archived",
|
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.
|
/// A single story row inside a project pipeline card.
|
||||||
/** Render one story row in a gateway-aggregate panel: `#<id> <name>` with stage badge. */
|
/** Render one story row in a gateway-aggregate panel: `#<id> <name>` with status badge. */
|
||||||
export function StoryRow({ item, mergeQueuePos }: { item: PipelineItem; mergeQueuePos?: number }) {
|
export function StoryRow({ item, mergeQueuePos }: { item: PipelineItem; mergeQueuePos?: number }) {
|
||||||
const isStuck = item.merge_failure != null || item.blocked;
|
const pipeline = itemPipeline(item);
|
||||||
const isMergeActive = item.stage === "merge" && !isStuck && item.agent?.status === "running";
|
const status = itemStatus(item);
|
||||||
|
const agentStatus = item.agent?.status;
|
||||||
|
|
||||||
let color: string;
|
let color: string;
|
||||||
let label: string;
|
let label: string;
|
||||||
|
let frozenPrefix = "";
|
||||||
|
|
||||||
if (isMergeActive) {
|
// Frozen items keep their underlying pipeline column but get a ❄️ badge.
|
||||||
color = "#58a6ff";
|
// (AC 4 — story 1085, subsumes the freeze-hides-item bug.)
|
||||||
label = "▶ MERGING";
|
if (status === "frozen") {
|
||||||
} else if (isStuck) {
|
color = "#79c0ff";
|
||||||
const agentStatus = item.agent?.status;
|
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") {
|
if (agentStatus === "running") {
|
||||||
color = "#e3b341";
|
color = "#e3b341";
|
||||||
label = "⟳ RECOVERING";
|
label = "⟳ RECOVERING";
|
||||||
} else if (agentStatus === "pending") {
|
} else if (agentStatus === "pending") {
|
||||||
color = "#e3b341";
|
color = "#e3b341";
|
||||||
label = "⏳ QUEUED";
|
label = "⏳ QUEUED";
|
||||||
} else if (item.merge_failure != null) {
|
} else {
|
||||||
color = "#f85149";
|
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 {
|
} else {
|
||||||
color = "#f85149";
|
color = "#f85149";
|
||||||
label = "⊘ BLOCKED";
|
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";
|
color = "#e3b341";
|
||||||
label = "⏳ QUEUED";
|
label = "⏳ QUEUED";
|
||||||
} else if (item.stage === "merge") {
|
} else if (pipeline === "merge") {
|
||||||
color = "#6e7681";
|
color = "#6e7681";
|
||||||
if (mergeQueuePos === 1) {
|
if (mergeQueuePos === 1) {
|
||||||
label = "NEXT IN QUEUE";
|
label = "NEXT IN QUEUE";
|
||||||
@@ -123,10 +170,11 @@ export function StoryRow({ item, mergeQueuePos }: { item: PipelineItem; mergeQue
|
|||||||
label = "awaiting-slot";
|
label = "awaiting-slot";
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
color = STAGE_COLORS[item.stage] ?? "#8b949e";
|
color = PIPELINE_COLORS[pipeline] ?? "#8b949e";
|
||||||
label = STAGE_LABELS[item.stage] ?? item.stage;
|
label = PIPELINE_LABELS[pipeline] ?? pipeline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isMergeActive = pipeline === "merge" && status === "active" && agentStatus === "running";
|
||||||
const idNum = item.story_id.match(/^(\d+)/)?.[1];
|
const idNum = item.story_id.match(/^(\d+)/)?.[1];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -158,7 +206,7 @@ export function StoryRow({ item, mergeQueuePos }: { item: PipelineItem; mergeQue
|
|||||||
</span>
|
</span>
|
||||||
<span style={{ color: "#e6edf3", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
<span style={{ color: "#e6edf3", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
{idNum && <span style={{ color: "#8b949e", fontFamily: "monospace" }}>#{idNum}{" "}</span>}
|
{idNum && <span style={{ color: "#8b949e", fontFamily: "monospace" }}>#{idNum}{" "}</span>}
|
||||||
{item.name}
|
{frozenPrefix}{item.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -388,6 +436,8 @@ function aggregateItems(
|
|||||||
story_id: b.story_id,
|
story_id: b.story_id,
|
||||||
name: b.name,
|
name: b.name,
|
||||||
stage: "backlog",
|
stage: "backlog",
|
||||||
|
pipeline: "backlog" as Pipeline,
|
||||||
|
status: "active" as Status,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -395,14 +445,14 @@ function aggregateItems(
|
|||||||
return {
|
return {
|
||||||
project,
|
project,
|
||||||
items: (status.active ?? []).filter(
|
items: (status.active ?? []).filter(
|
||||||
(i) => i.stage !== "done",
|
(i) => itemPipeline(i) !== "done",
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (tab === "done") {
|
if (tab === "done") {
|
||||||
return {
|
return {
|
||||||
project,
|
project,
|
||||||
items: (status.active ?? []).filter((i) => i.stage === "done"),
|
items: (status.active ?? []).filter((i) => itemPipeline(i) === "done"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// archived
|
// archived
|
||||||
@@ -419,12 +469,12 @@ function tabCount(pipeline: AllProjectsPipeline, tab: TabKey): number {
|
|||||||
if (tab === "in-progress") {
|
if (tab === "in-progress") {
|
||||||
return (
|
return (
|
||||||
sum +
|
sum +
|
||||||
(status.active ?? []).filter((i) => i.stage !== "done").length
|
(status.active ?? []).filter((i) => itemPipeline(i) !== "done").length
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (tab === "done") {
|
if (tab === "done") {
|
||||||
return (
|
return (
|
||||||
sum + (status.active ?? []).filter((i) => i.stage === "done").length
|
sum + (status.active ?? []).filter((i) => itemPipeline(i) === "done").length
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return sum + (status.archived ?? []).length;
|
return sum + (status.archived ?? []).length;
|
||||||
@@ -518,13 +568,16 @@ function ProjectStoryRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const IN_PROGRESS_STAGE_LABELS: Record<string, string> = {
|
const IN_PROGRESS_PIPELINE_LABELS: Record<"coding" | "qa" | "merge", string> = {
|
||||||
current: "Coding",
|
coding: "Coding",
|
||||||
qa: "QA",
|
qa: "QA",
|
||||||
merge: "Merging",
|
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({
|
function InProgressTabContent({
|
||||||
groups,
|
groups,
|
||||||
}: {
|
}: {
|
||||||
@@ -535,25 +588,22 @@ function InProgressTabContent({
|
|||||||
);
|
);
|
||||||
const multiProject = new Set(allItems.map((x) => x.project)).size > 1;
|
const multiProject = new Set(allItems.map((x) => x.project)).size > 1;
|
||||||
|
|
||||||
const byStage = {
|
const byPipeline = {
|
||||||
current: allItems.filter((x) => x.item.stage === "current"),
|
coding: allItems.filter((x) => itemPipeline(x.item) === "coding"),
|
||||||
qa: allItems.filter((x) => x.item.stage === "qa"),
|
qa: allItems.filter((x) => itemPipeline(x.item) === "qa"),
|
||||||
merge: allItems.filter((x) => x.item.stage === "merge"),
|
merge: allItems.filter((x) => itemPipeline(x.item) === "merge"),
|
||||||
};
|
};
|
||||||
|
|
||||||
const stages = (["current", "qa", "merge"] as const).filter(
|
const pipelines = (["coding", "qa", "merge"] as const).filter(
|
||||||
(s) => byStage[s].length > 0,
|
(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<string, number>();
|
const mergeQueuePosMap = new Map<string, number>();
|
||||||
let queuePos = 0;
|
let queuePos = 0;
|
||||||
for (const { project, item } of byStage.merge) {
|
for (const { project, item } of byPipeline.merge) {
|
||||||
if (
|
if (itemStatus(item) === "active" && item.agent?.status !== "running") {
|
||||||
!item.blocked &&
|
|
||||||
!item.merge_failure &&
|
|
||||||
item.agent?.status !== "running"
|
|
||||||
) {
|
|
||||||
queuePos += 1;
|
queuePos += 1;
|
||||||
mergeQueuePosMap.set(`${project}:${item.story_id}`, queuePos);
|
mergeQueuePosMap.set(`${project}:${item.story_id}`, queuePos);
|
||||||
}
|
}
|
||||||
@@ -569,33 +619,33 @@ function InProgressTabContent({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{stages.map((stage) => (
|
{pipelines.map((p) => (
|
||||||
<div key={stage} style={{ marginBottom: "20px" }}>
|
<div key={p} style={{ marginBottom: "20px" }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: "0.8em",
|
fontSize: "0.8em",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: STAGE_COLORS[stage] ?? "#8b949e",
|
color: PIPELINE_COLORS[p] ?? "#8b949e",
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: "0.06em",
|
letterSpacing: "0.06em",
|
||||||
marginBottom: "8px",
|
marginBottom: "8px",
|
||||||
paddingBottom: "4px",
|
paddingBottom: "4px",
|
||||||
borderBottom: `1px solid ${STAGE_COLORS[stage] ?? "#8b949e"}33`,
|
borderBottom: `1px solid ${PIPELINE_COLORS[p] ?? "#8b949e"}33`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{IN_PROGRESS_STAGE_LABELS[stage]}{" "}
|
{IN_PROGRESS_PIPELINE_LABELS[p]}{" "}
|
||||||
<span style={{ color: "#6e7681" }}>
|
<span style={{ color: "#6e7681" }}>
|
||||||
({byStage[stage].length})
|
({byPipeline[p].length})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{byStage[stage].map(({ project, item }) => (
|
{byPipeline[p].map(({ project, item }) => (
|
||||||
<ProjectStoryRow
|
<ProjectStoryRow
|
||||||
key={`${project}:${item.story_id}`}
|
key={`${project}:${item.story_id}`}
|
||||||
project={project}
|
project={project}
|
||||||
item={item}
|
item={item}
|
||||||
showProject={multiProject}
|
showProject={multiProject}
|
||||||
mergeQueuePos={
|
mergeQueuePos={
|
||||||
stage === "merge"
|
p === "merge"
|
||||||
? mergeQueuePosMap.get(`${project}:${item.story_id}`)
|
? mergeQueuePosMap.get(`${project}:${item.story_id}`)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,37 +2,30 @@
|
|||||||
|
|
||||||
use crate::agents::{AgentPool, AgentStatus};
|
use crate::agents::{AgentPool, AgentStatus};
|
||||||
use crate::config::ProjectConfig;
|
use crate::config::ProjectConfig;
|
||||||
use crate::pipeline_state::{ArchiveReason, PipelineItem, Stage};
|
use crate::pipeline_state::{ArchiveReason, Pipeline, PipelineItem, Stage, Status};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
/// Map a stage to its display section label, or `None` to skip it entirely.
|
/// Map a stage to its display section label, or `None` to skip it entirely.
|
||||||
///
|
///
|
||||||
/// This is the single source of truth for the "where does this item appear"
|
/// This routes through [`Stage::pipeline`] so chat output and the web UI use
|
||||||
/// decision. It mirrors the bucket routing in `http/workflow/pipeline.rs`
|
/// the same column derivation. Frozen stories appear in their underlying
|
||||||
/// so that chat output and the web UI are always consistent.
|
/// `resume_to` column (handled inside `Stage::pipeline`) and items in
|
||||||
///
|
/// `Stage::Archived` (with non-Blocked reasons) stay hidden.
|
||||||
/// `Stage::Frozen { resume_to }` is handled recursively: a frozen story
|
|
||||||
/// appears in the same section its `resume_to` stage would land in.
|
|
||||||
pub(crate) fn display_section(s: &Stage) -> Option<&'static str> {
|
pub(crate) fn display_section(s: &Stage) -> Option<&'static str> {
|
||||||
match s {
|
// Archived items with non-Blocked reasons are hidden from chat output.
|
||||||
Stage::Upcoming | Stage::Backlog => Some("Backlog"),
|
if matches!(s, Stage::Archived { reason, .. } if !matches!(reason, ArchiveReason::Blocked { .. }))
|
||||||
Stage::Coding { .. }
|
{
|
||||||
| Stage::Blocked { .. }
|
return None;
|
||||||
| 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
|
|
||||||
}
|
}
|
||||||
|
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.
|
/// 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();
|
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<String, crate::service::git_ops::DirtyFiles> = items
|
let dirty_files_by_story: HashMap<String, crate::service::git_ops::DirtyFiles> = items
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|i| matches!(i.stage, Stage::Coding { .. }))
|
.filter(|i| i.stage.pipeline() == Pipeline::Coding && i.stage.status() == Status::Active)
|
||||||
.filter_map(|i| {
|
.filter_map(|i| {
|
||||||
let wt = crate::worktree::worktree_path(project_root, &i.story_id.0);
|
let wt = crate::worktree::worktree_path(project_root, &i.story_id.0);
|
||||||
if wt.is_dir() {
|
if wt.is_dir() {
|
||||||
@@ -137,10 +130,13 @@ pub(crate) fn build_status_from_items(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.collect();
|
.collect();
|
||||||
// Merge-failure detail now lives on the typed MergeJob CRDT entry
|
// 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<String, String> = items
|
let merge_failures: HashMap<String, String> = items
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|i| matches!(i.stage, Stage::Merge { .. }))
|
.filter(|i| i.stage.pipeline() == Pipeline::Merge && i.stage.status() == Status::Active)
|
||||||
.filter_map(|i| {
|
.filter_map(|i| {
|
||||||
let job = crate::crdt_state::read_merge_job(&i.story_id.0)?;
|
let job = crate::crdt_state::read_merge_job(&i.story_id.0)?;
|
||||||
let err = job.error?;
|
let err = job.error?;
|
||||||
@@ -260,8 +256,10 @@ fn render_item_line(
|
|||||||
} else {
|
} else {
|
||||||
Some(item.name.as_str())
|
Some(item.name.as_str())
|
||||||
};
|
};
|
||||||
// Use the typed CRDT stage as the sole source of truth (story 945).
|
// Use the new Pipeline + Status helpers (story 1085).
|
||||||
let frozen = matches!(item.stage, Stage::Frozen { .. });
|
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 base_label = super::story_short_label(story_id, name_opt);
|
||||||
let display = if frozen {
|
let display = if frozen {
|
||||||
format!("\u{2744}\u{FE0F} {base_label}") // ❄️ prefix
|
format!("\u{2744}\u{FE0F} {base_label}") // ❄️ prefix
|
||||||
@@ -282,41 +280,52 @@ fn render_item_line(
|
|||||||
format!(" *(waiting on: {})*", nums.join(", "))
|
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.
|
// distinct indicator and optionally display their metadata.
|
||||||
match &item.stage {
|
match status {
|
||||||
Stage::Abandoned { .. } => {
|
Status::Abandoned => {
|
||||||
return format!(" \u{1F5D1}\u{FE0F} {display}{cost_suffix}\n"); // 🗑️
|
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!(
|
return format!(
|
||||||
" \u{1F500} {display}{cost_suffix} — superseded by {}\n", // 🔀
|
" \u{1F500} {display}{cost_suffix} — superseded by {superseded_by}\n", // 🔀
|
||||||
superseded_by.0
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Stage::Rejected { reason, .. } => {
|
Status::Rejected => {
|
||||||
|
let reason = match &item.stage {
|
||||||
|
Stage::Rejected { reason, .. } => reason.as_str(),
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
let snippet = first_non_empty_snippet(reason, 120);
|
let snippet = first_non_empty_snippet(reason, 120);
|
||||||
return format!(" \u{1F6AB} {display}{cost_suffix} — {snippet}\n"); // 🚫
|
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
|
// generic traffic-light dot. MergeFailure / MergeFailureFinal items
|
||||||
// now also appear in the Merge section (in-place) so they are handled
|
// appear in the Merge column (in-place) and are handled by the same arm.
|
||||||
// here alongside normal Merge items.
|
if pipeline == Pipeline::Merge {
|
||||||
if matches!(
|
match status {
|
||||||
item.stage,
|
|
||||||
Stage::Merge { .. } | Stage::MergeFailure { .. } | Stage::MergeFailureFinal { .. }
|
|
||||||
) {
|
|
||||||
match &item.stage {
|
|
||||||
// MergeFailureFinal: mergemaster already tried and gave up — always ⛔.
|
// 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);
|
let snippet = first_non_empty_snippet(&kind.display_reason(), 120);
|
||||||
return format!(" \u{26D4} {display}{cost_suffix}{dep_suffix} — {snippet}\n");
|
return format!(" \u{26D4} {display}{cost_suffix}{dep_suffix} — {snippet}\n");
|
||||||
}
|
}
|
||||||
// MergeFailure: a recovery agent may be running or queued.
|
// 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) {
|
return match agent.map(|a| &a.status) {
|
||||||
Some(AgentStatus::Running) => format!(
|
Some(AgentStatus::Running) => format!(
|
||||||
" \u{1F916} {display}{cost_suffix}{dep_suffix} — mergemaster running\n"
|
" \u{1F916} {display}{cost_suffix}{dep_suffix} — mergemaster running\n"
|
||||||
@@ -353,16 +362,7 @@ fn render_item_line(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let blocked = matches!(
|
let blocked = status == Status::Blocked;
|
||||||
item.stage,
|
|
||||||
Stage::Blocked { .. }
|
|
||||||
| Stage::MergeFailure { .. }
|
|
||||||
| Stage::MergeFailureFinal { .. }
|
|
||||||
| Stage::Archived {
|
|
||||||
reason: ArchiveReason::Blocked { .. },
|
|
||||||
..
|
|
||||||
}
|
|
||||||
);
|
|
||||||
// Blocked items with a recovery agent get differentiated indicators.
|
// Blocked items with a recovery agent get differentiated indicators.
|
||||||
if blocked {
|
if blocked {
|
||||||
return match agent.map(|a| &a.status) {
|
return match agent.map(|a| &a.status) {
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ pub(crate) fn tool_get_pipeline_status(ctx: &AppContext) -> Result<String, Strin
|
|||||||
"story_id": s.story_id,
|
"story_id": s.story_id,
|
||||||
"name": slim_name(&s.name),
|
"name": slim_name(&s.name),
|
||||||
"stage": stage,
|
"stage": stage,
|
||||||
|
"pipeline": s.pipeline.as_str(),
|
||||||
|
"status": s.status.as_str(),
|
||||||
"agent": s.agent.as_ref().map(|a| json!({
|
"agent": s.agent.as_ref().map(|a| json!({
|
||||||
"agent_name": a.agent_name,
|
"agent_name": a.agent_name,
|
||||||
"model": a.model,
|
"model": a.model,
|
||||||
@@ -83,7 +85,15 @@ pub(crate) fn tool_get_pipeline_status(ctx: &AppContext) -> Result<String, Strin
|
|||||||
let archived: Vec<Value> = state
|
let archived: Vec<Value> = state
|
||||||
.archived
|
.archived
|
||||||
.iter()
|
.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();
|
.collect();
|
||||||
|
|
||||||
serde_json::to_string_pretty(&json!({
|
serde_json::to_string_pretty(&json!({
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ pub struct UpcomingStory {
|
|||||||
pub merge_failure: Option<String>,
|
pub merge_failure: Option<String>,
|
||||||
/// Active agent working on this item, if any.
|
/// Active agent working on this item, if any.
|
||||||
pub agent: Option<AgentAssignment>,
|
pub agent: Option<AgentAssignment>,
|
||||||
|
/// 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.
|
/// True when the item is held in QA for human review.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub review_hold: Option<bool>,
|
pub review_hold: Option<bool>,
|
||||||
@@ -142,6 +146,8 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
|
|||||||
error: None,
|
error: None,
|
||||||
merge_failure,
|
merge_failure,
|
||||||
agent,
|
agent,
|
||||||
|
pipeline: item.stage.pipeline(),
|
||||||
|
status: item.stage.status(),
|
||||||
review_hold,
|
review_hold,
|
||||||
qa,
|
qa,
|
||||||
retry_count: if item.retry_count() > 0 {
|
retry_count: if item.retry_count() > 0 {
|
||||||
@@ -278,6 +284,8 @@ pub fn load_upcoming_stories(_ctx: &AppContext) -> Result<Vec<UpcomingStory>, St
|
|||||||
error: None,
|
error: None,
|
||||||
merge_failure: None,
|
merge_failure: None,
|
||||||
agent: None,
|
agent: None,
|
||||||
|
pipeline: item.stage.pipeline(),
|
||||||
|
status: item.stage.status(),
|
||||||
review_hold: None,
|
review_hold: None,
|
||||||
qa: None,
|
qa: None,
|
||||||
retry_count: if item_retry_count > 0 {
|
retry_count: if item_retry_count > 0 {
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ mod tests;
|
|||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use types::{
|
pub use types::{
|
||||||
AgentClaim, AgentName, ArchiveReason, BranchName, ExecutionState, GitSha, MergeFailureKind,
|
AgentClaim, AgentName, ArchiveReason, BranchName, ExecutionState, GitSha, MergeFailureKind,
|
||||||
NodePubkey, PipelineItem, PlanState, Stage, StoryId, TransitionError, stage_dir_name,
|
NodePubkey, Pipeline, PipelineItem, PlanState, Stage, Status, StoryId, TransitionError,
|
||||||
stage_label,
|
stage_dir_name, stage_label,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
|
|||||||
@@ -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 state ────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Per-node execution tracking, stored in the CRDT under each node's pubkey.
|
/// Per-node execution tracking, stored in the CRDT under each node's pubkey.
|
||||||
|
|||||||
@@ -212,6 +212,8 @@ mod tests {
|
|||||||
error: None,
|
error: None,
|
||||||
merge_failure: None,
|
merge_failure: None,
|
||||||
agent: None,
|
agent: None,
|
||||||
|
pipeline: crate::pipeline_state::Pipeline::Backlog,
|
||||||
|
status: crate::pipeline_state::Status::Active,
|
||||||
review_hold: None,
|
review_hold: None,
|
||||||
qa: None,
|
qa: None,
|
||||||
retry_count: None,
|
retry_count: None,
|
||||||
@@ -226,6 +228,8 @@ mod tests {
|
|||||||
error: None,
|
error: None,
|
||||||
merge_failure: None,
|
merge_failure: None,
|
||||||
agent: None,
|
agent: None,
|
||||||
|
pipeline: crate::pipeline_state::Pipeline::Coding,
|
||||||
|
status: crate::pipeline_state::Status::Active,
|
||||||
review_hold: None,
|
review_hold: None,
|
||||||
qa: None,
|
qa: None,
|
||||||
retry_count: None,
|
retry_count: None,
|
||||||
@@ -242,6 +246,8 @@ mod tests {
|
|||||||
error: None,
|
error: None,
|
||||||
merge_failure: None,
|
merge_failure: None,
|
||||||
agent: None,
|
agent: None,
|
||||||
|
pipeline: crate::pipeline_state::Pipeline::Done,
|
||||||
|
status: crate::pipeline_state::Status::Done,
|
||||||
review_hold: None,
|
review_hold: None,
|
||||||
qa: None,
|
qa: None,
|
||||||
retry_count: None,
|
retry_count: None,
|
||||||
@@ -303,6 +309,8 @@ mod tests {
|
|||||||
model: Some(crate::agents::AgentModel::Sonnet),
|
model: Some(crate::agents::AgentModel::Sonnet),
|
||||||
status: crate::agents::AgentStatus::Running,
|
status: crate::agents::AgentStatus::Running,
|
||||||
}),
|
}),
|
||||||
|
pipeline: crate::pipeline_state::Pipeline::Coding,
|
||||||
|
status: crate::pipeline_state::Status::Active,
|
||||||
review_hold: None,
|
review_hold: None,
|
||||||
qa: None,
|
qa: None,
|
||||||
retry_count: None,
|
retry_count: None,
|
||||||
|
|||||||
@@ -205,6 +205,8 @@ mod tests {
|
|||||||
error: None,
|
error: None,
|
||||||
merge_failure: None,
|
merge_failure: None,
|
||||||
agent: None,
|
agent: None,
|
||||||
|
pipeline: crate::pipeline_state::Pipeline::Backlog,
|
||||||
|
status: crate::pipeline_state::Status::Active,
|
||||||
review_hold: None,
|
review_hold: None,
|
||||||
qa: None,
|
qa: None,
|
||||||
retry_count: None,
|
retry_count: None,
|
||||||
|
|||||||
Reference in New Issue
Block a user