Spike 61: filesystem watcher and UI simplification

Add notify-based filesystem watcher for .story_kit/work/ that
auto-commits changes with deterministic messages and broadcasts
events over WebSocket. Push full pipeline state (Upcoming, Current,
QA, To Merge) to frontend on connect and after every watcher event.

Strip dead UI: remove ReviewPanel, GatePanel, TodoPanel,
UpcomingPanel and all associated REST polling. Replace with 4
generic StagePanel components driven by WebSocket. Simplify
AgentPanel to roster-only.

Delete all 11 workflow HTTP endpoints and 16 request/response types
from the server. Clean dead code from workflow module. MCP tools
call Rust functions directly and need none of the HTTP layer.

Net: ~4,100 lines deleted, ~400 added.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dave
2026-02-20 19:39:19 +00:00
parent 65b104edc5
commit 810608d3d8
29 changed files with 1041 additions and 4526 deletions

191
Cargo.lock generated
View File

@@ -255,6 +255,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
dependencies = [
"crossbeam-utils",
]
[[package]] [[package]]
name = "crossbeam-deque" name = "crossbeam-deque"
version = "0.8.6" version = "0.8.6"
@@ -446,6 +455,17 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "filetime"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
dependencies = [
"cfg-if",
"libc",
"libredox",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@@ -479,6 +499,15 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "fsevent-sys"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "futures" name = "futures"
version = "0.3.32" version = "0.3.32"
@@ -981,6 +1010,26 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "inotify"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff"
dependencies = [
"bitflags 1.3.2",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.11.0" version = "2.11.0"
@@ -1054,6 +1103,26 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "kqueue"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
dependencies = [
"kqueue-sys",
"libc",
]
[[package]]
name = "kqueue-sys"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
dependencies = [
"bitflags 1.3.2",
"libc",
]
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@@ -1072,6 +1141,17 @@ version = "0.2.182"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
[[package]]
name = "libredox"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
dependencies = [
"bitflags 2.11.0",
"libc",
"redox_syscall 0.7.1",
]
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.11.0" version = "0.11.0"
@@ -1133,6 +1213,18 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "mio"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.48.0",
]
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.1.1" version = "1.1.1"
@@ -1196,6 +1288,25 @@ dependencies = [
"minimal-lexical", "minimal-lexical",
] ]
[[package]]
name = "notify"
version = "6.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d"
dependencies = [
"bitflags 2.11.0",
"crossbeam-channel",
"filetime",
"fsevent-sys",
"inotify",
"kqueue",
"libc",
"log",
"mio 0.8.11",
"walkdir",
"windows-sys 0.48.0",
]
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -1235,7 +1346,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"redox_syscall", "redox_syscall 0.5.18",
"smallvec", "smallvec",
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
@@ -1540,6 +1651,15 @@ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.0",
] ]
[[package]]
name = "redox_syscall"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b"
dependencies = [
"bitflags 2.11.0",
]
[[package]] [[package]]
name = "regex" name = "regex"
version = "1.12.3" version = "1.12.3"
@@ -2016,6 +2136,7 @@ dependencies = [
"homedir", "homedir",
"ignore", "ignore",
"mime_guess", "mime_guess",
"notify",
"poem", "poem",
"poem-openapi", "poem-openapi",
"portable-pty", "portable-pty",
@@ -2191,7 +2312,7 @@ checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
dependencies = [ dependencies = [
"bytes", "bytes",
"libc", "libc",
"mio", "mio 1.1.1",
"pin-project-lite", "pin-project-lite",
"socket2", "socket2",
"tokio-macros", "tokio-macros",
@@ -2887,6 +3008,15 @@ dependencies = [
"windows-targets 0.42.2", "windows-targets 0.42.2",
] ]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.52.0" version = "0.52.0"
@@ -2929,6 +3059,21 @@ dependencies = [
"windows_x86_64_msvc 0.42.2", "windows_x86_64_msvc 0.42.2",
] ]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]] [[package]]
name = "windows-targets" name = "windows-targets"
version = "0.52.6" version = "0.52.6"
@@ -2977,6 +3122,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]] [[package]]
name = "windows_aarch64_gnullvm" name = "windows_aarch64_gnullvm"
version = "0.52.6" version = "0.52.6"
@@ -2995,6 +3146,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.52.6" version = "0.52.6"
@@ -3013,6 +3170,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.52.6" version = "0.52.6"
@@ -3043,6 +3206,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.52.6" version = "0.52.6"
@@ -3061,6 +3230,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.52.6" version = "0.52.6"
@@ -3079,6 +3254,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.52.6" version = "0.52.6"
@@ -3097,6 +3278,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.52.6" version = "0.52.6"

View File

@@ -21,3 +21,4 @@ mime_guess = "2"
homedir = "0.3.6" homedir = "0.3.6"
portable-pty = "0.9.0" portable-pty = "0.9.0"
strip-ansi-escapes = "0.2" strip-ansi-escapes = "0.2"
notify = "6"

View File

