Story 26: Establish TDD workflow and quality gates

Add workflow engine with acceptance gates, test recording, and review
queue. Frontend displays gate status (blocked/ready), test summaries,
failing badges, and warnings. Proceed action is disabled when gates
are not met. Includes 13 unit tests (Vitest) and 9 E2E tests
(Playwright) covering all five acceptance criteria.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dave
2026-02-19 12:54:04 +00:00
parent 3a98669c4c
commit 013b28d77f
31 changed files with 3627 additions and 417 deletions

1
.gitignore vendored
View File

@@ -30,3 +30,4 @@ server/target
*.njsproj
*.sln
*.sw?
/test-results/.last-run.json

View File

@@ -9,6 +9,7 @@ This project is a standalone Rust **web server binary** that serves a Vite/React
* **Framework:** Poem HTTP server with WebSocket support for streaming; HTTP APIs should use Poem OpenAPI (Swagger) for non-streaming endpoints.
* **Frontend:** TypeScript + React
* **Build Tool:** Vite
* **Package Manager:** pnpm (required)
* **Styling:** CSS Modules or Tailwind (TBD - Defaulting to CSS Modules)
* **State Management:** React Context / Hooks
* **Chat UI:** Rendered Markdown with syntax highlighting.

View File

@@ -0,0 +1,42 @@
---
name: Establish the TDD Workflow and Gates
test_plan: approved
---
# Story 26: Establish the TDD Workflow and Gates
## User Story
As a user, I want a clear, enforceable TDD workflow with quality gates, so development is test-first and regressions are blocked.
## Acceptance Criteria
- [ ] A test-first workflow is defined and enforced before implementation begins.
- [ ] Each story requires both unit tests and integration tests (standard Rust `tests/` layout).
- [ ] A test plan is produced and approved before any code changes.
- [ ] Stories cannot be accepted unless all required tests pass.
- [ ] The system warns when multiple tests fail and blocks acceptance until all required tests pass.
## Test Plan (Approved)
### Backend (Rust) — Unit + Integration
- AC1/AC3: Block write/exec when no approved test plan exists.
- AC2: Enforce presence of both unit + integration test categories before a story can proceed.
- AC4: Block story acceptance unless all required test results are passing.
- AC5: Allow only one failing test at a time (reject registering a second failure).
**Integration coverage:**
- Attempt to write before test plan approval → expect rejection.
- Add/approve test plan → write succeeds.
- Attempt acceptance with failing/missing tests → expect rejection.
- Acceptance with all passing tests → expect success.
- Register second failing test while one is red → expect rejection.
### Frontend (React) — Vitest + Playwright
- AC1/AC3: Gate status shown in story view; tools blocked until test plan approved.
- AC4: Acceptance action disabled when required tests are failing or missing.
- AC5: UI surfaces “red test count” and blocks when more than one failing test is present.
- E2E: Attempt blocked actions show a visible banner/toast and do not execute.
## Out of Scope
- Backfilling tests for legacy code (covered by a separate story).
- Adding new test frameworks beyond those defined in `specs/tech/STACK.md`.

View File

@@ -1,15 +0,0 @@
# Story 26: Establish the TDD Workflow and Gates
## User Story
As a user, I want a clear, enforceable TDD workflow with quality gates, so development is test-first and regressions are blocked.
## Acceptance Criteria
- [ ] A test-first workflow is defined and enforced before implementation begins.
- [ ] Each story requires both unit tests and integration tests (standard Rust `tests/` layout).
- [ ] A test plan is produced and approved before any code changes.
- [ ] Stories cannot be accepted unless all required tests pass.
- [ ] Only one failing test is allowed at a time during red-green-refactor.
## Out of Scope
- Backfilling tests for legacy code (covered by a separate story).
- Adding new test frameworks beyond those defined in `specs/tech/STACK.md`.

1
CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
Read .story_kit/README.md to see our dev process.

1
Cargo.lock generated
View File

@@ -1894,6 +1894,7 @@ dependencies = [
"rust-embed",
"serde",
"serde_json",
"serde_yaml",
"tempfile",
"tokio",
"uuid",

View File

@@ -8,7 +8,9 @@
"build": "tsc && vite build",
"preview": "vite preview",
"server": "cargo run --manifest-path server/Cargo.toml",
"test": "jest"
"test": "vitest run",
"test:unit": "vitest run",
"test:e2e": "playwright test"
},
"dependencies": {
"@types/react-syntax-highlighter": "^15.5.13",
@@ -18,15 +20,20 @@
"react-syntax-highlighter": "^16.1.0"
},
"devDependencies": {
"@biomejs/biome": "^2.4.2",
"@playwright/test": "^1.47.2",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.4.3",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/node": "^25.0.0",
"@vitejs/plugin-react": "^4.6.0",
"jest": "^29.0.0",
"jsdom": "^28.1.0",
"ts-jest": "^29.0.0",
"typescript": "~5.8.3",
"vite": "^7.0.4"
"vite": "^5.4.21",
"vitest": "^2.1.4"
}
}

View File

@@ -0,0 +1,27 @@
import { defineConfig } from "@playwright/test";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const configDir = dirname(fileURLToPath(new URL(import.meta.url)));
const frontendRoot = resolve(configDir, ".");
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: true,
timeout: 30_000,
expect: {
timeout: 5_000,
},
use: {
baseURL: "http://127.0.0.1:41700",
trace: "on-first-retry",
},
webServer: {
command:
"pnpm exec vite --config vite.config.ts --host 127.0.0.1 --port 41700 --strictPort",
url: "http://127.0.0.1:41700/@vite/client",
reuseExistingServer: false,
timeout: 120_000,
cwd: frontendRoot,
},
});

1094
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,101 @@
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 AcceptanceResponse {
can_accept: boolean;
reasons: string[];
warning?: string | null;
summary: TestRunSummaryResponse;
missing_categories: string[];
}
export interface ReviewStory {
story_id: string;
can_accept: boolean;
reasons: string[];
warning?: string | null;
summary: TestRunSummaryResponse;
missing_categories: string[];
}
export interface ReviewListResponse {
stories: ReviewStory[];
}
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 = {
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);
},
ensureAcceptance(payload: AcceptanceRequest, baseUrl?: string) {
return requestJson<boolean>(
"/workflow/acceptance/ensure",
{ method: "POST", body: JSON.stringify(payload) },
baseUrl,
);
},
};

View File

