Spike 61: filesystem watcher and UI simplification
Add notify-based filesystem watcher for .story_kit/work/ that auto-commits changes with deterministic messages and broadcasts events over WebSocket. Push full pipeline state (Upcoming, Current, QA, To Merge) to frontend on connect and after every watcher event. Strip dead UI: remove ReviewPanel, GatePanel, TodoPanel, UpcomingPanel and all associated REST polling. Replace with 4 generic StagePanel components driven by WebSocket. Simplify AgentPanel to roster-only. Delete all 11 workflow HTTP endpoints and 16 request/response types from the server. Clean dead code from workflow module. MCP tools call Rust functions directly and need none of the HTTP layer. Net: ~4,100 lines deleted, ~400 added. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,199 +0,0 @@
|
||||
export type TestStatus = "pass" | "fail";
|
||||
|
||||
export interface TestCasePayload {
|
||||
name: string;
|
||||
status: TestStatus;
|
||||
details?: string | null;
|
||||
}
|
||||
|
||||
export interface RecordTestsPayload {
|
||||
story_id: string;
|
||||
unit: TestCasePayload[];
|
||||
integration: TestCasePayload[];
|
||||
}
|
||||
|
||||
export interface AcceptanceRequest {
|
||||
story_id: string;
|
||||
}
|
||||
|
||||
export interface TestRunSummaryResponse {
|
||||
total: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export interface CoverageReportResponse {
|
||||
current_percent: number;
|
||||
threshold_percent: number;
|
||||
baseline_percent?: number | null;
|
||||
}
|
||||
|
||||
export interface AcceptanceResponse {
|
||||
can_accept: boolean;
|
||||
reasons: string[];
|
||||
warning?: string | null;
|
||||
summary: TestRunSummaryResponse;
|
||||
missing_categories: string[];
|
||||
coverage_report?: CoverageReportResponse | null;
|
||||
}
|
||||
|
||||
export interface ReviewStory {
|
||||
story_id: string;
|
||||
can_accept: boolean;
|
||||
reasons: string[];
|
||||
warning?: string | null;
|
||||
summary: TestRunSummaryResponse;
|
||||
missing_categories: string[];
|
||||
coverage_report?: CoverageReportResponse | null;
|
||||
}
|
||||
|
||||
export interface RecordCoveragePayload {
|
||||
story_id: string;
|
||||
current_percent: number;
|
||||
threshold_percent?: number | null;
|
||||
}
|
||||
|
||||
export interface CollectCoverageRequest {
|
||||
story_id: string;
|
||||
threshold_percent?: number | null;
|
||||
}
|
||||
|
||||
export interface ReviewListResponse {
|
||||
stories: ReviewStory[];
|
||||
}
|
||||
|
||||
export interface StoryTodosResponse {
|
||||
story_id: string;
|
||||
story_name: string | null;
|
||||
todos: string[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface TodoListResponse {
|
||||
stories: StoryTodosResponse[];
|
||||
}
|
||||
|
||||
export interface UpcomingStory {
|
||||
story_id: string;
|
||||
name: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface UpcomingStoriesResponse {
|
||||
stories: UpcomingStory[];
|
||||
}
|
||||
|
||||
export interface StoryValidationResult {
|
||||
story_id: string;
|
||||
valid: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface ValidateStoriesResponse {
|
||||
stories: StoryValidationResult[];
|
||||
}
|
||||
|
||||
export interface CreateStoryPayload {
|
||||
name: string;
|
||||
user_story?: string | null;
|
||||
acceptance_criteria?: string[] | null;
|
||||
}
|
||||
|
||||
export interface CreateStoryResponse {
|
||||
story_id: string;
|
||||
}
|
||||
|
||||
const DEFAULT_API_BASE = "/api";
|
||||
|
||||
function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string {
|
||||
return `${baseUrl}${path}`;
|
||||
}
|
||||
|
||||
async function requestJson<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
baseUrl = DEFAULT_API_BASE,
|
||||
): Promise<T> {
|
||||
const res = await fetch(buildApiUrl(path, baseUrl), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `Request failed (${res.status})`);
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const workflowApi = {
|
||||
collectCoverage(payload: CollectCoverageRequest, baseUrl?: string) {
|
||||
return requestJson<CoverageReportResponse>(
|
||||
"/workflow/coverage/collect",
|
||||
{ method: "POST", body: JSON.stringify(payload) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
recordCoverage(payload: RecordCoveragePayload, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/workflow/coverage/record",
|
||||
{ method: "POST", body: JSON.stringify(payload) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
recordTests(payload: RecordTestsPayload, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/workflow/tests/record",
|
||||
{ method: "POST", body: JSON.stringify(payload) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
getAcceptance(payload: AcceptanceRequest, baseUrl?: string) {
|
||||
return requestJson<AcceptanceResponse>(
|
||||
"/workflow/acceptance",
|
||||
{ method: "POST", body: JSON.stringify(payload) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
getReviewQueue(baseUrl?: string) {
|
||||
return requestJson<ReviewListResponse>("/workflow/review", {}, baseUrl);
|
||||
},
|
||||
getReviewQueueAll(baseUrl?: string) {
|
||||
return requestJson<ReviewListResponse>("/workflow/review/all", {}, baseUrl);
|
||||
},
|
||||
getUpcomingStories(baseUrl?: string) {
|
||||
return requestJson<UpcomingStoriesResponse>(
|
||||
"/workflow/upcoming",
|
||||
{},
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
ensureAcceptance(payload: AcceptanceRequest, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/workflow/acceptance/ensure",
|
||||
{ method: "POST", body: JSON.stringify(payload) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
getStoryTodos(baseUrl?: string) {
|
||||
return requestJson<TodoListResponse>("/workflow/todos", {}, baseUrl);
|
||||
},
|
||||
validateStories(baseUrl?: string) {
|
||||
return requestJson<ValidateStoriesResponse>(
|
||||
"/workflow/stories/validate",
|
||||
{},
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
createStory(payload: CreateStoryPayload, baseUrl?: string) {
|
||||
return requestJson<CreateStoryResponse>(
|
||||
"/workflow/stories/create",
|
||||
{ method: "POST", body: JSON.stringify(payload) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user