@@ -8,11 +8,25 @@ export type WsRequest =
type: "cancel"; type: "cancel";
}; };
export interface PipelineStageItem {
story_id: string;
name: string | null;
error: string | null;
}
export interface PipelineState {
upcoming: PipelineStageItem[];
current: PipelineStageItem[];
qa: PipelineStageItem[];
merge: PipelineStageItem[];
}
export type WsResponse = export type WsResponse =
| { type: "token"; content: string } | { type: "token"; content: string }
| { type: "update"; messages: Message[] } | { type: "update"; messages: Message[] }
| { type: "session_id"; session_id: string } | { type: "session_id"; session_id: string }
| { type: "error"; message: string }; | { type: "error"; message: string }
| { type: "pipeline_state"; upcoming: PipelineStageItem[]; current: PipelineStageItem[]; qa: PipelineStageItem[]; merge: PipelineStageItem[] };
export interface ProviderConfig { export interface ProviderConfig {
provider: string; provider: string;
@@ -216,6 +230,7 @@ export class ChatWebSocket {
private onUpdate?: (messages: Message[]) => void; private onUpdate?: (messages: Message[]) => void;
private onSessionId?: (sessionId: string) => void; private onSessionId?: (sessionId: string) => void;
private onError?: (message: string) => void; private onError?: (message: string) => void;
private onPipelineState?: (state: PipelineState) => void;
private connected = false; private connected = false;
private closeTimer?: number; private closeTimer?: number;
@@ -225,6 +240,7 @@ export class ChatWebSocket {
onUpdate?: (messages: Message[]) => void; onUpdate?: (messages: Message[]) => void;
onSessionId?: (sessionId: string) => void; onSessionId?: (sessionId: string) => void;
onError?: (message: string) => void; onError?: (message: string) => void;
onPipelineState?: (state: PipelineState) => void;
}, },
wsPath = DEFAULT_WS_PATH, wsPath = DEFAULT_WS_PATH,
) { ) {
@@ -232,6 +248,7 @@ export class ChatWebSocket {
this.onUpdate = handlers.onUpdate; this.onUpdate = handlers.onUpdate;
this.onSessionId = handlers.onSessionId; this.onSessionId = handlers.onSessionId;
this.onError = handlers.onError; this.onError = handlers.onError;
this.onPipelineState = handlers.onPipelineState;
if (this.connected) { if (this.connected) {
return; return;
@@ -263,6 +280,7 @@ export class ChatWebSocket {
if (data.type === "update") this.onUpdate?.(data.messages); if (data.type === "update") this.onUpdate?.(data.messages);
if (data.type === "session_id") this.onSessionId?.(data.session_id); if (data.type === "session_id") this.onSessionId?.(data.session_id);
if (data.type === "error") this.onError?.(data.message); if (data.type === "error") this.onError?.(data.message);
if (data.type === "pipeline_state") this.onPipelineState?.({ upcoming: data.upcoming, current: data.current, qa: data.qa, merge: data.merge });
} catch (err) { } catch (err) {
this.onError?.(String(err)); this.onError?.(String(err));
} }

View File

@@ -1,135 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { workflowApi } from "./workflow";
const mockFetch = vi.fn();
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
});
afterEach(() => {
vi.restoreAllMocks();
});
function okResponse(body: unknown) {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
function errorResponse(status: number, text: string) {
return new Response(text, { status });
}
describe("workflowApi", () => {
describe("recordTests", () => {
it("sends POST to /workflow/tests/record", async () => {
mockFetch.mockResolvedValueOnce(okResponse(true));
const payload = {
story_id: "story-29",
unit: [{ name: "t1", status: "pass" as const }],
integration: [],
};
await workflowApi.recordTests(payload);
expect(mockFetch).toHaveBeenCalledWith(
"/api/workflow/tests/record",
expect.objectContaining({ method: "POST" }),
);
});
});
describe("getAcceptance", () => {
it("sends POST and returns acceptance response", async () => {
const response = {
can_accept: true,
reasons: [],
warning: null,
summary: { total: 2, passed: 2, failed: 0 },
missing_categories: [],
};
mockFetch.mockResolvedValueOnce(okResponse(response));
const result = await workflowApi.getAcceptance({
story_id: "story-29",
});
expect(result.can_accept).toBe(true);
expect(result.summary.total).toBe(2);
});
});
describe("getReviewQueueAll", () => {
it("sends GET to /workflow/review/all", async () => {
mockFetch.mockResolvedValueOnce(okResponse({ stories: [] }));
const result = await workflowApi.getReviewQueueAll();
expect(mockFetch).toHaveBeenCalledWith(
"/api/workflow/review/all",
expect.objectContaining({}),
);
expect(result.stories).toEqual([]);
});
});
describe("ensureAcceptance", () => {
it("returns true when acceptance passes", async () => {
mockFetch.mockResolvedValueOnce(okResponse(true));
const result = await workflowApi.ensureAcceptance({
story_id: "story-29",
});
expect(result).toBe(true);
});
it("throws on error response", async () => {
mockFetch.mockResolvedValueOnce(
errorResponse(400, "Acceptance is blocked"),
);
await expect(
workflowApi.ensureAcceptance({ story_id: "story-29" }),
).rejects.toThrow("Acceptance is blocked");
});
});
describe("getUpcomingStories", () => {
it("sends GET to /workflow/upcoming", async () => {
const response = {
stories: [
{ story_id: "31_view_upcoming", name: "View Upcoming" },
{ story_id: "32_worktree", name: null },
],
};
mockFetch.mockResolvedValueOnce(okResponse(response));
const result = await workflowApi.getUpcomingStories();
expect(mockFetch).toHaveBeenCalledWith(
"/api/workflow/upcoming",
expect.objectContaining({}),
);
expect(result.stories).toHaveLength(2);
expect(result.stories[0].name).toBe("View Upcoming");
expect(result.stories[1].name).toBeNull();
});
});
describe("getReviewQueue", () => {
it("sends GET to /workflow/review", async () => {
mockFetch.mockResolvedValueOnce(
okResponse({ stories: [{ story_id: "s1", can_accept: true }] }),
);
const result = await workflowApi.getReviewQueue();
expect(result.stories).toHaveLength(1);
expect(result.stories[0].story_id).toBe("s1");
});
});
});

View File

@@ -1,199 +0,0 @@
export type TestStatus = "pass" | "fail";
export interface TestCasePayload {
name: string;
status: TestStatus;
details?: string | null;
}
export interface RecordTestsPayload {
story_id: string;
unit: TestCasePayload[];
integration: TestCasePayload[];
}
export interface AcceptanceRequest {
story_id: string;
}
export interface TestRunSummaryResponse {
total: number;
passed: number;
failed: number;
}
export interface CoverageReportResponse {
current_percent: number;
threshold_percent: number;
baseline_percent?: number | null;
}
export interface AcceptanceResponse {
can_accept: boolean;
reasons: string[];
warning?: string | null;
summary: TestRunSummaryResponse;
missing_categories: string[];
coverage_report?: CoverageReportResponse | null;
}
export interface ReviewStory {
story_id: string;
can_accept: boolean;
reasons: string[];
warning?: string | null;
summary: TestRunSummaryResponse;
missing_categories: string[];
coverage_report?: CoverageReportResponse | null;
}
export interface RecordCoveragePayload {
story_id: string;
current_percent: number;
threshold_percent?: number | null;
}
export interface CollectCoverageRequest {
story_id: string;
threshold_percent?: number | null;
}
export interface ReviewListResponse {
stories: ReviewStory[];
}
export interface StoryTodosResponse {
story_id: string;
story_name: string | null;
todos: string[];
error: string | null;
}
export interface TodoListResponse {
stories: StoryTodosResponse[];
}
export interface UpcomingStory {
story_id: string;
name: string | null;
error: string | null;
}
export interface UpcomingStoriesResponse {
stories: UpcomingStory[];
}
export interface StoryValidationResult {
story_id: string;
valid: boolean;
error: string | null;
}
export interface ValidateStoriesResponse {
stories: StoryValidationResult[];
}
export interface CreateStoryPayload {
name: string;
user_story?: string | null;
acceptance_criteria?: string[] | null;
}
export interface CreateStoryResponse {
story_id: string;
}
const DEFAULT_API_BASE = "/api";
function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string {
return `${baseUrl}${path}`;
}
async function requestJson<T>(
path: string,
options: RequestInit = {},
baseUrl = DEFAULT_API_BASE,
): Promise<T> {
const res = await fetch(buildApiUrl(path, baseUrl), {
headers: {
"Content-Type": "application/json",
...(options.headers ?? {}),
},
...options,
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `Request failed (${res.status})`);
}
return res.json() as Promise<T>;
}
export const workflowApi = {
collectCoverage(payload: CollectCoverageRequest, baseUrl?: string) {
return requestJson<CoverageReportResponse>(
"/workflow/coverage/collect",
{ method: "POST", body: JSON.stringify(payload) },
baseUrl,
);
},
recordCoverage(payload: RecordCoveragePayload, baseUrl?: string) {
return requestJson<boolean>(
"/workflow/coverage/record",
{ method: "POST", body: JSON.stringify(payload) },
baseUrl,
);
},
recordTests(payload: RecordTestsPayload, baseUrl?: string) {
return requestJson<boolean>(
"/workflow/tests/record",
{ method: "POST", body: JSON.stringify(payload) },
baseUrl,
);
},
getAcceptance(payload: AcceptanceRequest, baseUrl?: string) {
return requestJson<AcceptanceResponse>(
"/workflow/acceptance",
{ method: "POST", body: JSON.stringify(payload) },
baseUrl,
);
},
getReviewQueue(baseUrl?: string) {
return requestJson<ReviewListResponse>("/workflow/review", {}, baseUrl);
},
getReviewQueueAll(baseUrl?: string) {
return requestJson<ReviewListResponse>("/workflow/review/all", {}, baseUrl);
},
getUpcomingStories(baseUrl?: string) {
return requestJson<UpcomingStoriesResponse>(
"/workflow/upcoming",
{},
baseUrl,
);
},
ensureAcceptance(payload: AcceptanceRequest, baseUrl?: string) {
return requestJson<boolean>(
"/workflow/acceptance/ensure",
{ method: "POST", body: JSON.stringify(payload) },
baseUrl,
);
},
getStoryTodos(baseUrl?: string) {
return requestJson<TodoListResponse>("/workflow/todos", {}, baseUrl);
},
validateStories(baseUrl?: string) {
return requestJson<ValidateStoriesResponse>(
"/workflow/stories/validate",
{},
baseUrl,
);
},
createStory(payload: CreateStoryPayload, baseUrl?: string) {
return requestJson<CreateStoryResponse>(
"/workflow/stories/create",
{ method: "POST", body: JSON.stringify(payload) },
baseUrl,
);
},
};

View File

@@ -58,13 +58,7 @@ describe("AgentPanel diff command", () => {
]; ];
mockedAgents.listAgents.mockResolvedValue(agentList); mockedAgents.listAgents.mockResolvedValue(agentList);
render( render(<AgentPanel />);
<AgentPanel
stories={[
{ story_id: "33_diff_commands", name: "Diff Commands", error: null },
]}
/>,
);
// Expand the agent detail by clicking the expand button // Expand the agent detail by clicking the expand button
const expandButton = await screen.findByText("▶"); const expandButton = await screen.findByText("▶");
@@ -99,13 +93,7 @@ describe("AgentPanel diff command", () => {
]; ];
mockedAgents.listAgents.mockResolvedValue(agentList); mockedAgents.listAgents.mockResolvedValue(agentList);
render( render(<AgentPanel />);
<AgentPanel
stories={[
{ story_id: "33_diff_commands", name: "Diff Commands", error: null },
]}
/>,
);
const expandButton = await screen.findByText("▶"); const expandButton = await screen.findByText("▶");
await userEvent.click(expandButton); await userEvent.click(expandButton);
@@ -135,13 +123,7 @@ describe("AgentPanel diff command", () => {
]; ];
mockedAgents.listAgents.mockResolvedValue(agentList); mockedAgents.listAgents.mockResolvedValue(agentList);
render( render(<AgentPanel />);
<AgentPanel
stories={[
{ story_id: "33_diff_commands", name: "Diff Commands", error: null },
]}
/>,
);
const expandButton = await screen.findByText("▶"); const expandButton = await screen.findByText("▶");
await userEvent.click(expandButton); await userEvent.click(expandButton);
@@ -164,13 +146,7 @@ describe("AgentPanel diff command", () => {
]; ];
mockedAgents.listAgents.mockResolvedValue(agentList); mockedAgents.listAgents.mockResolvedValue(agentList);
render( render(<AgentPanel />);
<AgentPanel
stories={[
{ story_id: "33_diff_commands", name: "Diff Commands", error: null },
]}
/>,
);
const expandButton = await screen.findByText("▶"); const expandButton = await screen.findByText("▶");
await userEvent.click(expandButton); await userEvent.click(expandButton);

View File

@@ -2,19 +2,13 @@ import * as React from "react";
import type { import type {
AgentConfigInfo, AgentConfigInfo,
AgentEvent, AgentEvent,
AgentInfo,
AgentStatusValue, AgentStatusValue,
} from "../api/agents"; } from "../api/agents";
import { agentsApi, subscribeAgentStream } from "../api/agents"; import { agentsApi, subscribeAgentStream } from "../api/agents";
import { settingsApi } from "../api/settings"; import { settingsApi } from "../api/settings";
import type { UpcomingStory } from "../api/workflow";
const { useCallback, useEffect, useRef, useState } = React; const { useCallback, useEffect, useRef, useState } = React;
interface AgentPanelProps {
stories: UpcomingStory[];
}
interface AgentState { interface AgentState {
agentName: string; agentName: string;
status: AgentStatusValue; status: AgentStatusValue;
@@ -238,13 +232,12 @@ export function EditorCommand({
); );
} }
export function AgentPanel({ stories }: AgentPanelProps) { export function AgentPanel() {
const [agents, setAgents] = useState<Record<string, AgentState>>({}); const [agents, setAgents] = useState<Record<string, AgentState>>({});
const [roster, setRoster] = useState<AgentConfigInfo[]>([]); const [roster, setRoster] = useState<AgentConfigInfo[]>([]);
const [expandedKey, setExpandedKey] = useState<string | null>(null); const [expandedKey, setExpandedKey] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null); const [actionError, setActionError] = useState<string | null>(null);
const [lastRefresh, setLastRefresh] = useState<Date | null>(null); const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
const [selectorStory, setSelectorStory] = useState<string | null>(null);
const [editorCommand, setEditorCommand] = useState<string | null>(null); const [editorCommand, setEditorCommand] = useState<string | null>(null);
const [editorInput, setEditorInput] = useState<string>(""); const [editorInput, setEditorInput] = useState<string>("");
const [editingEditor, setEditingEditor] = useState(false); const [editingEditor, setEditingEditor] = useState(false);
@@ -374,31 +367,6 @@ export function AgentPanel({ stories }: AgentPanelProps) {
} }
}, [expandedKey, agents]); }, [expandedKey, agents]);
const handleStart = async (storyId: string, agentName?: string) => {
setActionError(null);
setSelectorStory(null);
try {
const info: AgentInfo = await agentsApi.startAgent(storyId, agentName);
const key = agentKey(info.story_id, info.agent_name);
setAgents((prev) => ({
...prev,
[key]: {
agentName: info.agent_name,
status: info.status,
log: [],
sessionId: info.session_id,
worktreePath: info.worktree_path,
baseBranch: info.base_branch,
},
}));
setExpandedKey(key);
subscribeToAgent(info.story_id, info.agent_name);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setActionError(`Failed to start agent for ${storyId}: ${message}`);
}
};
const handleStop = async (storyId: string, agentName: string) => { const handleStop = async (storyId: string, agentName: string) => {
setActionError(null); setActionError(null);
const key = agentKey(storyId, agentName); const key = agentKey(storyId, agentName);
@@ -417,14 +385,6 @@ export function AgentPanel({ stories }: AgentPanelProps) {
} }
}; };
const handleRunClick = (storyId: string) => {
if (roster.length <= 1) {
handleStart(storyId);
} else {
setSelectorStory(selectorStory === storyId ? null : storyId);
}
};
const handleSaveEditor = async () => { const handleSaveEditor = async () => {
try { try {
const trimmed = editorInput.trim() || null; const trimmed = editorInput.trim() || null;
@@ -438,17 +398,6 @@ export function AgentPanel({ stories }: AgentPanelProps) {
} }
}; };
/** Get all active agent keys for a story. */
const getActiveKeysForStory = (storyId: string): string[] => {
return Object.keys(agents).filter((key) => {
const a = agents[key];
return (
key.startsWith(`${storyId}:`) &&
(a.status === "running" || a.status === "pending")
);
});
};
return ( return (
<div <div
style={{ style={{
@@ -599,11 +548,8 @@ export function AgentPanel({ stories }: AgentPanelProps) {
</div> </div>
)} )}
{stories.length === 0 ? ( {/* Active agents */}
<div style={{ fontSize: "0.85em", color: "#aaa" }}> {Object.entries(agents).length > 0 && (
No stories available. Add stories to .story_kit/stories/upcoming/.
</div>
) : (
<div <div
style={{ style={{
display: "flex", display: "flex",
@@ -611,317 +557,156 @@ export function AgentPanel({ stories }: AgentPanelProps) {
gap: "6px", gap: "6px",
}} }}
> >
{stories.map((story) => { {Object.entries(agents).map(([key, a]) => (
const activeKeys = getActiveKeysForStory(story.story_id); <div
const hasActive = activeKeys.length > 0; key={`agent-${key}`}
style={{
// Gather all agent states for this story border: "1px solid #2a2a2a",
const storyAgentEntries = Object.entries(agents).filter(([key]) => borderRadius: "8px",
key.startsWith(`${story.story_id}:`), background: "#191919",
); overflow: "hidden",
}}
return ( >
<div <div
key={`agent-${story.story_id}`}
style={{ style={{
border: "1px solid #2a2a2a", padding: "8px 12px",
borderRadius: "8px", display: "flex",
background: "#191919", alignItems: "center",
overflow: "hidden", gap: "8px",
}} }}
> >
<div <button
type="button"
onClick={() =>
setExpandedKey(expandedKey === key ? null : key)
}
style={{ style={{
padding: "8px 12px", background: "none",
display: "flex", border: "none",
alignItems: "center", color: "#aaa",
gap: "8px", cursor: "pointer",
fontSize: "0.8em",
padding: "0 4px",
transform:
expandedKey === key
? "rotate(90deg)"
: "rotate(0deg)",
transition: "transform 0.15s",
}} }}
> >
<button &#9654;
type="button" </button>
onClick={() => {
const isExpanded =
expandedKey?.startsWith(`${story.story_id}:`) ||
expandedKey === story.story_id;
setExpandedKey(
isExpanded
? null
: (storyAgentEntries[0]?.[0] ?? story.story_id),
);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
const isExpanded =
expandedKey?.startsWith(`${story.story_id}:`) ||
expandedKey === story.story_id;
setExpandedKey(
isExpanded
? null
: (storyAgentEntries[0]?.[0] ?? story.story_id),
);
}
}}
style={{
background: "none",
border: "none",
color: "#aaa",
cursor: "pointer",
fontSize: "0.8em",
padding: "0 4px",
transform:
expandedKey?.startsWith(`${story.story_id}:`) ||
expandedKey === story.story_id
? "rotate(90deg)"
: "rotate(0deg)",
transition: "transform 0.15s",
}}
>
&#9654;
</button>
<div <div
style={{ style={{
flex: 1, flex: 1,
fontWeight: 600, fontWeight: 600,
fontSize: "0.9em", fontSize: "0.9em",
overflow: "hidden", overflow: "hidden",
textOverflow: "ellipsis", textOverflow: "ellipsis",
whiteSpace: "nowrap", whiteSpace: "nowrap",
}} }}
> >
{story.name ?? story.story_id} <span style={{ color: "#888" }}>{a.agentName}</span>
</div> <span style={{ color: "#555", margin: "0 6px" }}>
{key.split(":")[0]}
{storyAgentEntries.map(([key, a]) => ( </span>
<span
key={`badge-${key}`}
style={{
display: "inline-flex",
alignItems: "center",
gap: "4px",
}}
>
<span
style={{
fontSize: "0.7em",
color: "#666",
}}
>
{a.agentName}
</span>
<StatusBadge status={a.status} />
</span>
))}
{hasActive ? (
<button
type="button"
onClick={() => {
for (const key of activeKeys) {
const a = agents[key];
if (a) {
handleStop(story.story_id, a.agentName);
}
}
}}
style={{
padding: "4px 10px",
borderRadius: "999px",
border: "1px solid #ff7b7244",
background: "#ff7b7211",
color: "#ff7b72",
cursor: "pointer",
fontSize: "0.75em",
fontWeight: 600,
}}
>
Stop
</button>
) : (
<div style={{ position: "relative" }}>
<button
type="button"
onClick={() => handleRunClick(story.story_id)}
style={{
padding: "4px 10px",
borderRadius: "999px",
border: "1px solid #7ee78744",
background: "#7ee78711",
color: "#7ee787",
cursor: "pointer",
fontSize: "0.75em",
fontWeight: 600,
}}
>
Run
</button>
{selectorStory === story.story_id &&
roster.length > 1 && (
<div
style={{
position: "absolute",
top: "100%",
right: 0,
marginTop: "4px",
background: "#222",
border: "1px solid #444",
borderRadius: "6px",
padding: "4px 0",
zIndex: 10,
minWidth: "160px",
}}
>
{roster.map((r) => (
<button
key={`sel-${r.name}`}
type="button"
onClick={() =>
handleStart(story.story_id, r.name)
}
style={{
display: "block",
width: "100%",
padding: "6px 12px",
background: "none",
border: "none",
color: "#ccc",
cursor: "pointer",
textAlign: "left",
fontSize: "0.8em",
}}
onMouseEnter={(e) => {
(
e.target as HTMLButtonElement
).style.background = "#333";
}}
onMouseLeave={(e) => {
(
e.target as HTMLButtonElement
).style.background = "none";
}}
>
<div style={{ fontWeight: 600 }}>{r.name}</div>
{r.role && (
<div
style={{
fontSize: "0.85em",
color: "#888",
}}
>
{r.role}
</div>
)}
</button>
))}
</div>
)}
</div>
)}
</div> </div>
{/* Empty state when expanded with no agents */} <StatusBadge status={a.status} />
{expandedKey === story.story_id &&
storyAgentEntries.length === 0 && ( {(a.status === "running" || a.status === "pending") && (
<button
type="button"
onClick={() =>
handleStop(key.split(":")[0], a.agentName)
}
style={{
padding: "4px 10px",
borderRadius: "999px",
border: "1px solid #ff7b7244",
background: "#ff7b7211",
color: "#ff7b72",
cursor: "pointer",
fontSize: "0.75em",
fontWeight: 600,
}}
>
Stop
</button>
)}
</div>
{expandedKey === key && (
<div
style={{
borderTop: "1px solid #2a2a2a",
padding: "8px 12px",
}}
>
{a.worktreePath && (
<div <div
style={{ style={{
borderTop: "1px solid #2a2a2a", fontSize: "0.75em",
padding: "12px", color: "#666",
fontSize: "0.8em", fontFamily: "monospace",
color: "#555", marginBottom: "6px",
textAlign: "center",
}} }}
> >
No agents started. Use the Run button to start an agent. Worktree: {a.worktreePath}
</div> </div>
)} )}
{a.worktreePath && (
{/* Expanded detail per agent */} <DiffCommand
{storyAgentEntries.map(([key, a]) => { worktreePath={a.worktreePath}
if (expandedKey !== key) return null; baseBranch={a.baseBranch ?? "master"}
return ( />
<div )}
key={`detail-${key}`} <div
style={{ style={{
borderTop: "1px solid #2a2a2a", maxHeight: "300px",
padding: "8px 12px", overflowY: "auto",
}} background: "#111",
> borderRadius: "6px",
<div padding: "8px",
style={{ fontFamily: "monospace",
fontSize: "0.75em", fontSize: "0.8em",
color: "#888", lineHeight: "1.5",
marginBottom: "4px", color: "#ccc",
fontWeight: 600, whiteSpace: "pre-wrap",
}} wordBreak: "break-word",
> }}
{a.agentName} >
</div> {a.log.length === 0 ? (
{a.worktreePath && ( <span style={{ color: "#555" }}>
{a.status === "pending" || a.status === "running"
? "Waiting for output..."
: "No output captured."}
</span>
) : (
a.log.map((line, i) => (
<div <div
key={`log-${key}-${i.toString()}`}
style={{ style={{
fontSize: "0.75em", color: line.startsWith("[ERROR]")
color: "#666", ? "#ff7b72"
fontFamily: "monospace", : "#ccc",
marginBottom: "6px",
}} }}
> >
Worktree: {a.worktreePath} {line}
</div> </div>
)} ))
{a.worktreePath && ( )}
<DiffCommand <div
worktreePath={a.worktreePath} ref={(el) => {
baseBranch={a.baseBranch ?? "master"} logEndRefs.current[key] = el;
/> }}
)} />
<div </div>
style={{ </div>
maxHeight: "300px", )}
overflowY: "auto", </div>
background: "#111", ))}
borderRadius: "6px",
padding: "8px",
fontFamily: "monospace",
fontSize: "0.8em",
lineHeight: "1.5",
color: "#ccc",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{a.log.length === 0 ? (
<span style={{ color: "#555" }}>
{a.status === "pending" || a.status === "running"
? "Waiting for output..."
: "No output captured."}
</span>
) : (
a.log.map((line, i) => (
<div
key={`log-${key}-${i.toString()}`}
style={{
color: line.startsWith("[ERROR]")
? "#ff7b72"
: "#ccc",
}}
>
{line}
</div>
))
)}
<div
ref={(el) => {
logEndRefs.current[key] = el;
}}
/>
</div>
</div>
);
})}
</div>
);
})}
</div> </div>
)} )}
</div> </div>

View File

@@ -1,10 +1,7 @@
import { act, render, screen, waitFor } from "@testing-library/react"; import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { api } from "../api/client"; import { api } from "../api/client";
import type { ReviewStory } from "../api/workflow";
import { workflowApi } from "../api/workflow";
import type { Message } from "../types"; import type { Message } from "../types";
import { Chat } from "./Chat"; import { Chat } from "./Chat";
@@ -39,21 +36,6 @@ vi.mock("../api/client", () => {
return { api, ChatWebSocket }; return { api, ChatWebSocket };
}); });
vi.mock("../api/workflow", () => {
return {
workflowApi: {
getAcceptance: vi.fn(),
getReviewQueue: vi.fn(),
getReviewQueueAll: vi.fn(),
ensureAcceptance: vi.fn(),
recordCoverage: vi.fn(),
collectCoverage: vi.fn(),
getStoryTodos: vi.fn(),
getUpcomingStories: vi.fn(),
},
};
});
const mockedApi = { const mockedApi = {
getOllamaModels: vi.mocked(api.getOllamaModels), getOllamaModels: vi.mocked(api.getOllamaModels),
getAnthropicApiKeyExists: vi.mocked(api.getAnthropicApiKeyExists), getAnthropicApiKeyExists: vi.mocked(api.getAnthropicApiKeyExists),
@@ -64,587 +46,20 @@ const mockedApi = {
setAnthropicApiKey: vi.mocked(api.setAnthropicApiKey), setAnthropicApiKey: vi.mocked(api.setAnthropicApiKey),
}; };
const mockedWorkflow = { function setupMocks() {
getAcceptance: vi.mocked(workflowApi.getAcceptance), mockedApi.getOllamaModels.mockResolvedValue(["llama3.1"]);
getReviewQueue: vi.mocked(workflowApi.getReviewQueue), mockedApi.getAnthropicApiKeyExists.mockResolvedValue(true);
getReviewQueueAll: vi.mocked(workflowApi.getReviewQueueAll), mockedApi.getAnthropicModels.mockResolvedValue([]);
ensureAcceptance: vi.mocked(workflowApi.ensureAcceptance), mockedApi.getModelPreference.mockResolvedValue(null);
getStoryTodos: vi.mocked(workflowApi.getStoryTodos), mockedApi.setModelPreference.mockResolvedValue(true);
getUpcomingStories: vi.mocked(workflowApi.getUpcomingStories), mockedApi.cancelChat.mockResolvedValue(true);
}; mockedApi.setAnthropicApiKey.mockResolvedValue(true);
}
describe("Chat review panel", () => {
beforeEach(() => {
mockedApi.getOllamaModels.mockResolvedValue(["llama3.1"]);
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(true);
mockedApi.getAnthropicModels.mockResolvedValue([]);
mockedApi.getModelPreference.mockResolvedValue(null);
mockedApi.setModelPreference.mockResolvedValue(true);
mockedApi.cancelChat.mockResolvedValue(true);
mockedApi.setAnthropicApiKey.mockResolvedValue(true);
mockedWorkflow.getAcceptance.mockResolvedValue({
can_accept: false,
reasons: ["No test results recorded for the story."],
warning: null,
summary: { total: 0, passed: 0, failed: 0 },
missing_categories: ["unit", "integration"],
});
mockedWorkflow.getReviewQueueAll.mockResolvedValue({ stories: [] });
mockedWorkflow.ensureAcceptance.mockResolvedValue(true);
mockedWorkflow.getStoryTodos.mockResolvedValue({ stories: [] });
mockedWorkflow.getUpcomingStories.mockResolvedValue({ stories: [] });
});
it("shows an empty review queue state", async () => {
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
expect(
await screen.findByText("Stories Awaiting Review"),
).toBeInTheDocument();
expect(await screen.findByText("0 ready / 0 total")).toBeInTheDocument();
expect(
await screen.findByText("No stories waiting for review."),
).toBeInTheDocument();
const updatedLabels = await screen.findAllByText(/Updated/i);
expect(updatedLabels.length).toBeGreaterThanOrEqual(2);
});
it("renders review stories and proceeds", async () => {
const story: ReviewStory = {
story_id: "26_establish_tdd_workflow_and_gates",
can_accept: true,
reasons: [],
warning: null,
summary: { total: 3, passed: 3, failed: 0 },
missing_categories: [],
};
mockedWorkflow.getReviewQueueAll
.mockResolvedValueOnce({ stories: [story] })
.mockResolvedValueOnce({ stories: [] });
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
expect(await screen.findByText(story.story_id)).toBeInTheDocument();
const proceedButton = screen.getByRole("button", { name: "Proceed" });
await userEvent.click(proceedButton);
await waitFor(() => {
expect(mockedWorkflow.ensureAcceptance).toHaveBeenCalledWith({
story_id: story.story_id,
});
});
expect(
await screen.findByText("No stories waiting for review."),
).toBeInTheDocument();
});
it("shows a review error when the queue fails to load", async () => {
mockedWorkflow.getReviewQueueAll.mockRejectedValueOnce(
new Error("Review queue failed"),
);
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
expect(await screen.findByText(/Review queue failed/i)).toBeInTheDocument();
expect(
await screen.findByText(/Use Refresh to try again\./i),
).toBeInTheDocument();
expect(
await screen.findByRole("button", { name: "Retry" }),
).toBeInTheDocument();
});
it("refreshes the review queue when clicking refresh", async () => {
mockedWorkflow.getReviewQueueAll
.mockResolvedValueOnce({ stories: [] })
.mockResolvedValueOnce({ stories: [] });
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
const refreshButtons = await screen.findAllByRole("button", {
name: "Refresh",
});
const refreshButton = refreshButtons[0];
await userEvent.click(refreshButton);
await waitFor(() => {
expect(mockedWorkflow.getReviewQueueAll).toHaveBeenCalled();
});
});
it("disables proceed when a story is blocked", async () => {
const story: ReviewStory = {
story_id: "26_establish_tdd_workflow_and_gates",
can_accept: false,
reasons: ["Missing unit tests"],
warning: null,
summary: { total: 1, passed: 0, failed: 1 },
missing_categories: ["unit"],
};
mockedWorkflow.getReviewQueueAll.mockResolvedValueOnce({
stories: [story],
});
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
expect(await screen.findByText(story.story_id)).toBeInTheDocument();
const blockedButton = screen.getByRole("button", { name: "Blocked" });
expect(blockedButton).toBeDisabled();
expect(await screen.findByText("Missing: unit")).toBeInTheDocument();
expect(await screen.findByText("Missing unit tests")).toBeInTheDocument();
});
it("shows gate panel blocked status with reasons (AC1/AC3)", async () => {
mockedWorkflow.getAcceptance.mockResolvedValueOnce({
can_accept: false,
reasons: ["No approved test plan for the story."],
warning: null,
summary: { total: 0, passed: 0, failed: 0 },
missing_categories: ["unit", "integration"],
});
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
expect(await screen.findByText("Blocked")).toBeInTheDocument();
expect(
await screen.findByText("No approved test plan for the story."),
).toBeInTheDocument();
expect(
await screen.findByText("Missing: unit, integration"),
).toBeInTheDocument();
expect(
await screen.findByText(/0\/0 passing, 0 failing/),
).toBeInTheDocument();
});
it("shows gate panel ready status when all tests pass (AC1/AC3)", async () => {
mockedWorkflow.getAcceptance.mockResolvedValueOnce({
can_accept: true,
reasons: [],
warning: null,
summary: { total: 5, passed: 5, failed: 0 },
missing_categories: [],
});
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
expect(await screen.findByText("Ready to accept")).toBeInTheDocument();
expect(
await screen.findByText(/5\/5 passing, 0 failing/),
).toBeInTheDocument();
});
it("shows failing badge and count in review panel (AC4/AC5)", async () => {
const story: ReviewStory = {
story_id: "26_establish_tdd_workflow_and_gates",
can_accept: false,
reasons: ["3 tests are failing."],
warning: "Multiple tests failing — fix one at a time.",
summary: { total: 5, passed: 2, failed: 3 },
missing_categories: [],
};
mockedWorkflow.getReviewQueueAll.mockResolvedValueOnce({
stories: [story],
});
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
expect(await screen.findByText("Failing 3")).toBeInTheDocument();
expect(await screen.findByText("Warning")).toBeInTheDocument();
expect(
await screen.findByText("Multiple tests failing — fix one at a time."),
).toBeInTheDocument();
expect(await screen.findByText("3 tests are failing.")).toBeInTheDocument();
expect(
await screen.findByText(/2\/5 passing, 3 failing/),
).toBeInTheDocument();
const blockedButton = screen.getByRole("button", { name: "Blocked" });
expect(blockedButton).toBeDisabled();
});
it("shows gate warning when multiple tests fail (AC5)", async () => {
mockedWorkflow.getAcceptance.mockResolvedValueOnce({
can_accept: false,
reasons: ["2 tests are failing."],
warning: "Multiple tests failing — fix one at a time.",
summary: { total: 4, passed: 2, failed: 2 },
missing_categories: [],
});
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
expect(await screen.findByText("Blocked")).toBeInTheDocument();
expect(
await screen.findByText("Multiple tests failing — fix one at a time."),
).toBeInTheDocument();
expect(
await screen.findByText(/2\/4 passing, 2 failing/),
).toBeInTheDocument();
expect(await screen.findByText("2 tests are failing.")).toBeInTheDocument();
});
it("does not call ensureAcceptance when clicking a blocked proceed button (AC4)", async () => {
const story: ReviewStory = {
story_id: "26_establish_tdd_workflow_and_gates",
can_accept: false,
reasons: ["Tests are failing."],
warning: null,
summary: { total: 3, passed: 1, failed: 2 },
missing_categories: [],
};
mockedWorkflow.getReviewQueueAll.mockResolvedValueOnce({
stories: [story],
});
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
const blockedButton = await screen.findByRole("button", {
name: "Blocked",
});
expect(blockedButton).toBeDisabled();
// Clear any prior calls then attempt click on disabled button
mockedWorkflow.ensureAcceptance.mockClear();
await userEvent.click(blockedButton);
expect(mockedWorkflow.ensureAcceptance).not.toHaveBeenCalled();
});
it("shows proceed error when ensureAcceptance fails", async () => {
const story: ReviewStory = {
story_id: "26_establish_tdd_workflow_and_gates",
can_accept: true,
reasons: [],
warning: null,
summary: { total: 3, passed: 3, failed: 0 },
missing_categories: [],
};
mockedWorkflow.getReviewQueueAll.mockResolvedValueOnce({
stories: [story],
});
mockedWorkflow.ensureAcceptance.mockRejectedValueOnce(
new Error("Acceptance blocked: tests still failing"),
);
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
const proceedButton = await screen.findByRole("button", {
name: "Proceed",
});
await userEvent.click(proceedButton);
expect(
await screen.findByText("Acceptance blocked: tests still failing"),
).toBeInTheDocument();
});
it("shows gate error when acceptance endpoint fails", async () => {
mockedWorkflow.getAcceptance.mockRejectedValueOnce(
new Error("Server unreachable"),
);
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
expect(await screen.findByText("Server unreachable")).toBeInTheDocument();
const retryButtons = await screen.findAllByRole("button", {
name: "Retry",
});
expect(retryButtons.length).toBeGreaterThanOrEqual(1);
});
it("refreshes gate status after proceeding on the current story", async () => {
const story: ReviewStory = {
story_id: "26_establish_tdd_workflow_and_gates",
can_accept: true,
reasons: [],
warning: null,
summary: { total: 2, passed: 2, failed: 0 },
missing_categories: [],
};
mockedWorkflow.getAcceptance
.mockResolvedValueOnce({
can_accept: false,
reasons: ["No test results recorded for the story."],
warning: null,
summary: { total: 0, passed: 0, failed: 0 },
missing_categories: ["unit", "integration"],
})
.mockResolvedValueOnce({
can_accept: true,
reasons: [],
warning: null,
summary: { total: 2, passed: 2, failed: 0 },
missing_categories: [],
});
mockedWorkflow.getReviewQueueAll
.mockResolvedValueOnce({ stories: [story] })
.mockResolvedValueOnce({ stories: [] });
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
const proceedButton = await screen.findByRole("button", {
name: "Proceed",
});
await userEvent.click(proceedButton);
await waitFor(() => {
expect(mockedWorkflow.ensureAcceptance).toHaveBeenCalledWith({
story_id: story.story_id,
});
});
expect(await screen.findByText("Ready to accept")).toBeInTheDocument();
});
it("shows coverage below threshold in gate panel (AC3)", async () => {
mockedWorkflow.getAcceptance.mockResolvedValueOnce({
can_accept: false,
reasons: ["Coverage below threshold (55.0% < 80.0%)."],
warning: null,
summary: { total: 3, passed: 3, failed: 0 },
missing_categories: [],
coverage_report: {
current_percent: 55.0,
threshold_percent: 80.0,
baseline_percent: null,
},
});
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
expect(await screen.findByText("Blocked")).toBeInTheDocument();
expect(await screen.findByText(/Coverage: 55\.0%/)).toBeInTheDocument();
expect(await screen.findByText(/threshold: 80\.0%/)).toBeInTheDocument();
expect(
await screen.findByText("Coverage below threshold (55.0% < 80.0%)."),
).toBeInTheDocument();
});
it("shows coverage regression in review panel (AC4)", async () => {
const story: ReviewStory = {
story_id: "27_protect_tests_and_coverage",
can_accept: false,
reasons: ["Coverage regression: 90.0% → 82.0% (threshold: 80.0%)."],
warning: null,
summary: { total: 4, passed: 4, failed: 0 },
missing_categories: [],
coverage_report: {
current_percent: 82.0,
threshold_percent: 80.0,
baseline_percent: 90.0,
},
};
mockedWorkflow.getReviewQueueAll.mockResolvedValueOnce({
stories: [story],
});
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
expect(
await screen.findByText(
"Coverage regression: 90.0% → 82.0% (threshold: 80.0%).",
),
).toBeInTheDocument();
expect(await screen.findByText(/Coverage: 82\.0%/)).toBeInTheDocument();
});
it("shows green coverage when above threshold (AC3)", async () => {
mockedWorkflow.getAcceptance.mockResolvedValueOnce({
can_accept: true,
reasons: [],
warning: null,
summary: { total: 5, passed: 5, failed: 0 },
missing_categories: [],
coverage_report: {
current_percent: 92.0,
threshold_percent: 80.0,
baseline_percent: 90.0,
},
});
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
expect(await screen.findByText("Ready to accept")).toBeInTheDocument();
expect(await screen.findByText(/Coverage: 92\.0%/)).toBeInTheDocument();
});
it("fetches upcoming stories on mount and renders panel", async () => {
mockedWorkflow.getUpcomingStories.mockResolvedValueOnce({
stories: [
{
story_id: "31_view_upcoming",
name: "View Upcoming Stories",
error: null,
},
{ story_id: "32_worktree", name: null, error: null },
],
});
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
expect(await screen.findByText("Upcoming Stories")).toBeInTheDocument();
// Both AgentPanel and ReviewPanel display story names, so multiple elements are expected
const storyNameElements = await screen.findAllByText(
"View Upcoming Stories",
);
expect(storyNameElements.length).toBeGreaterThan(0);
const worktreeElements = await screen.findAllByText("32_worktree");
expect(worktreeElements.length).toBeGreaterThan(0);
});
it("collect coverage button triggers collection and refreshes gate", async () => {
const mockedCollectCoverage = vi.mocked(workflowApi.collectCoverage);
mockedCollectCoverage.mockResolvedValueOnce({
current_percent: 85.0,
threshold_percent: 80.0,
baseline_percent: null,
});
mockedWorkflow.getAcceptance
.mockResolvedValueOnce({
can_accept: false,
reasons: ["No test results recorded for the story."],
warning: null,
summary: { total: 0, passed: 0, failed: 0 },
missing_categories: ["unit", "integration"],
})
.mockResolvedValueOnce({
can_accept: true,
reasons: [],
warning: null,
summary: { total: 5, passed: 5, failed: 0 },
missing_categories: [],
coverage_report: {
current_percent: 85.0,
threshold_percent: 80.0,
baseline_percent: null,
},
});
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
const collectButton = await screen.findByRole("button", {
name: "Collect Coverage",
});
await userEvent.click(collectButton);
await waitFor(() => {
expect(mockedCollectCoverage).toHaveBeenCalledWith({
story_id: "26_establish_tdd_workflow_and_gates",
});
});
expect(await screen.findByText(/Coverage: 85\.0%/)).toBeInTheDocument();
});
it("shows story TODOs when unchecked criteria exist", async () => {
mockedWorkflow.getStoryTodos.mockResolvedValueOnce({
stories: [
{
story_id: "28_ui_show_test_todos",
story_name: "Show Remaining Test TODOs in the UI",
todos: [
"The UI lists unchecked acceptance criteria.",
"Each TODO is displayed as its full text.",
],
error: null,
},
],
});
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
expect(
await screen.findByText("The UI lists unchecked acceptance criteria."),
).toBeInTheDocument();
expect(
await screen.findByText("Each TODO is displayed as its full text."),
).toBeInTheDocument();
expect(await screen.findByText("2 remaining")).toBeInTheDocument();
});
it("shows completion message when all criteria are checked", async () => {
mockedWorkflow.getStoryTodos.mockResolvedValueOnce({
stories: [
{
story_id: "28_ui_show_test_todos",
story_name: "Show Remaining Test TODOs in the UI",
todos: [],
error: null,
},
],
});
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
expect(
await screen.findByText("All acceptance criteria complete."),
).toBeInTheDocument();
});
it("shows TODO error when endpoint fails", async () => {
mockedWorkflow.getStoryTodos.mockRejectedValueOnce(
new Error("Cannot read stories"),
);
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
expect(await screen.findByText("Cannot read stories")).toBeInTheDocument();
});
it("does not fetch Anthropic models when no API key exists", async () => {
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(false);
mockedApi.getAnthropicModels.mockClear();
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => {
expect(mockedApi.getAnthropicApiKeyExists).toHaveBeenCalled();
});
expect(mockedApi.getAnthropicModels).not.toHaveBeenCalled();
});
});
describe("Chat message rendering — unified tool call UI", () => { describe("Chat message rendering — unified tool call UI", () => {
beforeEach(() => { beforeEach(() => {
capturedWsHandlers = null; capturedWsHandlers = null;
setupMocks();
mockedApi.getOllamaModels.mockResolvedValue(["llama3.1"]);
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(true);
mockedApi.getAnthropicModels.mockResolvedValue([]);
mockedApi.getModelPreference.mockResolvedValue(null);
mockedApi.setModelPreference.mockResolvedValue(true);
mockedApi.cancelChat.mockResolvedValue(true);
mockedWorkflow.getAcceptance.mockResolvedValue({
can_accept: true,
reasons: [],
warning: null,
summary: { total: 0, passed: 0, failed: 0 },
missing_categories: [],
});
mockedWorkflow.getReviewQueueAll.mockResolvedValue({ stories: [] });
mockedWorkflow.ensureAcceptance.mockResolvedValue(true);
mockedWorkflow.getStoryTodos.mockResolvedValue({ stories: [] });
mockedWorkflow.getUpcomingStories.mockResolvedValue({ stories: [] });
}); });
it("renders tool call badge for assistant message with tool_calls (AC3)", async () => { it("renders tool call badge for assistant message with tool_calls (AC3)", async () => {
@@ -675,7 +90,6 @@ describe("Chat message rendering — unified tool call UI", () => {
}); });
expect(await screen.findByText("I'll read that file.")).toBeInTheDocument(); expect(await screen.findByText("I'll read that file.")).toBeInTheDocument();
// Tool call badge should appear showing the function name and first arg
expect(await screen.findByText("Read(src/main.rs)")).toBeInTheDocument(); expect(await screen.findByText("Read(src/main.rs)")).toBeInTheDocument();
}); });
@@ -709,7 +123,6 @@ describe("Chat message rendering — unified tool call UI", () => {
capturedWsHandlers?.onUpdate(messages); capturedWsHandlers?.onUpdate(messages);
}); });
// Tool output section should be collapsible
expect(await screen.findByText(/Tool Output/)).toBeInTheDocument(); expect(await screen.findByText(/Tool Output/)).toBeInTheDocument();
expect( expect(
await screen.findByText("The file contains a main function."), await screen.findByText("The file contains a main function."),
@@ -733,7 +146,6 @@ describe("Chat message rendering — unified tool call UI", () => {
expect( expect(
await screen.findByText("Hi there! How can I help?"), await screen.findByText("Hi there! How can I help?"),
).toBeInTheDocument(); ).toBeInTheDocument();
// No tool call badges should appear
expect(screen.queryByText(/Tool Output/)).toBeNull(); expect(screen.queryByText(/Tool Output/)).toBeNull();
}); });
@@ -769,30 +181,25 @@ describe("Chat message rendering — unified tool call UI", () => {
expect(await screen.findByText("Bash(cargo test)")).toBeInTheDocument(); expect(await screen.findByText("Bash(cargo test)")).toBeInTheDocument();
expect(await screen.findByText("Read(Cargo.toml)")).toBeInTheDocument(); expect(await screen.findByText("Read(Cargo.toml)")).toBeInTheDocument();
}); });
it("does not fetch Anthropic models when no API key exists", async () => {
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(false);
mockedApi.getAnthropicModels.mockClear();
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => {
expect(mockedApi.getAnthropicApiKeyExists).toHaveBeenCalled();
});
expect(mockedApi.getAnthropicModels).not.toHaveBeenCalled();
});
}); });
describe("Chat two-column layout", () => { describe("Chat two-column layout", () => {
beforeEach(() => { beforeEach(() => {
capturedWsHandlers = null; capturedWsHandlers = null;
setupMocks();
mockedApi.getOllamaModels.mockResolvedValue(["llama3.1"]);
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(true);
mockedApi.getAnthropicModels.mockResolvedValue([]);
mockedApi.getModelPreference.mockResolvedValue(null);
mockedApi.setModelPreference.mockResolvedValue(true);
mockedApi.cancelChat.mockResolvedValue(true);
mockedWorkflow.getAcceptance.mockResolvedValue({
can_accept: true,
reasons: [],
warning: null,
summary: { total: 0, passed: 0, failed: 0 },
missing_categories: [],
});
mockedWorkflow.getReviewQueueAll.mockResolvedValue({ stories: [] });
mockedWorkflow.ensureAcceptance.mockResolvedValue(true);
mockedWorkflow.getStoryTodos.mockResolvedValue({ stories: [] });
mockedWorkflow.getUpcomingStories.mockResolvedValue({ stories: [] });
}); });
it("renders left and right column containers (AC1, AC2)", async () => { it("renders left and right column containers (AC1, AC2)", async () => {
@@ -812,13 +219,11 @@ describe("Chat two-column layout", () => {
}); });
it("renders panels inside the right column (AC2)", async () => { it("renders panels inside the right column (AC2)", async () => {
mockedWorkflow.getReviewQueueAll.mockResolvedValue({ stories: [] });
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />); render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
const rightColumn = await screen.findByTestId("chat-right-column"); const rightColumn = await screen.findByTestId("chat-right-column");
const reviewPanel = await screen.findByText("Stories Awaiting Review"); const agentsPanel = await screen.findByText("Agents");
expect(rightColumn).toContainElement(reviewPanel); expect(rightColumn).toContainElement(agentsPanel);
}); });
it("uses row flex-direction on wide screens (AC3)", async () => { it("uses row flex-direction on wide screens (AC3)", async () => {

View File

@@ -3,15 +3,11 @@ import Markdown from "react-markdown";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import { api, ChatWebSocket } from "../api/client"; import { api, ChatWebSocket } from "../api/client";
import type { ReviewStory, UpcomingStory } from "../api/workflow"; import type { PipelineState } from "../api/client";
import { workflowApi } from "../api/workflow";
import type { Message, ProviderConfig, ToolCall } from "../types"; import type { Message, ProviderConfig, ToolCall } from "../types";
import { AgentPanel } from "./AgentPanel"; import { AgentPanel } from "./AgentPanel";
import { ChatHeader } from "./ChatHeader"; import { ChatHeader } from "./ChatHeader";
import { GatePanel } from "./GatePanel"; import { StagePanel } from "./StagePanel";
import { ReviewPanel } from "./ReviewPanel";
import { TodoPanel } from "./TodoPanel";
import { UpcomingPanel } from "./UpcomingPanel";
const { useCallback, useEffect, useRef, useState } = React; const { useCallback, useEffect, useRef, useState } = React;
@@ -22,23 +18,6 @@ interface ChatProps {
onCloseProject: () => void; onCloseProject: () => void;
} }
interface GateState {
canAccept: boolean;
reasons: string[];
warning: string | null;
summary: {
total: number;
passed: number;
failed: number;
};
missingCategories: string[];
coverageReport: {
currentPercent: number;
thresholdPercent: number;
baselinePercent: number | null;
} | null;
}
export function Chat({ projectPath, onCloseProject }: ChatProps) { export function Chat({ projectPath, onCloseProject }: ChatProps) {
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
@@ -51,55 +30,17 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
const [showApiKeyDialog, setShowApiKeyDialog] = useState(false); const [showApiKeyDialog, setShowApiKeyDialog] = useState(false);
const [apiKeyInput, setApiKeyInput] = useState(""); const [apiKeyInput, setApiKeyInput] = useState("");
const [hasAnthropicKey, setHasAnthropicKey] = useState(false); const [hasAnthropicKey, setHasAnthropicKey] = useState(false);
const [gateState, setGateState] = useState<GateState | null>(null); const [pipeline, setPipeline] = useState<PipelineState>({
const [gateError, setGateError] = useState<string | null>(null); upcoming: [],
const [isGateLoading, setIsGateLoading] = useState(false); current: [],
const [reviewQueue, setReviewQueue] = useState<ReviewStory[]>([]); qa: [],
const [reviewError, setReviewError] = useState<string | null>(null); merge: [],
const [isReviewLoading, setIsReviewLoading] = useState(false); });
const [proceedingStoryId, setProceedingStoryId] = useState<string | null>(
null,
);
const [proceedError, setProceedError] = useState<string | null>(null);
const [proceedSuccess, setProceedSuccess] = useState<string | null>(null);
const [lastReviewRefresh, setLastReviewRefresh] = useState<Date | null>(null);
const [lastGateRefresh, setLastGateRefresh] = useState<Date | null>(null);
const [isCollectingCoverage, setIsCollectingCoverage] = useState(false);
const [coverageError, setCoverageError] = useState<string | null>(null);
const [storyTodos, setStoryTodos] = useState<
{
storyId: string;
storyName: string | null;
items: string[];
error: string | null;
}[]
>([]);
const [todoError, setTodoError] = useState<string | null>(null);
const [isTodoLoading, setIsTodoLoading] = useState(false);
const [lastTodoRefresh, setLastTodoRefresh] = useState<Date | null>(null);
const [upcomingStories, setUpcomingStories] = useState<UpcomingStory[]>([]);
const [upcomingError, setUpcomingError] = useState<string | null>(null);
const [isUpcomingLoading, setIsUpcomingLoading] = useState(false);
const [lastUpcomingRefresh, setLastUpcomingRefresh] = useState<Date | null>(
null,
);
const [claudeSessionId, setClaudeSessionId] = useState<string | null>(null); const [claudeSessionId, setClaudeSessionId] = useState<string | null>(null);
const [isNarrowScreen, setIsNarrowScreen] = useState( const [isNarrowScreen, setIsNarrowScreen] = useState(
window.innerWidth < NARROW_BREAKPOINT, window.innerWidth < NARROW_BREAKPOINT,
); );
const storyId = "26_establish_tdd_workflow_and_gates";
const gateStatusColor = isGateLoading
? "#aaa"
: gateState?.canAccept
? "#7ee787"
: "#ff7b72";
const gateStatusLabel = isGateLoading
? "Checking..."
: gateState?.canAccept
? "Ready to accept"
: "Blocked";
const wsRef = useRef<ChatWebSocket | null>(null); const wsRef = useRef<ChatWebSocket | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -198,293 +139,6 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
}); });
}, []); }, []);
useEffect(() => {
let active = true;
setIsGateLoading(true);
setGateError(null);
workflowApi
.getAcceptance({ story_id: storyId })
.then((response) => {
if (!active) return;
setGateState({
canAccept: response.can_accept,
reasons: response.reasons,
warning: response.warning ?? null,
summary: response.summary,
missingCategories: response.missing_categories,
coverageReport: response.coverage_report
? {
currentPercent: response.coverage_report.current_percent,
thresholdPercent: response.coverage_report.threshold_percent,
baselinePercent:
response.coverage_report.baseline_percent ?? null,
}
: null,
});
setLastGateRefresh(new Date());
})
.catch((error) => {
if (!active) return;
const message =
error instanceof Error
? error.message
: "Failed to load workflow gates.";
setGateError(message);
setGateState(null);
})
.finally(() => {
if (active) {
setIsGateLoading(false);
}
});
return () => {
active = false;
};
}, [storyId]);
useEffect(() => {
let active = true;
setIsReviewLoading(true);
setReviewError(null);
workflowApi
.getReviewQueueAll()
.then((response) => {
if (!active) return;
setReviewQueue(response.stories);
setLastReviewRefresh(new Date());
})
.catch((error) => {
if (!active) return;
const message =
error instanceof Error
? error.message
: "Failed to load review queue.";
setReviewError(message);
setReviewQueue([]);
})
.finally(() => {
if (active) {
setIsReviewLoading(false);
}
});
return () => {
active = false;
};
}, []);
useEffect(() => {
let active = true;
setIsTodoLoading(true);
setTodoError(null);
workflowApi
.getStoryTodos()
.then((response) => {
if (!active) return;
setStoryTodos(
response.stories.map((s) => ({
storyId: s.story_id,
storyName: s.story_name,
items: s.todos,
error: s.error ?? null,
})),
);
setLastTodoRefresh(new Date());
})
.catch((error) => {
if (!active) return;
const message =
error instanceof Error
? error.message
: "Failed to load story TODOs.";
setTodoError(message);
setStoryTodos([]);
})
.finally(() => {
if (active) {
setIsTodoLoading(false);
}
});
return () => {
active = false;
};
}, []);
const refreshTodos = async () => {
setIsTodoLoading(true);
setTodoError(null);
try {
const response = await workflowApi.getStoryTodos();
setStoryTodos(
response.stories.map((s) => ({
storyId: s.story_id,
storyName: s.story_name,
items: s.todos,
error: s.error ?? null,
})),
);
setLastTodoRefresh(new Date());
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to load story TODOs.";
setTodoError(message);
setStoryTodos([]);
} finally {
setIsTodoLoading(false);
}
};
const refreshGateState = async (targetStoryId: string = storyId) => {
setIsGateLoading(true);
setGateError(null);
try {
const response = await workflowApi.getAcceptance({
story_id: targetStoryId,
});
setGateState({
canAccept: response.can_accept,
reasons: response.reasons,
warning: response.warning ?? null,
summary: response.summary,
missingCategories: response.missing_categories,
coverageReport: response.coverage_report
? {
currentPercent: response.coverage_report.current_percent,
thresholdPercent: response.coverage_report.threshold_percent,
baselinePercent:
response.coverage_report.baseline_percent ?? null,
}
: null,
});
setLastGateRefresh(new Date());
} catch (error) {
const message =
error instanceof Error
? error.message
: "Failed to load workflow gates.";
setGateError(message);
setGateState(null);
} finally {
setIsGateLoading(false);
}
};
const handleCollectCoverage = async () => {
setIsCollectingCoverage(true);
setCoverageError(null);
try {
await workflowApi.collectCoverage({ story_id: storyId });
await refreshGateState(storyId);
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to collect coverage.";
setCoverageError(message);
} finally {
setIsCollectingCoverage(false);
}
};
useEffect(() => {
let active = true;
setIsUpcomingLoading(true);
setUpcomingError(null);
workflowApi
.getUpcomingStories()
.then((response) => {
if (!active) return;
setUpcomingStories(response.stories);
setLastUpcomingRefresh(new Date());
})
.catch((error) => {
if (!active) return;
const message =
error instanceof Error
? error.message
: "Failed to load upcoming stories.";
setUpcomingError(message);
setUpcomingStories([]);
})
.finally(() => {
if (active) {
setIsUpcomingLoading(false);
}
});
return () => {
active = false;
};
}, []);
const refreshUpcomingStories = async () => {
setIsUpcomingLoading(true);
setUpcomingError(null);
try {
const response = await workflowApi.getUpcomingStories();
setUpcomingStories(response.stories);
setLastUpcomingRefresh(new Date());
} catch (error) {
const message =
error instanceof Error
? error.message
: "Failed to load upcoming stories.";
setUpcomingError(message);
setUpcomingStories([]);
} finally {
setIsUpcomingLoading(false);
}
};
const refreshReviewQueue = async () => {
setIsReviewLoading(true);
setReviewError(null);
try {
const response = await workflowApi.getReviewQueueAll();
setReviewQueue(response.stories);
setLastReviewRefresh(new Date());
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to load review queue.";
setReviewError(message);
setReviewQueue([]);
} finally {
setIsReviewLoading(false);
}
};
const handleProceed = async (storyIdToProceed: string) => {
setProceedingStoryId(storyIdToProceed);
setProceedError(null);
setProceedSuccess(null);
try {
await workflowApi.ensureAcceptance({
story_id: storyIdToProceed,
});
setProceedSuccess(`Proceeding with ${storyIdToProceed}.`);
await refreshReviewQueue();
if (storyIdToProceed === storyId) {
await refreshGateState(storyId);
}
} catch (error) {
const message =
error instanceof Error
? error.message
: "Failed to proceed with review.";
setProceedError(message);
} finally {
setProceedingStoryId(null);
}
};
useEffect(() => { useEffect(() => {
const ws = new ChatWebSocket(); const ws = new ChatWebSocket();
wsRef.current = ws; wsRef.current = ws;
@@ -508,6 +162,9 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
console.error("WebSocket error:", message); console.error("WebSocket error:", message);
setLoading(false); setLoading(false);
}, },
onPipelineState: (state) => {
setPipeline(state);
},
}); });
return () => { return () => {
@@ -1056,50 +713,13 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
gap: "12px", gap: "12px",
}} }}
> >
<AgentPanel stories={upcomingStories} /> <AgentPanel />
<ReviewPanel <StagePanel title="To Merge" items={pipeline.merge} />
reviewQueue={reviewQueue} <StagePanel title="QA" items={pipeline.qa} />
isReviewLoading={isReviewLoading} <StagePanel title="Current" items={pipeline.current} />
reviewError={reviewError} <StagePanel title="Upcoming" items={pipeline.upcoming} />
proceedingStoryId={proceedingStoryId}
storyId={storyId}
isGateLoading={isGateLoading}
proceedError={proceedError}
proceedSuccess={proceedSuccess}
lastReviewRefresh={lastReviewRefresh}
onRefresh={refreshReviewQueue}
onProceed={handleProceed}
/>
<GatePanel
gateState={gateState}
gateStatusLabel={gateStatusLabel}
gateStatusColor={gateStatusColor}
isGateLoading={isGateLoading}
gateError={gateError}
coverageError={coverageError}
lastGateRefresh={lastGateRefresh}
onRefresh={() => refreshGateState(storyId)}
onCollectCoverage={handleCollectCoverage}
isCollectingCoverage={isCollectingCoverage}
/>
<TodoPanel
todos={storyTodos}
isTodoLoading={isTodoLoading}
todoError={todoError}
lastTodoRefresh={lastTodoRefresh}
onRefresh={refreshTodos}
/>
<UpcomingPanel
stories={upcomingStories}
isLoading={isUpcomingLoading}
error={upcomingError}
lastRefresh={lastUpcomingRefresh}
onRefresh={refreshUpcomingStories}
/>
</div> </div>
</div> </div>