@@ -0,0 +1,393 @@
import { 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 { Chat } from "./Chat";
vi.mock("../api/client", () => {
const api = {
getOllamaModels: vi.fn(),
getAnthropicApiKeyExists: vi.fn(),
getAnthropicModels: vi.fn(),
getModelPreference: vi.fn(),
setModelPreference: vi.fn(),
cancelChat: vi.fn(),
setAnthropicApiKey: vi.fn(),
};
class ChatWebSocket {
connect() {}
close() {}
sendChat() {}
cancel() {}
}
return { api, ChatWebSocket };
});
vi.mock("../api/workflow", () => {
return {
workflowApi: {
getAcceptance: vi.fn(),
getReviewQueue: vi.fn(),
getReviewQueueAll: vi.fn(),
ensureAcceptance: vi.fn(),
},
};
});
const mockedApi = {
getOllamaModels: vi.mocked(api.getOllamaModels),
getAnthropicApiKeyExists: vi.mocked(api.getAnthropicApiKeyExists),
getAnthropicModels: vi.mocked(api.getAnthropicModels),
getModelPreference: vi.mocked(api.getModelPreference),
setModelPreference: vi.mocked(api.setModelPreference),
cancelChat: vi.mocked(api.cancelChat),
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),
};
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);
});
it("shows an empty review queue state", async () => {
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
expect(
await screen.findByText("Stories Awaiting Review"),
).toBeInTheDocument();
expect(await screen.findByText("0 ready / 0 total")).toBeInTheDocument();
expect(
await screen.findByText("No stories waiting for review."),
).toBeInTheDocument();
const updatedLabels = await screen.findAllByText(/Updated/i);
expect(updatedLabels.length).toBeGreaterThanOrEqual(2);
});
it("renders review stories and proceeds", async () => {
const story: ReviewStory = {
story_id: "26_establish_tdd_workflow_and_gates",
can_accept: true,
reasons: [],
warning: null,
summary: { total: 3, passed: 3, failed: 0 },
missing_categories: [],
};
mockedWorkflow.getReviewQueueAll
.mockResolvedValueOnce({ stories: [story] })
.mockResolvedValueOnce({ stories: [] });
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
expect(await screen.findByText(story.story_id)).toBeInTheDocument();
const proceedButton = screen.getByRole("button", { name: "Proceed" });
await userEvent.click(proceedButton);
await waitFor(() => {
expect(mockedWorkflow.ensureAcceptance).toHaveBeenCalledWith({
story_id: story.story_id,
});
});
expect(
await screen.findByText("No stories waiting for review."),
).toBeInTheDocument();
});
it("shows a review error when the queue fails to load", async () => {
mockedWorkflow.getReviewQueueAll.mockRejectedValueOnce(
new Error("Review queue failed"),
);
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
expect(await screen.findByText(/Review queue failed/i)).toBeInTheDocument();
expect(
await screen.findByText(/Use Refresh to try again\./i),
).toBeInTheDocument();
expect(
await screen.findByRole("button", { name: "Retry" }),
).toBeInTheDocument();
});
it("refreshes the review queue when clicking refresh", async () => {
mockedWorkflow.getReviewQueueAll
.mockResolvedValueOnce({ stories: [] })
.mockResolvedValueOnce({ stories: [] });
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
const refreshButtons = await screen.findAllByRole("button", {
name: "Refresh",
});
const refreshButton = refreshButtons[0];
await userEvent.click(refreshButton);
await waitFor(() => {
expect(mockedWorkflow.getReviewQueueAll).toHaveBeenCalled();
});
});
it("disables proceed when a story is blocked", async () => {
const story: ReviewStory = {
story_id: "26_establish_tdd_workflow_and_gates",
can_accept: false,
reasons: ["Missing unit tests"],
warning: null,
summary: { total: 1, passed: 0, failed: 1 },
missing_categories: ["unit"],
};
mockedWorkflow.getReviewQueueAll.mockResolvedValueOnce({
stories: [story],
});
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
expect(await screen.findByText(story.story_id)).toBeInTheDocument();
const blockedButton = screen.getByRole("button", { name: "Blocked" });
expect(blockedButton).toBeDisabled();
expect(await screen.findByText("Missing: unit")).toBeInTheDocument();
expect(await screen.findByText("Missing unit tests")).toBeInTheDocument();
});
it("shows gate panel blocked status with reasons (AC1/AC3)", async () => {
mockedWorkflow.getAcceptance.mockResolvedValueOnce({
can_accept: false,
reasons: ["No approved test plan for the story."],
warning: null,
summary: { total: 0, passed: 0, failed: 0 },
missing_categories: ["unit", "integration"],
});
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
expect(await screen.findByText("Blocked")).toBeInTheDocument();
expect(
await screen.findByText("No approved test plan for the story."),
).toBeInTheDocument();
expect(
await screen.findByText("Missing: unit, integration"),
).toBeInTheDocument();
expect(
await screen.findByText(/0\/0 passing, 0 failing/),
).toBeInTheDocument();
});
it("shows gate panel ready status when all tests pass (AC1/AC3)", async () => {
mockedWorkflow.getAcceptance.mockResolvedValueOnce({
can_accept: true,
reasons: [],
warning: null,
summary: { total: 5, passed: 5, failed: 0 },
missing_categories: [],
});
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
expect(await screen.findByText("Ready to accept")).toBeInTheDocument();
expect(
await screen.findByText(/5\/5 passing, 0 failing/),
).toBeInTheDocument();
});
it("shows failing badge and count in review panel (AC4/AC5)", async () => {
const story: ReviewStory = {
story_id: "26_establish_tdd_workflow_and_gates",
can_accept: false,
reasons: ["3 tests are failing."],
warning: "Multiple tests failing — fix one at a time.",
summary: { total: 5, passed: 2, failed: 3 },
missing_categories: [],
};
mockedWorkflow.getReviewQueueAll.mockResolvedValueOnce({
stories: [story],
});
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
expect(await screen.findByText("Failing 3")).toBeInTheDocument();
expect(await screen.findByText("Warning")).toBeInTheDocument();
expect(
await screen.findByText("Multiple tests failing — fix one at a time."),
).toBeInTheDocument();
expect(await screen.findByText("3 tests are failing.")).toBeInTheDocument();
expect(
await screen.findByText(/2\/5 passing, 3 failing/),
).toBeInTheDocument();
const blockedButton = screen.getByRole("button", { name: "Blocked" });
expect(blockedButton).toBeDisabled();
});
it("shows gate warning when multiple tests fail (AC5)", async () => {
mockedWorkflow.getAcceptance.mockResolvedValueOnce({
can_accept: false,
reasons: ["2 tests are failing."],
warning: "Multiple tests failing — fix one at a time.",
summary: { total: 4, passed: 2, failed: 2 },
missing_categories: [],
});
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
expect(await screen.findByText("Blocked")).toBeInTheDocument();
expect(
await screen.findByText("Multiple tests failing — fix one at a time."),
).toBeInTheDocument();
expect(
await screen.findByText(/2\/4 passing, 2 failing/),
).toBeInTheDocument();
expect(await screen.findByText("2 tests are failing.")).toBeInTheDocument();
});
it("does not call ensureAcceptance when clicking a blocked proceed button (AC4)", async () => {
const story: ReviewStory = {
story_id: "26_establish_tdd_workflow_and_gates",
can_accept: false,
reasons: ["Tests are failing."],
warning: null,
summary: { total: 3, passed: 1, failed: 2 },
missing_categories: [],
};
mockedWorkflow.getReviewQueueAll.mockResolvedValueOnce({
stories: [story],
});
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
const blockedButton = await screen.findByRole("button", {
name: "Blocked",
});
expect(blockedButton).toBeDisabled();
// Clear any prior calls then attempt click on disabled button
mockedWorkflow.ensureAcceptance.mockClear();
await userEvent.click(blockedButton);
expect(mockedWorkflow.ensureAcceptance).not.toHaveBeenCalled();
});
it("shows proceed error when ensureAcceptance fails", async () => {
const story: ReviewStory = {
story_id: "26_establish_tdd_workflow_and_gates",
can_accept: true,
reasons: [],
warning: null,
summary: { total: 3, passed: 3, failed: 0 },
missing_categories: [],
};
mockedWorkflow.getReviewQueueAll.mockResolvedValueOnce({
stories: [story],
});
mockedWorkflow.ensureAcceptance.mockRejectedValueOnce(
new Error("Acceptance blocked: tests still failing"),
);
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
const proceedButton = await screen.findByRole("button", {
name: "Proceed",
});
await userEvent.click(proceedButton);
expect(
await screen.findByText("Acceptance blocked: tests still failing"),
).toBeInTheDocument();
});
it("shows gate error when acceptance endpoint fails", async () => {
mockedWorkflow.getAcceptance.mockRejectedValueOnce(
new Error("Server unreachable"),
);
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
expect(await screen.findByText("Server unreachable")).toBeInTheDocument();
const retryButtons = await screen.findAllByRole("button", {
name: "Retry",
});
expect(retryButtons.length).toBeGreaterThanOrEqual(1);
});
it("refreshes gate status after proceeding on the current story", async () => {
const story: ReviewStory = {
story_id: "26_establish_tdd_workflow_and_gates",
can_accept: true,
reasons: [],
warning: null,
summary: { total: 2, passed: 2, failed: 0 },
missing_categories: [],
};
mockedWorkflow.getAcceptance
.mockResolvedValueOnce({
can_accept: false,
reasons: ["No test results recorded for the story."],
warning: null,
summary: { total: 0, passed: 0, failed: 0 },
missing_categories: ["unit", "integration"],
})
.mockResolvedValueOnce({
can_accept: true,
reasons: [],
warning: null,
summary: { total: 2, passed: 2, failed: 0 },
missing_categories: [],
});
mockedWorkflow.getReviewQueueAll
.mockResolvedValueOnce({ stories: [story] })
.mockResolvedValueOnce({ stories: [] });
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
const proceedButton = await screen.findByRole("button", {
name: "Proceed",
});
await userEvent.click(proceedButton);
await waitFor(() => {
expect(mockedWorkflow.ensureAcceptance).toHaveBeenCalledWith({
story_id: story.story_id,
});
});
expect(await screen.findByText("Ready to accept")).toBeInTheDocument();
});
});

