diff --git a/Cargo.lock b/Cargo.lock index a43aa3c..1edbdc2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -255,6 +255,15 @@ dependencies = [ "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]] name = "crossbeam-deque" version = "0.8.6" @@ -446,6 +455,17 @@ dependencies = [ "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]] name = "find-msvc-tools" version = "0.1.9" @@ -479,6 +499,15 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "futures" version = "0.3.32" @@ -981,6 +1010,26 @@ dependencies = [ "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]] name = "ipnet" version = "2.11.0" @@ -1054,6 +1103,26 @@ dependencies = [ "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]] name = "lazy_static" version = "1.5.0" @@ -1072,6 +1141,17 @@ version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "linux-raw-sys" version = "0.11.0" @@ -1133,6 +1213,18 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "mio" version = "1.1.1" @@ -1196,6 +1288,25 @@ dependencies = [ "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]] name = "num-traits" version = "0.2.19" @@ -1235,7 +1346,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link 0.2.1", ] @@ -1540,6 +1651,15 @@ dependencies = [ "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]] name = "regex" version = "1.12.3" @@ -2016,6 +2136,7 @@ dependencies = [ "homedir", "ignore", "mime_guess", + "notify", "poem", "poem-openapi", "portable-pty", @@ -2191,7 +2312,7 @@ checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ "bytes", "libc", - "mio", + "mio 1.1.1", "pin-project-lite", "socket2", "tokio-macros", @@ -2887,6 +3008,15 @@ dependencies = [ "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]] name = "windows-sys" version = "0.52.0" @@ -2929,6 +3059,21 @@ dependencies = [ "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]] name = "windows-targets" version = "0.52.6" @@ -2977,6 +3122,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -2995,6 +3146,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -3013,6 +3170,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -3043,6 +3206,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -3061,6 +3230,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -3079,6 +3254,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -3097,6 +3278,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index f4b35d1..c684179 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,3 +21,4 @@ mime_guess = "2" homedir = "0.3.6" portable-pty = "0.9.0" strip-ansi-escapes = "0.2" +notify = "6" diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index d9aa4cb..0524571 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -8,11 +8,25 @@ export type WsRequest = 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 = | { type: "token"; content: string } | { type: "update"; messages: Message[] } | { 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 { provider: string; @@ -216,6 +230,7 @@ export class ChatWebSocket { private onUpdate?: (messages: Message[]) => void; private onSessionId?: (sessionId: string) => void; private onError?: (message: string) => void; + private onPipelineState?: (state: PipelineState) => void; private connected = false; private closeTimer?: number; @@ -225,6 +240,7 @@ export class ChatWebSocket { onUpdate?: (messages: Message[]) => void; onSessionId?: (sessionId: string) => void; onError?: (message: string) => void; + onPipelineState?: (state: PipelineState) => void; }, wsPath = DEFAULT_WS_PATH, ) { @@ -232,6 +248,7 @@ export class ChatWebSocket { this.onUpdate = handlers.onUpdate; this.onSessionId = handlers.onSessionId; this.onError = handlers.onError; + this.onPipelineState = handlers.onPipelineState; if (this.connected) { return; @@ -263,6 +280,7 @@ export class ChatWebSocket { if (data.type === "update") this.onUpdate?.(data.messages); if (data.type === "session_id") this.onSessionId?.(data.session_id); 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) { this.onError?.(String(err)); } diff --git a/frontend/src/api/workflow.test.ts b/frontend/src/api/workflow.test.ts deleted file mode 100644 index c059452..0000000 --- a/frontend/src/api/workflow.test.ts +++ /dev/null @@ -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"); - }); - }); -}); diff --git a/frontend/src/api/workflow.ts b/frontend/src/api/workflow.ts deleted file mode 100644 index a87ebf0..0000000 --- a/frontend/src/api/workflow.ts +++ /dev/null @@ -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( - path: string, - options: RequestInit = {}, - baseUrl = DEFAULT_API_BASE, -): Promise { - 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; -} - -export const workflowApi = { - collectCoverage(payload: CollectCoverageRequest, baseUrl?: string) { - return requestJson( - "/workflow/coverage/collect", - { method: "POST", body: JSON.stringify(payload) }, - baseUrl, - ); - }, - recordCoverage(payload: RecordCoveragePayload, baseUrl?: string) { - return requestJson( - "/workflow/coverage/record", - { method: "POST", body: JSON.stringify(payload) }, - baseUrl, - ); - }, - recordTests(payload: RecordTestsPayload, baseUrl?: string) { - return requestJson( - "/workflow/tests/record", - { method: "POST", body: JSON.stringify(payload) }, - baseUrl, - ); - }, - getAcceptance(payload: AcceptanceRequest, baseUrl?: string) { - return requestJson( - "/workflow/acceptance", - { method: "POST", body: JSON.stringify(payload) }, - baseUrl, - ); - }, - getReviewQueue(baseUrl?: string) { - return requestJson("/workflow/review", {}, baseUrl); - }, - getReviewQueueAll(baseUrl?: string) { - return requestJson("/workflow/review/all", {}, baseUrl); - }, - getUpcomingStories(baseUrl?: string) { - return requestJson( - "/workflow/upcoming", - {}, - baseUrl, - ); - }, - ensureAcceptance(payload: AcceptanceRequest, baseUrl?: string) { - return requestJson( - "/workflow/acceptance/ensure", - { method: "POST", body: JSON.stringify(payload) }, - baseUrl, - ); - }, - getStoryTodos(baseUrl?: string) { - return requestJson("/workflow/todos", {}, baseUrl); - }, - validateStories(baseUrl?: string) { - return requestJson( - "/workflow/stories/validate", - {}, - baseUrl, - ); - }, - createStory(payload: CreateStoryPayload, baseUrl?: string) { - return requestJson( - "/workflow/stories/create", - { method: "POST", body: JSON.stringify(payload) }, - baseUrl, - ); - }, -}; diff --git a/frontend/src/components/AgentPanel.test.tsx b/frontend/src/components/AgentPanel.test.tsx index b2e6864..c9cf4e1 100644 --- a/frontend/src/components/AgentPanel.test.tsx +++ b/frontend/src/components/AgentPanel.test.tsx @@ -58,13 +58,7 @@ describe("AgentPanel diff command", () => { ]; mockedAgents.listAgents.mockResolvedValue(agentList); - render( - , - ); + render(); // Expand the agent detail by clicking the expand button const expandButton = await screen.findByText("▶"); @@ -99,13 +93,7 @@ describe("AgentPanel diff command", () => { ]; mockedAgents.listAgents.mockResolvedValue(agentList); - render( - , - ); + render(); const expandButton = await screen.findByText("▶"); await userEvent.click(expandButton); @@ -135,13 +123,7 @@ describe("AgentPanel diff command", () => { ]; mockedAgents.listAgents.mockResolvedValue(agentList); - render( - , - ); + render(); const expandButton = await screen.findByText("▶"); await userEvent.click(expandButton); @@ -164,13 +146,7 @@ describe("AgentPanel diff command", () => { ]; mockedAgents.listAgents.mockResolvedValue(agentList); - render( - , - ); + render(); const expandButton = await screen.findByText("▶"); await userEvent.click(expandButton); diff --git a/frontend/src/components/AgentPanel.tsx b/frontend/src/components/AgentPanel.tsx index 19014a8..700324d 100644 --- a/frontend/src/components/AgentPanel.tsx +++ b/frontend/src/components/AgentPanel.tsx @@ -2,19 +2,13 @@ import * as React from "react"; import type { AgentConfigInfo, AgentEvent, - AgentInfo, AgentStatusValue, } from "../api/agents"; import { agentsApi, subscribeAgentStream } from "../api/agents"; import { settingsApi } from "../api/settings"; -import type { UpcomingStory } from "../api/workflow"; const { useCallback, useEffect, useRef, useState } = React; -interface AgentPanelProps { - stories: UpcomingStory[]; -} - interface AgentState { agentName: string; status: AgentStatusValue; @@ -238,13 +232,12 @@ export function EditorCommand({ ); } -export function AgentPanel({ stories }: AgentPanelProps) { +export function AgentPanel() { const [agents, setAgents] = useState>({}); const [roster, setRoster] = useState([]); const [expandedKey, setExpandedKey] = useState(null); const [actionError, setActionError] = useState(null); const [lastRefresh, setLastRefresh] = useState(null); - const [selectorStory, setSelectorStory] = useState(null); const [editorCommand, setEditorCommand] = useState(null); const [editorInput, setEditorInput] = useState(""); const [editingEditor, setEditingEditor] = useState(false); @@ -374,31 +367,6 @@ export function AgentPanel({ stories }: AgentPanelProps) { } }, [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) => { setActionError(null); 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 () => { try { 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 (
)} - {stories.length === 0 ? ( -
- No stories available. Add stories to .story_kit/stories/upcoming/. -
- ) : ( + {/* Active agents */} + {Object.entries(agents).length > 0 && (
- {stories.map((story) => { - const activeKeys = getActiveKeysForStory(story.story_id); - const hasActive = activeKeys.length > 0; - - // Gather all agent states for this story - const storyAgentEntries = Object.entries(agents).filter(([key]) => - key.startsWith(`${story.story_id}:`), - ); - - return ( + {Object.entries(agents).map(([key, a]) => ( +
-
+ setExpandedKey(expandedKey === key ? null : key) + } style={{ - padding: "8px 12px", - display: "flex", - alignItems: "center", - gap: "8px", + background: "none", + border: "none", + color: "#aaa", + cursor: "pointer", + fontSize: "0.8em", + padding: "0 4px", + transform: + expandedKey === key + ? "rotate(90deg)" + : "rotate(0deg)", + transition: "transform 0.15s", }} > - + ▶ + -
- {story.name ?? story.story_id} -
- - {storyAgentEntries.map(([key, a]) => ( - - - {a.agentName} - - - - ))} - - {hasActive ? ( - - ) : ( -
- - {selectorStory === story.story_id && - roster.length > 1 && ( -
- {roster.map((r) => ( - - ))} -
- )} -
- )} +
+ {a.agentName} + + {key.split(":")[0]} +
- {/* Empty state when expanded with no agents */} - {expandedKey === story.story_id && - storyAgentEntries.length === 0 && ( + + + {(a.status === "running" || a.status === "pending") && ( + + )} +
+ + {expandedKey === key && ( +
+ {a.worktreePath && (
- No agents started. Use the Run button to start an agent. + Worktree: {a.worktreePath}
)} - - {/* Expanded detail per agent */} - {storyAgentEntries.map(([key, a]) => { - if (expandedKey !== key) return null; - return ( -
-
- {a.agentName} -
- {a.worktreePath && ( + {a.worktreePath && ( + + )} +
+ {a.log.length === 0 ? ( + + {a.status === "pending" || a.status === "running" + ? "Waiting for output..." + : "No output captured."} + + ) : ( + a.log.map((line, i) => (
- Worktree: {a.worktreePath} + {line}
- )} - {a.worktreePath && ( - - )} -
- {a.log.length === 0 ? ( - - {a.status === "pending" || a.status === "running" - ? "Waiting for output..." - : "No output captured."} - - ) : ( - a.log.map((line, i) => ( -
- {line} -
- )) - )} -
{ - logEndRefs.current[key] = el; - }} - /> -
-
- ); - })} -
- ); - })} + )) + )} +
{ + logEndRefs.current[key] = el; + }} + /> +
+
+ )} +
+ ))}
)}
diff --git a/frontend/src/components/Chat.test.tsx b/frontend/src/components/Chat.test.tsx index 7d11c99..62ca5ba 100644 --- a/frontend/src/components/Chat.test.tsx +++ b/frontend/src/components/Chat.test.tsx @@ -1,10 +1,7 @@ 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 { api } from "../api/client"; -import type { ReviewStory } from "../api/workflow"; -import { workflowApi } from "../api/workflow"; import type { Message } from "../types"; import { Chat } from "./Chat"; @@ -39,21 +36,6 @@ vi.mock("../api/client", () => { 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 = { getOllamaModels: vi.mocked(api.getOllamaModels), getAnthropicApiKeyExists: vi.mocked(api.getAnthropicApiKeyExists), @@ -64,587 +46,20 @@ const mockedApi = { setAnthropicApiKey: vi.mocked(api.setAnthropicApiKey), }; -const mockedWorkflow = { - getAcceptance: vi.mocked(workflowApi.getAcceptance), - getReviewQueue: vi.mocked(workflowApi.getReviewQueue), - getReviewQueueAll: vi.mocked(workflowApi.getReviewQueueAll), - ensureAcceptance: vi.mocked(workflowApi.ensureAcceptance), - getStoryTodos: vi.mocked(workflowApi.getStoryTodos), - getUpcomingStories: vi.mocked(workflowApi.getUpcomingStories), -}; - -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(); - - 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(); - - 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(); - - 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(); - - 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(); - - 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(); - - 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(); - - 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(); - - 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(); - - 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(); - - 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(); - - 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(); - - 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(); - - 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(); - - 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(); - - 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(); - - 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(); - - 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(); - - 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(); - - 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(); - - 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(); - - 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(); - - await waitFor(() => { - expect(mockedApi.getAnthropicApiKeyExists).toHaveBeenCalled(); - }); - - expect(mockedApi.getAnthropicModels).not.toHaveBeenCalled(); - }); -}); +function 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); + mockedApi.setAnthropicApiKey.mockResolvedValue(true); +} describe("Chat message rendering — unified tool call UI", () => { beforeEach(() => { capturedWsHandlers = null; - - 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: [] }); + setupMocks(); }); 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(); - // Tool call badge should appear showing the function name and first arg expect(await screen.findByText("Read(src/main.rs)")).toBeInTheDocument(); }); @@ -709,7 +123,6 @@ describe("Chat message rendering — unified tool call UI", () => { capturedWsHandlers?.onUpdate(messages); }); - // Tool output section should be collapsible expect(await screen.findByText(/Tool Output/)).toBeInTheDocument(); expect( await screen.findByText("The file contains a main function."), @@ -733,7 +146,6 @@ describe("Chat message rendering — unified tool call UI", () => { expect( await screen.findByText("Hi there! How can I help?"), ).toBeInTheDocument(); - // No tool call badges should appear 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("Read(Cargo.toml)")).toBeInTheDocument(); }); + + it("does not fetch Anthropic models when no API key exists", async () => { + mockedApi.getAnthropicApiKeyExists.mockResolvedValue(false); + mockedApi.getAnthropicModels.mockClear(); + + render(); + + await waitFor(() => { + expect(mockedApi.getAnthropicApiKeyExists).toHaveBeenCalled(); + }); + + expect(mockedApi.getAnthropicModels).not.toHaveBeenCalled(); + }); }); describe("Chat two-column layout", () => { beforeEach(() => { capturedWsHandlers = null; - - 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: [] }); + setupMocks(); }); 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 () => { - mockedWorkflow.getReviewQueueAll.mockResolvedValue({ stories: [] }); - render(); const rightColumn = await screen.findByTestId("chat-right-column"); - const reviewPanel = await screen.findByText("Stories Awaiting Review"); - expect(rightColumn).toContainElement(reviewPanel); + const agentsPanel = await screen.findByText("Agents"); + expect(rightColumn).toContainElement(agentsPanel); }); it("uses row flex-direction on wide screens (AC3)", async () => { diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index 3a80766..554ac7a 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -3,15 +3,11 @@ import Markdown from "react-markdown"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; import { api, ChatWebSocket } from "../api/client"; -import type { ReviewStory, UpcomingStory } from "../api/workflow"; -import { workflowApi } from "../api/workflow"; +import type { PipelineState } from "../api/client"; import type { Message, ProviderConfig, ToolCall } from "../types"; import { AgentPanel } from "./AgentPanel"; import { ChatHeader } from "./ChatHeader"; -import { GatePanel } from "./GatePanel"; -import { ReviewPanel } from "./ReviewPanel"; -import { TodoPanel } from "./TodoPanel"; -import { UpcomingPanel } from "./UpcomingPanel"; +import { StagePanel } from "./StagePanel"; const { useCallback, useEffect, useRef, useState } = React; @@ -22,23 +18,6 @@ interface ChatProps { 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) { const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); @@ -51,55 +30,17 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { const [showApiKeyDialog, setShowApiKeyDialog] = useState(false); const [apiKeyInput, setApiKeyInput] = useState(""); const [hasAnthropicKey, setHasAnthropicKey] = useState(false); - const [gateState, setGateState] = useState(null); - const [gateError, setGateError] = useState(null); - const [isGateLoading, setIsGateLoading] = useState(false); - const [reviewQueue, setReviewQueue] = useState([]); - const [reviewError, setReviewError] = useState(null); - const [isReviewLoading, setIsReviewLoading] = useState(false); - const [proceedingStoryId, setProceedingStoryId] = useState( - null, - ); - const [proceedError, setProceedError] = useState(null); - const [proceedSuccess, setProceedSuccess] = useState(null); - const [lastReviewRefresh, setLastReviewRefresh] = useState(null); - const [lastGateRefresh, setLastGateRefresh] = useState(null); - const [isCollectingCoverage, setIsCollectingCoverage] = useState(false); - const [coverageError, setCoverageError] = useState(null); - const [storyTodos, setStoryTodos] = useState< - { - storyId: string; - storyName: string | null; - items: string[]; - error: string | null; - }[] - >([]); - const [todoError, setTodoError] = useState(null); - const [isTodoLoading, setIsTodoLoading] = useState(false); - const [lastTodoRefresh, setLastTodoRefresh] = useState(null); - const [upcomingStories, setUpcomingStories] = useState([]); - const [upcomingError, setUpcomingError] = useState(null); - const [isUpcomingLoading, setIsUpcomingLoading] = useState(false); - const [lastUpcomingRefresh, setLastUpcomingRefresh] = useState( - null, - ); + const [pipeline, setPipeline] = useState({ + upcoming: [], + current: [], + qa: [], + merge: [], + }); const [claudeSessionId, setClaudeSessionId] = useState(null); const [isNarrowScreen, setIsNarrowScreen] = useState( 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(null); const messagesEndRef = useRef(null); const inputRef = useRef(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(() => { const ws = new ChatWebSocket(); wsRef.current = ws; @@ -508,6 +162,9 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { console.error("WebSocket error:", message); setLoading(false); }, + onPipelineState: (state) => { + setPipeline(state); + }, }); return () => { @@ -1056,50 +713,13 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { gap: "12px", }} > - + - + + + + - refreshGateState(storyId)} - onCollectCoverage={handleCollectCoverage} - isCollectingCoverage={isCollectingCoverage} - /> - - - -
diff --git a/frontend/src/components/GatePanel.test.tsx b/frontend/src/components/GatePanel.test.tsx deleted file mode 100644 index ef480d6..0000000 --- a/frontend/src/components/GatePanel.test.tsx +++ /dev/null @@ -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(); - expect(screen.getByText("No workflow data yet.")).toBeInTheDocument(); - }); - - it("shows loading message when isGateLoading is true", () => { - render(); - expect(screen.getByText("Loading workflow gates...")).toBeInTheDocument(); - }); - - it("shows error with retry button", async () => { - const onRefresh = vi.fn(); - render( - , - ); - - 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( - , - ); - expect(screen.getByText("Blocked")).toBeInTheDocument(); - }); - - it("shows test summary when gateState is provided", () => { - render( - , - ); - expect(screen.getByText(/5\/5 passing, 0 failing/)).toBeInTheDocument(); - }); - - it("shows missing categories", () => { - render( - , - ); - expect(screen.getByText("Missing: unit, integration")).toBeInTheDocument(); - }); - - it("shows warning text", () => { - render( - , - ); - expect( - screen.getByText("Multiple tests failing — fix one at a time."), - ).toBeInTheDocument(); - }); - - it("shows reasons as list items", () => { - render( - , - ); - 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(); - - await userEvent.click(screen.getByRole("button", { name: "Refresh" })); - expect(onRefresh).toHaveBeenCalledOnce(); - }); - - it("disables Refresh button when loading", () => { - render(); - expect(screen.getByRole("button", { name: "Refresh" })).toBeDisabled(); - }); -}); diff --git a/frontend/src/components/GatePanel.tsx b/frontend/src/components/GatePanel.tsx deleted file mode 100644 index 930929b..0000000 --- a/frontend/src/components/GatePanel.tsx +++ /dev/null @@ -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 ( -
-
-
-
Workflow Gates
- - -
-
-
{gateStatusLabel}
-
- Updated {formatTimestamp(lastGateRefresh)} -
-
-
- - {isGateLoading ? ( -
- Loading workflow gates... -
- ) : gateError ? ( -
- {gateError} - -
- ) : gateState ? ( -
-
- Summary: {gateState.summary.passed}/{gateState.summary.total}{" "} - passing, {gateState.summary.failed} failing -
- {gateState.coverageReport && ( -
- Coverage: {gateState.coverageReport.currentPercent.toFixed(1)}% - (threshold: {gateState.coverageReport.thresholdPercent.toFixed(1)} - %) -
- )} - {coverageError && ( -
- Coverage error: {coverageError} -
- )} - {gateState.missingCategories.length > 0 && ( -
- Missing: {gateState.missingCategories.join(", ")} -
- )} - {gateState.warning && ( -
- {gateState.warning} -
- )} - {gateState.reasons.length > 0 && ( -
    - {gateState.reasons.map((reason) => ( -
  • {reason}
  • - ))} -
- )} -
- ) : ( -
- No workflow data yet. -
- )} -
- ); -} diff --git a/frontend/src/components/ReviewPanel.test.tsx b/frontend/src/components/ReviewPanel.test.tsx deleted file mode 100644 index 00e00bb..0000000 --- a/frontend/src/components/ReviewPanel.test.tsx +++ /dev/null @@ -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(); - expect( - screen.getByText("No stories waiting for review."), - ).toBeInTheDocument(); - }); - - it("shows loading state", () => { - render(); - expect(screen.getByText("Loading review queue...")).toBeInTheDocument(); - }); - - it("shows error with retry button", async () => { - const onRefresh = vi.fn(); - render( - , - ); - - 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(); - - 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(); - - 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(); - expect(screen.getByText("Failing 2")).toBeInTheDocument(); - }); - - it("shows warning badge", () => { - render(); - expect(screen.getByText("Warning")).toBeInTheDocument(); - }); - - it("shows test summary per story", () => { - render(); - expect(screen.getByText(/5\/5 passing,\s*0 failing/)).toBeInTheDocument(); - }); - - it("shows missing categories", () => { - const missingStory: ReviewStory = { - ...blockedStory, - missing_categories: ["unit", "integration"], - }; - render(); - expect(screen.getByText("Missing: unit, integration")).toBeInTheDocument(); - }); - - it("calls onProceed when Proceed is clicked", async () => { - const onProceed = vi.fn().mockResolvedValue(undefined); - render( - , - ); - - await userEvent.click(screen.getByRole("button", { name: "Proceed" })); - expect(onProceed).toHaveBeenCalledWith("29_backfill_tests"); - }); - - it("shows queue counts in header", () => { - render( - , - ); - expect(screen.getByText(/1 ready \/ 2 total/)).toBeInTheDocument(); - }); - - it("shows proceedError message", () => { - render( - , - ); - expect( - screen.getByText("Acceptance blocked: tests failing"), - ).toBeInTheDocument(); - }); - - it("shows proceedSuccess message", () => { - render( - , - ); - expect(screen.getByText("Story accepted successfully")).toBeInTheDocument(); - }); - - it("shows reasons as list items", () => { - render(); - expect(screen.getByText("2 tests are failing.")).toBeInTheDocument(); - }); -}); diff --git a/frontend/src/components/ReviewPanel.tsx b/frontend/src/components/ReviewPanel.tsx deleted file mode 100644 index 228ece5..0000000 --- a/frontend/src/components/ReviewPanel.tsx +++ /dev/null @@ -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; -} - -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 ( -
-
-
-
Stories Awaiting Review
- -
-
-
- {reviewQueue.filter((story) => story.can_accept).length} ready /{" "} - {reviewQueue.length} total -
-
- Updated {formatTimestamp(lastReviewRefresh)} -
-
-
- - {isReviewLoading ? ( -
- Loading review queue... -
- ) : reviewError ? ( -
- {reviewError} Use Refresh to try again. - -
- ) : reviewQueue.length === 0 ? ( -
- No stories waiting for review. -
- ) : ( -
- {reviewQueue.map((story) => ( -
-
-
-
{story.story_id}
- - {story.can_accept ? "Ready" : "Blocked"} - - {story.summary.failed > 0 && ( - - Failing {story.summary.failed} - - )} - {story.warning && ( - - Warning - - )} - {story.missing_categories.length > 0 && ( - - Missing - - )} -
- -
-
- Summary: {story.summary.passed}/{story.summary.total} passing,{" "} - {` ${story.summary.failed}`} failing -
- {story.coverage_report && ( -
- Coverage: {story.coverage_report.current_percent.toFixed(1)}% - (threshold:{" "} - {story.coverage_report.threshold_percent.toFixed(1)}%) -
- )} - {story.missing_categories.length > 0 && ( -
- Missing: {story.missing_categories.join(", ")} -
- )} - {story.reasons.length > 0 && ( -
    - {story.reasons.map((reason) => ( -
  • - {reason} -
  • - ))} -
- )} - {story.warning && ( -
- {story.warning} -
- )} -
- ))} -
- )} - - {proceedError && ( -
- {proceedError} -
- )} - {proceedSuccess && ( -
- {proceedSuccess} -
- )} -
- ); -} diff --git a/frontend/src/components/StagePanel.tsx b/frontend/src/components/StagePanel.tsx new file mode 100644 index 0000000..11c8a04 --- /dev/null +++ b/frontend/src/components/StagePanel.tsx @@ -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 ( +
+
+
{title}
+
+ {items.length} +
+
+ + {items.length === 0 ? ( +
+ {emptyMessage} +
+ ) : ( +
+ {items.map((item) => { + const itemNumber = item.story_id.match(/^(\d+)/)?.[1]; + return ( +
+
+
+ {itemNumber && ( + + #{itemNumber} + + )} + {item.name ?? item.story_id} +
+ {item.error && ( +
+ {item.error} +
+ )} +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/frontend/src/components/TodoPanel.test.tsx b/frontend/src/components/TodoPanel.test.tsx deleted file mode 100644 index 10bf36b..0000000 --- a/frontend/src/components/TodoPanel.test.tsx +++ /dev/null @@ -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( - , - ); - - expect(screen.getByText("Missing front matter")).toBeInTheDocument(); - expect(screen.getByText("28_todos")).toBeInTheDocument(); - }); - - it("shows error alongside todo items", () => { - render( - , - ); - - 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( - , - ); - - expect(screen.queryByTestId("story-error-28_todos")).toBeNull(); - expect(screen.getByText("A criterion")).toBeInTheDocument(); - }); -}); diff --git a/frontend/src/components/TodoPanel.tsx b/frontend/src/components/TodoPanel.tsx deleted file mode 100644 index 324b70f..0000000 --- a/frontend/src/components/TodoPanel.tsx +++ /dev/null @@ -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 ( -
-
-
-
Story TODOs
- -
-
-
{totalTodos} remaining
-
- Updated {formatTimestamp(lastTodoRefresh)} -
-
-
- - {isTodoLoading ? ( -
- Loading story TODOs... -
- ) : todoError ? ( -
- {todoError} - -
- ) : totalTodos === 0 && !hasErrors ? ( -
- All acceptance criteria complete. -
- ) : ( -
- {todos - .filter((s) => s.items.length > 0 || s.error) - .map((story) => ( -
-
- {story.storyName ?? story.storyId} -
- {story.error && ( -
- {story.error} -
- )} - {story.items.length > 0 && ( -
    - {story.items.map((item) => ( -
  • {item}
  • - ))} -
- )} -
- ))} -
- )} -
- ); -} diff --git a/frontend/src/components/UpcomingPanel.test.tsx b/frontend/src/components/UpcomingPanel.test.tsx deleted file mode 100644 index 79fd13f..0000000 --- a/frontend/src/components/UpcomingPanel.test.tsx +++ /dev/null @@ -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(); - expect(screen.getByText("No upcoming stories.")).toBeInTheDocument(); - }); - - it("shows loading state", () => { - render(); - expect(screen.getByText("Loading upcoming stories...")).toBeInTheDocument(); - }); - - it("shows error with retry button", async () => { - const onRefresh = vi.fn(); - render( - , - ); - - 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(); - - 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(); - - expect(screen.getByText("33_no_name")).toBeInTheDocument(); - }); - - it("calls onRefresh when Refresh clicked", async () => { - const onRefresh = vi.fn(); - render(); - - await userEvent.click(screen.getByRole("button", { name: "Refresh" })); - expect(onRefresh).toHaveBeenCalledOnce(); - }); - - it("disables Refresh while loading", () => { - render(); - expect(screen.getByRole("button", { name: "Refresh" })).toBeDisabled(); - }); -}); diff --git a/frontend/src/components/UpcomingPanel.tsx b/frontend/src/components/UpcomingPanel.tsx deleted file mode 100644 index a32db72..0000000 --- a/frontend/src/components/UpcomingPanel.tsx +++ /dev/null @@ -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 ( -
-
-
-
Upcoming Stories
- -
-
-
{stories.length} stories
-
- Updated {formatTimestamp(lastRefresh)} -
-
-
- - {isLoading ? ( -
- Loading upcoming stories... -
- ) : error ? ( -
- {error} Use Refresh to try again. - -
- ) : stories.length === 0 ? ( -
- No upcoming stories. -
- ) : ( -
- {stories.map((story) => ( -
-
-
-
- {story.name ?? story.story_id} -
- {story.name && ( -
- {story.story_id} -
- )} -
- {story.error && ( -
- {story.error} -
- )} -
-
- ))} -
- )} -
- ); -} diff --git a/server/Cargo.toml b/server/Cargo.toml index b2e4b92..1aefc8c 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -27,6 +27,7 @@ async-stream = "0.3" bytes = "1" portable-pty = { workspace = true } strip-ansi-escapes = { workspace = true } +notify = { workspace = true } [dev-dependencies] diff --git a/server/src/agents.rs b/server/src/agents.rs index 1e1edb5..fbacc22 100644 --- a/server/src/agents.rs +++ b/server/src/agents.rs @@ -753,42 +753,6 @@ pub struct MergeReport { 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}`). /// Returns "bug", "spike", or "story". #[allow(dead_code)] @@ -850,12 +814,7 @@ pub fn move_story_to_current(project_root: &Path, story_id: &str) -> Result<(), source_dir.display() ); - let msg = format!("story-kit: start {story_id}"); - git_stage_and_commit( - project_root, - &[current_path.as_path(), source_path.as_path()], - &msg, - ) + Ok(()) } /// 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/"); - let msg = format!("story-kit: accept story {story_id}"); - git_stage_and_commit( - project_root, - &[archived_path.as_path(), source_path.as_path()], - &msg, - ) + Ok(()) } /// 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/"); - let msg = format!("story-kit: queue {story_id} for merge"); - git_stage_and_commit( - project_root, - &[merge_path.as_path(), current_path.as_path()], - &msg, - ) + Ok(()) } /// 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/"); - let msg = format!("story-kit: queue {story_id} for QA"); - git_stage_and_commit( - project_root, - &[qa_path.as_path(), current_path.as_path()], - &msg, - ) + Ok(()) } /// 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/" ); - let msg = format!("story-kit: close bug {bug_id}"); - git_stage_and_commit( - project_root, - &[archive_path.as_path(), source_path.as_path()], - &msg, - ) + Ok(()) } // ── Acceptance-gate helpers ─────────────────────────────────────────────────── @@ -1634,6 +1573,7 @@ mod tests { } // ── move_story_to_current tests ──────────────────────────────────────────── + // No git repo needed: the watcher handles commits asynchronously. fn init_git_repo(repo: &std::path::Path) { Command::new("git") @@ -1659,179 +1599,86 @@ mod tests { } #[test] - fn move_story_to_current_moves_file_and_commits() { + fn move_story_to_current_moves_file() { use std::fs; - use tempfile::tempdir; - - let tmp = tempdir().unwrap(); - let repo = tmp.path(); - init_git_repo(repo); - - let upcoming = repo.join(".story_kit/work/1_upcoming"); - let current_dir = repo.join(".story_kit/work/2_current"); + 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(¤t_dir).unwrap(); + fs::create_dir_all(¤t).unwrap(); + fs::write(upcoming.join("10_story_foo.md"), "test").unwrap(); - let story_file = upcoming.join("10_story_my_story.md"); - fs::write(&story_file, "---\nname: Test\ntest_plan: pending\n---\n").unwrap(); + move_story_to_current(root, "10_story_foo").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_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" - ); + assert!(!upcoming.join("10_story_foo.md").exists()); + assert!(current.join("10_story_foo.md").exists()); } #[test] fn move_story_to_current_is_idempotent_when_already_current() { 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(¤t).unwrap(); + fs::write(current.join("11_story_foo.md"), "test").unwrap(); - let tmp = tempdir().unwrap(); - let repo = tmp.path(); - init_git_repo(repo); - - let current_dir = repo.join(".story_kit/work/2_current"); - fs::create_dir_all(¤t_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()); + move_story_to_current(root, "11_story_foo").unwrap(); + assert!(current.join("11_story_foo.md").exists()); } #[test] fn move_story_to_current_noop_when_not_in_upcoming() { - use tempfile::tempdir; - - 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"); + let tmp = tempfile::tempdir().unwrap(); + assert!(move_story_to_current(tmp.path(), "99_missing").is_ok()); } #[test] - fn move_bug_to_current_moves_from_bugs_dir() { + fn move_bug_to_current_moves_from_upcoming() { 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(¤t).unwrap(); + fs::write(upcoming.join("1_bug_test.md"), "# Bug 1\n").unwrap(); - let tmp = tempdir().unwrap(); - let repo = tmp.path(); - init_git_repo(repo); + move_story_to_current(root, "1_bug_test").unwrap(); - let upcoming_dir = repo.join(".story_kit/work/1_upcoming"); - let current_dir = repo.join(".story_kit/work/2_current"); - fs::create_dir_all(&upcoming_dir).unwrap(); - fs::create_dir_all(¤t_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" - ); + assert!(!upcoming.join("1_bug_test.md").exists()); + assert!(current.join("1_bug_test.md").exists()); } #[test] fn close_bug_moves_from_current_to_archive() { 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(¤t).unwrap(); + fs::write(current.join("2_bug_test.md"), "# Bug 2\n").unwrap(); - let tmp = tempdir().unwrap(); - let repo = tmp.path(); - init_git_repo(repo); + close_bug_to_archive(root, "2_bug_test").unwrap(); - let current_dir = repo.join(".story_kit/work/2_current"); - fs::create_dir_all(¤t_dir).unwrap(); - - 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"); + assert!(!current.join("2_bug_test.md").exists()); + assert!(root.join(".story_kit/work/5_archived/2_bug_test.md").exists()); } #[test] - fn close_bug_moves_from_bugs_dir_when_not_started() { + fn close_bug_moves_from_upcoming_when_not_started() { 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(); - let repo = tmp.path(); - init_git_repo(repo); + close_bug_to_archive(root, "3_bug_test").unwrap(); - let upcoming_dir = repo.join(".story_kit/work/1_upcoming"); - fs::create_dir_all(&upcoming_dir).unwrap(); - - 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"); + assert!(!upcoming.join("3_bug_test.md").exists()); + assert!(root.join(".story_kit/work/5_archived/3_bug_test.md").exists()); } #[test] @@ -1842,216 +1689,102 @@ mod tests { 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 ────────────────────────────────────────────── #[test] - fn move_story_to_merge_moves_file_and_commits() { + fn move_story_to_merge_moves_file() { 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(¤t).unwrap(); + fs::write(current.join("20_story_foo.md"), "test").unwrap(); - let tmp = tempdir().unwrap(); - let repo = tmp.path(); - init_git_repo(repo); + move_story_to_merge(root, "20_story_foo").unwrap(); - let current_dir = repo.join(".story_kit/work/2_current"); - fs::create_dir_all(¤t_dir).unwrap(); - 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"); + assert!(!current.join("20_story_foo.md").exists()); + assert!(root.join(".story_kit/work/4_merge/20_story_foo.md").exists()); } #[test] fn move_story_to_merge_idempotent_when_already_in_merge() { use std::fs; - use tempfile::tempdir; - - let tmp = tempdir().unwrap(); - let repo = tmp.path(); - init_git_repo(repo); - - let merge_dir = repo.join(".story_kit/work/4_merge"); + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let merge_dir = root.join(".story_kit/work/4_merge"); fs::create_dir_all(&merge_dir).unwrap(); - fs::write( - merge_dir.join("21_story_test.md"), - "---\nname: Test\ntest_plan: approved\n---\n", - ) - .unwrap(); + fs::write(merge_dir.join("21_story_test.md"), "test").unwrap(); - // Should succeed without error even though there's nothing to move - move_story_to_merge(repo, "21_story_test").unwrap(); + move_story_to_merge(root, "21_story_test").unwrap(); assert!(merge_dir.join("21_story_test.md").exists()); } #[test] fn move_story_to_merge_errors_when_not_in_current() { - use tempfile::tempdir; - - 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()); + let tmp = tempfile::tempdir().unwrap(); + let result = move_story_to_merge(tmp.path(), "99_nonexistent"); assert!(result.unwrap_err().contains("not found in work/2_current/")); } // ── move_story_to_qa tests ──────────────────────────────────────────────── #[test] - fn move_story_to_qa_moves_file_and_commits() { + fn move_story_to_qa_moves_file() { 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(¤t).unwrap(); + fs::write(current.join("30_story_qa.md"), "test").unwrap(); - let tmp = tempdir().unwrap(); - let repo = tmp.path(); - init_git_repo(repo); + move_story_to_qa(root, "30_story_qa").unwrap(); - let current_dir = repo.join(".story_kit/work/2_current"); - fs::create_dir_all(¤t_dir).unwrap(); - 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"); + assert!(!current.join("30_story_qa.md").exists()); + assert!(root.join(".story_kit/work/3_qa/30_story_qa.md").exists()); } #[test] fn move_story_to_qa_idempotent_when_already_in_qa() { use std::fs; - use tempfile::tempdir; - - let tmp = tempdir().unwrap(); - let repo = tmp.path(); - init_git_repo(repo); - - let qa_dir = repo.join(".story_kit/work/3_qa"); + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let qa_dir = root.join(".story_kit/work/3_qa"); fs::create_dir_all(&qa_dir).unwrap(); - fs::write( - qa_dir.join("31_story_test.md"), - "---\nname: Test\ntest_plan: approved\n---\n", - ) - .unwrap(); + fs::write(qa_dir.join("31_story_test.md"), "test").unwrap(); - // Should succeed without error even though there's nothing to move - move_story_to_qa(repo, "31_story_test").unwrap(); + move_story_to_qa(root, "31_story_test").unwrap(); assert!(qa_dir.join("31_story_test.md").exists()); } #[test] fn move_story_to_qa_errors_when_not_in_current() { - use tempfile::tempdir; - - 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()); + let tmp = tempfile::tempdir().unwrap(); + let result = move_story_to_qa(tmp.path(), "99_nonexistent"); 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] fn move_story_to_archived_finds_in_merge_dir() { use std::fs; - use tempfile::tempdir; - - let tmp = tempdir().unwrap(); - let repo = tmp.path(); - init_git_repo(repo); - - let merge_dir = repo.join(".story_kit/work/4_merge"); + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let merge_dir = root.join(".story_kit/work/4_merge"); fs::create_dir_all(&merge_dir).unwrap(); - let story_file = merge_dir.join("22_story_test.md"); - fs::write(&story_file, "---\nname: Test\ntest_plan: approved\n---\n").unwrap(); + fs::write(merge_dir.join("22_story_test.md"), "test").unwrap(); - Command::new("git") - .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(root, "22_story_test").unwrap(); - move_story_to_archived(repo, "22_story_test").unwrap(); - - 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"); + assert!(!merge_dir.join("22_story_test.md").exists()); + assert!(root.join(".story_kit/work/5_archived/22_story_test.md").exists()); } #[test] fn move_story_to_archived_error_when_not_in_current_or_merge() { - use tempfile::tempdir; - - let tmp = tempdir().unwrap(); - 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}"); + let tmp = tempfile::tempdir().unwrap(); + let result = move_story_to_archived(tmp.path(), "99_nonexistent"); + assert!(result.unwrap_err().contains("4_merge")); } // ── merge_agent_work tests ──────────────────────────────────────────────── diff --git a/server/src/http/context.rs b/server/src/http/context.rs index 26d805b..2b1ba8f 100644 --- a/server/src/http/context.rs +++ b/server/src/http/context.rs @@ -1,9 +1,11 @@ use crate::agents::AgentPool; +use crate::io::watcher::WatcherEvent; use crate::state::SessionState; use crate::store::JsonFileStore; use crate::workflow::WorkflowState; use poem::http::StatusCode; use std::sync::Arc; +use tokio::sync::broadcast; #[derive(Clone)] pub struct AppContext { @@ -11,6 +13,9 @@ pub struct AppContext { pub store: Arc, pub workflow: Arc>, pub agents: Arc, + /// Broadcast channel for filesystem watcher events. WebSocket handlers + /// subscribe to this to push lifecycle notifications to connected clients. + pub watcher_tx: broadcast::Sender, } #[cfg(test)] @@ -19,11 +24,13 @@ impl AppContext { let state = SessionState::default(); *state.project_root.lock().unwrap() = Some(project_root.clone()); let store_path = project_root.join(".story_kit_store.json"); + let (watcher_tx, _) = broadcast::channel(64); Self { state: Arc::new(state), store: Arc::new(JsonFileStore::new(store_path).unwrap()), workflow: Arc::new(std::sync::Mutex::new(WorkflowState::default())), agents: Arc::new(AgentPool::new(3001)), + watcher_tx, } } } diff --git a/server/src/http/mcp.rs b/server/src/http/mcp.rs index 973f958..fe8a8b8 100644 --- a/server/src/http/mcp.rs +++ b/server/src/http/mcp.rs @@ -848,8 +848,9 @@ fn tool_create_story(args: &Value, ctx: &AppContext) -> Result { let acceptance_criteria: Option> = args .get("acceptance_criteria") .and_then(|v| serde_json::from_value(v.clone()).ok()); - // MCP tool always auto-commits the new story file to master. - let commit = true; + // Spike 61: write the file only — the filesystem watcher detects the new + // .md file in work/1_upcoming/ and auto-commits with a deterministic message. + let commit = false; let root = ctx.state.get_project_root()?; let story_id = create_story_file( @@ -1607,30 +1608,10 @@ mod tests { #[test] fn tool_create_story_and_list_upcoming() { let tmp = tempfile::tempdir().unwrap(); - // The MCP tool always commits, so we need a real git repo. - std::process::Command::new("git") - .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(); + // No git repo needed: spike 61 — create_story just writes the file; + // the filesystem watcher handles the commit asynchronously. let ctx = test_ctx(tmp.path()); - // Create a story (always auto-commits in MCP handler) let result = tool_create_story( &json!({"name": "Test Story", "acceptance_criteria": ["AC1", "AC2"]}), &ctx, diff --git a/server/src/http/mod.rs b/server/src/http/mod.rs index 5c062a8..c3636e7 100644 --- a/server/src/http/mod.rs +++ b/server/src/http/mod.rs @@ -26,7 +26,6 @@ use poem_openapi::OpenApiService; use project::ProjectApi; use settings::SettingsApi; use std::sync::Arc; -use workflow::WorkflowApi; pub fn build_routes(ctx: AppContext) -> impl poem::Endpoint { let ctx_arc = std::sync::Arc::new(ctx); @@ -58,7 +57,6 @@ type ApiTuple = ( AnthropicApi, IoApi, ChatApi, - WorkflowApi, AgentsApi, SettingsApi, ); @@ -73,7 +71,6 @@ pub fn build_openapi_service(ctx: Arc) -> (ApiService, ApiService) { AnthropicApi::new(ctx.clone()), IoApi { ctx: ctx.clone() }, ChatApi { ctx: ctx.clone() }, - WorkflowApi { ctx: ctx.clone() }, AgentsApi { ctx: ctx.clone() }, SettingsApi { ctx: ctx.clone() }, ); @@ -87,7 +84,6 @@ pub fn build_openapi_service(ctx: Arc) -> (ApiService, ApiService) { AnthropicApi::new(ctx.clone()), IoApi { ctx: ctx.clone() }, ChatApi { ctx: ctx.clone() }, - WorkflowApi { ctx: ctx.clone() }, AgentsApi { ctx: ctx.clone() }, SettingsApi { ctx }, ); diff --git a/server/src/http/workflow.rs b/server/src/http/workflow.rs index 4c62902..8535d6d 100644 --- a/server/src/http/workflow.rs +++ b/server/src/http/workflow.rs @@ -1,158 +1,55 @@ -use crate::agents::git_stage_and_commit; -use crate::http::context::{AppContext, OpenApiResult, bad_request}; -use crate::io::story_metadata::{StoryMetadata, parse_front_matter, parse_unchecked_todos}; -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 crate::http::context::AppContext; +use crate::io::story_metadata::parse_front_matter; +use serde::Serialize; use std::fs; use std::path::{Path, PathBuf}; -use std::sync::Arc; -#[derive(Tags)] -enum WorkflowTags { - Workflow, -} - -#[derive(Deserialize, Object)] -struct TestCasePayload { - pub name: String, - pub status: String, - pub details: Option, -} - -#[derive(Deserialize, Object)] -struct RecordTestsPayload { - pub story_id: String, - pub unit: Vec, - pub integration: Vec, -} - -#[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, -} - -#[derive(Object)] -struct AcceptanceResponse { - pub can_accept: bool, - pub reasons: Vec, - pub warning: Option, - pub summary: TestRunSummaryResponse, - pub missing_categories: Vec, - pub coverage_report: Option, -} - -#[derive(Object)] -struct ReviewStory { - pub story_id: String, - pub can_accept: bool, - pub reasons: Vec, - pub warning: Option, - pub summary: TestRunSummaryResponse, - pub missing_categories: Vec, - pub coverage_report: Option, -} - -#[derive(Deserialize, Object)] -struct RecordCoveragePayload { - pub story_id: String, - pub current_percent: f64, - pub threshold_percent: Option, -} - -#[derive(Deserialize, Object)] -struct CollectCoverageRequest { - pub story_id: String, - pub threshold_percent: Option, -} - -#[derive(Object)] -struct ReviewListResponse { - pub stories: Vec, -} - -#[derive(Object)] -struct StoryTodosResponse { - pub story_id: String, - pub story_name: Option, - pub todos: Vec, - pub error: Option, -} - -#[derive(Object)] -struct TodoListResponse { - pub stories: Vec, -} - -#[derive(Object)] +#[derive(Clone, Debug, Serialize)] pub struct UpcomingStory { pub story_id: String, pub name: Option, pub error: Option, } -#[derive(Object)] -struct UpcomingStoriesResponse { - pub stories: Vec, -} - -#[derive(Deserialize, Object)] -struct CreateStoryPayload { - pub name: String, - pub user_story: Option, - pub acceptance_criteria: Option>, - /// If true, git-add and git-commit the new story file to the current branch. - pub commit: Option, -} - -#[derive(Object)] -struct CreateStoryResponse { - pub story_id: String, -} - -#[derive(Object)] pub struct StoryValidationResult { pub story_id: String, pub valid: bool, pub error: Option, } -#[derive(Object)] -struct ValidateStoriesResponse { - pub stories: Vec, +/// Full pipeline state across all stages. +#[derive(Clone, Debug, Serialize)] +pub struct PipelineState { + pub upcoming: Vec, + pub current: Vec, + pub qa: Vec, + pub merge: Vec, } -pub fn load_upcoming_stories(ctx: &AppContext) -> Result, String> { - let root = ctx.state.get_project_root()?; - let upcoming_dir = root.join(".story_kit").join("work").join("1_upcoming"); +/// Load the full pipeline state (all 4 active stages). +pub fn load_pipeline_state(ctx: &AppContext) -> Result { + 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, 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()); } let mut stories = Vec::new(); - for entry in fs::read_dir(&upcoming_dir) - .map_err(|e| format!("Failed to read upcoming stories directory: {e}"))? + for entry in fs::read_dir(&dir) + .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(); if path.extension().and_then(|ext| ext.to_str()) != Some("md") { continue; @@ -175,449 +72,8 @@ pub fn load_upcoming_stories(ctx: &AppContext) -> Result, Str Ok(stories) } -fn load_current_story_metadata(ctx: &AppContext) -> Result, String> { - let root = ctx.state.get_project_root()?; - 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(¤t_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, -} - -#[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) -> OpenApiResult> { - let unit = payload - .0 - .unit - .into_iter() - .map(to_test_case) - .collect::, String>>() - .map_err(bad_request)?; - let integration = payload - .0 - .integration - .into_iter() - .map(to_test_case) - .collect::, 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, - ) -> OpenApiResult> { - 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> { - 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::>() - }; - - 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> { - 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::>() - }; - - Ok(Json(ReviewListResponse { stories })) - } - - /// Record coverage data for a story. - #[oai(path = "/workflow/coverage/record", method = "post")] - async fn record_coverage( - &self, - payload: Json, - ) -> OpenApiResult> { - 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, - ) -> OpenApiResult> { - 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> { - 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(¤t_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> { - 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> { - 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, - ) -> OpenApiResult> { - 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, - ) -> OpenApiResult> { - 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)) - } +pub fn load_upcoming_stories(ctx: &AppContext) -> Result, String> { + load_stage_items(ctx, "1_upcoming") } /// 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) .map_err(|e| format!("Failed to write story file: {e}"))?; - if commit { - git_commit_story_file(root, &filepath, &story_id)?; - } + // Watcher handles the git commit asynchronously. + let _ = commit; // kept for API compat, ignored 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 ────────────────────────────────────────────── /// 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}"))?; - let msg = format!("story-kit: create bug {bug_id}"); - git_stage_and_commit(root, &[filepath.as_path()], &msg)?; + // Watcher handles the git commit asynchronously. Ok(bug_id) } @@ -898,8 +346,8 @@ pub fn check_criterion_in_file( fs::write(&filepath, &new_str) .map_err(|e| format!("Failed to write story file: {e}"))?; - let msg = format!("story-kit: check criterion {criterion_index} for story {story_id}"); - git_stage_and_commit(project_root, &[filepath.as_path()], &msg) + // Watcher handles the git commit asynchronously. + Ok(()) } /// 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) .map_err(|e| format!("Failed to write story file: {e}"))?; - let msg = format!("story-kit: set test_plan to {status} for story {story_id}"); - git_stage_and_commit(project_root, &[filepath.as_path()], &msg) + // Watcher handles the git commit asynchronously. + Ok(()) } fn slugify_name(name: &str) -> String { @@ -1084,128 +532,9 @@ pub fn validate_story_dirs( Ok(results) } -fn to_test_case(input: TestCasePayload) -> Result { - let status = parse_test_status(&input.status)?; - Ok(TestCaseResult { - name: input.name, - status, - details: input.details, - }) -} - -fn parse_test_status(value: &str) -> Result { - match value { - "pass" => Ok(TestStatus::Pass), - "fail" => Ok(TestStatus::Fail), - other => Err(format!( - "Invalid test status '{other}'. Use 'pass' or 'fail'." - )), - } -} - #[cfg(test)] mod tests { 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] fn load_upcoming_returns_empty_when_no_dir() { diff --git a/server/src/http/ws.rs b/server/src/http/ws.rs index eea34fd..2e30c4c 100644 --- a/server/src/http/ws.rs +++ b/server/src/http/ws.rs @@ -1,4 +1,6 @@ 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::types::Message; use futures::{SinkExt, StreamExt}; @@ -30,16 +32,56 @@ enum WsRequest { /// - `token` streams partial model output. /// - `update` pushes the updated message history. /// - `error` reports a request or processing failure. +/// - `work_item_changed` notifies that a `.story_kit/work/` file changed. enum WsResponse { Token { content: String }, Update { messages: Vec }, /// Session ID for Claude Code conversation resumption. SessionId { session_id: 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, + current: Vec, + qa: Vec, + merge: Vec, + }, +} + +impl From 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 for WsResponse { + fn from(s: PipelineState) -> Self { + WsResponse::PipelineState { + upcoming: s.upcoming, + current: s.current, + qa: s.qa, + merge: s.merge, + } + } } #[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. pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc>) -> impl poem::IntoResponse { @@ -58,6 +100,37 @@ pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc>) -> 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 { if let WsMessage::Text(text) = msg { let parsed: Result = serde_json::from_str(&text); diff --git a/server/src/io/mod.rs b/server/src/io/mod.rs index ba8c0b8..16688a5 100644 --- a/server/src/io/mod.rs +++ b/server/src/io/mod.rs @@ -2,3 +2,4 @@ pub mod fs; pub mod search; pub mod shell; pub mod story_metadata; +pub mod watcher; diff --git a/server/src/io/watcher.rs b/server/src/io/watcher.rs new file mode 100644 index 0000000..ced9b9e --- /dev/null +++ b/server/src/io/watcher.rs @@ -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 { + 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 { + 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, + git_root: &Path, + event_tx: &broadcast::Sender, +) { + // 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, +) { + std::thread::spawn(move || { + let (notify_tx, notify_rx) = mpsc::channel::>(); + + 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 = HashMap::new(); + let mut deadline: Option = 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()); + } +} diff --git a/server/src/main.rs b/server/src/main.rs index 116cb79..3a5f4d9 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -18,6 +18,7 @@ use poem::Server; use poem::listener::TcpListener; use std::path::{Path, PathBuf}; use std::sync::Arc; +use tokio::sync::broadcast; const DEFAULT_PORT: u16 = 3001; @@ -88,11 +89,21 @@ async fn main() -> Result<(), std::io::Error> { let port = resolve_port(); let agents = Arc::new(AgentPool::new(port)); + // Filesystem watcher: broadcast channel for work/ pipeline changes. + let (watcher_tx, _) = broadcast::channel::(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 { state: app_state, store, workflow, agents, + watcher_tx, }; let app = build_routes(ctx); diff --git a/server/src/workflow.rs b/server/src/workflow.rs index 7c60da7..71f7f34 100644 --- a/server/src/workflow.rs +++ b/server/src/workflow.rs @@ -1,14 +1,5 @@ -//! Workflow module: story gating and test result tracking. -//! -//! 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. +//! Workflow module: test result tracking and acceptance evaluation. -use crate::io::story_metadata::{StoryMetadata, TestPlanStatus}; use std::collections::HashMap; #[derive(Debug, Clone, PartialEq, Eq)] @@ -24,11 +15,9 @@ pub struct TestCaseResult { pub details: Option, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TestRunSummary { - pub total: usize, - pub passed: usize, - pub failed: usize, +struct TestRunSummary { + total: usize, + failed: usize, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -45,44 +34,12 @@ pub struct StoryTestResults { } #[derive(Debug, Clone, Default)] -#[allow(dead_code)] pub struct WorkflowState { - pub stories: HashMap, pub results: HashMap, pub coverage: HashMap, } -#[allow(dead_code)] 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, - integration: Vec, - ) { - let _ = self.record_test_results_validated(story_id, unit, integration); - } - pub fn record_test_results_validated( &mut self, story_id: String, @@ -107,65 +64,23 @@ impl WorkflowState { Ok(()) } - pub fn record_coverage( - &mut self, - story_id: String, - current_percent: f64, - threshold_percent: Option, - ) { - 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)] -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 { +fn summarize_results(results: &StoryTestResults) -> TestRunSummary { let mut total = 0; - let mut passed = 0; let mut failed = 0; for test in results.unit.iter().chain(results.integration.iter()) { total += 1; - match test.status { - TestStatus::Pass => passed += 1, - TestStatus::Fail => failed += 1, + if test.status == TestStatus::Fail { + failed += 1; } } - TestRunSummary { - total, - passed, - failed, - } + TestRunSummary { total, failed } } -pub fn evaluate_acceptance(results: &StoryTestResults) -> AcceptanceDecision { +fn evaluate_acceptance(results: &StoryTestResults) -> AcceptanceDecision { let summary = summarize_results(results); if summary.failed == 0 && summary.total > 0 { @@ -211,32 +126,6 @@ pub struct CoverageReport { pub baseline_percent: Option, } -/// Parse coverage percentage from a vitest coverage-summary.json string. -/// Expects JSON with `{"total": {"lines": {"pct": }}}`. -pub fn parse_coverage_json(json_str: &str) -> Result { - 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. pub fn evaluate_acceptance_with_coverage( results: &StoryTestResults, @@ -269,43 +158,7 @@ pub fn evaluate_acceptance_with_coverage( mod tests { use super::*; - // === parse_coverage_json === - - #[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 === + // === evaluate_acceptance_with_coverage === #[test] fn acceptance_blocked_by_coverage_below_threshold() { @@ -403,49 +256,7 @@ mod tests { assert!(decision.can_accept); } - // === record_coverage === - - #[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 === + // === evaluate_acceptance === #[test] 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] fn accepts_when_all_tests_pass() { let results = StoryTestResults { @@ -557,49 +342,32 @@ mod tests { assert!(decision.warning.is_none()); } - #[test] - 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); - } + // === record_test_results_validated === #[test] - fn can_start_implementation_requires_approved_plan() { - let approved = StoryMetadata { - name: Some("Test".to_string()), - test_plan: Some(TestPlanStatus::Approved), - }; - assert!(can_start_implementation(&approved).is_ok()); + 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 waiting = StoryMetadata { - name: Some("Test".to_string()), - test_plan: Some(TestPlanStatus::WaitingForApproval), - }; - assert!(can_start_implementation(&waiting).is_err()); + let result = state.record_test_results_validated("story-26".to_string(), unit, integration); - let unknown = StoryMetadata { - 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()); + assert!(result.is_err()); } #[test] @@ -626,22 +394,4 @@ mod tests { assert_eq!(state.results["story-29"].unit.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)); - } }