View File

@@ -1,142 +0,0 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { GatePanel } from "./GatePanel";
const baseProps = {
gateState: null,
gateStatusLabel: "Unknown",
gateStatusColor: "#aaa",
isGateLoading: false,
gateError: null,
coverageError: null,
lastGateRefresh: null,
onRefresh: vi.fn(),
onCollectCoverage: vi.fn(),
isCollectingCoverage: false,
};
describe("GatePanel", () => {
it("shows 'no workflow data' when gateState is null", () => {
render(<GatePanel {...baseProps} />);
expect(screen.getByText("No workflow data yet.")).toBeInTheDocument();
});
it("shows loading message when isGateLoading is true", () => {
render(<GatePanel {...baseProps} isGateLoading={true} />);
expect(screen.getByText("Loading workflow gates...")).toBeInTheDocument();
});
it("shows error with retry button", async () => {
const onRefresh = vi.fn();
render(
<GatePanel
{...baseProps}
gateError="Connection failed"
onRefresh={onRefresh}
/>,
);
expect(screen.getByText("Connection failed")).toBeInTheDocument();
const retryButton = screen.getByRole("button", { name: "Retry" });
await userEvent.click(retryButton);
expect(onRefresh).toHaveBeenCalledOnce();
});
it("shows gate status label and color", () => {
render(
<GatePanel
{...baseProps}
gateStatusLabel="Blocked"
gateStatusColor="#ff7b72"
/>,
);
expect(screen.getByText("Blocked")).toBeInTheDocument();
});
it("shows test summary when gateState is provided", () => {
render(
<GatePanel
{...baseProps}
gateState={{
canAccept: true,
reasons: [],
warning: null,
summary: { total: 5, passed: 5, failed: 0 },
missingCategories: [],
coverageReport: null,
}}
gateStatusLabel="Ready to accept"
/>,
);
expect(screen.getByText(/5\/5 passing, 0 failing/)).toBeInTheDocument();
});
it("shows missing categories", () => {
render(
<GatePanel
{...baseProps}
gateState={{
canAccept: false,
reasons: [],
warning: null,
summary: { total: 0, passed: 0, failed: 0 },
missingCategories: ["unit", "integration"],
coverageReport: null,
}}
/>,
);
expect(screen.getByText("Missing: unit, integration")).toBeInTheDocument();
});
it("shows warning text", () => {
render(
<GatePanel
{...baseProps}
gateState={{
canAccept: false,
reasons: [],
warning: "Multiple tests failing — fix one at a time.",
summary: { total: 4, passed: 2, failed: 2 },
missingCategories: [],
coverageReport: null,
}}
/>,
);
expect(
screen.getByText("Multiple tests failing — fix one at a time."),
).toBeInTheDocument();
});
it("shows reasons as list items", () => {
render(
<GatePanel
{...baseProps}
gateState={{
canAccept: false,
reasons: ["No approved test plan.", "Tests are failing."],
warning: null,
summary: { total: 2, passed: 1, failed: 1 },
missingCategories: [],
coverageReport: null,
}}
/>,
);
expect(screen.getByText("No approved test plan.")).toBeInTheDocument();
expect(screen.getByText("Tests are failing.")).toBeInTheDocument();
});
it("calls onRefresh when Refresh button is clicked", async () => {
const onRefresh = vi.fn();
render(<GatePanel {...baseProps} onRefresh={onRefresh} />);
await userEvent.click(screen.getByRole("button", { name: "Refresh" }));
expect(onRefresh).toHaveBeenCalledOnce();
});
it("disables Refresh button when loading", () => {
render(<GatePanel {...baseProps} isGateLoading={true} />);
expect(screen.getByRole("button", { name: "Refresh" })).toBeDisabled();
});
});