View File

@@ -3,7 +3,12 @@ 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 } from "../api/workflow";
import { workflowApi } from "../api/workflow";
import type { Message, ProviderConfig, ToolCall } from "../types";
import { ChatHeader } from "./ChatHeader";
import { GatePanel } from "./GatePanel";
import { ReviewPanel } from "./ReviewPanel";
const { useCallback, useEffect, useRef, useState } = React;
@@ -12,6 +17,18 @@ interface ChatProps {
onCloseProject: () => void;
}
interface GateState {
canAccept: boolean;
reasons: string[];
warning: string | null;
summary: {
total: number;
passed: number;
failed: number;
};
missingCategories: string[];
}
export function Chat({ projectPath, onCloseProject }: ChatProps) {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
@@ -24,6 +41,31 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
const [showApiKeyDialog, setShowApiKeyDialog] = useState(false);
const [apiKeyInput, setApiKeyInput] = useState("");
const [hasAnthropicKey, setHasAnthropicKey] = useState(false);
const [gateState, setGateState] = useState<GateState | null>(null);
const [gateError, setGateError] = useState<string | null>(null);
const [isGateLoading, setIsGateLoading] = useState(false);
const [reviewQueue, setReviewQueue] = useState<ReviewStory[]>([]);
const [reviewError, setReviewError] = useState<string | null>(null);
const [isReviewLoading, setIsReviewLoading] = useState(false);
const [proceedingStoryId, setProceedingStoryId] = useState<string | null>(
null,
);
const [proceedError, setProceedError] = useState<string | null>(null);
const [proceedSuccess, setProceedSuccess] = useState<string | null>(null);
const [lastReviewRefresh, setLastReviewRefresh] = useState<Date | null>(null);
const [lastGateRefresh, setLastGateRefresh] = useState<Date | null>(null);
const storyId = "26_establish_tdd_workflow_and_gates";
const gateStatusColor = isGateLoading
? "#aaa"
: gateState?.canAccept
? "#7ee787"
: "#ff7b72";
const gateStatusLabel = isGateLoading
? "Checking..."
: gateState?.canAccept
? "Ready to accept"
: "Blocked";
const wsRef = useRef<ChatWebSocket | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
@@ -76,12 +118,6 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
const contextUsage = calculateContextUsage();
const getContextEmoji = (percentage: number): string => {
if (percentage >= 90) return "🔴";
if (percentage >= 75) return "🟡";
return "🟢";
};
useEffect(() => {
api
.getOllamaModels()
@@ -134,6 +170,146 @@ 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,
});
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;
};
}, []);
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,
});
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 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;
@@ -321,209 +497,61 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
color: "#ececec",
}}
>
<ChatHeader
projectPath={projectPath}
onCloseProject={onCloseProject}
contextUsage={contextUsage}
onClearSession={clearSession}
model={model}
availableModels={availableModels}
claudeModels={claudeModels}
hasAnthropicKey={hasAnthropicKey}
onModelChange={(newModel) => {
setModel(newModel);
api.setModelPreference(newModel).catch(console.error);
}}
enableTools={enableTools}
onToggleTools={setEnableTools}
/>
<div
style={{
padding: "12px 24px",
borderBottom: "1px solid #333",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
background: "#171717",
flexShrink: 0,
fontSize: "0.9rem",
color: "#ececec",
maxWidth: "768px",
margin: "0 auto",
width: "100%",
padding: "12px 24px 0",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
flexDirection: "column",
gap: "12px",
overflow: "hidden",
flex: 1,
marginRight: "20px",
}}
>
<div
title={projectPath}
style={{
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
fontWeight: "500",
color: "#aaa",
direction: "rtl",
textAlign: "left",
fontFamily: "monospace",
fontSize: "0.85em",
}}
>
{projectPath}
</div>
<button
type="button"
onClick={onCloseProject}
style={{
background: "transparent",
border: "none",
cursor: "pointer",
color: "#999",
fontSize: "0.8em",
padding: "4px 8px",
borderRadius: "4px",
}}
onMouseOver={(e) => {
e.currentTarget.style.background = "#333";
}}
onMouseOut={(e) => {
e.currentTarget.style.background = "transparent";
}}
onFocus={(e) => {
e.currentTarget.style.background = "#333";
}}
onBlur={(e) => {
e.currentTarget.style.background = "transparent";
}}
>
</button>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "16px" }}>
<div
style={{
fontSize: "0.9em",
color: "#ccc",
whiteSpace: "nowrap",
}}
title={`Context: ${contextUsage.used.toLocaleString()} / ${contextUsage.total.toLocaleString()} tokens (${contextUsage.percentage}%)`}
>
{getContextEmoji(contextUsage.percentage)} {contextUsage.percentage}
%
</div>
<button
type="button"
onClick={clearSession}
style={{
padding: "6px 12px",
borderRadius: "99px",
border: "none",
fontSize: "0.85em",
backgroundColor: "#2f2f2f",
color: "#888",
cursor: "pointer",
outline: "none",
transition: "all 0.2s",
}}
onMouseOver={(e) => {
e.currentTarget.style.backgroundColor = "#3f3f3f";
e.currentTarget.style.color = "#ccc";
}}
onMouseOut={(e) => {
e.currentTarget.style.backgroundColor = "#2f2f2f";
e.currentTarget.style.color = "#888";
}}
onFocus={(e) => {
e.currentTarget.style.backgroundColor = "#3f3f3f";
e.currentTarget.style.color = "#ccc";
}}
onBlur={(e) => {
e.currentTarget.style.backgroundColor = "#2f2f2f";
e.currentTarget.style.color = "#888";
}}
>
🔄 New Session
</button>
{availableModels.length > 0 || claudeModels.length > 0 ? (
<select
value={model}
onChange={(e) => {
const newModel = e.target.value;
setModel(newModel);
api.setModelPreference(newModel).catch(console.error);
}}
style={{
padding: "6px 32px 6px 16px",
borderRadius: "99px",
border: "none",
fontSize: "0.9em",
backgroundColor: "#2f2f2f",
color: "#ececec",
cursor: "pointer",
outline: "none",
appearance: "none",
WebkitAppearance: "none",
backgroundImage: `url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23ececec%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E")`,
backgroundRepeat: "no-repeat",
backgroundPosition: "right 12px center",
backgroundSize: "10px",
}}
>
{(claudeModels.length > 0 || !hasAnthropicKey) && (
<optgroup label="Anthropic">
{claudeModels.length > 0 ? (
claudeModels.map((m: string) => (
<option key={m} value={m}>
{m}
</option>
))
) : (
<option value="" disabled>
Add Anthropic API key to load models
</option>
)}
</optgroup>
)}
{availableModels.length > 0 && (
<optgroup label="Ollama">
{availableModels.map((m: string) => (
<option key={m} value={m}>
{m}
</option>
))}
</optgroup>
)}
</select>
) : (
<input
value={model}
onChange={(e) => {
const newModel = e.target.value;
setModel(newModel);
api.setModelPreference(newModel).catch(console.error);
}}
placeholder="Model"
style={{
padding: "6px 12px",
borderRadius: "99px",
border: "none",
fontSize: "0.9em",
background: "#2f2f2f",
color: "#ececec",
outline: "none",
}}
<ReviewPanel
reviewQueue={reviewQueue}
isReviewLoading={isReviewLoading}
reviewError={reviewError}
proceedingStoryId={proceedingStoryId}
storyId={storyId}
isGateLoading={isGateLoading}
proceedError={proceedError}
proceedSuccess={proceedSuccess}
lastReviewRefresh={lastReviewRefresh}
onRefresh={refreshReviewQueue}
onProceed={handleProceed}
/>
)}
<label
style={{
display: "flex",
alignItems: "center",
gap: "6px",
cursor: "pointer",
fontSize: "0.9em",
color: "#aaa",
}}
title="Allow the Agent to read/write files"
>
<input
type="checkbox"
checked={enableTools}
onChange={(e) => setEnableTools(e.target.checked)}
style={{ accentColor: "#000" }}
<GatePanel
gateState={gateState}
gateStatusLabel={gateStatusLabel}
gateStatusColor={gateStatusColor}
isGateLoading={isGateLoading}
gateError={gateError}
lastGateRefresh={lastGateRefresh}
onRefresh={() => refreshGateState(storyId)}
/>
<span>Tools</span>
</label>
</div>
</div>

View File

@@ -0,0 +1,242 @@
interface ContextUsage {
used: number;
total: number;
percentage: number;
}
interface ChatHeaderProps {
projectPath: string;
onCloseProject: () => void;
contextUsage: ContextUsage;
onClearSession: () => void;
model: string;
availableModels: string[];
claudeModels: string[];
hasAnthropicKey: boolean;
onModelChange: (model: string) => void;
enableTools: boolean;
onToggleTools: (enabled: boolean) => void;
}
const getContextEmoji = (percentage: number): string => {
if (percentage >= 90) return "🔴";
if (percentage >= 75) return "🟡";
return "🟢";
};
export function ChatHeader({
projectPath,
onCloseProject,
contextUsage,
onClearSession,
model,
availableModels,
claudeModels,
hasAnthropicKey,
onModelChange,
enableTools,
onToggleTools,
}: ChatHeaderProps) {
const hasModelOptions = availableModels.length > 0 || claudeModels.length > 0;
return (
<div
style={{
padding: "12px 24px",
borderBottom: "1px solid #333",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
background: "#171717",
flexShrink: 0,
fontSize: "0.9rem",
color: "#ececec",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "12px",
overflow: "hidden",
flex: 1,
marginRight: "20px",
}}
>
<div
title={projectPath}
style={{
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
fontWeight: "500",
color: "#aaa",
direction: "rtl",
textAlign: "left",
fontFamily: "monospace",
fontSize: "0.85em",
}}
>
{projectPath}
</div>
<button
type="button"
onClick={onCloseProject}
style={{
background: "transparent",
border: "none",
cursor: "pointer",
color: "#999",
fontSize: "0.8em",
padding: "4px 8px",
borderRadius: "4px",
}}
onMouseOver={(e) => {
e.currentTarget.style.background = "#333";
}}
onMouseOut={(e) => {
e.currentTarget.style.background = "transparent";
}}
onFocus={(e) => {
e.currentTarget.style.background = "#333";
}}
onBlur={(e) => {
e.currentTarget.style.background = "transparent";
}}
>
</button>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "16px" }}>
<div
style={{
fontSize: "0.9em",
color: "#ccc",
whiteSpace: "nowrap",
}}
title={`Context: ${contextUsage.used.toLocaleString()} / ${contextUsage.total.toLocaleString()} tokens (${contextUsage.percentage}%)`}
>
{getContextEmoji(contextUsage.percentage)} {contextUsage.percentage}%
</div>
<button
type="button"
onClick={onClearSession}
style={{
padding: "6px 12px",
borderRadius: "99px",
border: "none",
fontSize: "0.85em",
backgroundColor: "#2f2f2f",
color: "#888",
cursor: "pointer",
outline: "none",
transition: "all 0.2s",
}}
onMouseOver={(e) => {
e.currentTarget.style.backgroundColor = "#3f3f3f";
e.currentTarget.style.color = "#ccc";
}}
onMouseOut={(e) => {
e.currentTarget.style.backgroundColor = "#2f2f2f";
e.currentTarget.style.color = "#888";
}}
onFocus={(e) => {
e.currentTarget.style.backgroundColor = "#3f3f3f";
e.currentTarget.style.color = "#ccc";
}}
onBlur={(e) => {
e.currentTarget.style.backgroundColor = "#2f2f2f";
e.currentTarget.style.color = "#888";
}}
>
🔄 New Session
</button>
{hasModelOptions ? (
<select
value={model}
onChange={(e) => onModelChange(e.target.value)}
style={{
padding: "6px 32px 6px 16px",
borderRadius: "99px",
border: "none",
fontSize: "0.9em",
backgroundColor: "#2f2f2f",
color: "#ececec",
cursor: "pointer",
outline: "none",
appearance: "none",
WebkitAppearance: "none",
backgroundImage: `url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23ececec%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E")`,
backgroundRepeat: "no-repeat",
backgroundPosition: "right 12px center",
backgroundSize: "10px",
}}
>
{(claudeModels.length > 0 || !hasAnthropicKey) && (
<optgroup label="Anthropic">
{claudeModels.length > 0 ? (
claudeModels.map((m: string) => (
<option key={m} value={m}>
{m}
</option>
))
) : (
<option value="" disabled>
Add Anthropic API key to load models
</option>
)}
</optgroup>
)}
{availableModels.length > 0 && (
<optgroup label="Ollama">
{availableModels.map((m: string) => (
<option key={m} value={m}>
{m}
</option>
))}
</optgroup>
)}
</select>
) : (
<input
value={model}
onChange={(e) => onModelChange(e.target.value)}
placeholder="Model"
style={{
padding: "6px 12px",
borderRadius: "99px",
border: "none",
fontSize: "0.9em",
background: "#2f2f2f",
color: "#ececec",
outline: "none",
}}
/>
)}
<label
style={{
display: "flex",
alignItems: "center",
gap: "6px",
cursor: "pointer",
fontSize: "0.9em",
color: "#aaa",
}}
title="Allow the Agent to read/write files"
>
<input
type="checkbox"
checked={enableTools}
onChange={(e) => onToggleTools(e.target.checked)}
style={{ accentColor: "#000" }}
/>
<span>Tools</span>
</label>
</div>
</div>
);
}

View File

@@ -0,0 +1,182 @@
interface GateState {
canAccept: boolean;
reasons: string[];
warning: string | null;
summary: {
total: number;
passed: number;
failed: number;
};
missingCategories: string[];
}
interface GatePanelProps {
gateState: GateState | null;
gateStatusLabel: string;
gateStatusColor: string;
isGateLoading: boolean;
gateError: string | null;
lastGateRefresh: 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 GatePanel({
gateState,
gateStatusLabel,
gateStatusColor,
isGateLoading,
gateError,
lastGateRefresh,
onRefresh,
}: GatePanelProps) {
return (
<div
style={{
border: "1px solid #333",
borderRadius: "10px",
padding: "12px 16px",
background: "#1f1f1f",
display: "flex",
flexDirection: "column",
gap: "8px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "12px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<div style={{ fontWeight: 600 }}>Workflow Gates</div>
<button
type="button"
onClick={onRefresh}
disabled={isGateLoading}
style={{
padding: "4px 10px",
borderRadius: "999px",
border: "1px solid #333",
background: isGateLoading ? "#2a2a2a" : "#2f2f2f",
color: isGateLoading ? "#777" : "#aaa",
cursor: isGateLoading ? "not-allowed" : "pointer",
fontSize: "0.75em",
fontWeight: 600,
}}
>
Refresh
</button>
</div>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
gap: "2px",
fontSize: "0.85em",
color: gateStatusColor,
}}
>
<div>{gateStatusLabel}</div>
<div style={{ fontSize: "0.8em", color: "#777" }}>
Updated {formatTimestamp(lastGateRefresh)}
</div>
</div>
</div>
{isGateLoading ? (
<div style={{ fontSize: "0.85em", color: "#aaa" }}>
Loading workflow gates...
</div>
) : gateError ? (
<div
style={{
fontSize: "0.85em",
color: "#ff7b72",
display: "flex",
alignItems: "center",
gap: "8px",
flexWrap: "wrap",
}}
>
<span>{gateError}</span>
<button
type="button"
onClick={onRefresh}
disabled={isGateLoading}
style={{
padding: "4px 10px",
borderRadius: "999px",
border: "1px solid #333",
background: isGateLoading ? "#2a2a2a" : "#2f2f2f",
color: isGateLoading ? "#777" : "#aaa",
cursor: isGateLoading ? "not-allowed" : "pointer",
fontSize: "0.75em",
fontWeight: 600,
}}
>
Retry
</button>
</div>
) : gateState ? (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "6px",
}}
>
<div style={{ fontSize: "0.85em", color: "#aaa" }}>
Summary: {gateState.summary.passed}/{gateState.summary.total}{" "}
passing, {gateState.summary.failed} failing
</div>
{gateState.missingCategories.length > 0 && (
<div style={{ fontSize: "0.85em", color: "#ffb86c" }}>
Missing: {gateState.missingCategories.join(", ")}
</div>
)}
{gateState.warning && (
<div style={{ fontSize: "0.85em", color: "#ffb86c" }}>
{gateState.warning}
</div>
)}
{gateState.reasons.length > 0 && (
<ul
style={{
margin: "0 0 0 16px",
padding: 0,
fontSize: "0.85em",
color: "#ccc",
}}
>
{gateState.reasons.map((reason) => (
<li key={`gate-reason-${reason}`}>{reason}</li>
))}
</ul>
)}
</div>
) : (
<div style={{ fontSize: "0.85em", color: "#aaa" }}>
No workflow data yet.
</div>
)}
</div>
);
}