View File

@@ -1,237 +0,0 @@
interface CoverageReport {
currentPercent: number;
thresholdPercent: number;
baselinePercent: number | null;
}
interface GateState {
canAccept: boolean;
reasons: string[];
warning: string | null;
summary: {
total: number;
passed: number;
failed: number;
};
missingCategories: string[];
coverageReport: CoverageReport | null;
}
interface GatePanelProps {
gateState: GateState | null;
gateStatusLabel: string;
gateStatusColor: string;
isGateLoading: boolean;
gateError: string | null;
coverageError: string | null;
lastGateRefresh: Date | null;
onRefresh: () => void;
onCollectCoverage: () => void;
isCollectingCoverage: boolean;
}
const formatTimestamp = (value: Date | null): string => {
if (!value) return "—";
return value.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
};
export function GatePanel({
gateState,
gateStatusLabel,
gateStatusColor,
isGateLoading,
gateError,
coverageError,
lastGateRefresh,
onRefresh,
onCollectCoverage,
isCollectingCoverage,
}: GatePanelProps) {
return (
<div
style={{
border: "1px solid #333",
borderRadius: "10px",
padding: "12px 16px",
background: "#1f1f1f",
display: "flex",
flexDirection: "column",
gap: "8px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "12px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<div style={{ fontWeight: 600 }}>Workflow Gates</div>
<button
type="button"
onClick={onRefresh}
disabled={isGateLoading}
style={{
padding: "4px 10px",
borderRadius: "999px",
border: "1px solid #333",
background: isGateLoading ? "#2a2a2a" : "#2f2f2f",
color: isGateLoading ? "#777" : "#aaa",
cursor: isGateLoading ? "not-allowed" : "pointer",
fontSize: "0.75em",
fontWeight: 600,
}}
>
Refresh
</button>
<button
type="button"
onClick={onCollectCoverage}
disabled={isCollectingCoverage || isGateLoading}
style={{
padding: "4px 10px",
borderRadius: "999px",
border: "1px solid #333",
background:
isCollectingCoverage || isGateLoading ? "#2a2a2a" : "#2f2f2f",
color: isCollectingCoverage || isGateLoading ? "#777" : "#aaa",
cursor:
isCollectingCoverage || isGateLoading
? "not-allowed"
: "pointer",
fontSize: "0.75em",
fontWeight: 600,
}}
>
{isCollectingCoverage ? "Collecting..." : "Collect Coverage"}
</button>
</div>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
gap: "2px",
fontSize: "0.85em",
color: gateStatusColor,
}}
>
<div>{gateStatusLabel}</div>
<div style={{ fontSize: "0.8em", color: "#777" }}>
Updated {formatTimestamp(lastGateRefresh)}
</div>
</div>
</div>
{isGateLoading ? (
<div style={{ fontSize: "0.85em", color: "#aaa" }}>
Loading workflow gates...
</div>
) : gateError ? (
<div
style={{
fontSize: "0.85em",
color: "#ff7b72",
display: "flex",
alignItems: "center",
gap: "8px",
flexWrap: "wrap",
}}
>
<span>{gateError}</span>
<button
type="button"
onClick={onRefresh}
disabled={isGateLoading}
style={{
padding: "4px 10px",
borderRadius: "999px",
border: "1px solid #333",
background: isGateLoading ? "#2a2a2a" : "#2f2f2f",
color: isGateLoading ? "#777" : "#aaa",
cursor: isGateLoading ? "not-allowed" : "pointer",
fontSize: "0.75em",
fontWeight: 600,
}}
>
Retry
</button>
</div>
) : gateState ? (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "6px",
}}
>
<div style={{ fontSize: "0.85em", color: "#aaa" }}>
Summary: {gateState.summary.passed}/{gateState.summary.total}{" "}
passing, {gateState.summary.failed} failing
</div>
{gateState.coverageReport && (
<div
style={{
fontSize: "0.85em",
color:
gateState.coverageReport.currentPercent <
gateState.coverageReport.thresholdPercent
? "#ff7b72"
: "#7ee787",
}}
>
Coverage: {gateState.coverageReport.currentPercent.toFixed(1)}%
(threshold: {gateState.coverageReport.thresholdPercent.toFixed(1)}
%)
</div>
)}
{coverageError && (
<div style={{ fontSize: "0.85em", color: "#ff7b72" }}>
Coverage error: {coverageError}
</div>
)}
{gateState.missingCategories.length > 0 && (
<div style={{ fontSize: "0.85em", color: "#ffb86c" }}>
Missing: {gateState.missingCategories.join(", ")}
</div>
)}
{gateState.warning && (
<div style={{ fontSize: "0.85em", color: "#ffb86c" }}>
{gateState.warning}
</div>
)}
{gateState.reasons.length > 0 && (
<ul
style={{
margin: "0 0 0 16px",
padding: 0,
fontSize: "0.85em",
color: "#ccc",
}}
>
{gateState.reasons.map((reason) => (
<li key={`gate-reason-${reason}`}>{reason}</li>
))}
</ul>
)}
</div>
) : (
<div style={{ fontSize: "0.85em", color: "#aaa" }}>
No workflow data yet.
</div>
)}
</div>
);
}

View File