View File

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

View File

@@ -0,0 +1 @@
import "@testing-library/jest-dom";

View File

@@ -0,0 +1,13 @@
import { expect, test } from "@playwright/test";
test.describe("App boot smoke test", () => {
test("renders the project selection screen", async ({ page }) => {
await page.goto("/");
await expect(page.getByText("StorkIt")).toBeVisible();
await expect(page.getByPlaceholder("/path/to/project")).toBeVisible();
await expect(
page.getByRole("button", { name: "Open Project" }),
).toBeVisible();
});
});

View File

@@ -0,0 +1,344 @@
import { expect, test } from "@playwright/test";
import type {
AcceptanceResponse,
ReviewListResponse,
} from "../../src/api/workflow";
/**
* Helper: mock all API routes needed to render the Chat view.
* Accepts overrides for the workflow-specific endpoints.
*/
function mockChatApis(
page: import("@playwright/test").Page,
overrides: {
acceptance?: AcceptanceResponse;
reviewQueue?: ReviewListResponse;
ensureAcceptance?: { status: number; body: unknown };
} = {},
) {
const acceptance: AcceptanceResponse = overrides.acceptance ?? {
can_accept: false,
reasons: ["No test results recorded for the story."],
warning: null,
summary: { total: 0, passed: 0, failed: 0 },
missing_categories: ["unit", "integration"],
};
const reviewQueue: ReviewListResponse = overrides.reviewQueue ?? {
stories: [],
};
const ensureResp = overrides.ensureAcceptance ?? {
status: 200,
body: true,
};
return Promise.all([
// Selection screen APIs
page.route("**/api/projects", (route) =>
route.fulfill({ json: ["/tmp/test-project"] }),
),
page.route("**/api/io/fs/home", (route) => route.fulfill({ json: "/tmp" })),
page.route("**/api/project", (route) => {
if (route.request().method() === "POST") {
return route.fulfill({ json: "/tmp/test-project" });
}
if (route.request().method() === "DELETE") {
return route.fulfill({ json: true });
}
return route.fulfill({ json: null });
}),
// Chat init APIs
page.route("**/api/ollama/models**", (route) =>
route.fulfill({ json: ["llama3.1"] }),
),
page.route("**/api/anthropic/key/exists", (route) =>
route.fulfill({ json: false }),
),
page.route("**/api/anthropic/models", (route) =>
route.fulfill({ json: [] }),
),
page.route("**/api/model", (route) => {
if (route.request().method() === "POST") {
return route.fulfill({ json: true });
}
return route.fulfill({ json: null });
}),
// Workflow APIs
page.route("**/api/workflow/acceptance", (route) => {
if (route.request().url().includes("/ensure")) return route.fallback();
return route.fulfill({ json: acceptance });
}),
page.route("**/api/workflow/review/all", (route) =>
route.fulfill({ json: reviewQueue }),
),
page.route("**/api/workflow/acceptance/ensure", (route) =>
route.fulfill({
status: ensureResp.status,
json: ensureResp.body,
}),
),
page.route("**/api/io/fs/list/absolute**", (route) =>
route.fulfill({ json: [] }),
),
]);
}
/** Navigate past the selection screen into the Chat view. */
async function openProject(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByPlaceholder("/path/to/project").fill("/tmp/test-project");
await page.getByRole("button", { name: "Open Project" }).click();
// Wait for the Chat view to appear
await expect(page.getByText("Workflow Gates", { exact: true })).toBeVisible();
}
test.describe("TDD gate panel (AC1/AC3)", () => {
test("shows Blocked status with reasons when no test plan is approved", async ({
page,
}) => {
await mockChatApis(page, {
acceptance: {
can_accept: false,
reasons: ["No approved test plan for the story."],
warning: null,
summary: { total: 0, passed: 0, failed: 0 },
missing_categories: ["unit", "integration"],
},
});
await openProject(page);
await expect(
page.getByText("Workflow Gates", { exact: true }),
).toBeVisible();
await expect(page.getByText("Blocked").first()).toBeVisible();
await expect(
page.getByText("No approved test plan for the story."),
).toBeVisible();
await expect(
page.getByText("Missing: unit, integration").first(),
).toBeVisible();
});
test("shows Ready to accept when all gates pass", async ({ page }) => {
await mockChatApis(page, {
acceptance: {
can_accept: true,
reasons: [],
warning: null,
summary: { total: 5, passed: 5, failed: 0 },
missing_categories: [],
},
});
await openProject(page);
await expect(page.getByText("Ready to accept")).toBeVisible();
await expect(page.getByText(/5\/5 passing, 0 failing/)).toBeVisible();
});
});
test.describe("Acceptance blocked by failing tests (AC4)", () => {
test("Proceed button is disabled and shows Blocked when tests fail", async ({
page,
}) => {
await mockChatApis(page, {
acceptance: {
can_accept: false,
reasons: ["Tests are failing."],
warning: null,
summary: { total: 4, passed: 2, failed: 2 },
missing_categories: [],
},
reviewQueue: {
stories: [
{
story_id: "26_establish_tdd_workflow_and_gates",
can_accept: false,
reasons: ["Tests are failing."],
warning: null,
summary: { total: 4, passed: 2, failed: 2 },
missing_categories: [],
},
],
},
});
await openProject(page);
const blockedButton = page.getByRole("button", { name: "Blocked" });
await expect(blockedButton).toBeVisible();
await expect(blockedButton).toBeDisabled();
await expect(page.getByText("Tests are failing.").first()).toBeVisible();
await expect(page.getByText(/2\/4 passing/).first()).toBeVisible();
});
test("Proceed button is disabled when missing test categories", async ({
page,
}) => {
await mockChatApis(page, {
reviewQueue: {
stories: [
{
story_id: "26_establish_tdd_workflow_and_gates",
can_accept: false,
reasons: ["Missing required test categories."],
warning: null,
summary: { total: 0, passed: 0, failed: 0 },
missing_categories: ["unit", "integration"],
},
],
},
});
await openProject(page);
const blockedButton = page.getByRole("button", { name: "Blocked" });
await expect(blockedButton).toBeDisabled();
await expect(page.getByText("Missing").first()).toBeVisible();
});
});
test.describe("Red test count and warnings (AC5)", () => {
test("shows Failing badge with count when tests fail", async ({ page }) => {
await mockChatApis(page, {
reviewQueue: {
stories: [
{
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: [],
},
],
},
});
await openProject(page);
await expect(page.getByText("Failing 3")).toBeVisible();
await expect(page.getByText("Warning")).toBeVisible();
await expect(
page.getByText("Multiple tests failing — fix one at a time."),
).toBeVisible();
});
test("gate panel shows warning for multiple failing tests", async ({
page,
}) => {
await mockChatApis(page, {
acceptance: {
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: [],
},
});
await openProject(page);
await expect(
page.getByText("Multiple tests failing — fix one at a time."),
).toBeVisible();
await expect(page.getByText(/2\/4 passing, 2 failing/)).toBeVisible();
});
});
test.describe("Blocked actions do not execute (E2E)", () => {
test("clicking a blocked Proceed button does not call ensureAcceptance", async ({
page,
}) => {
let ensureCalled = false;
await mockChatApis(page, {
reviewQueue: {
stories: [
{
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: [],
},
],
},
});
// Override the ensure route to track calls
await page.route("**/api/workflow/acceptance/ensure", (route) => {
ensureCalled = true;
return route.fulfill({ json: true });
});
await openProject(page);
const blockedButton = page.getByRole("button", { name: "Blocked" });
await expect(blockedButton).toBeDisabled();
// Force-click to attempt bypass — should still not fire
await blockedButton.click({ force: true });
// Give a moment for any potential network call
await page.waitForTimeout(500);
expect(ensureCalled).toBe(false);
});
test("ready story proceeds successfully", async ({ page }) => {
let ensureCalled = false;
await mockChatApis(page, {
acceptance: {
can_accept: true,
reasons: [],
warning: null,
summary: { total: 3, passed: 3, failed: 0 },
missing_categories: [],
},
reviewQueue: {
stories: [
{
story_id: "26_establish_tdd_workflow_and_gates",
can_accept: true,
reasons: [],
warning: null,
summary: { total: 3, passed: 3, failed: 0 },
missing_categories: [],
},
],
},
ensureAcceptance: { status: 200, body: true },
});
// Intercept ensure to track it was called
await page.route("**/api/workflow/acceptance/ensure", (route) => {
ensureCalled = true;
return route.fulfill({ json: true });
});
await openProject(page);
const proceedButton = page.getByRole("button", { name: "Proceed" });
await expect(proceedButton).toBeEnabled();
// Before clicking, swap review queue to return empty on next fetch
await page.unroute("**/api/workflow/review/all");
await page.route("**/api/workflow/review/all", (route) =>
route.fulfill({ json: { stories: [] } }),
);
await proceedButton.click();
// After proceed, the review queue refreshes — now returns empty
await expect(
page.getByText("No stories waiting for review."),
).toBeVisible();
expect(ensureCalled).toBe(true);
});
});

View File

@@ -20,6 +20,5 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
"include": ["src"]
}

View File

@@ -4,7 +4,8 @@
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
"allowSyntheticDefaultImports": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
"include": ["vite.config.ts", "playwright.config.ts"]
}

13
frontend/vitest.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./src/setupTests.ts"],
css: true,
exclude: ["tests/e2e/**", "node_modules/**"],
},
});

View File

@@ -21,6 +21,7 @@ eventsource-stream = { workspace = true }
rust-embed = { workspace = true }
mime_guess = { workspace = true }
homedir = { workspace = true }
serde_yaml = "0.9"
[dev-dependencies]

View File

@@ -1,5 +1,6 @@
use crate::state::SessionState;
use crate::store::JsonFileStore;
use crate::workflow::WorkflowState;
use poem::http::StatusCode;
use std::sync::Arc;
@@ -7,6 +8,7 @@ use std::sync::Arc;
pub struct AppContext {
pub state: Arc<SessionState>,
pub store: Arc<JsonFileStore>,
pub workflow: Arc<std::sync::Mutex<WorkflowState>>,
}
pub type OpenApiResult<T> = poem::Result<T>;

View File

@@ -5,6 +5,7 @@ pub mod context;
pub mod health;
pub mod io;
pub mod model;
pub mod workflow;
pub mod project;
pub mod ws;
@@ -19,6 +20,7 @@ use poem::{Route, get};
use poem_openapi::OpenApiService;
use project::ProjectApi;
use std::sync::Arc;
use workflow::WorkflowApi;
pub fn build_routes(ctx: AppContext) -> impl poem::Endpoint {
let ctx_arc = std::sync::Arc::new(ctx);
@@ -36,7 +38,14 @@ pub fn build_routes(ctx: AppContext) -> impl poem::Endpoint {
.data(ctx_arc)
}
type ApiTuple = (ProjectApi, ModelApi, AnthropicApi, IoApi, ChatApi);
type ApiTuple = (
ProjectApi,
ModelApi,
AnthropicApi,
IoApi,
ChatApi,
WorkflowApi,
);
type ApiService = OpenApiService<ApiTuple, ()>;
@@ -48,6 +57,7 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
AnthropicApi::new(ctx.clone()),
IoApi { ctx: ctx.clone() },
ChatApi { ctx: ctx.clone() },
WorkflowApi { ctx: ctx.clone() },
);
let api_service =
@@ -58,7 +68,8 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
ModelApi { ctx: ctx.clone() },
AnthropicApi::new(ctx.clone()),
IoApi { ctx: ctx.clone() },
ChatApi { ctx },
ChatApi { ctx: ctx.clone() },
WorkflowApi { ctx },
);
let docs_service =

319
server/src/http/workflow.rs Normal file
View File