@@ -1,157 +0,0 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { ReviewStory } from "../api/workflow";
import { ReviewPanel } from "./ReviewPanel";
const readyStory: ReviewStory = {
story_id: "29_backfill_tests",
can_accept: true,
reasons: [],
warning: null,
summary: { total: 5, passed: 5, failed: 0 },
missing_categories: [],
};
const blockedStory: ReviewStory = {
story_id: "26_tdd_gates",
can_accept: false,
reasons: ["2 tests are failing."],
warning: "Multiple tests failing — fix one at a time.",
summary: { total: 5, passed: 3, failed: 2 },
missing_categories: [],
};
const baseProps = {
reviewQueue: [] as ReviewStory[],
isReviewLoading: false,
reviewError: null,
proceedingStoryId: null,
storyId: "",
isGateLoading: false,
proceedError: null,
proceedSuccess: null,
lastReviewRefresh: null,
onRefresh: vi.fn(),
onProceed: vi.fn().mockResolvedValue(undefined),
};
describe("ReviewPanel", () => {
it("shows empty state when no stories", () => {
render(<ReviewPanel {...baseProps} />);
expect(
screen.getByText("No stories waiting for review."),
).toBeInTheDocument();
});
it("shows loading state", () => {
render(<ReviewPanel {...baseProps} isReviewLoading={true} />);
expect(screen.getByText("Loading review queue...")).toBeInTheDocument();
});
it("shows error with retry button", async () => {
const onRefresh = vi.fn();
render(
<ReviewPanel
{...baseProps}
reviewError="Network error"
onRefresh={onRefresh}
/>,
);
expect(
screen.getByText(/Network error.*Use Refresh to try again\./),
).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(onRefresh).toHaveBeenCalledOnce();
});
it("renders ready story with Proceed button", () => {
render(<ReviewPanel {...baseProps} reviewQueue={[readyStory]} />);
expect(screen.getByText("29_backfill_tests")).toBeInTheDocument();
expect(screen.getByText("Ready")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Proceed" })).toBeEnabled();
});
it("renders blocked story with disabled button", () => {
render(<ReviewPanel {...baseProps} reviewQueue={[blockedStory]} />);
expect(screen.getByText("26_tdd_gates")).toBeInTheDocument();
expect(screen.getAllByText("Blocked")).toHaveLength(2);
expect(screen.getByRole("button", { name: "Blocked" })).toBeDisabled();
});
it("shows failing badge with count", () => {
render(<ReviewPanel {...baseProps} reviewQueue={[blockedStory]} />);
expect(screen.getByText("Failing 2")).toBeInTheDocument();
});
it("shows warning badge", () => {
render(<ReviewPanel {...baseProps} reviewQueue={[blockedStory]} />);
expect(screen.getByText("Warning")).toBeInTheDocument();
});
it("shows test summary per story", () => {
render(<ReviewPanel {...baseProps} reviewQueue={[readyStory]} />);
expect(screen.getByText(/5\/5 passing,\s*0 failing/)).toBeInTheDocument();
});
it("shows missing categories", () => {
const missingStory: ReviewStory = {
...blockedStory,
missing_categories: ["unit", "integration"],
};
render(<ReviewPanel {...baseProps} reviewQueue={[missingStory]} />);
expect(screen.getByText("Missing: unit, integration")).toBeInTheDocument();
});
it("calls onProceed when Proceed is clicked", async () => {
const onProceed = vi.fn().mockResolvedValue(undefined);
render(
<ReviewPanel
{...baseProps}
reviewQueue={[readyStory]}
onProceed={onProceed}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Proceed" }));
expect(onProceed).toHaveBeenCalledWith("29_backfill_tests");
});
it("shows queue counts in header", () => {
render(
<ReviewPanel {...baseProps} reviewQueue={[readyStory, blockedStory]} />,
);
expect(screen.getByText(/1 ready \/ 2 total/)).toBeInTheDocument();
});
it("shows proceedError message", () => {
render(
<ReviewPanel
{...baseProps}
proceedError="Acceptance blocked: tests failing"
/>,
);
expect(
screen.getByText("Acceptance blocked: tests failing"),
).toBeInTheDocument();
});
it("shows proceedSuccess message", () => {
render(
<ReviewPanel
{...baseProps}
proceedSuccess="Story accepted successfully"
/>,
);
expect(screen.getByText("Story accepted successfully")).toBeInTheDocument();
});
it("shows reasons as list items", () => {
render(<ReviewPanel {...baseProps} reviewQueue={[blockedStory]} />);
expect(screen.getByText("2 tests are failing.")).toBeInTheDocument();
});
});

View File

@@ -1,340 +0,0 @@
import type { ReviewStory } from "../api/workflow";
interface ReviewPanelProps {
reviewQueue: ReviewStory[];
isReviewLoading: boolean;
reviewError: string | null;
proceedingStoryId: string | null;
storyId: string;
isGateLoading: boolean;
proceedError: string | null;
proceedSuccess: string | null;
lastReviewRefresh: Date | null;
onRefresh: () => void;
onProceed: (storyId: string) => Promise<void>;
}
const formatTimestamp = (value: Date | null): string => {
if (!value) return "—";
return value.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
};
export function ReviewPanel({
reviewQueue,
isReviewLoading,
reviewError,
proceedingStoryId,
storyId,
isGateLoading,
proceedError,
proceedSuccess,
lastReviewRefresh,
onRefresh,
onProceed,
}: ReviewPanelProps) {
return (
<div
style={{
border: "1px solid #333",
borderRadius: "10px",
padding: "12px 16px",
background: "#1f1f1f",
display: "flex",
flexDirection: "column",
gap: "8px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "12px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<div style={{ fontWeight: 600 }}>Stories Awaiting Review</div>
<button
type="button"
onClick={onRefresh}
disabled={isReviewLoading}
style={{
padding: "4px 10px",
borderRadius: "999px",
border: "1px solid #333",
background: isReviewLoading ? "#2a2a2a" : "#2f2f2f",
color: isReviewLoading ? "#777" : "#aaa",
cursor: isReviewLoading ? "not-allowed" : "pointer",
fontSize: "0.75em",
fontWeight: 600,
}}
>
Refresh
</button>
</div>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
gap: "2px",
fontSize: "0.85em",
color: "#aaa",
}}
>
<div>
{reviewQueue.filter((story) => story.can_accept).length} ready /{" "}
{reviewQueue.length} total
</div>
<div style={{ fontSize: "0.8em", color: "#777" }}>
Updated {formatTimestamp(lastReviewRefresh)}
</div>
</div>
</div>
{isReviewLoading ? (
<div style={{ fontSize: "0.85em", color: "#aaa" }}>
Loading review queue...
</div>
) : reviewError ? (
<div
style={{
fontSize: "0.85em",
color: "#ff7b72",
display: "flex",
alignItems: "center",
gap: "8px",
flexWrap: "wrap",
}}
>
<span>{reviewError} Use Refresh to try again.</span>
<button
type="button"
onClick={onRefresh}
disabled={isReviewLoading}
style={{
padding: "4px 10px",
borderRadius: "999px",
border: "1px solid #333",
background: isReviewLoading ? "#2a2a2a" : "#2f2f2f",
color: isReviewLoading ? "#777" : "#aaa",
cursor: isReviewLoading ? "not-allowed" : "pointer",
fontSize: "0.75em",
fontWeight: 600,
}}
>
Retry
</button>
</div>
) : reviewQueue.length === 0 ? (
<div style={{ fontSize: "0.85em", color: "#aaa" }}>
No stories waiting for review.
</div>
) : (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "8px",
}}
>
{reviewQueue.map((story) => (
<div
key={`review-${story.story_id}`}
style={{
border: "1px solid #2a2a2a",
borderRadius: "8px",
padding: "10px 12px",
background: "#191919",
display: "flex",
flexDirection: "column",
gap: "6px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "12px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
flexWrap: "wrap",
}}
>
<div style={{ fontWeight: 600 }}>{story.story_id}</div>
<span
style={{
padding: "2px 8px",
borderRadius: "999px",
fontSize: "0.7em",
fontWeight: 600,
background: story.can_accept ? "#7ee787" : "#ff7b72",
color: story.can_accept ? "#000" : "#1a1a1a",
}}
>
{story.can_accept ? "Ready" : "Blocked"}
</span>
{story.summary.failed > 0 && (
<span
style={{
padding: "2px 8px",
borderRadius: "999px",
fontSize: "0.7em",
fontWeight: 600,
background: "#ffb86c",
color: "#1a1a1a",
}}
>
Failing {story.summary.failed}
</span>
)}
{story.warning && (
<span
style={{
padding: "2px 8px",
borderRadius: "999px",
fontSize: "0.7em",
fontWeight: 600,
background: "#ffb86c",
color: "#1a1a1a",
}}
>
Warning
</span>
)}
{story.missing_categories.length > 0 && (
<span
style={{
padding: "2px 8px",
borderRadius: "999px",
fontSize: "0.7em",
fontWeight: 600,
background: "#3a2a1a",
color: "#ffb86c",
border: "1px solid #5a3a1a",
}}
>
Missing
</span>
)}
</div>
<button
type="button"
disabled={
proceedingStoryId === story.story_id ||
isReviewLoading ||
(story.story_id === storyId && isGateLoading) ||
!story.can_accept
}
onClick={() => onProceed(story.story_id)}
style={{
padding: "6px 12px",
borderRadius: "8px",
border: "none",
background:
proceedingStoryId === story.story_id
? "#444"
: story.can_accept
? "#7ee787"
: "#333",
color:
proceedingStoryId === story.story_id
? "#bbb"
: story.can_accept
? "#000"
: "#aaa",
cursor:
proceedingStoryId === story.story_id || !story.can_accept
? "not-allowed"
: "pointer",
fontSize: "0.85em",
fontWeight: 600,
}}
>
{proceedingStoryId === story.story_id
? "Proceeding..."
: story.can_accept
? "Proceed"
: "Blocked"}
</button>
</div>
<div style={{ fontSize: "0.85em", color: "#aaa" }}>
Summary: {story.summary.passed}/{story.summary.total} passing,{" "}
{` ${story.summary.failed}`} failing
</div>
{story.coverage_report && (
<div
style={{
fontSize: "0.85em",
color:
story.coverage_report.current_percent <
story.coverage_report.threshold_percent
? "#ff7b72"
: "#7ee787",
}}
>
Coverage: {story.coverage_report.current_percent.toFixed(1)}%
(threshold:{" "}
{story.coverage_report.threshold_percent.toFixed(1)}%)
</div>
)}
{story.missing_categories.length > 0 && (
<div style={{ fontSize: "0.85em", color: "#ffb86c" }}>
Missing: {story.missing_categories.join(", ")}
</div>
)}
{story.reasons.length > 0 && (
<ul
style={{
margin: "0 0 0 16px",
padding: 0,
fontSize: "0.85em",
color: "#ccc",
}}
>
{story.reasons.map((reason) => (
<li key={`review-reason-${story.story_id}-${reason}`}>
{reason}
</li>
))}
</ul>
)}
{story.warning && (
<div style={{ fontSize: "0.85em", color: "#ffb86c" }}>
{story.warning}
</div>
)}
</div>
))}
</div>
)}
{proceedError && (
<div style={{ fontSize: "0.85em", color: "#ff7b72" }}>
{proceedError}
</div>
)}
{proceedSuccess && (
<div style={{ fontSize: "0.85em", color: "#7ee787" }}>
{proceedSuccess}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,106 @@
import type { PipelineStageItem } from "../api/client";
interface StagePanelProps {
title: string;
items: PipelineStageItem[];
emptyMessage?: string;
}
export function StagePanel({
title,
items,
emptyMessage = "Empty.",
}: StagePanelProps) {
return (
<div
style={{
border: "1px solid #333",
borderRadius: "10px",
padding: "12px 16px",
background: "#1f1f1f",
display: "flex",
flexDirection: "column",
gap: "8px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "12px",
}}
>
<div style={{ fontWeight: 600 }}>{title}</div>
<div
style={{
fontSize: "0.85em",
color: "#aaa",
}}
>
{items.length}
</div>
</div>
{items.length === 0 ? (
<div style={{ fontSize: "0.85em", color: "#555" }}>
{emptyMessage}
</div>
) : (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "6px",
}}
>
{items.map((item) => {
const itemNumber = item.story_id.match(/^(\d+)/)?.[1];
return (
<div
key={`${title}-${item.story_id}`}
style={{
border: "1px solid #2a2a2a",
borderRadius: "8px",
padding: "8px 12px",
background: "#191919",
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, fontSize: "0.9em" }}>
{itemNumber && (
<span
style={{
color: "#777",
fontFamily: "monospace",
marginRight: "8px",
}}
>
#{itemNumber}
</span>
)}
{item.name ?? item.story_id}
</div>
{item.error && (
<div
style={{
fontSize: "0.8em",
color: "#ff7b72",
marginTop: "4px",
}}
>
{item.error}
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -1,76 +0,0 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { TodoPanel } from "./TodoPanel";
const baseProps = {
todos: [] as {
storyId: string;
storyName: string | null;
items: string[];
error: string | null;
}[],
isTodoLoading: false,
todoError: null,
lastTodoRefresh: null,
onRefresh: vi.fn(),
};
describe("TodoPanel", () => {
it("shows per-story front matter error", () => {
render(
<TodoPanel
{...baseProps}
todos={[
{
storyId: "28_todos",
storyName: null,
items: [],
error: "Missing front matter",
},
]}
/>,
);
expect(screen.getByText("Missing front matter")).toBeInTheDocument();
expect(screen.getByText("28_todos")).toBeInTheDocument();
});
it("shows error alongside todo items", () => {
render(
<TodoPanel
{...baseProps}
todos={[
{
storyId: "28_todos",
storyName: "Show TODOs",
items: ["First criterion"],
error: "Missing 'test_plan' field",
},
]}
/>,
);
expect(screen.getByText("Missing 'test_plan' field")).toBeInTheDocument();
expect(screen.getByText("First criterion")).toBeInTheDocument();
expect(screen.getByText("Show TODOs")).toBeInTheDocument();
});
it("does not show error when null", () => {
render(
<TodoPanel
{...baseProps}
todos={[
{
storyId: "28_todos",
storyName: "Show TODOs",
items: ["A criterion"],
error: null,
},
]}
/>,
);
expect(screen.queryByTestId("story-error-28_todos")).toBeNull();
expect(screen.getByText("A criterion")).toBeInTheDocument();
});
});

View File

@@ -1,189 +0,0 @@
interface StoryTodos {
storyId: string;
storyName: string | null;
items: string[];
error: string | null;
}
interface TodoPanelProps {
todos: StoryTodos[];
isTodoLoading: boolean;
todoError: string | null;
lastTodoRefresh: Date | null;
onRefresh: () => void;
}
const formatTimestamp = (value: Date | null): string => {
if (!value) return "\u2014";
return value.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
};
export function TodoPanel({
todos,
isTodoLoading,
todoError,
lastTodoRefresh,
onRefresh,
}: TodoPanelProps) {
const totalTodos = todos.reduce((sum, s) => sum + s.items.length, 0);
const hasErrors = todos.some((s) => s.error);
return (
<div
style={{
border: "1px solid #333",
borderRadius: "10px",
padding: "12px 16px",
background: "#1f1f1f",
display: "flex",
flexDirection: "column",
gap: "8px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "12px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<div style={{ fontWeight: 600 }}>Story TODOs</div>
<button
type="button"
onClick={onRefresh}
disabled={isTodoLoading}
style={{
padding: "4px 10px",
borderRadius: "999px",
border: "1px solid #333",
background: isTodoLoading ? "#2a2a2a" : "#2f2f2f",
color: isTodoLoading ? "#777" : "#aaa",
cursor: isTodoLoading ? "not-allowed" : "pointer",
fontSize: "0.75em",
fontWeight: 600,
}}
>
Refresh
</button>
</div>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
gap: "2px",
fontSize: "0.85em",
color: totalTodos === 0 ? "#7ee787" : "#aaa",
}}
>
<div>{totalTodos} remaining</div>
<div style={{ fontSize: "0.8em", color: "#777" }}>
Updated {formatTimestamp(lastTodoRefresh)}
</div>
</div>
</div>
{isTodoLoading ? (
<div style={{ fontSize: "0.85em", color: "#aaa" }}>
Loading story TODOs...
</div>
) : todoError ? (
<div
style={{
fontSize: "0.85em",
color: "#ff7b72",
display: "flex",
alignItems: "center",
gap: "8px",
flexWrap: "wrap",
}}
>
<span>{todoError}</span>
<button
type="button"
onClick={onRefresh}
disabled={isTodoLoading}
style={{
padding: "4px 10px",
borderRadius: "999px",
border: "1px solid #333",
background: isTodoLoading ? "#2a2a2a" : "#2f2f2f",
color: isTodoLoading ? "#777" : "#aaa",
cursor: isTodoLoading ? "not-allowed" : "pointer",
fontSize: "0.75em",
fontWeight: 600,
}}
>
Retry
</button>
</div>
) : totalTodos === 0 && !hasErrors ? (
<div style={{ fontSize: "0.85em", color: "#7ee787" }}>
All acceptance criteria complete.
</div>
) : (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "6px",
}}
>
{todos
.filter((s) => s.items.length > 0 || s.error)
.map((story) => (
<div key={story.storyId}>
<div
style={{
fontSize: "0.8em",
color: "#777",
marginBottom: "4px",
}}
>
{story.storyName ?? story.storyId}
</div>
{story.error && (
<div
style={{
fontSize: "0.8em",
color: "#ff7b72",
marginBottom: "4px",
}}
data-testid={`story-error-${story.storyId}`}
>
{story.error}
</div>
)}
{story.items.length > 0 && (
<ul
style={{
margin: "0 0 0 16px",
padding: 0,
fontSize: "0.85em",
color: "#ccc",
}}
>
{story.items.map((item) => (
<li key={`todo-${story.storyId}-${item}`}>{item}</li>
))}
</ul>
)}
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -1,82 +0,0 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { UpcomingStory } from "../api/workflow";
import { UpcomingPanel } from "./UpcomingPanel";
const baseProps = {
stories: [] as UpcomingStory[],
isLoading: false,
error: null,
lastRefresh: null,
onRefresh: vi.fn(),
};
describe("UpcomingPanel", () => {
it("shows empty state when no stories", () => {
render(<UpcomingPanel {...baseProps} />);
expect(screen.getByText("No upcoming stories.")).toBeInTheDocument();
});
it("shows loading state", () => {
render(<UpcomingPanel {...baseProps} isLoading={true} />);
expect(screen.getByText("Loading upcoming stories...")).toBeInTheDocument();
});
it("shows error with retry button", async () => {
const onRefresh = vi.fn();
render(
<UpcomingPanel
{...baseProps}
error="Network error"
onRefresh={onRefresh}
/>,
);
expect(
screen.getByText(/Network error.*Use Refresh to try again\./),
).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(onRefresh).toHaveBeenCalledOnce();
});
it("renders story list with names", () => {
const stories: UpcomingStory[] = [
{
story_id: "31_view_upcoming",
name: "View Upcoming Stories",
error: null,
},
{ story_id: "32_worktree", name: "Worktree Orchestration", error: null },
];
render(<UpcomingPanel {...baseProps} stories={stories} />);
expect(screen.getByText("View Upcoming Stories")).toBeInTheDocument();
expect(screen.getByText("Worktree Orchestration")).toBeInTheDocument();
expect(screen.getByText("31_view_upcoming")).toBeInTheDocument();
expect(screen.getByText("32_worktree")).toBeInTheDocument();
});
it("renders story without name using story_id", () => {
const stories: UpcomingStory[] = [
{ story_id: "33_no_name", name: null, error: null },
];
render(<UpcomingPanel {...baseProps} stories={stories} />);
expect(screen.getByText("33_no_name")).toBeInTheDocument();
});
it("calls onRefresh when Refresh clicked", async () => {
const onRefresh = vi.fn();
render(<UpcomingPanel {...baseProps} onRefresh={onRefresh} />);
await userEvent.click(screen.getByRole("button", { name: "Refresh" }));
expect(onRefresh).toHaveBeenCalledOnce();
});
it("disables Refresh while loading", () => {
render(<UpcomingPanel {...baseProps} isLoading={true} />);
expect(screen.getByRole("button", { name: "Refresh" })).toBeDisabled();
});
});

View File

@@ -1,190 +0,0 @@
import type { UpcomingStory } from "../api/workflow";
interface UpcomingPanelProps {
stories: UpcomingStory[];
isLoading: boolean;
error: string | null;
lastRefresh: Date | null;
onRefresh: () => void;
}
const formatTimestamp = (value: Date | null): string => {
if (!value) return "—";
return value.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
};
export function UpcomingPanel({
stories,
isLoading,
error,
lastRefresh,
onRefresh,
}: UpcomingPanelProps) {
return (
<div
style={{
border: "1px solid #333",
borderRadius: "10px",
padding: "12px 16px",
background: "#1f1f1f",
display: "flex",
flexDirection: "column",
gap: "8px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "12px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<div style={{ fontWeight: 600 }}>Upcoming Stories</div>
<button
type="button"
onClick={onRefresh}
disabled={isLoading}
style={{
padding: "4px 10px",
borderRadius: "999px",
border: "1px solid #333",
background: isLoading ? "#2a2a2a" : "#2f2f2f",
color: isLoading ? "#777" : "#aaa",
cursor: isLoading ? "not-allowed" : "pointer",
fontSize: "0.75em",
fontWeight: 600,
}}
>
Refresh
</button>
</div>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
gap: "2px",
fontSize: "0.85em",
color: "#aaa",
}}
>
<div>{stories.length} stories</div>
<div style={{ fontSize: "0.8em", color: "#777" }}>
Updated {formatTimestamp(lastRefresh)}
</div>
</div>
</div>
{isLoading ? (
<div style={{ fontSize: "0.85em", color: "#aaa" }}>
Loading upcoming stories...
</div>
) : error ? (
<div
style={{
fontSize: "0.85em",
color: "#ff7b72",
display: "flex",
alignItems: "center",
gap: "8px",
flexWrap: "wrap",
}}
>
<span>{error} Use Refresh to try again.</span>
<button
type="button"
onClick={onRefresh}
disabled={isLoading}
style={{
padding: "4px 10px",
borderRadius: "999px",
border: "1px solid #333",
background: "#2f2f2f",
color: "#aaa",
cursor: "pointer",
fontSize: "0.75em",
fontWeight: 600,
}}
>
Retry
</button>
</div>
) : stories.length === 0 ? (
<div style={{ fontSize: "0.85em", color: "#aaa" }}>
No upcoming stories.
</div>
) : (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "6px",
}}
>
{stories.map((story) => (
<div
key={`upcoming-${story.story_id}`}
style={{
border: "1px solid #2a2a2a",
borderRadius: "8px",
padding: "8px 12px",
background: "#191919",
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<div style={{ flex: 1 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<div style={{ fontWeight: 600, fontSize: "0.9em" }}>
{story.name ?? story.story_id}
</div>
{story.name && (
<div
style={{
fontSize: "0.75em",
color: "#777",
fontFamily: "monospace",
}}
>
{story.story_id}
</div>
)}
</div>
{story.error && (
<div
style={{
fontSize: "0.8em",
color: "#ff7b72",
marginTop: "4px",
}}
>
{story.error}
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -27,6 +27,7 @@ async-stream = "0.3"
bytes = "1" bytes = "1"
portable-pty = { workspace = true } portable-pty = { workspace = true }
strip-ansi-escapes = { workspace = true } strip-ansi-escapes = { workspace = true }
notify = { workspace = true }
[dev-dependencies] [dev-dependencies]

View File

@@ -753,42 +753,6 @@ pub struct MergeReport {
pub story_archived: bool, pub story_archived: bool,
} }
/// Stage one or more file paths and create a deterministic commit in the given git root.
///
/// Pass deleted paths too so git stages their removal alongside any new files.
pub fn git_stage_and_commit(
git_root: &Path,
paths: &[&Path],
message: &str,
) -> Result<(), String> {
let mut add_cmd = Command::new("git");
add_cmd.arg("add").current_dir(git_root);
for path in paths {
add_cmd.arg(path.to_string_lossy().as_ref());
}
let output = add_cmd.output().map_err(|e| format!("git add: {e}"))?;
if !output.status.success() {
return Err(format!(
"git add failed: {}",
String::from_utf8_lossy(&output.stderr)
));
}
let output = Command::new("git")
.args(["commit", "-m", message])
.current_dir(git_root)
.output()
.map_err(|e| format!("git commit: {e}"))?;
if !output.status.success() {
return Err(format!(
"git commit failed: {}",
String::from_utf8_lossy(&output.stderr)
));
}
Ok(())
}
/// Determine the work item type from its ID (new naming: `{N}_{type}_{slug}`). /// Determine the work item type from its ID (new naming: `{N}_{type}_{slug}`).
/// Returns "bug", "spike", or "story". /// Returns "bug", "spike", or "story".
#[allow(dead_code)] #[allow(dead_code)]
@@ -850,12 +814,7 @@ pub fn move_story_to_current(project_root: &Path, story_id: &str) -> Result<(),
source_dir.display() source_dir.display()
); );
let msg = format!("story-kit: start {story_id}"); Ok(())
git_stage_and_commit(
project_root,
&[current_path.as_path(), source_path.as_path()],
&msg,
)
} }
/// Move a story from `work/2_current/` to `work/5_archived/` and auto-commit. /// Move a story from `work/2_current/` to `work/5_archived/` and auto-commit.
@@ -899,12 +858,7 @@ pub fn move_story_to_archived(project_root: &Path, story_id: &str) -> Result<(),
}; };
eprintln!("[lifecycle] Moved story '{story_id}' from {from_dir} to work/5_archived/"); eprintln!("[lifecycle] Moved story '{story_id}' from {from_dir} to work/5_archived/");
let msg = format!("story-kit: accept story {story_id}"); Ok(())
git_stage_and_commit(
project_root,
&[archived_path.as_path(), source_path.as_path()],
&msg,
)
} }
/// Move a story/bug from `work/2_current/` to `work/4_merge/` and auto-commit. /// Move a story/bug from `work/2_current/` to `work/4_merge/` and auto-commit.
@@ -935,12 +889,7 @@ pub fn move_story_to_merge(project_root: &Path, story_id: &str) -> Result<(), St
eprintln!("[lifecycle] Moved '{story_id}' from work/2_current/ to work/4_merge/"); eprintln!("[lifecycle] Moved '{story_id}' from work/2_current/ to work/4_merge/");
let msg = format!("story-kit: queue {story_id} for merge"); Ok(())
git_stage_and_commit(
project_root,
&[merge_path.as_path(), current_path.as_path()],
&msg,
)
} }
/// Move a story/bug from `work/2_current/` to `work/3_qa/` and auto-commit. /// Move a story/bug from `work/2_current/` to `work/3_qa/` and auto-commit.
@@ -971,12 +920,7 @@ pub fn move_story_to_qa(project_root: &Path, story_id: &str) -> Result<(), Strin
eprintln!("[lifecycle] Moved '{story_id}' from work/2_current/ to work/3_qa/"); eprintln!("[lifecycle] Moved '{story_id}' from work/2_current/ to work/3_qa/");
let msg = format!("story-kit: queue {story_id} for QA"); Ok(())
git_stage_and_commit(
project_root,
&[qa_path.as_path(), current_path.as_path()],
&msg,
)
} }
/// Move a bug from `work/2_current/` or `work/1_upcoming/` to `work/5_archived/` and auto-commit. /// Move a bug from `work/2_current/` or `work/1_upcoming/` to `work/5_archived/` and auto-commit.
@@ -1015,12 +959,7 @@ pub fn close_bug_to_archive(project_root: &Path, bug_id: &str) -> Result<(), Str
"[lifecycle] Closed bug '{bug_id}' → work/5_archived/" "[lifecycle] Closed bug '{bug_id}' → work/5_archived/"
); );
let msg = format!("story-kit: close bug {bug_id}"); Ok(())
git_stage_and_commit(
project_root,
&[archive_path.as_path(), source_path.as_path()],
&msg,
)
} }
// ── Acceptance-gate helpers ─────────────────────────────────────────────────── // ── Acceptance-gate helpers ───────────────────────────────────────────────────
@@ -1634,6 +1573,7 @@ mod tests {
} }
// ── move_story_to_current tests ──────────────────────────────────────────── // ── move_story_to_current tests ────────────────────────────────────────────
// No git repo needed: the watcher handles commits asynchronously.
fn init_git_repo(repo: &std::path::Path) { fn init_git_repo(repo: &std::path::Path) {
Command::new("git") Command::new("git")
@@ -1659,179 +1599,86 @@ mod tests {
} }
#[test] #[test]
fn move_story_to_current_moves_file_and_commits() { fn move_story_to_current_moves_file() {
use std::fs; use std::fs;
use tempfile::tempdir; let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let tmp = tempdir().unwrap(); let upcoming = root.join(".story_kit/work/1_upcoming");
let repo = tmp.path(); let current = root.join(".story_kit/work/2_current");
init_git_repo(repo);
let upcoming = repo.join(".story_kit/work/1_upcoming");
let current_dir = repo.join(".story_kit/work/2_current");
fs::create_dir_all(&upcoming).unwrap(); fs::create_dir_all(&upcoming).unwrap();
fs::create_dir_all(&current_dir).unwrap(); fs::create_dir_all(&current).unwrap();
fs::write(upcoming.join("10_story_foo.md"), "test").unwrap();
let story_file = upcoming.join("10_story_my_story.md"); move_story_to_current(root, "10_story_foo").unwrap();
fs::write(&story_file, "---\nname: Test\ntest_plan: pending\n---\n").unwrap();
Command::new("git") assert!(!upcoming.join("10_story_foo.md").exists());
.args(["add", "."]) assert!(current.join("10_story_foo.md").exists());
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add story"])
.current_dir(repo)
.output()
.unwrap();
move_story_to_current(repo, "10_story_my_story").unwrap();
assert!(!story_file.exists(), "upcoming file should be gone");
assert!(
current_dir.join("10_story_my_story.md").exists(),
"current/ file should exist"
);
} }
#[test] #[test]
fn move_story_to_current_is_idempotent_when_already_current() { fn move_story_to_current_is_idempotent_when_already_current() {
use std::fs; use std::fs;
use tempfile::tempdir; let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let current = root.join(".story_kit/work/2_current");
fs::create_dir_all(&current).unwrap();
fs::write(current.join("11_story_foo.md"), "test").unwrap();
let tmp = tempdir().unwrap(); move_story_to_current(root, "11_story_foo").unwrap();
let repo = tmp.path(); assert!(current.join("11_story_foo.md").exists());
init_git_repo(repo);
let current_dir = repo.join(".story_kit/work/2_current");
fs::create_dir_all(&current_dir).unwrap();
fs::write(
current_dir.join("11_story_my_story.md"),
"---\nname: Test\ntest_plan: pending\n---\n",
)
.unwrap();
// Should succeed without error even though there's nothing to move
move_story_to_current(repo, "11_story_my_story").unwrap();
assert!(current_dir.join("11_story_my_story.md").exists());
} }
#[test] #[test]
fn move_story_to_current_noop_when_not_in_upcoming() { fn move_story_to_current_noop_when_not_in_upcoming() {
use tempfile::tempdir; let tmp = tempfile::tempdir().unwrap();
assert!(move_story_to_current(tmp.path(), "99_missing").is_ok());
let tmp = tempdir().unwrap();
let repo = tmp.path();
init_git_repo(repo);
// Story doesn't exist anywhere — should return Ok (lenient)
let result = move_story_to_current(repo, "99_missing");
assert!(result.is_ok(), "should return Ok when story is not found");
} }
#[test] #[test]
fn move_bug_to_current_moves_from_bugs_dir() { fn move_bug_to_current_moves_from_upcoming() {
use std::fs; use std::fs;
use tempfile::tempdir; let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let upcoming = root.join(".story_kit/work/1_upcoming");
let current = root.join(".story_kit/work/2_current");
fs::create_dir_all(&upcoming).unwrap();
fs::create_dir_all(&current).unwrap();
fs::write(upcoming.join("1_bug_test.md"), "# Bug 1\n").unwrap();
let tmp = tempdir().unwrap(); move_story_to_current(root, "1_bug_test").unwrap();
let repo = tmp.path();
init_git_repo(repo);
let upcoming_dir = repo.join(".story_kit/work/1_upcoming"); assert!(!upcoming.join("1_bug_test.md").exists());
let current_dir = repo.join(".story_kit/work/2_current"); assert!(current.join("1_bug_test.md").exists());
fs::create_dir_all(&upcoming_dir).unwrap();
fs::create_dir_all(&current_dir).unwrap();
let bug_file = upcoming_dir.join("1_bug_test.md");
fs::write(&bug_file, "# Bug 1\n").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add bug"])
.current_dir(repo)
.output()
.unwrap();
move_story_to_current(repo, "1_bug_test").unwrap();
assert!(!bug_file.exists(), "upcoming/ file should be gone");
assert!(
current_dir.join("1_bug_test.md").exists(),
"current/ file should exist"
);
} }
#[test] #[test]
fn close_bug_moves_from_current_to_archive() { fn close_bug_moves_from_current_to_archive() {
use std::fs; use std::fs;
use tempfile::tempdir; let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let current = root.join(".story_kit/work/2_current");
fs::create_dir_all(&current).unwrap();
fs::write(current.join("2_bug_test.md"), "# Bug 2\n").unwrap();
let tmp = tempdir().unwrap(); close_bug_to_archive(root, "2_bug_test").unwrap();
let repo = tmp.path();
init_git_repo(repo);
let current_dir = repo.join(".story_kit/work/2_current"); assert!(!current.join("2_bug_test.md").exists());
fs::create_dir_all(&current_dir).unwrap(); assert!(root.join(".story_kit/work/5_archived/2_bug_test.md").exists());
let bug_in_current = current_dir.join("2_bug_test.md");
fs::write(&bug_in_current, "# Bug 2\n").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add bug to current"])
.current_dir(repo)
.output()
.unwrap();
close_bug_to_archive(repo, "2_bug_test").unwrap();
let archive_path = repo.join(".story_kit/work/5_archived/2_bug_test.md");
assert!(!bug_in_current.exists(), "current/ file should be gone");
assert!(archive_path.exists(), "archive file should exist");
} }
#[test] #[test]
fn close_bug_moves_from_bugs_dir_when_not_started() { fn close_bug_moves_from_upcoming_when_not_started() {
use std::fs; use std::fs;
use tempfile::tempdir; let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let upcoming = root.join(".story_kit/work/1_upcoming");
fs::create_dir_all(&upcoming).unwrap();
fs::write(upcoming.join("3_bug_test.md"), "# Bug 3\n").unwrap();
let tmp = tempdir().unwrap(); close_bug_to_archive(root, "3_bug_test").unwrap();
let repo = tmp.path();
init_git_repo(repo);
let upcoming_dir = repo.join(".story_kit/work/1_upcoming"); assert!(!upcoming.join("3_bug_test.md").exists());
fs::create_dir_all(&upcoming_dir).unwrap(); assert!(root.join(".story_kit/work/5_archived/3_bug_test.md").exists());
let bug_file = upcoming_dir.join("3_bug_test.md");
fs::write(&bug_file, "# Bug 3\n").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add bug"])
.current_dir(repo)
.output()
.unwrap();
close_bug_to_archive(repo, "3_bug_test").unwrap();
let archive_path = repo.join(".story_kit/work/5_archived/3_bug_test.md");
assert!(!bug_file.exists(), "upcoming/ file should be gone");
assert!(archive_path.exists(), "archive file should exist");
} }
#[test] #[test]
@@ -1842,216 +1689,102 @@ mod tests {
assert_eq!(item_type_from_id("1_story_simple"), "story"); assert_eq!(item_type_from_id("1_story_simple"), "story");
} }
// ── git_stage_and_commit tests ─────────────────────────────────────────────
#[test]
fn git_stage_and_commit_creates_commit() {
use std::fs;
use tempfile::tempdir;
let tmp = tempdir().unwrap();
let repo = tmp.path();
init_git_repo(repo);
let file = repo.join("hello.txt");
fs::write(&file, "hello").unwrap();
git_stage_and_commit(repo, &[file.as_path()], "story-kit: test commit").unwrap();
// Verify the commit exists
let output = Command::new("git")
.args(["log", "--oneline", "-1"])
.current_dir(repo)
.output()
.unwrap();
let log = String::from_utf8_lossy(&output.stdout);
assert!(log.contains("story-kit: test commit"), "commit should appear in log: {log}");
}
// ── move_story_to_merge tests ────────────────────────────────────────────── // ── move_story_to_merge tests ──────────────────────────────────────────────
#[test] #[test]
fn move_story_to_merge_moves_file_and_commits() { fn move_story_to_merge_moves_file() {
use std::fs; use std::fs;
use tempfile::tempdir; let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let current = root.join(".story_kit/work/2_current");
fs::create_dir_all(&current).unwrap();
fs::write(current.join("20_story_foo.md"), "test").unwrap();
let tmp = tempdir().unwrap(); move_story_to_merge(root, "20_story_foo").unwrap();
let repo = tmp.path();
init_git_repo(repo);
let current_dir = repo.join(".story_kit/work/2_current"); assert!(!current.join("20_story_foo.md").exists());
fs::create_dir_all(&current_dir).unwrap(); assert!(root.join(".story_kit/work/4_merge/20_story_foo.md").exists());
let story_file = current_dir.join("20_story_my_story.md");
fs::write(&story_file, "---\nname: Test\ntest_plan: approved\n---\n").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add story"])
.current_dir(repo)
.output()
.unwrap();
move_story_to_merge(repo, "20_story_my_story").unwrap();
let merge_path = repo.join(".story_kit/work/4_merge/20_story_my_story.md");
assert!(!story_file.exists(), "2_current file should be gone");
assert!(merge_path.exists(), "4_merge file should exist");
} }
#[test] #[test]
fn move_story_to_merge_idempotent_when_already_in_merge() { fn move_story_to_merge_idempotent_when_already_in_merge() {
use std::fs; use std::fs;
use tempfile::tempdir; let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let tmp = tempdir().unwrap(); let merge_dir = root.join(".story_kit/work/4_merge");
let repo = tmp.path();
init_git_repo(repo);
let merge_dir = repo.join(".story_kit/work/4_merge");
fs::create_dir_all(&merge_dir).unwrap(); fs::create_dir_all(&merge_dir).unwrap();
fs::write( fs::write(merge_dir.join("21_story_test.md"), "test").unwrap();
merge_dir.join("21_story_test.md"),
"---\nname: Test\ntest_plan: approved\n---\n",
)
.unwrap();
// Should succeed without error even though there's nothing to move move_story_to_merge(root, "21_story_test").unwrap();
move_story_to_merge(repo, "21_story_test").unwrap();
assert!(merge_dir.join("21_story_test.md").exists()); assert!(merge_dir.join("21_story_test.md").exists());
} }
#[test] #[test]
fn move_story_to_merge_errors_when_not_in_current() { fn move_story_to_merge_errors_when_not_in_current() {
use tempfile::tempdir; let tmp = tempfile::tempdir().unwrap();
let result = move_story_to_merge(tmp.path(), "99_nonexistent");
let tmp = tempdir().unwrap();
let repo = tmp.path();
init_git_repo(repo);
let result = move_story_to_merge(repo, "99_nonexistent");
assert!(result.is_err());
assert!(result.unwrap_err().contains("not found in work/2_current/")); assert!(result.unwrap_err().contains("not found in work/2_current/"));
} }
// ── move_story_to_qa tests ──────────────────────────────────────────────── // ── move_story_to_qa tests ────────────────────────────────────────────────
#[test] #[test]
fn move_story_to_qa_moves_file_and_commits() { fn move_story_to_qa_moves_file() {
use std::fs; use std::fs;
use tempfile::tempdir; let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let current = root.join(".story_kit/work/2_current");
fs::create_dir_all(&current).unwrap();
fs::write(current.join("30_story_qa.md"), "test").unwrap();
let tmp = tempdir().unwrap(); move_story_to_qa(root, "30_story_qa").unwrap();
let repo = tmp.path();
init_git_repo(repo);
let current_dir = repo.join(".story_kit/work/2_current"); assert!(!current.join("30_story_qa.md").exists());
fs::create_dir_all(&current_dir).unwrap(); assert!(root.join(".story_kit/work/3_qa/30_story_qa.md").exists());
let story_file = current_dir.join("30_story_qa_test.md");
fs::write(&story_file, "---\nname: QA Test\ntest_plan: approved\n---\n").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add story"])
.current_dir(repo)
.output()
.unwrap();
move_story_to_qa(repo, "30_story_qa_test").unwrap();
let qa_path = repo.join(".story_kit/work/3_qa/30_story_qa_test.md");
assert!(!story_file.exists(), "2_current file should be gone");
assert!(qa_path.exists(), "3_qa file should exist");
} }
#[test] #[test]
fn move_story_to_qa_idempotent_when_already_in_qa() { fn move_story_to_qa_idempotent_when_already_in_qa() {
use std::fs; use std::fs;
use tempfile::tempdir; let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let tmp = tempdir().unwrap(); let qa_dir = root.join(".story_kit/work/3_qa");
let repo = tmp.path();
init_git_repo(repo);
let qa_dir = repo.join(".story_kit/work/3_qa");
fs::create_dir_all(&qa_dir).unwrap(); fs::create_dir_all(&qa_dir).unwrap();
fs::write( fs::write(qa_dir.join("31_story_test.md"), "test").unwrap();
qa_dir.join("31_story_test.md"),
"---\nname: Test\ntest_plan: approved\n---\n",
)
.unwrap();
// Should succeed without error even though there's nothing to move move_story_to_qa(root, "31_story_test").unwrap();
move_story_to_qa(repo, "31_story_test").unwrap();
assert!(qa_dir.join("31_story_test.md").exists()); assert!(qa_dir.join("31_story_test.md").exists());
} }
#[test] #[test]
fn move_story_to_qa_errors_when_not_in_current() { fn move_story_to_qa_errors_when_not_in_current() {
use tempfile::tempdir; let tmp = tempfile::tempdir().unwrap();
let result = move_story_to_qa(tmp.path(), "99_nonexistent");
let tmp = tempdir().unwrap();
let repo = tmp.path();
init_git_repo(repo);
let result = move_story_to_qa(repo, "99_nonexistent");
assert!(result.is_err());
assert!(result.unwrap_err().contains("not found in work/2_current/")); assert!(result.unwrap_err().contains("not found in work/2_current/"));
} }
// ── move_story_to_archived with 4_merge source ──────────────────────────── // ── move_story_to_archived tests ──────────────────────────────────────────
#[test] #[test]
fn move_story_to_archived_finds_in_merge_dir() { fn move_story_to_archived_finds_in_merge_dir() {
use std::fs; use std::fs;
use tempfile::tempdir; let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let tmp = tempdir().unwrap(); let merge_dir = root.join(".story_kit/work/4_merge");
let repo = tmp.path();
init_git_repo(repo);
let merge_dir = repo.join(".story_kit/work/4_merge");
fs::create_dir_all(&merge_dir).unwrap(); fs::create_dir_all(&merge_dir).unwrap();
let story_file = merge_dir.join("22_story_test.md"); fs::write(merge_dir.join("22_story_test.md"), "test").unwrap();
fs::write(&story_file, "---\nname: Test\ntest_plan: approved\n---\n").unwrap();
Command::new("git") move_story_to_archived(root, "22_story_test").unwrap();
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add story in merge"])
.current_dir(repo)
.output()
.unwrap();
move_story_to_archived(repo, "22_story_test").unwrap(); assert!(!merge_dir.join("22_story_test.md").exists());
assert!(root.join(".story_kit/work/5_archived/22_story_test.md").exists());
let archived = repo.join(".story_kit/work/5_archived/22_story_test.md");
assert!(!story_file.exists(), "4_merge file should be gone");
assert!(archived.exists(), "5_archived file should exist");
} }
#[test] #[test]
fn move_story_to_archived_error_when_not_in_current_or_merge() { fn move_story_to_archived_error_when_not_in_current_or_merge() {
use tempfile::tempdir; let tmp = tempfile::tempdir().unwrap();
let result = move_story_to_archived(tmp.path(), "99_nonexistent");
let tmp = tempdir().unwrap(); assert!(result.unwrap_err().contains("4_merge"));
let repo = tmp.path();
init_git_repo(repo);
let result = move_story_to_archived(repo, "99_nonexistent");
assert!(result.is_err());
let msg = result.unwrap_err();
assert!(msg.contains("4_merge"), "error should mention 4_merge: {msg}");
} }
// ── merge_agent_work tests ──────────────────────────────────────────────── // ── merge_agent_work tests ────────────────────────────────────────────────

View File

@@ -1,9 +1,11 @@
use crate::agents::AgentPool; use crate::agents::AgentPool;
use crate::io::watcher::WatcherEvent;
use crate::state::SessionState; use crate::state::SessionState;
use crate::store::JsonFileStore; use crate::store::JsonFileStore;
use crate::workflow::WorkflowState; use crate::workflow::WorkflowState;
use poem::http::StatusCode; use poem::http::StatusCode;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::broadcast;
#[derive(Clone)] #[derive(Clone)]
pub struct AppContext { pub struct AppContext {
@@ -11,6 +13,9 @@ pub struct AppContext {
pub store: Arc<JsonFileStore>, pub store: Arc<JsonFileStore>,
pub workflow: Arc<std::sync::Mutex<WorkflowState>>, pub workflow: Arc<std::sync::Mutex<WorkflowState>>,
pub agents: Arc<AgentPool>, pub agents: Arc<AgentPool>,
/// Broadcast channel for filesystem watcher events. WebSocket handlers
/// subscribe to this to push lifecycle notifications to connected clients.
pub watcher_tx: broadcast::Sender<WatcherEvent>,
} }
#[cfg(test)] #[cfg(test)]
@@ -19,11 +24,13 @@ impl AppContext {
let state = SessionState::default(); let state = SessionState::default();
*state.project_root.lock().unwrap() = Some(project_root.clone()); *state.project_root.lock().unwrap() = Some(project_root.clone());
let store_path = project_root.join(".story_kit_store.json"); let store_path = project_root.join(".story_kit_store.json");
let (watcher_tx, _) = broadcast::channel(64);
Self { Self {
state: Arc::new(state), state: Arc::new(state),
store: Arc::new(JsonFileStore::new(store_path).unwrap()), store: Arc::new(JsonFileStore::new(store_path).unwrap()),
workflow: Arc::new(std::sync::Mutex::new(WorkflowState::default())), workflow: Arc::new(std::sync::Mutex::new(WorkflowState::default())),
agents: Arc::new(AgentPool::new(3001)), agents: Arc::new(AgentPool::new(3001)),
watcher_tx,
} }
} }
} }

View File

@@ -848,8 +848,9 @@ fn tool_create_story(args: &Value, ctx: &AppContext) -> Result<String, String> {
let acceptance_criteria: Option<Vec<String>> = args let acceptance_criteria: Option<Vec<String>> = args
.get("acceptance_criteria") .get("acceptance_criteria")
.and_then(|v| serde_json::from_value(v.clone()).ok()); .and_then(|v| serde_json::from_value(v.clone()).ok());
// MCP tool always auto-commits the new story file to master. // Spike 61: write the file only — the filesystem watcher detects the new
let commit = true; // .md file in work/1_upcoming/ and auto-commits with a deterministic message.
let commit = false;
let root = ctx.state.get_project_root()?; let root = ctx.state.get_project_root()?;
let story_id = create_story_file( let story_id = create_story_file(
@@ -1607,30 +1608,10 @@ mod tests {
#[test] #[test]
fn tool_create_story_and_list_upcoming() { fn tool_create_story_and_list_upcoming() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
// The MCP tool always commits, so we need a real git repo. // No git repo needed: spike 61 — create_story just writes the file;
std::process::Command::new("git") // the filesystem watcher handles the commit asynchronously.
.args(["init"])
.current_dir(tmp.path())
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(tmp.path())
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(tmp.path())
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "--allow-empty", "-m", "init"])
.current_dir(tmp.path())
.output()
.unwrap();
let ctx = test_ctx(tmp.path()); let ctx = test_ctx(tmp.path());
// Create a story (always auto-commits in MCP handler)
let result = tool_create_story( let result = tool_create_story(
&json!({"name": "Test Story", "acceptance_criteria": ["AC1", "AC2"]}), &json!({"name": "Test Story", "acceptance_criteria": ["AC1", "AC2"]}),
&ctx, &ctx,

View File

@@ -26,7 +26,6 @@ use poem_openapi::OpenApiService;
use project::ProjectApi; use project::ProjectApi;
use settings::SettingsApi; use settings::SettingsApi;
use std::sync::Arc; use std::sync::Arc;
use workflow::WorkflowApi;
pub fn build_routes(ctx: AppContext) -> impl poem::Endpoint { pub fn build_routes(ctx: AppContext) -> impl poem::Endpoint {
let ctx_arc = std::sync::Arc::new(ctx); let ctx_arc = std::sync::Arc::new(ctx);
@@ -58,7 +57,6 @@ type ApiTuple = (
AnthropicApi, AnthropicApi,
IoApi, IoApi,
ChatApi, ChatApi,
WorkflowApi,
AgentsApi, AgentsApi,
SettingsApi, SettingsApi,
); );
@@ -73,7 +71,6 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
AnthropicApi::new(ctx.clone()), AnthropicApi::new(ctx.clone()),
IoApi { ctx: ctx.clone() }, IoApi { ctx: ctx.clone() },
ChatApi { ctx: ctx.clone() }, ChatApi { ctx: ctx.clone() },
WorkflowApi { ctx: ctx.clone() },
AgentsApi { ctx: ctx.clone() }, AgentsApi { ctx: ctx.clone() },
SettingsApi { ctx: ctx.clone() }, SettingsApi { ctx: ctx.clone() },
); );
@@ -87,7 +84,6 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
AnthropicApi::new(ctx.clone()), AnthropicApi::new(ctx.clone()),
IoApi { ctx: ctx.clone() }, IoApi { ctx: ctx.clone() },
ChatApi { ctx: ctx.clone() }, ChatApi { ctx: ctx.clone() },
WorkflowApi { ctx: ctx.clone() },
AgentsApi { ctx: ctx.clone() }, AgentsApi { ctx: ctx.clone() },
SettingsApi { ctx }, SettingsApi { ctx },
); );

View File

@@ -1,158 +1,55 @@
use crate::agents::git_stage_and_commit; use crate::http::context::AppContext;
use crate::http::context::{AppContext, OpenApiResult, bad_request}; use crate::io::story_metadata::parse_front_matter;
use crate::io::story_metadata::{StoryMetadata, parse_front_matter, parse_unchecked_todos}; use serde::Serialize;
use crate::workflow::{
CoverageReport, StoryTestResults, TestCaseResult, TestStatus,
evaluate_acceptance_with_coverage, parse_coverage_json, summarize_results,
};
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
use serde::Deserialize;
use std::collections::BTreeSet;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc;
#[derive(Tags)] #[derive(Clone, Debug, Serialize)]
enum WorkflowTags {
Workflow,
}
#[derive(Deserialize, Object)]
struct TestCasePayload {
pub name: String,
pub status: String,
pub details: Option<String>,
}
#[derive(Deserialize, Object)]
struct RecordTestsPayload {
pub story_id: String,
pub unit: Vec<TestCasePayload>,
pub integration: Vec<TestCasePayload>,
}
#[derive(Deserialize, Object)]
struct AcceptanceRequest {
pub story_id: String,
}
#[derive(Object)]
struct TestRunSummaryResponse {
pub total: usize,
pub passed: usize,
pub failed: usize,
}
#[derive(Object)]
struct CoverageReportResponse {
pub current_percent: f64,
pub threshold_percent: f64,
pub baseline_percent: Option<f64>,
}
#[derive(Object)]
struct AcceptanceResponse {
pub can_accept: bool,
pub reasons: Vec<String>,
pub warning: Option<String>,
pub summary: TestRunSummaryResponse,
pub missing_categories: Vec<String>,
pub coverage_report: Option<CoverageReportResponse>,
}
#[derive(Object)]
struct ReviewStory {
pub story_id: String,
pub can_accept: bool,
pub reasons: Vec<String>,
pub warning: Option<String>,
pub summary: TestRunSummaryResponse,
pub missing_categories: Vec<String>,
pub coverage_report: Option<CoverageReportResponse>,
}
#[derive(Deserialize, Object)]
struct RecordCoveragePayload {
pub story_id: String,
pub current_percent: f64,
pub threshold_percent: Option<f64>,
}
#[derive(Deserialize, Object)]
struct CollectCoverageRequest {
pub story_id: String,
pub threshold_percent: Option<f64>,
}
#[derive(Object)]
struct ReviewListResponse {
pub stories: Vec<ReviewStory>,
}
#[derive(Object)]
struct StoryTodosResponse {
pub story_id: String,
pub story_name: Option<String>,
pub todos: Vec<String>,
pub error: Option<String>,
}
#[derive(Object)]
struct TodoListResponse {
pub stories: Vec<StoryTodosResponse>,
}
#[derive(Object)]
pub struct UpcomingStory { pub struct UpcomingStory {
pub story_id: String, pub story_id: String,
pub name: Option<String>, pub name: Option<String>,
pub error: Option<String>, pub error: Option<String>,
} }
#[derive(Object)]
struct UpcomingStoriesResponse {
pub stories: Vec<UpcomingStory>,
}
#[derive(Deserialize, Object)]
struct CreateStoryPayload {
pub name: String,
pub user_story: Option<String>,
pub acceptance_criteria: Option<Vec<String>>,
/// If true, git-add and git-commit the new story file to the current branch.
pub commit: Option<bool>,
}
#[derive(Object)]
struct CreateStoryResponse {
pub story_id: String,
}
#[derive(Object)]
pub struct StoryValidationResult { pub struct StoryValidationResult {
pub story_id: String, pub story_id: String,
pub valid: bool, pub valid: bool,
pub error: Option<String>, pub error: Option<String>,
} }
#[derive(Object)] /// Full pipeline state across all stages.
struct ValidateStoriesResponse { #[derive(Clone, Debug, Serialize)]
pub stories: Vec<StoryValidationResult>, pub struct PipelineState {
pub upcoming: Vec<UpcomingStory>,
pub current: Vec<UpcomingStory>,
pub qa: Vec<UpcomingStory>,
pub merge: Vec<UpcomingStory>,
} }
pub fn load_upcoming_stories(ctx: &AppContext) -> Result<Vec<UpcomingStory>, String> { /// Load the full pipeline state (all 4 active stages).
let root = ctx.state.get_project_root()?; pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
let upcoming_dir = root.join(".story_kit").join("work").join("1_upcoming"); Ok(PipelineState {
upcoming: load_stage_items(ctx, "1_upcoming")?,
current: load_stage_items(ctx, "2_current")?,
qa: load_stage_items(ctx, "3_qa")?,
merge: load_stage_items(ctx, "4_merge")?,
})
}
if !upcoming_dir.exists() { /// Load work items from any pipeline stage directory.
fn load_stage_items(ctx: &AppContext, stage_dir: &str) -> Result<Vec<UpcomingStory>, String> {
let root = ctx.state.get_project_root()?;
let dir = root.join(".story_kit").join("work").join(stage_dir);
if !dir.exists() {
return Ok(Vec::new()); return Ok(Vec::new());
} }
let mut stories = Vec::new(); let mut stories = Vec::new();
for entry in fs::read_dir(&upcoming_dir) for entry in fs::read_dir(&dir)
.map_err(|e| format!("Failed to read upcoming stories directory: {e}"))? .map_err(|e| format!("Failed to read {stage_dir} directory: {e}"))?
{ {
let entry = entry.map_err(|e| format!("Failed to read upcoming story entry: {e}"))?; let entry = entry.map_err(|e| format!("Failed to read {stage_dir} entry: {e}"))?;
let path = entry.path(); let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("md") { if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
continue; continue;
@@ -175,449 +72,8 @@ pub fn load_upcoming_stories(ctx: &AppContext) -> Result<Vec<UpcomingStory>, Str
Ok(stories) Ok(stories)
} }
fn load_current_story_metadata(ctx: &AppContext) -> Result<Vec<(String, StoryMetadata)>, String> { pub fn load_upcoming_stories(ctx: &AppContext) -> Result<Vec<UpcomingStory>, String> {
let root = ctx.state.get_project_root()?; load_stage_items(ctx, "1_upcoming")
let current_dir = root.join(".story_kit").join("work").join("2_current");
if !current_dir.exists() {
return Ok(Vec::new());
}
let mut stories = Vec::new();
for entry in fs::read_dir(&current_dir)
.map_err(|e| format!("Failed to read current stories directory: {e}"))?
{
let entry = entry.map_err(|e| format!("Failed to read current story entry: {e}"))?;
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
continue;
}
let story_id = path
.file_stem()
.and_then(|stem| stem.to_str())
.ok_or_else(|| "Invalid story file name.".to_string())?
.to_string();
let contents = fs::read_to_string(&path)
.map_err(|e| format!("Failed to read story file {}: {e}", path.display()))?;
let metadata = parse_front_matter(&contents)
.map_err(|e| format!("Failed to parse front matter for {story_id}: {e:?}"))?;
stories.push((story_id, metadata));
}
Ok(stories)
}
fn to_review_story(
story_id: &str,
results: &StoryTestResults,
coverage: Option<&CoverageReport>,
) -> ReviewStory {
let decision = evaluate_acceptance_with_coverage(results, coverage);
let summary = summarize_results(results);
let mut missing_categories = Vec::new();
let mut reasons = decision.reasons;
if results.unit.is_empty() {
missing_categories.push("unit".to_string());
reasons.push("Missing unit test results.".to_string());
}
if results.integration.is_empty() {
missing_categories.push("integration".to_string());
reasons.push("Missing integration test results.".to_string());
}
let can_accept = decision.can_accept && missing_categories.is_empty();
let coverage_report = coverage.map(|c| CoverageReportResponse {
current_percent: c.current_percent,
threshold_percent: c.threshold_percent,
baseline_percent: c.baseline_percent,
});
ReviewStory {
story_id: story_id.to_string(),
can_accept,
reasons,
warning: decision.warning,
summary: TestRunSummaryResponse {
total: summary.total,
passed: summary.passed,
failed: summary.failed,
},
missing_categories,
coverage_report,
}
}
pub struct WorkflowApi {
pub ctx: Arc<AppContext>,
}
#[OpenApi(tag = "WorkflowTags::Workflow")]
impl WorkflowApi {
/// Record test results for a story (unit + integration).
#[oai(path = "/workflow/tests/record", method = "post")]
async fn record_tests(&self, payload: Json<RecordTestsPayload>) -> OpenApiResult<Json<bool>> {
let unit = payload
.0
.unit
.into_iter()
.map(to_test_case)
.collect::<Result<Vec<_>, String>>()
.map_err(bad_request)?;
let integration = payload
.0
.integration
.into_iter()
.map(to_test_case)
.collect::<Result<Vec<_>, String>>()
.map_err(bad_request)?;
let mut workflow = self
.ctx
.workflow
.lock()
.map_err(|e| bad_request(e.to_string()))?;
workflow
.record_test_results_validated(payload.0.story_id, unit, integration)
.map_err(bad_request)?;
Ok(Json(true))
}
/// Evaluate acceptance readiness for a story.
#[oai(path = "/workflow/acceptance", method = "post")]
async fn acceptance(
&self,
payload: Json<AcceptanceRequest>,
) -> OpenApiResult<Json<AcceptanceResponse>> {
let (results, coverage) = {
let workflow = self
.ctx
.workflow
.lock()
.map_err(|e| bad_request(e.to_string()))?;
let results = workflow
.results
.get(&payload.0.story_id)
.cloned()
.unwrap_or_default();
let coverage = workflow.coverage.get(&payload.0.story_id).cloned();
(results, coverage)
};
let decision =
evaluate_acceptance_with_coverage(&results, coverage.as_ref());
let summary = summarize_results(&results);
let mut missing_categories = Vec::new();
let mut reasons = decision.reasons;
if results.unit.is_empty() {
missing_categories.push("unit".to_string());
reasons.push("Missing unit test results.".to_string());
}
if results.integration.is_empty() {
missing_categories.push("integration".to_string());
reasons.push("Missing integration test results.".to_string());
}
let can_accept = decision.can_accept && missing_categories.is_empty();
let coverage_report = coverage.map(|c| CoverageReportResponse {
current_percent: c.current_percent,
threshold_percent: c.threshold_percent,
baseline_percent: c.baseline_percent,
});
Ok(Json(AcceptanceResponse {
can_accept,
reasons,
warning: decision.warning,
summary: TestRunSummaryResponse {
total: summary.total,
passed: summary.passed,
failed: summary.failed,
},
missing_categories,
coverage_report,
}))
}
/// List stories that are ready for human review.
#[oai(path = "/workflow/review", method = "get")]
async fn review_queue(&self) -> OpenApiResult<Json<ReviewListResponse>> {
let stories = {
let workflow = self
.ctx
.workflow
.lock()
.map_err(|e| bad_request(e.to_string()))?;
workflow
.results
.iter()
.map(|(story_id, results)| {
let coverage = workflow.coverage.get(story_id);
to_review_story(story_id, results, coverage)
})
.filter(|story| story.can_accept)
.collect::<Vec<_>>()
};
Ok(Json(ReviewListResponse { stories }))
}
/// List stories in the review queue, including blocked items and current stories.
#[oai(path = "/workflow/review/all", method = "get")]
async fn review_queue_all(&self) -> OpenApiResult<Json<ReviewListResponse>> {
let current_stories =
load_current_story_metadata(self.ctx.as_ref()).map_err(bad_request)?;
let stories = {
let mut workflow = self
.ctx
.workflow
.lock()
.map_err(|e| bad_request(e.to_string()))?;
if !current_stories.is_empty() {
workflow.load_story_metadata(current_stories);
}
let mut story_ids = BTreeSet::new();
for story_id in workflow.results.keys() {
story_ids.insert(story_id.clone());
}
for story_id in workflow.stories.keys() {
story_ids.insert(story_id.clone());
}
story_ids
.into_iter()
.map(|story_id| {
let results = workflow.results.get(&story_id).cloned().unwrap_or_default();
let coverage = workflow.coverage.get(&story_id);
to_review_story(&story_id, &results, coverage)
})
.collect::<Vec<_>>()
};
Ok(Json(ReviewListResponse { stories }))
}
/// Record coverage data for a story.
#[oai(path = "/workflow/coverage/record", method = "post")]
async fn record_coverage(
&self,
payload: Json<RecordCoveragePayload>,
) -> OpenApiResult<Json<bool>> {
let mut workflow = self
.ctx
.workflow
.lock()
.map_err(|e| bad_request(e.to_string()))?;
workflow.record_coverage(
payload.0.story_id,
payload.0.current_percent,
payload.0.threshold_percent,
);
Ok(Json(true))
}
/// Run coverage collection: execute test:coverage, parse output, record result.
#[oai(path = "/workflow/coverage/collect", method = "post")]
async fn collect_coverage(
&self,
payload: Json<CollectCoverageRequest>,
) -> OpenApiResult<Json<CoverageReportResponse>> {
let root = self
.ctx
.state
.get_project_root()
.map_err(bad_request)?;
let frontend_dir = root.join("frontend");
// Run pnpm run test:coverage in the frontend directory
let output = tokio::task::spawn_blocking(move || {
std::process::Command::new("pnpm")
.args(["run", "test:coverage"])
.current_dir(&frontend_dir)
.output()
})
.await
.map_err(|e| bad_request(format!("Task join error: {e}")))?
.map_err(|e| bad_request(format!("Failed to run coverage command: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let combined: Vec<&str> = stdout
.lines()
.chain(stderr.lines())
.filter(|l| !l.trim().is_empty())
.collect();
let tail: Vec<&str> = combined
.iter()
.rev()
.take(5)
.rev()
.copied()
.collect();
let summary = if tail.is_empty() {
"Unknown error. Check server logs for details.".to_string()
} else {
tail.join("\n")
};
return Err(bad_request(format!("Coverage command failed:\n{summary}")));
}
// Read the coverage summary JSON
let summary_path = root
.join("frontend")
.join("coverage")
.join("coverage-summary.json");
let json_str = fs::read_to_string(&summary_path)
.map_err(|e| bad_request(format!("Failed to read coverage summary: {e}")))?;
let current_percent = parse_coverage_json(&json_str).map_err(bad_request)?;
// Record coverage in workflow state
let coverage_report = {
let mut workflow = self
.ctx
.workflow
.lock()
.map_err(|e| bad_request(e.to_string()))?;
workflow.record_coverage(
payload.0.story_id.clone(),
current_percent,
payload.0.threshold_percent,
);
workflow
.coverage
.get(&payload.0.story_id)
.cloned()
.expect("just inserted")
};
Ok(Json(CoverageReportResponse {
current_percent: coverage_report.current_percent,
threshold_percent: coverage_report.threshold_percent,
baseline_percent: coverage_report.baseline_percent,
}))
}
/// List unchecked acceptance criteria (TODOs) for all current stories.
#[oai(path = "/workflow/todos", method = "get")]
async fn story_todos(&self) -> OpenApiResult<Json<TodoListResponse>> {
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
let current_dir = root.join(".story_kit").join("work").join("2_current");
if !current_dir.exists() {
return Ok(Json(TodoListResponse {
stories: Vec::new(),
}));
}
let mut stories = Vec::new();
let mut entries: Vec<_> = fs::read_dir(&current_dir)
.map_err(|e| bad_request(format!("Failed to read current stories: {e}")))?
.filter_map(|e| e.ok())
.collect();
entries.sort_by_key(|e| e.file_name());
for entry in entries {
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
continue;
}
let story_id = path
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or_default()
.to_string();
let contents = fs::read_to_string(&path)
.map_err(|e| bad_request(format!("Failed to read {}: {e}", path.display())))?;
let (story_name, error) = match parse_front_matter(&contents) {
Ok(m) => (m.name, None),
Err(e) => (None, Some(e.to_string())),
};
let todos = parse_unchecked_todos(&contents);
stories.push(StoryTodosResponse {
story_id,
story_name,
todos,
error,
});
}
Ok(Json(TodoListResponse { stories }))
}
/// List upcoming stories from .story_kit/stories/upcoming/.
#[oai(path = "/workflow/upcoming", method = "get")]
async fn list_upcoming_stories(&self) -> OpenApiResult<Json<UpcomingStoriesResponse>> {
let stories = load_upcoming_stories(self.ctx.as_ref()).map_err(bad_request)?;
Ok(Json(UpcomingStoriesResponse { stories }))
}
/// Validate front matter on all current and upcoming story files.
#[oai(path = "/workflow/stories/validate", method = "get")]
async fn validate_stories(&self) -> OpenApiResult<Json<ValidateStoriesResponse>> {
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
let stories = validate_story_dirs(&root).map_err(bad_request)?;
Ok(Json(ValidateStoriesResponse { stories }))
}
/// Create a new story file with correct front matter in upcoming/.
#[oai(path = "/workflow/stories/create", method = "post")]
async fn create_story(
&self,
payload: Json<CreateStoryPayload>,
) -> OpenApiResult<Json<CreateStoryResponse>> {
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
let commit = payload.0.commit.unwrap_or(false);
let story_id = create_story_file(
&root,
&payload.0.name,
payload.0.user_story.as_deref(),
payload.0.acceptance_criteria.as_deref(),
commit,
)
.map_err(bad_request)?;
Ok(Json(CreateStoryResponse { story_id }))
}
/// Ensure a story can be accepted; returns an error when gates fail.
#[oai(path = "/workflow/acceptance/ensure", method = "post")]
async fn ensure_acceptance(
&self,
payload: Json<AcceptanceRequest>,
) -> OpenApiResult<Json<bool>> {
let response = self.acceptance(payload).await?.0;
if response.can_accept {
return Ok(Json(true));
}
let mut parts = Vec::new();
if !response.reasons.is_empty() {
parts.push(response.reasons.join("; "));
}
if let Some(warning) = response.warning {
parts.push(warning);
}
let message = if parts.is_empty() {
"Acceptance is blocked.".to_string()
} else {
format!("Acceptance is blocked: {}", parts.join("; "))
};
Err(bad_request(message))
}
} }
/// Shared create-story logic used by both the OpenApi and MCP handlers. /// Shared create-story logic used by both the OpenApi and MCP handlers.
@@ -686,19 +142,12 @@ pub fn create_story_file(
fs::write(&filepath, &content) fs::write(&filepath, &content)
.map_err(|e| format!("Failed to write story file: {e}"))?; .map_err(|e| format!("Failed to write story file: {e}"))?;
if commit { // Watcher handles the git commit asynchronously.
git_commit_story_file(root, &filepath, &story_id)?; let _ = commit; // kept for API compat, ignored
}
Ok(story_id) Ok(story_id)
} }
/// Git-add and git-commit a newly created story file using a deterministic message.
fn git_commit_story_file(root: &Path, filepath: &Path, story_id: &str) -> Result<(), String> {
let msg = format!("story-kit: create story {story_id}");
git_stage_and_commit(root, &[filepath], &msg)
}
// ── Bug file helpers ────────────────────────────────────────────── // ── Bug file helpers ──────────────────────────────────────────────
/// Create a bug file in `work/1_upcoming/` with a deterministic filename and auto-commit. /// Create a bug file in `work/1_upcoming/` with a deterministic filename and auto-commit.
@@ -761,8 +210,7 @@ pub fn create_bug_file(
fs::write(&filepath, &content).map_err(|e| format!("Failed to write bug file: {e}"))?; fs::write(&filepath, &content).map_err(|e| format!("Failed to write bug file: {e}"))?;
let msg = format!("story-kit: create bug {bug_id}"); // Watcher handles the git commit asynchronously.
git_stage_and_commit(root, &[filepath.as_path()], &msg)?;
Ok(bug_id) Ok(bug_id)
} }
@@ -898,8 +346,8 @@ pub fn check_criterion_in_file(
fs::write(&filepath, &new_str) fs::write(&filepath, &new_str)
.map_err(|e| format!("Failed to write story file: {e}"))?; .map_err(|e| format!("Failed to write story file: {e}"))?;
let msg = format!("story-kit: check criterion {criterion_index} for story {story_id}"); // Watcher handles the git commit asynchronously.
git_stage_and_commit(project_root, &[filepath.as_path()], &msg) Ok(())
} }
/// Update the `test_plan` front-matter field in a story file and auto-commit. /// Update the `test_plan` front-matter field in a story file and auto-commit.
@@ -952,8 +400,8 @@ pub fn set_test_plan_in_file(
fs::write(&filepath, &new_str) fs::write(&filepath, &new_str)
.map_err(|e| format!("Failed to write story file: {e}"))?; .map_err(|e| format!("Failed to write story file: {e}"))?;
let msg = format!("story-kit: set test_plan to {status} for story {story_id}"); // Watcher handles the git commit asynchronously.
git_stage_and_commit(project_root, &[filepath.as_path()], &msg) Ok(())
} }
fn slugify_name(name: &str) -> String { fn slugify_name(name: &str) -> String {
@@ -1084,128 +532,9 @@ pub fn validate_story_dirs(
Ok(results) Ok(results)
} }
fn to_test_case(input: TestCasePayload) -> Result<TestCaseResult, String> {
let status = parse_test_status(&input.status)?;
Ok(TestCaseResult {
name: input.name,
status,
details: input.details,
})
}
fn parse_test_status(value: &str) -> Result<TestStatus, String> {
match value {
"pass" => Ok(TestStatus::Pass),
"fail" => Ok(TestStatus::Fail),
other => Err(format!(
"Invalid test status '{other}'. Use 'pass' or 'fail'."
)),
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::workflow::{StoryTestResults, TestCaseResult, TestStatus};
#[test]
fn parse_test_status_pass() {
assert_eq!(parse_test_status("pass").unwrap(), TestStatus::Pass);
}
#[test]
fn parse_test_status_fail() {
assert_eq!(parse_test_status("fail").unwrap(), TestStatus::Fail);
}
#[test]
fn parse_test_status_invalid() {
let result = parse_test_status("unknown");
assert!(result.is_err());
assert!(result.unwrap_err().contains("Invalid test status"));
}
#[test]
fn to_test_case_converts_pass() {
let payload = TestCasePayload {
name: "my_test".to_string(),
status: "pass".to_string(),
details: Some("all good".to_string()),
};
let result = to_test_case(payload).unwrap();
assert_eq!(result.name, "my_test");
assert_eq!(result.status, TestStatus::Pass);
assert_eq!(result.details, Some("all good".to_string()));
}
#[test]
fn to_test_case_rejects_invalid_status() {
let payload = TestCasePayload {
name: "bad".to_string(),
status: "maybe".to_string(),
details: None,
};
assert!(to_test_case(payload).is_err());
}
#[test]
fn to_review_story_all_passing() {
let results = StoryTestResults {
unit: vec![TestCaseResult {
name: "u1".to_string(),
status: TestStatus::Pass,
details: None,
}],
integration: vec![TestCaseResult {
name: "i1".to_string(),
status: TestStatus::Pass,
details: None,
}],
};
let review = to_review_story("story-29", &results, None);
assert!(review.can_accept);
assert!(review.reasons.is_empty());
assert!(review.missing_categories.is_empty());
assert_eq!(review.summary.total, 2);
assert_eq!(review.summary.passed, 2);
}
#[test]
fn to_review_story_missing_integration() {
let results = StoryTestResults {
unit: vec![TestCaseResult {
name: "u1".to_string(),
status: TestStatus::Pass,
details: None,
}],
integration: vec![],
};
let review = to_review_story("story-29", &results, None);
assert!(!review.can_accept);
assert!(review.missing_categories.contains(&"integration".to_string()));
}
#[test]
fn to_review_story_with_failures() {
let results = StoryTestResults {
unit: vec![TestCaseResult {
name: "u1".to_string(),
status: TestStatus::Fail,
details: None,
}],
integration: vec![TestCaseResult {
name: "i1".to_string(),
status: TestStatus::Pass,
details: None,
}],
};
let review = to_review_story("story-29", &results, None);
assert!(!review.can_accept);
assert_eq!(review.summary.failed, 1);
}
#[test] #[test]
fn load_upcoming_returns_empty_when_no_dir() { fn load_upcoming_returns_empty_when_no_dir() {

View File

@@ -1,4 +1,6 @@
use crate::http::context::AppContext; use crate::http::context::AppContext;
use crate::http::workflow::{PipelineState, load_pipeline_state};
use crate::io::watcher::WatcherEvent;
use crate::llm::chat; use crate::llm::chat;
use crate::llm::types::Message; use crate::llm::types::Message;
use futures::{SinkExt, StreamExt}; use futures::{SinkExt, StreamExt};
@@ -30,16 +32,56 @@ enum WsRequest {
/// - `token` streams partial model output. /// - `token` streams partial model output.
/// - `update` pushes the updated message history. /// - `update` pushes the updated message history.
/// - `error` reports a request or processing failure. /// - `error` reports a request or processing failure.
/// - `work_item_changed` notifies that a `.story_kit/work/` file changed.
enum WsResponse { enum WsResponse {
Token { content: String }, Token { content: String },
Update { messages: Vec<Message> }, Update { messages: Vec<Message> },
/// Session ID for Claude Code conversation resumption. /// Session ID for Claude Code conversation resumption.
SessionId { session_id: String }, SessionId { session_id: String },
Error { message: String }, Error { message: String },
/// Filesystem watcher notification: a work-pipeline file was created or
/// modified and auto-committed. The frontend can use this to refresh its
/// story/bug list without polling.
WorkItemChanged {
stage: String,
item_id: String,
action: String,
commit_msg: String,
},
/// Full pipeline state pushed on connect and after every watcher event.
PipelineState {
upcoming: Vec<crate::http::workflow::UpcomingStory>,
current: Vec<crate::http::workflow::UpcomingStory>,
qa: Vec<crate::http::workflow::UpcomingStory>,
merge: Vec<crate::http::workflow::UpcomingStory>,
},
}
impl From<WatcherEvent> for WsResponse {
fn from(e: WatcherEvent) -> Self {
WsResponse::WorkItemChanged {
stage: e.stage,
item_id: e.item_id,
action: e.action,
commit_msg: e.commit_msg,
}
}
}
impl From<PipelineState> for WsResponse {
fn from(s: PipelineState) -> Self {
WsResponse::PipelineState {
upcoming: s.upcoming,
current: s.current,
qa: s.qa,
merge: s.merge,
}
}
} }
#[handler] #[handler]
/// WebSocket endpoint for streaming chat responses and cancellation. /// WebSocket endpoint for streaming chat responses, cancellation, and
/// filesystem watcher notifications.
/// ///
/// Accepts JSON `WsRequest` messages and streams `WsResponse` messages. /// Accepts JSON `WsRequest` messages and streams `WsResponse` messages.
pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc<AppContext>>) -> impl poem::IntoResponse { pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc<AppContext>>) -> impl poem::IntoResponse {
@@ -58,6 +100,37 @@ pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc<AppContext>>) -> impl poem
} }
}); });
// Push initial pipeline state to the client on connect.
if let Ok(state) = load_pipeline_state(ctx.as_ref()) {
let _ = tx.send(state.into());
}
// Subscribe to filesystem watcher events and forward them to the client.
// After each watcher event, also push the updated pipeline state.
let tx_watcher = tx.clone();
let ctx_watcher = ctx.clone();
let mut watcher_rx = ctx.watcher_tx.subscribe();
tokio::spawn(async move {
loop {
match watcher_rx.recv().await {
Ok(evt) => {
if tx_watcher.send(evt.into()).is_err() {
break;
}
// Push refreshed pipeline state after the change.
if let Ok(state) = load_pipeline_state(ctx_watcher.as_ref()) {
if tx_watcher.send(state.into()).is_err() {
break;
}
}
}
// Lagged: skip missed events, keep going.
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
});
while let Some(Ok(msg)) = stream.next().await { while let Some(Ok(msg)) = stream.next().await {
if let WsMessage::Text(text) = msg { if let WsMessage::Text(text) = msg {
let parsed: Result<WsRequest, _> = serde_json::from_str(&text); let parsed: Result<WsRequest, _> = serde_json::from_str(&text);

View File

@@ -2,3 +2,4 @@ pub mod fs;
pub mod search; pub mod search;
pub mod shell; pub mod shell;
pub mod story_metadata; pub mod story_metadata;
pub mod watcher;

282
server/src/io/watcher.rs Normal file
View File

@@ -0,0 +1,282 @@
//! Filesystem watcher for `.story_kit/work/`.
//!
//! Watches the work pipeline directories for file changes, infers the lifecycle
//! stage from the target directory name, auto-commits with a deterministic message,
//! and broadcasts a [`WatcherEvent`] to all connected WebSocket clients.
//!
//! # Debouncing
//! Events are buffered for 300 ms after the last activity. All changes within the
//! window are batched into a single `git add + commit`. This avoids double-commits
//! when `fs::rename` fires both a remove and a create event.
//!
//! # Race conditions
//! If a mutation handler (e.g. `move_story_to_current`) already committed the
//! change, `git commit` will return "nothing to commit". The watcher detects this
//! via exit-code inspection and silently skips the commit while still broadcasting
//! the event so connected clients stay in sync.
use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher, recommended_watcher};
use serde::Serialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use tokio::sync::broadcast;
/// A lifecycle event emitted by the filesystem watcher after auto-committing.
#[derive(Clone, Debug, Serialize)]
pub struct WatcherEvent {
/// Pipeline stage directory (e.g. `"2_current"`, `"5_archived"`).
pub stage: String,
/// Work item ID (filename stem without extension, e.g. `"42_story_my_feature"`).
pub item_id: String,
/// Semantic action inferred from the stage (e.g. `"start"`, `"accept"`).
pub action: String,
/// The deterministic git commit message used (or that would have been used).
pub commit_msg: String,
}
/// Map a pipeline directory name to a (action, commit-message-prefix) pair.
fn stage_metadata(stage: &str, item_id: &str) -> Option<(&'static str, String)> {
let (action, prefix) = match stage {
"1_upcoming" => ("create", format!("story-kit: create {item_id}")),
"2_current" => ("start", format!("story-kit: start {item_id}")),
"3_qa" => ("qa", format!("story-kit: queue {item_id} for QA")),
"4_merge" => ("merge", format!("story-kit: queue {item_id} for merge")),
"5_archived" => ("accept", format!("story-kit: accept {item_id}")),
_ => return None,
};
Some((action, prefix))
}
/// Return the pipeline stage name for a path if it is a `.md` file living
/// directly inside one of the known work subdirectories, otherwise `None`.
fn stage_for_path(path: &Path) -> Option<String> {
if path.extension().is_none_or(|e| e != "md") {
return None;
}
let stage = path
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())?;
matches!(stage, "1_upcoming" | "2_current" | "3_qa" | "4_merge" | "5_archived")
.then(|| stage.to_string())
}
/// Stage all changes in the work directory and commit with the given message.
///
/// Uses `git add -A .story_kit/work/` to catch both additions and deletions in
/// a single commit. Returns `Ok(true)` if a commit was made, `Ok(false)` if
/// there was nothing to commit, and `Err` for unexpected failures.
fn git_add_work_and_commit(git_root: &Path, message: &str) -> Result<bool, String> {
let work_rel = PathBuf::from(".story_kit").join("work");
let add_out = std::process::Command::new("git")
.args(["add", "-A"])
.arg(&work_rel)
.current_dir(git_root)
.output()
.map_err(|e| format!("git add: {e}"))?;
if !add_out.status.success() {
return Err(format!(
"git add failed: {}",
String::from_utf8_lossy(&add_out.stderr)
));
}
let commit_out = std::process::Command::new("git")
.args(["commit", "-m", message])
.current_dir(git_root)
.output()
.map_err(|e| format!("git commit: {e}"))?;
if commit_out.status.success() {
return Ok(true);
}
let stderr = String::from_utf8_lossy(&commit_out.stderr);
let stdout = String::from_utf8_lossy(&commit_out.stdout);
if stdout.contains("nothing to commit") || stderr.contains("nothing to commit") {
return Ok(false);
}
Err(format!("git commit failed: {stderr}"))
}
/// Process a batch of pending (path → stage) entries: commit and broadcast.
///
/// Only files that still exist on disk are used to derive the commit message
/// (they represent the destination of a move or a new file). Deletions are
/// captured by `git add -A .story_kit/work/` automatically.
fn flush_pending(
pending: &HashMap<PathBuf, String>,
git_root: &Path,
event_tx: &broadcast::Sender<WatcherEvent>,
) {
// Separate into files that exist (additions) vs gone (deletions).
let mut additions: Vec<(&PathBuf, &str)> = Vec::new();
for (path, stage) in pending {
if path.exists() {
additions.push((path, stage.as_str()));
}
}
// Pick the commit message from the first addition (the meaningful side of a move).
// If there are only deletions, use a generic message.
let (action, item_id, commit_msg) = if let Some((path, stage)) = additions.first() {
let item = path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown");
if let Some((act, msg)) = stage_metadata(stage, item) {
(act, item.to_string(), msg)
} else {
return;
}
} else {
// Only deletions — pick any pending path for the item name.
let Some((path, _)) = pending.iter().next() else {
return;
};
let item = path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown");
("remove", item.to_string(), format!("story-kit: remove {item}"))
};
eprintln!("[watcher] flush: {commit_msg}");
match git_add_work_and_commit(git_root, &commit_msg) {
Ok(committed) => {
if committed {
eprintln!("[watcher] committed: {commit_msg}");
} else {
eprintln!("[watcher] skipped (already committed): {commit_msg}");
}
let stage = additions.first().map_or("unknown", |(_, s)| s);
let evt = WatcherEvent {
stage: stage.to_string(),
item_id,
action: action.to_string(),
commit_msg,
};
let _ = event_tx.send(evt);
}
Err(e) => {
eprintln!("[watcher] git error: {e}");
}
}
}
/// Start the filesystem watcher on a dedicated OS thread.
///
/// `work_dir` — absolute path to `.story_kit/work/` (watched recursively).
/// `git_root` — project root (passed to `git` commands as cwd).
/// `event_tx` — broadcast sender; each connected WebSocket client holds a receiver.
pub fn start_watcher(
work_dir: PathBuf,
git_root: PathBuf,
event_tx: broadcast::Sender<WatcherEvent>,
) {
std::thread::spawn(move || {
let (notify_tx, notify_rx) = mpsc::channel::<notify::Result<notify::Event>>();
let mut watcher: RecommendedWatcher = match recommended_watcher(move |res| {
let _ = notify_tx.send(res);
}) {
Ok(w) => w,
Err(e) => {
eprintln!("[watcher] failed to create watcher: {e}");
return;
}
};
if let Err(e) = watcher.watch(&work_dir, RecursiveMode::Recursive) {
eprintln!("[watcher] failed to watch {}: {e}", work_dir.display());
return;
}
eprintln!("[watcher] watching {}", work_dir.display());
const DEBOUNCE: Duration = Duration::from_millis(300);
// Map path → stage for pending (uncommitted) changes.
let mut pending: HashMap<PathBuf, String> = HashMap::new();
let mut deadline: Option<Instant> = None;
loop {
// How long until the debounce window closes (or wait for next event).
let timeout = deadline.map_or(Duration::from_secs(60), |d| {
d.saturating_duration_since(Instant::now())
});
let flush = match notify_rx.recv_timeout(timeout) {
Ok(Ok(event)) => {
// Track creates, modifies, AND removes. Removes are needed so
// that standalone deletions trigger a flush, and so that moves
// (which fire Remove + Create) land in the same debounce window.
let is_relevant_kind = matches!(
event.kind,
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
);
if is_relevant_kind {
for path in event.paths {
if let Some(stage) = stage_for_path(&path) {
pending.insert(path, stage);
deadline = Some(Instant::now() + DEBOUNCE);
}
}
}
false
}
Ok(Err(e)) => {
eprintln!("[watcher] notify error: {e}");
false
}
// Debounce window expired — time to flush.
Err(mpsc::RecvTimeoutError::Timeout) => true,
Err(mpsc::RecvTimeoutError::Disconnected) => {
eprintln!("[watcher] channel disconnected, shutting down");
break;
}
};
if flush && !pending.is_empty() {
flush_pending(&pending, &git_root, &event_tx);
pending.clear();
deadline = None;
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stage_for_path_recognises_pipeline_dirs() {
let base = PathBuf::from("/proj/.story_kit/work");
assert_eq!(
stage_for_path(&base.join("2_current/42_story_foo.md")),
Some("2_current".to_string())
);
assert_eq!(
stage_for_path(&base.join("5_archived/10_bug_bar.md")),
Some("5_archived".to_string())
);
assert_eq!(stage_for_path(&base.join("other/file.md")), None);
assert_eq!(
stage_for_path(&base.join("2_current/42_story_foo.txt")),
None
);
}
#[test]
fn stage_metadata_returns_correct_actions() {
let (action, msg) = stage_metadata("2_current", "42_story_foo").unwrap();
assert_eq!(action, "start");
assert_eq!(msg, "story-kit: start 42_story_foo");
let (action, msg) = stage_metadata("5_archived", "42_story_foo").unwrap();
assert_eq!(action, "accept");
assert_eq!(msg, "story-kit: accept 42_story_foo");
assert!(stage_metadata("unknown", "id").is_none());
}
}

View File

@@ -18,6 +18,7 @@ use poem::Server;
use poem::listener::TcpListener; use poem::listener::TcpListener;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::broadcast;
const DEFAULT_PORT: u16 = 3001; const DEFAULT_PORT: u16 = 3001;
@@ -88,11 +89,21 @@ async fn main() -> Result<(), std::io::Error> {
let port = resolve_port(); let port = resolve_port();
let agents = Arc::new(AgentPool::new(port)); let agents = Arc::new(AgentPool::new(port));
// Filesystem watcher: broadcast channel for work/ pipeline changes.
let (watcher_tx, _) = broadcast::channel::<io::watcher::WatcherEvent>(1024);
if let Some(ref root) = *app_state.project_root.lock().unwrap() {
let work_dir = root.join(".story_kit").join("work");
if work_dir.is_dir() {
io::watcher::start_watcher(work_dir, root.clone(), watcher_tx.clone());
}
}
let ctx = AppContext { let ctx = AppContext {
state: app_state, state: app_state,
store, store,
workflow, workflow,
agents, agents,
watcher_tx,
}; };
let app = build_routes(ctx); let app = build_routes(ctx);

View File

@@ -1,14 +1,5 @@
//! Workflow module: story gating and test result tracking. //! Workflow module: test result tracking and acceptance evaluation.
//!
//! This module provides the in-memory primitives for:
//! - reading story metadata (front matter) for gating decisions
//! - tracking test run results
//! - evaluating acceptance readiness
//!
//! NOTE: This is a naive, local-only implementation that will be
//! refactored later into orchestration-aware components.
use crate::io::story_metadata::{StoryMetadata, TestPlanStatus};
use std::collections::HashMap; use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -24,11 +15,9 @@ pub struct TestCaseResult {
pub details: Option<String>, pub details: Option<String>,
} }
#[derive(Debug, Clone, PartialEq, Eq)] struct TestRunSummary {
pub struct TestRunSummary { total: usize,
pub total: usize, failed: usize,
pub passed: usize,
pub failed: usize,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -45,44 +34,12 @@ pub struct StoryTestResults {
} }
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
#[allow(dead_code)]
pub struct WorkflowState { pub struct WorkflowState {
pub stories: HashMap<String, StoryMetadata>,
pub results: HashMap<String, StoryTestResults>, pub results: HashMap<String, StoryTestResults>,
pub coverage: HashMap<String, CoverageReport>, pub coverage: HashMap<String, CoverageReport>,
} }
#[allow(dead_code)]
impl WorkflowState { impl WorkflowState {
pub fn upsert_story(&mut self, story_id: String, metadata: StoryMetadata) {
self.stories.insert(story_id, metadata);
}
pub fn load_story_metadata(&mut self, stories: Vec<(String, StoryMetadata)>) {
for (story_id, metadata) in stories {
self.stories.insert(story_id, metadata);
}
}
pub fn refresh_story_metadata(&mut self, story_id: String, metadata: StoryMetadata) -> bool {
match self.stories.get(&story_id) {
Some(existing) if existing == &metadata => false,
_ => {
self.stories.insert(story_id, metadata);
true
}
}
}
pub fn record_test_results(
&mut self,
story_id: String,
unit: Vec<TestCaseResult>,
integration: Vec<TestCaseResult>,
) {
let _ = self.record_test_results_validated(story_id, unit, integration);
}
pub fn record_test_results_validated( pub fn record_test_results_validated(
&mut self, &mut self,
story_id: String, story_id: String,
@@ -107,65 +64,23 @@ impl WorkflowState {
Ok(()) Ok(())
} }
pub fn record_coverage(
&mut self,
story_id: String,
current_percent: f64,
threshold_percent: Option<f64>,
) {
let threshold = threshold_percent.unwrap_or(80.0);
let baseline = self
.coverage
.get(&story_id)
.map(|existing| existing.baseline_percent.unwrap_or(existing.current_percent));
self.coverage.insert(
story_id,
CoverageReport {
current_percent,
threshold_percent: threshold,
baseline_percent: baseline,
},
);
}
} }
#[allow(dead_code)] fn summarize_results(results: &StoryTestResults) -> TestRunSummary {
pub fn can_start_implementation(metadata: &StoryMetadata) -> Result<(), String> {
match metadata.test_plan {
Some(TestPlanStatus::Approved) => Ok(()),
Some(TestPlanStatus::WaitingForApproval) => {
Err("Test plan is waiting for approval; implementation is blocked.".to_string())
}
Some(TestPlanStatus::Unknown(ref value)) => Err(format!(
"Test plan state is unknown ({value}); implementation is blocked."
)),
None => Err("Missing test plan status; implementation is blocked.".to_string()),
}
}
pub fn summarize_results(results: &StoryTestResults) -> TestRunSummary {
let mut total = 0; let mut total = 0;
let mut passed = 0;
let mut failed = 0; let mut failed = 0;
for test in results.unit.iter().chain(results.integration.iter()) { for test in results.unit.iter().chain(results.integration.iter()) {
total += 1; total += 1;
match test.status { if test.status == TestStatus::Fail {
TestStatus::Pass => passed += 1, failed += 1;
TestStatus::Fail => failed += 1,
} }
} }
TestRunSummary { TestRunSummary { total, failed }
total,
passed,
failed,
}
} }
pub fn evaluate_acceptance(results: &StoryTestResults) -> AcceptanceDecision { fn evaluate_acceptance(results: &StoryTestResults) -> AcceptanceDecision {
let summary = summarize_results(results); let summary = summarize_results(results);
if summary.failed == 0 && summary.total > 0 { if summary.failed == 0 && summary.total > 0 {
@@ -211,32 +126,6 @@ pub struct CoverageReport {
pub baseline_percent: Option<f64>, pub baseline_percent: Option<f64>,
} }
/// Parse coverage percentage from a vitest coverage-summary.json string.
/// Expects JSON with `{"total": {"lines": {"pct": <number>}}}`.
pub fn parse_coverage_json(json_str: &str) -> Result<f64, String> {
let value: serde_json::Value =
serde_json::from_str(json_str).map_err(|e| format!("Invalid coverage JSON: {e}"))?;
value
.get("total")
.and_then(|t| t.get("lines"))
.and_then(|l| l.get("pct"))
.and_then(|p| p.as_f64())
.ok_or_else(|| "Missing total.lines.pct in coverage JSON.".to_string())
}
/// Check whether coverage meets the threshold.
#[allow(dead_code)]
pub fn check_coverage_threshold(current: f64, threshold: f64) -> Result<(), String> {
if current >= threshold {
Ok(())
} else {
Err(format!(
"Coverage below threshold ({current:.1}% < {threshold:.1}%)."
))
}
}
/// Evaluate acceptance with optional coverage data. /// Evaluate acceptance with optional coverage data.
pub fn evaluate_acceptance_with_coverage( pub fn evaluate_acceptance_with_coverage(
results: &StoryTestResults, results: &StoryTestResults,
@@ -269,43 +158,7 @@ pub fn evaluate_acceptance_with_coverage(
mod tests { mod tests {
use super::*; use super::*;
// === parse_coverage_json === // === evaluate_acceptance_with_coverage ===
#[test]
fn parses_valid_coverage_json() {
let json = r#"{"total":{"lines":{"total":100,"covered":85,"pct":85.0},"statements":{"pct":85.0}}}"#;
assert_eq!(parse_coverage_json(json).unwrap(), 85.0);
}
#[test]
fn rejects_invalid_coverage_json() {
assert!(parse_coverage_json("not json").is_err());
}
#[test]
fn rejects_missing_total_lines_pct() {
let json = r#"{"total":{"branches":{"pct":90.0}}}"#;
assert!(parse_coverage_json(json).is_err());
}
// === AC1: check_coverage_threshold ===
#[test]
fn coverage_threshold_passes_when_met() {
assert!(check_coverage_threshold(80.0, 80.0).is_ok());
assert!(check_coverage_threshold(95.5, 80.0).is_ok());
}
#[test]
fn coverage_threshold_fails_when_below() {
let result = check_coverage_threshold(72.3, 80.0);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.contains("72.3%"));
assert!(err.contains("80.0%"));
}
// === AC2: evaluate_acceptance_with_coverage ===
#[test] #[test]
fn acceptance_blocked_by_coverage_below_threshold() { fn acceptance_blocked_by_coverage_below_threshold() {
@@ -403,49 +256,7 @@ mod tests {
assert!(decision.can_accept); assert!(decision.can_accept);
} }
// === record_coverage === // === evaluate_acceptance ===
#[test]
fn record_coverage_first_time_has_no_baseline() {
let mut state = WorkflowState::default();
state.record_coverage("story-27".to_string(), 85.0, Some(80.0));
let report = state.coverage.get("story-27").unwrap();
assert_eq!(report.current_percent, 85.0);
assert_eq!(report.threshold_percent, 80.0);
assert_eq!(report.baseline_percent, None);
}
#[test]
fn record_coverage_subsequent_sets_baseline() {
let mut state = WorkflowState::default();
state.record_coverage("story-27".to_string(), 85.0, Some(80.0));
state.record_coverage("story-27".to_string(), 78.0, Some(80.0));
let report = state.coverage.get("story-27").unwrap();
assert_eq!(report.current_percent, 78.0);
assert_eq!(report.baseline_percent, Some(85.0));
}
#[test]
fn record_coverage_default_threshold() {
let mut state = WorkflowState::default();
state.record_coverage("story-27".to_string(), 90.0, None);
let report = state.coverage.get("story-27").unwrap();
assert_eq!(report.threshold_percent, 80.0);
}
#[test]
fn record_coverage_custom_threshold() {
let mut state = WorkflowState::default();
state.record_coverage("story-27".to_string(), 90.0, Some(95.0));
let report = state.coverage.get("story-27").unwrap();
assert_eq!(report.threshold_percent, 95.0);
}
// === Existing tests ===
#[test] #[test]
fn warns_when_multiple_tests_fail() { fn warns_when_multiple_tests_fail() {
@@ -478,32 +289,6 @@ mod tests {
); );
} }
#[test]
fn rejects_recording_multiple_failures() {
let mut state = WorkflowState::default();
let unit = vec![
TestCaseResult {
name: "unit-1".to_string(),
status: TestStatus::Fail,
details: None,
},
TestCaseResult {
name: "unit-2".to_string(),
status: TestStatus::Fail,
details: None,
},
];
let integration = vec![TestCaseResult {
name: "integration-1".to_string(),
status: TestStatus::Pass,
details: None,
}];
let result = state.record_test_results_validated("story-26".to_string(), unit, integration);
assert!(result.is_err());
}
#[test] #[test]
fn accepts_when_all_tests_pass() { fn accepts_when_all_tests_pass() {
let results = StoryTestResults { let results = StoryTestResults {
@@ -557,49 +342,32 @@ mod tests {
assert!(decision.warning.is_none()); assert!(decision.warning.is_none());
} }
#[test] // === record_test_results_validated ===
fn summarize_results_counts_correctly() {
let results = StoryTestResults {
unit: vec![
TestCaseResult { name: "u1".to_string(), status: TestStatus::Pass, details: None },
TestCaseResult { name: "u2".to_string(), status: TestStatus::Fail, details: None },
],
integration: vec![
TestCaseResult { name: "i1".to_string(), status: TestStatus::Pass, details: None },
],
};
let summary = summarize_results(&results);
assert_eq!(summary.total, 3);
assert_eq!(summary.passed, 2);
assert_eq!(summary.failed, 1);
}
#[test] #[test]
fn can_start_implementation_requires_approved_plan() { fn rejects_recording_multiple_failures() {
let approved = StoryMetadata { let mut state = WorkflowState::default();
name: Some("Test".to_string()), let unit = vec![
test_plan: Some(TestPlanStatus::Approved), TestCaseResult {
}; name: "unit-1".to_string(),
assert!(can_start_implementation(&approved).is_ok()); status: TestStatus::Fail,
details: None,
},
TestCaseResult {
name: "unit-2".to_string(),
status: TestStatus::Fail,
details: None,
},
];
let integration = vec![TestCaseResult {
name: "integration-1".to_string(),
status: TestStatus::Pass,
details: None,
}];
let waiting = StoryMetadata { let result = state.record_test_results_validated("story-26".to_string(), unit, integration);
name: Some("Test".to_string()),
test_plan: Some(TestPlanStatus::WaitingForApproval),
};
assert!(can_start_implementation(&waiting).is_err());
let unknown = StoryMetadata { assert!(result.is_err());
name: Some("Test".to_string()),
test_plan: Some(TestPlanStatus::Unknown("draft".to_string())),
};
assert!(can_start_implementation(&unknown).is_err());
let missing = StoryMetadata {
name: Some("Test".to_string()),
test_plan: None,
};
assert!(can_start_implementation(&missing).is_err());
} }
#[test] #[test]
@@ -626,22 +394,4 @@ mod tests {
assert_eq!(state.results["story-29"].unit.len(), 1); assert_eq!(state.results["story-29"].unit.len(), 1);
assert_eq!(state.results["story-29"].integration.len(), 1); assert_eq!(state.results["story-29"].integration.len(), 1);
} }
#[test]
fn refresh_story_metadata_returns_false_when_unchanged() {
let mut state = WorkflowState::default();
let meta = StoryMetadata {
name: Some("Test".to_string()),
test_plan: Some(TestPlanStatus::Approved),
};
assert!(state.refresh_story_metadata("s1".to_string(), meta.clone()));
assert!(!state.refresh_story_metadata("s1".to_string(), meta.clone()));
let updated = StoryMetadata {
name: Some("Updated".to_string()),
test_plan: Some(TestPlanStatus::Approved),
};
assert!(state.refresh_story_metadata("s1".to_string(), updated));
}
} }