@@ -0,0 +1,319 @@
use crate::http::context::{AppContext, OpenApiResult, bad_request};
use crate::io::story_metadata::{StoryMetadata, parse_front_matter};
use crate::workflow::{
StoryTestResults, TestCaseResult, TestStatus, evaluate_acceptance, summarize_results,
};
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
use serde::Deserialize;
use std::collections::BTreeSet;
use std::fs;
use std::sync::Arc;
#[derive(Tags)]
enum WorkflowTags {
Workflow,
}
#[derive(Deserialize, Object)]
struct TestCasePayload {
pub name: String,
pub status: String,
pub details: Option<String>,
}
#[derive(Deserialize, Object)]
struct RecordTestsPayload {
pub story_id: String,
pub unit: Vec<TestCasePayload>,
pub integration: Vec<TestCasePayload>,
}
#[derive(Deserialize, Object)]
struct AcceptanceRequest {
pub story_id: String,
}
#[derive(Object)]
struct TestRunSummaryResponse {
pub total: usize,
pub passed: usize,
pub failed: usize,
}
#[derive(Object)]
struct AcceptanceResponse {
pub can_accept: bool,
pub reasons: Vec<String>,
pub warning: Option<String>,
pub summary: TestRunSummaryResponse,
pub missing_categories: Vec<String>,
}
#[derive(Object)]
struct ReviewStory {
pub story_id: String,
pub can_accept: bool,
pub reasons: Vec<String>,
pub warning: Option<String>,
pub summary: TestRunSummaryResponse,
pub missing_categories: Vec<String>,
}
#[derive(Object)]
struct ReviewListResponse {
pub stories: Vec<ReviewStory>,
}
fn load_current_story_metadata(ctx: &AppContext) -> Result<Vec<(String, StoryMetadata)>, String> {
let root = ctx.state.get_project_root()?;
let current_dir = root.join(".story_kit").join("stories").join("current");
if !current_dir.exists() {
return Ok(Vec::new());
}
let mut stories = Vec::new();
for entry in fs::read_dir(&current_dir)
.map_err(|e| format!("Failed to read current stories directory: {e}"))?
{
let entry = entry.map_err(|e| format!("Failed to read current story entry: {e}"))?;
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
continue;
}
let story_id = path
.file_stem()
.and_then(|stem| stem.to_str())
.ok_or_else(|| "Invalid story file name.".to_string())?
.to_string();
let contents = fs::read_to_string(&path)
.map_err(|e| format!("Failed to read story file {}: {e}", path.display()))?;
let metadata = parse_front_matter(&contents)
.map_err(|e| format!("Failed to parse front matter for {story_id}: {e:?}"))?;
stories.push((story_id, metadata));
}
Ok(stories)
}
fn to_review_story(story_id: &str, results: &StoryTestResults) -> ReviewStory {
let decision = evaluate_acceptance(results);
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();
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,
}
}
pub struct WorkflowApi {
pub ctx: Arc<AppContext>,
}
#[OpenApi(tag = "WorkflowTags::Workflow")]
impl WorkflowApi {
/// Record test results for a story (unit + integration).
#[oai(path = "/workflow/tests/record", method = "post")]
async fn record_tests(&self, payload: Json<RecordTestsPayload>) -> OpenApiResult<Json<bool>> {
let unit = payload
.0
.unit
.into_iter()
.map(to_test_case)
.collect::<Result<Vec<_>, String>>()
.map_err(bad_request)?;
let integration = payload
.0
.integration
.into_iter()
.map(to_test_case)
.collect::<Result<Vec<_>, String>>()
.map_err(bad_request)?;
let mut workflow = self
.ctx
.workflow
.lock()
.map_err(|e| bad_request(e.to_string()))?;
workflow
.record_test_results_validated(payload.0.story_id, unit, integration)
.map_err(bad_request)?;
Ok(Json(true))
}
/// Evaluate acceptance readiness for a story.
#[oai(path = "/workflow/acceptance", method = "post")]
async fn acceptance(
&self,
payload: Json<AcceptanceRequest>,
) -> OpenApiResult<Json<AcceptanceResponse>> {
let results = {
let workflow = self
.ctx
.workflow
.lock()
.map_err(|e| bad_request(e.to_string()))?;
workflow
.results
.get(&payload.0.story_id)
.cloned()
.unwrap_or_default()
};
let decision = evaluate_acceptance(&results);
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();
Ok(Json(AcceptanceResponse {
can_accept,
reasons,
warning: decision.warning,
summary: TestRunSummaryResponse {
total: summary.total,
passed: summary.passed,
failed: summary.failed,
},
missing_categories,
}))
}
/// List stories that are ready for human review.
#[oai(path = "/workflow/review", method = "get")]
async fn review_queue(&self) -> OpenApiResult<Json<ReviewListResponse>> {
let stories = {
let workflow = self
.ctx
.workflow
.lock()
.map_err(|e| bad_request(e.to_string()))?;
workflow
.results
.iter()
.map(|(story_id, results)| to_review_story(story_id, results))
.filter(|story| story.can_accept)
.collect::<Vec<_>>()
};
Ok(Json(ReviewListResponse { stories }))
}
/// List stories in the review queue, including blocked items and current stories.
#[oai(path = "/workflow/review/all", method = "get")]
async fn review_queue_all(&self) -> OpenApiResult<Json<ReviewListResponse>> {
let current_stories =
load_current_story_metadata(self.ctx.as_ref()).map_err(bad_request)?;
let stories = {
let mut workflow = self
.ctx
.workflow
.lock()
.map_err(|e| bad_request(e.to_string()))?;
if !current_stories.is_empty() {
workflow.load_story_metadata(current_stories);
}
let mut story_ids = BTreeSet::new();
for story_id in workflow.results.keys() {
story_ids.insert(story_id.clone());
}
for story_id in workflow.stories.keys() {
story_ids.insert(story_id.clone());
}
story_ids
.into_iter()
.map(|story_id| {
let results = workflow.results.get(&story_id).cloned().unwrap_or_default();
to_review_story(&story_id, &results)
})
.collect::<Vec<_>>()
};
Ok(Json(ReviewListResponse { stories }))
}
/// Ensure a story can be accepted; returns an error when gates fail.
#[oai(path = "/workflow/acceptance/ensure", method = "post")]
async fn ensure_acceptance(
&self,
payload: Json<AcceptanceRequest>,
) -> OpenApiResult<Json<bool>> {
let response = self.acceptance(payload).await?.0;
if response.can_accept {
return Ok(Json(true));
}
let mut parts = Vec::new();
if !response.reasons.is_empty() {
parts.push(response.reasons.join("; "));
}
if let Some(warning) = response.warning {
parts.push(warning);
}
let message = if parts.is_empty() {
"Acceptance is blocked.".to_string()
} else {
format!("Acceptance is blocked: {}", parts.join("; "))
};
Err(bad_request(message))
}
}
fn to_test_case(input: TestCasePayload) -> Result<TestCaseResult, String> {
let status = parse_test_status(&input.status)?;
Ok(TestCaseResult {
name: input.name,
status,
details: input.details,
})
}
fn parse_test_status(value: &str) -> Result<TestStatus, String> {
match value {
"pass" => Ok(TestStatus::Pass),
"fail" => Ok(TestStatus::Fail),
other => Err(format!(
"Invalid test status '{other}'. Use 'pass' or 'fail'."
)),
}
}

View File

@@ -1,3 +1,4 @@
use crate::io::story_metadata::{TestPlanStatus, parse_front_matter};
use crate::state::SessionState;
use crate::store::StoreOps;
use serde::Serialize;
@@ -276,6 +277,7 @@ This project is a standalone Rust **web server binary** that serves a Vite/React
* **Framework:** Poem HTTP server with WebSocket support for streaming; HTTP APIs should use Poem OpenAPI (Swagger) for non-streaming endpoints.
* **Frontend:** TypeScript + React
* **Build Tool:** Vite
* **Package Manager:** pnpm (required)
* **Styling:** CSS Modules or Tailwind (TBD - Defaulting to CSS Modules)
* **State Management:** React Context / Hooks
* **Chat UI:** Rendered Markdown with syntax highlighting.
@@ -394,6 +396,34 @@ fn resolve_path_impl(root: PathBuf, relative_path: &str) -> Result<PathBuf, Stri
Ok(root.join(relative_path))
}
fn is_story_kit_path(path: &str) -> bool {
path == ".story_kit" || path.starts_with(".story_kit/")
}
async fn ensure_test_plan_approved(root: PathBuf) -> Result<(), String> {
let approved = tokio::task::spawn_blocking(move || {
let story_path = root
.join(".story_kit")
.join("stories")
.join("current")
.join("26_establish_tdd_workflow_and_gates.md");
let contents = fs::read_to_string(&story_path)
.map_err(|e| format!("Failed to read story file for test plan approval: {e}"))?;
let metadata = parse_front_matter(&contents)
.map_err(|e| format!("Failed to parse story front matter: {e:?}"))?;
Ok::<bool, String>(matches!(metadata.test_plan, Some(TestPlanStatus::Approved)))
})
.await
.map_err(|e| format!("Task failed: {e}"))??;
if approved {
Ok(())
} else {
Err("Test plan is not approved for the current story.".to_string())
}
}
/// Resolves a relative path against the active project root.
/// Returns error if no project is open or if path attempts traversal (..).
fn resolve_path(state: &SessionState, relative_path: &str) -> Result<PathBuf, String> {
@@ -597,7 +627,11 @@ async fn write_file_impl(full_path: PathBuf, content: String) -> Result<(), Stri
}
pub async fn write_file(path: String, content: String, state: &SessionState) -> Result<(), String> {
let full_path = resolve_path(state, &path)?;
let root = state.get_project_root()?;
if !is_story_kit_path(&path) {
ensure_test_plan_approved(root.clone()).await?;
}
let full_path = resolve_path_impl(root, &path)?;
write_file_impl(full_path, content).await
}
@@ -658,3 +692,27 @@ pub async fn create_directory_absolute(path: String) -> Result<bool, String> {
.await
.map_err(|e| format!("Task failed: {}", e))?
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[tokio::test]
async fn write_file_requires_approved_test_plan() {
let dir = tempdir().expect("tempdir");
let state = SessionState::default();
{
let mut root = state.project_root.lock().expect("lock project root");
*root = Some(dir.path().to_path_buf());
}
let result = write_file("notes.txt".to_string(), "hello".to_string(), &state).await;
assert!(
result.is_err(),
"expected write to be blocked when test plan is not approved"
);
}
}

View File

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

View File

@@ -1,5 +1,7 @@
use crate::io::story_metadata::{TestPlanStatus, parse_front_matter};
use crate::state::SessionState;
use serde::Serialize;
use std::fs;
use std::path::PathBuf;
use std::process::Command;
@@ -8,6 +10,30 @@ fn get_project_root(state: &SessionState) -> Result<PathBuf, String> {
state.get_project_root()
}
async fn ensure_test_plan_approved(root: PathBuf) -> Result<(), String> {
let approved = tokio::task::spawn_blocking(move || {
let story_path = root
.join(".story_kit")
.join("stories")
.join("current")
.join("26_establish_tdd_workflow_and_gates.md");
let contents = fs::read_to_string(&story_path)
.map_err(|e| format!("Failed to read story file for test plan approval: {e}"))?;
let metadata = parse_front_matter(&contents)
.map_err(|e| format!("Failed to parse story front matter: {e:?}"))?;
Ok::<bool, String>(matches!(metadata.test_plan, Some(TestPlanStatus::Approved)))
})
.await
.map_err(|e| format!("Task failed: {e}"))??;
if approved {
Ok(())
} else {
Err("Test plan is not approved for the current story.".to_string())
}
}
#[derive(Serialize, Debug, poem_openapi::Object)]
pub struct CommandOutput {
pub stdout: String,
@@ -54,5 +80,30 @@ pub async fn exec_shell(
state: &SessionState,
) -> Result<CommandOutput, String> {
let root = get_project_root(state)?;
ensure_test_plan_approved(root.clone()).await?;
exec_shell_impl(command, args, root).await
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[tokio::test]
async fn exec_shell_requires_approved_test_plan() {
let dir = tempdir().expect("tempdir");
let state = SessionState::default();
{
let mut root = state.project_root.lock().expect("lock project root");
*root = Some(dir.path().to_path_buf());
}
let result = exec_shell("ls".to_string(), Vec::new(), &state).await;
assert!(
result.is_err(),
"expected shell execution to be blocked when test plan is not approved"
);
}
}

View File

@@ -0,0 +1,111 @@
use serde::Deserialize;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TestPlanStatus {
Approved,
WaitingForApproval,
Unknown(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct StoryMetadata {
pub name: Option<String>,
pub test_plan: Option<TestPlanStatus>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StoryMetaError {
MissingFrontMatter,
InvalidFrontMatter(String),
}
#[derive(Debug, Deserialize)]
struct FrontMatter {
name: Option<String>,
test_plan: Option<String>,
}
pub fn parse_front_matter(contents: &str) -> Result<StoryMetadata, StoryMetaError> {
let mut lines = contents.lines();
let first = lines.next().unwrap_or_default().trim();
if first != "---" {
return Err(StoryMetaError::MissingFrontMatter);
}
let mut front_lines = Vec::new();
for line in &mut lines {
let trimmed = line.trim();
if trimmed == "---" {
let raw = front_lines.join("\n");
let front: FrontMatter = serde_yaml::from_str(&raw)
.map_err(|e| StoryMetaError::InvalidFrontMatter(e.to_string()))?;
return Ok(build_metadata(front));
}
front_lines.push(line);
}
Err(StoryMetaError::InvalidFrontMatter(
"Missing closing front matter delimiter".to_string(),
))
}
fn build_metadata(front: FrontMatter) -> StoryMetadata {
let test_plan = front.test_plan.as_deref().map(parse_test_plan_status);
StoryMetadata {
name: front.name,
test_plan,
}
}
fn parse_test_plan_status(value: &str) -> TestPlanStatus {
match value {
"approved" => TestPlanStatus::Approved,
"waiting_for_approval" => TestPlanStatus::WaitingForApproval,
other => TestPlanStatus::Unknown(other.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_front_matter_metadata() {
let input = r#"---
name: Establish the TDD Workflow and Gates
test_plan: approved
workflow: tdd
---
# Story 26
"#;
let meta = parse_front_matter(input).expect("front matter");
assert_eq!(
meta,
StoryMetadata {
name: Some("Establish the TDD Workflow and Gates".to_string()),
test_plan: Some(TestPlanStatus::Approved),
}
);
}
#[test]
fn rejects_missing_front_matter() {
let input = "# Story 26\n";
assert_eq!(
parse_front_matter(input),
Err(StoryMetaError::MissingFrontMatter)
);
}
#[test]
fn rejects_unclosed_front_matter() {
let input = "---\nname: Test\n";
assert!(matches!(
parse_front_matter(input),
Err(StoryMetaError::InvalidFrontMatter(_))
));
}
}

View File

@@ -3,11 +3,13 @@ mod io;
mod llm;
mod state;
mod store;
mod workflow;
use crate::http::build_routes;
use crate::http::context::AppContext;
use crate::state::SessionState;
use crate::store::JsonFileStore;
use crate::workflow::WorkflowState;
use poem::Server;
use poem::listener::TcpListener;
use std::path::PathBuf;
@@ -19,10 +21,12 @@ async fn main() -> Result<(), std::io::Error> {
let store = Arc::new(
JsonFileStore::from_path(PathBuf::from("store.json")).map_err(std::io::Error::other)?,
);
let workflow = Arc::new(std::sync::Mutex::new(WorkflowState::default()));
let ctx = AppContext {
state: app_state,
store,
workflow,
};
let app = build_routes(ctx);

242
server/src/workflow.rs Normal file
View File

@@ -0,0 +1,242 @@
//! 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.
use crate::io::story_metadata::{StoryMetadata, TestPlanStatus};
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TestStatus {
Pass,
Fail,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TestCaseResult {
pub name: String,
pub status: TestStatus,
pub details: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TestRunSummary {
pub total: usize,
pub passed: usize,
pub failed: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AcceptanceDecision {
pub can_accept: bool,
pub reasons: Vec<String>,
pub warning: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct StoryTestResults {
pub unit: Vec<TestCaseResult>,
pub integration: Vec<TestCaseResult>,
}
#[derive(Debug, Clone, Default)]
#[allow(dead_code)]
pub struct WorkflowState {
pub stories: HashMap<String, StoryMetadata>,
pub results: HashMap<String, StoryTestResults>,
}
#[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<TestCaseResult>,
integration: Vec<TestCaseResult>,
) {
let _ = self.record_test_results_validated(story_id, unit, integration);
}
pub fn record_test_results_validated(
&mut self,
story_id: String,
unit: Vec<TestCaseResult>,
integration: Vec<TestCaseResult>,
) -> Result<(), String> {
let failures = unit
.iter()
.chain(integration.iter())
.filter(|test| test.status == TestStatus::Fail)
.count();
if failures > 1 {
return Err(format!(
"Multiple failing tests detected ({failures}); register failures one at a time."
));
}
self.results
.insert(story_id, StoryTestResults { unit, integration });
Ok(())
}
}
#[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 {
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,
}
}
TestRunSummary {
total,
passed,
failed,
}
}
pub fn evaluate_acceptance(results: &StoryTestResults) -> AcceptanceDecision {
let summary = summarize_results(results);
if summary.failed == 0 && summary.total > 0 {
return AcceptanceDecision {
can_accept: true,
reasons: Vec::new(),
warning: None,
};
}
let mut reasons = Vec::new();
if summary.total == 0 {
reasons.push("No test results recorded for the story.".to_string());
}
if summary.failed > 0 {
reasons.push(format!(
"{} test(s) are failing; acceptance is blocked.",
summary.failed
));
}
let warning = if summary.failed > 1 {
Some(format!(
"Multiple tests are failing ({} failures).",
summary.failed
))
} else {
None
};
AcceptanceDecision {
can_accept: false,
reasons,
warning,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn warns_when_multiple_tests_fail() {
let results = StoryTestResults {
unit: vec![
TestCaseResult {
name: "unit-1".to_string(),
status: TestStatus::Fail,
details: None,
},
TestCaseResult {
name: "unit-2".to_string(),
status: TestStatus::Fail,
details: None,
},
],
integration: vec![TestCaseResult {
name: "integration-1".to_string(),
status: TestStatus::Pass,
details: None,
}],
};
let decision = evaluate_acceptance(&results);
assert!(!decision.can_accept);
assert_eq!(
decision.warning,
Some("Multiple tests are failing (2 failures).".to_string())
);
}
#[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());
}
}