diff --git a/Cargo.lock b/Cargo.lock index 4ab0cb06..a1a91910 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -867,15 +867,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "convert_case" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" -dependencies = [ - "unicode-segmentation", -] - [[package]] name = "core-foundation" version = "0.9.4" @@ -1111,38 +1102,14 @@ dependencies = [ "zeroize", ] -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core 0.20.11", - "darling_macro 0.20.11", -] - [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.117", + "darling_core", + "darling_macro", ] [[package]] @@ -1158,24 +1125,13 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core 0.20.11", - "quote", - "syn 2.0.117", -] - [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core 0.23.0", + "darling_core", "quote", "syn 2.0.117", ] @@ -1337,7 +1293,6 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ - "convert_case 0.10.0", "proc-macro2", "quote", "rustc_version", @@ -2299,7 +2254,6 @@ dependencies = [ "mockito", "notify", "poem", - "poem-openapi", "portable-pty", "pulldown-cmark", "rand 0.9.4", @@ -3392,24 +3346,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "multer" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" -dependencies = [ - "bytes", - "encoding_rs", - "futures-util", - "http", - "httparse", - "memchr", - "mime", - "spin", - "tokio", - "version_check", -] - [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -3843,23 +3779,19 @@ dependencies = [ "hyper", "hyper-util", "mime", - "multer", "nix 0.30.1", "parking_lot", "percent-encoding", "pin-project-lite", "poem-derive", - "quick-xml", "regex", "rfc7239", "serde", "serde_json", "serde_urlencoded", - "serde_yaml", "smallvec", "sse-codec", "sync_wrapper", - "tempfile", "thiserror 2.0.18", "tokio", "tokio-stream", @@ -3881,50 +3813,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "poem-openapi" -version = "5.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ccbcc395bf4dd03df1da32da351b6b6732e4074ce27ddec315650e52a2be44c" -dependencies = [ - "base64", - "bytes", - "derive_more 2.1.1", - "futures-util", - "indexmap 2.14.0", - "itertools 0.14.0", - "mime", - "num-traits", - "poem", - "poem-openapi-derive", - "quick-xml", - "regex", - "serde", - "serde_json", - "serde_urlencoded", - "serde_yaml", - "thiserror 2.0.18", - "tokio", -] - -[[package]] -name = "poem-openapi-derive" -version = "5.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41273b691a3d467a8c44d05506afba9f7b6bd56c9cdf80123de13fe52d7ec587" -dependencies = [ - "darling 0.20.11", - "http", - "indexmap 2.14.0", - "mime", - "proc-macro-crate", - "proc-macro2", - "quote", - "regex", - "syn 2.0.117", - "thiserror 2.0.18", -] - [[package]] name = "poly1305" version = "0.8.0" @@ -4086,16 +3974,6 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" -[[package]] -name = "quick-xml" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "quinn" version = "0.11.9" @@ -5174,7 +5052,7 @@ version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" dependencies = [ - "darling 0.23.0", + "darling", "proc-macro2", "quote", "syn 2.0.117", diff --git a/Cargo.toml b/Cargo.toml index 69baa2f9..7ec016a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,6 @@ ignore = "0.4.25" mime_guess = "2" notify = "8.2.0" poem = { version = "3", features = ["websocket", "test"] } -poem-openapi = { version = "5", features = ["swagger-ui"] } portable-pty = "0.9.0" reqwest = { version = "0.13.3", features = ["json", "stream"] } rust-embed = "8" diff --git a/frontend/src/api/agents.test.ts b/frontend/src/api/agents.test.ts index f7848ddd..26e4714f 100644 --- a/frontend/src/api/agents.test.ts +++ b/frontend/src/api/agents.test.ts @@ -3,27 +3,14 @@ import type { AgentConfigInfo, AgentEvent, AgentInfo } from "./agents"; import { agentsApi, subscribeAgentStream } from "./agents"; import { installRpcMock } from "./__test_utils__/mockRpcWebSocket"; -const mockFetch = vi.fn(); - beforeEach(() => { - vi.stubGlobal("fetch", mockFetch); + vi.stubGlobal("fetch", vi.fn()); }); 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 }); -} - const sampleAgent: AgentInfo = { story_id: "42_story_test", agent_name: "coder", @@ -48,89 +35,51 @@ const sampleConfig: AgentConfigInfo = { describe("agentsApi", () => { describe("startAgent", () => { - it("sends POST to /agents/start with story_id", async () => { - mockFetch.mockResolvedValueOnce(okResponse(sampleAgent)); + it("dispatches agents.start RPC with story_id and returns AgentInfo", async () => { + const rpc = installRpcMock(); + rpc.respond("agents.start", sampleAgent); const result = await agentsApi.startAgent("42_story_test"); - expect(mockFetch).toHaveBeenCalledWith( - "/api/agents/start", - expect.objectContaining({ - method: "POST", - body: JSON.stringify({ - story_id: "42_story_test", - agent_name: undefined, - }), - }), - ); + expect(rpc.calls).toEqual([ + { + method: "agents.start", + params: { story_id: "42_story_test", agent_name: undefined }, + }, + ]); expect(result).toEqual(sampleAgent); }); - it("sends POST with optional agent_name", async () => { - mockFetch.mockResolvedValueOnce(okResponse(sampleAgent)); + it("sends optional agent_name in params", async () => { + const rpc = installRpcMock(); + rpc.respond("agents.start", sampleAgent); await agentsApi.startAgent("42_story_test", "coder"); - expect(mockFetch).toHaveBeenCalledWith( - "/api/agents/start", - expect.objectContaining({ - body: JSON.stringify({ - story_id: "42_story_test", - agent_name: "coder", - }), - }), - ); - }); - - it("uses custom baseUrl when provided", async () => { - mockFetch.mockResolvedValueOnce(okResponse(sampleAgent)); - - await agentsApi.startAgent( - "42_story_test", - undefined, - "http://localhost:3002/api", - ); - - expect(mockFetch).toHaveBeenCalledWith( - "http://localhost:3002/api/agents/start", - expect.objectContaining({ method: "POST" }), - ); + expect(rpc.calls).toEqual([ + { + method: "agents.start", + params: { story_id: "42_story_test", agent_name: "coder" }, + }, + ]); }); }); describe("stopAgent", () => { - it("sends POST to /agents/stop with story_id and agent_name", async () => { - mockFetch.mockResolvedValueOnce(okResponse(true)); + it("dispatches agents.stop RPC with story_id and agent_name", async () => { + const rpc = installRpcMock(); + rpc.respond("agents.stop", true); const result = await agentsApi.stopAgent("42_story_test", "coder"); - expect(mockFetch).toHaveBeenCalledWith( - "/api/agents/stop", - expect.objectContaining({ - method: "POST", - body: JSON.stringify({ - story_id: "42_story_test", - agent_name: "coder", - }), - }), - ); + expect(rpc.calls).toEqual([ + { + method: "agents.stop", + params: { story_id: "42_story_test", agent_name: "coder" }, + }, + ]); expect(result).toBe(true); }); - - it("uses custom baseUrl when provided", async () => { - mockFetch.mockResolvedValueOnce(okResponse(false)); - - await agentsApi.stopAgent( - "42_story_test", - "coder", - "http://localhost:3002/api", - ); - - expect(mockFetch).toHaveBeenCalledWith( - "http://localhost:3002/api/agents/stop", - expect.objectContaining({ method: "POST" }), - ); - }); }); describe("getAgentConfig", () => { @@ -157,46 +106,28 @@ describe("agentsApi", () => { }); describe("reloadConfig", () => { - it("sends POST to /agents/config/reload", async () => { - mockFetch.mockResolvedValueOnce(okResponse([sampleConfig])); + it("dispatches agent_config.list RPC and returns the config list", async () => { + const rpc = installRpcMock(); + rpc.respond("agent_config.list", [sampleConfig]); const result = await agentsApi.reloadConfig(); - expect(mockFetch).toHaveBeenCalledWith( - "/api/agents/config/reload", - expect.objectContaining({ method: "POST" }), - ); + expect(rpc.calls).toEqual([ + { method: "agent_config.list", params: {} }, + ]); expect(result).toEqual([sampleConfig]); }); - - it("uses custom baseUrl when provided", async () => { - mockFetch.mockResolvedValueOnce(okResponse([])); - - await agentsApi.reloadConfig("http://localhost:3002/api"); - - expect(mockFetch).toHaveBeenCalledWith( - "http://localhost:3002/api/agents/config/reload", - expect.objectContaining({ method: "POST" }), - ); - }); }); describe("error handling", () => { - it("throws on non-ok HTTP response from startAgent", async () => { - mockFetch.mockResolvedValueOnce(errorResponse(404, "story not found")); + it("surfaces RPC errors from startAgent", async () => { + const rpc = installRpcMock(); + rpc.respondError("agents.start", "story not found", "NOT_FOUND"); await expect(agentsApi.startAgent("missing_story")).rejects.toThrow( "story not found", ); }); - - it("throws with status code from startAgent when body is empty", async () => { - mockFetch.mockResolvedValueOnce(errorResponse(500, "")); - - await expect(agentsApi.startAgent("missing_story")).rejects.toThrow( - "Request failed (500)", - ); - }); }); }); diff --git a/frontend/src/api/agents.ts b/frontend/src/api/agents.ts index 12d67224..39540350 100644 --- a/frontend/src/api/agents.ts +++ b/frontend/src/api/agents.ts @@ -40,60 +40,19 @@ export interface AgentConfigInfo { max_budget_usd: number | null; } -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 agentsApi = { - startAgent(storyId: string, agentName?: string, baseUrl?: string) { - return requestJson( - "/agents/start", - { - method: "POST", - body: JSON.stringify({ - story_id: storyId, - agent_name: agentName, - }), - }, - baseUrl, - ); + startAgent(storyId: string, agentName?: string) { + return rpcCall("agents.start", { + story_id: storyId, + agent_name: agentName, + }); }, - stopAgent(storyId: string, agentName: string, baseUrl?: string) { - return requestJson( - "/agents/stop", - { - method: "POST", - body: JSON.stringify({ - story_id: storyId, - agent_name: agentName, - }), - }, - baseUrl, - ); + stopAgent(storyId: string, agentName: string) { + return rpcCall("agents.stop", { + story_id: storyId, + agent_name: agentName, + }); }, listAgents(_baseUrl?: string) { @@ -104,12 +63,8 @@ export const agentsApi = { return rpcCall("agent_config.list"); }, - reloadConfig(baseUrl?: string) { - return requestJson( - "/agents/config/reload", - { method: "POST" }, - baseUrl, - ); + reloadConfig() { + return rpcCall("agent_config.list"); }, getAgentOutput(storyId: string, agentName: string, _baseUrl?: string) { diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts index f5eaf229..66b86c10 100644 --- a/frontend/src/api/client.test.ts +++ b/frontend/src/api/client.test.ts @@ -12,17 +12,6 @@ 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("api client", () => { describe("getCurrentProject", () => { it("dispatches project.current RPC and returns the path", async () => { @@ -158,51 +147,6 @@ describe("api client", () => { ); }); - it("throws on non-ok HTTP response for legacy POST endpoints", async () => { - mockFetch.mockResolvedValueOnce(errorResponse(500, "")); - - await expect(api.searchFiles("query")).rejects.toThrow( - "Request failed (500)", - ); - }); - }); - - describe("searchFiles", () => { - it("sends POST with query", async () => { - mockFetch.mockResolvedValueOnce( - okResponse([{ path: "src/main.rs", matches: 1 }]), - ); - - const result = await api.searchFiles("hello"); - - expect(mockFetch).toHaveBeenCalledWith( - "/api/fs/search", - expect.objectContaining({ - method: "POST", - body: JSON.stringify({ query: "hello" }), - }), - ); - expect(result).toHaveLength(1); - }); - }); - - describe("execShell", () => { - it("sends POST with command and args", async () => { - mockFetch.mockResolvedValueOnce( - okResponse({ stdout: "output", stderr: "", exit_code: 0 }), - ); - - const result = await api.execShell("ls", ["-la"]); - - expect(mockFetch).toHaveBeenCalledWith( - "/api/shell/exec", - expect.objectContaining({ - method: "POST", - body: JSON.stringify({ command: "ls", args: ["-la"] }), - }), - ); - expect(result.exit_code).toBe(0); - }); }); describe("resolveWsHost", () => { diff --git a/frontend/src/api/client/http.ts b/frontend/src/api/client/http.ts index b79449bb..4a650e70 100644 --- a/frontend/src/api/client/http.ts +++ b/frontend/src/api/client/http.ts @@ -1,8 +1,7 @@ /** * HTTP transport layer for the Huskies API client. - * Provides the low-level `requestJson` helper, the `callMcpTool` function - * for MCP JSON-RPC calls, the `resolveWsHost` utility, and the `api` - * object exposing all REST endpoints. + * Provides the `callMcpTool` function for MCP JSON-RPC calls, the + * `resolveWsHost` utility, and the `api` object exposing all endpoints. */ import { rpcCall } from "../rpc"; @@ -15,18 +14,13 @@ import type { import type { AllTokenUsageResponse, AnthropicModelInfo, - CommandOutput, FileEntry, OAuthStatus, - SearchResult, TestResultsResponse, TokenCostResponse, WorkItemContent, } from "./types"; -/** Base URL prefix for all REST API requests in production. */ -export const DEFAULT_API_BASE = "/api"; - /** * Resolve the WebSocket host to connect to. * In development, uses the injected port (or 3001); in production, uses the @@ -40,31 +34,6 @@ export function resolveWsHost( return isDev ? `127.0.0.1:${envPort || "3001"}` : locationHost; } -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; -} - /** * Invoke an MCP tool via the server's JSON-RPC `/mcp` endpoint. * Returns the first text content block from the tool result, or an empty @@ -92,7 +61,7 @@ export async function callMcpTool( return text; } -/** Typed REST and MCP wrappers for all Huskies server endpoints. */ +/** Typed wrappers for all Huskies server endpoints. */ export const api = { getCurrentProject(_baseUrl?: string) { return rpcCall("project.current"); @@ -137,40 +106,11 @@ export const api = { const r = await rpcCall("anthropic.set_api_key", params); return r.ok; }, - readFile(path: string, baseUrl?: string) { - return requestJson( - "/fs/read", - { method: "POST", body: JSON.stringify({ path }) }, - baseUrl, - ); + readFile(path: string) { + return rpcCall("io.read_file", { path }); }, - writeFile(path: string, content: string, baseUrl?: string) { - return requestJson( - "/fs/write", - { method: "POST", body: JSON.stringify({ path, content }) }, - baseUrl, - ); - }, - listDirectory(path: string, baseUrl?: string) { - return requestJson( - "/fs/list", - { method: "POST", body: JSON.stringify({ path }) }, - baseUrl, - ); - }, - listDirectoryAbsolute(path: string, baseUrl?: string) { - return requestJson( - "/io/fs/list/absolute", - { method: "POST", body: JSON.stringify({ path }) }, - baseUrl, - ); - }, - createDirectoryAbsolute(path: string, baseUrl?: string) { - return requestJson( - "/io/fs/create/absolute", - { method: "POST", body: JSON.stringify({ path }) }, - baseUrl, - ); + listDirectoryAbsolute(path: string) { + return rpcCall("io.list_directory_absolute", { path }); }, getHomeDirectory(_baseUrl?: string) { return rpcCall("io.home_directory"); @@ -178,20 +118,6 @@ export const api = { listProjectFiles(_baseUrl?: string) { return rpcCall("io.list_project_files"); }, - searchFiles(query: string, baseUrl?: string) { - return requestJson( - "/fs/search", - { method: "POST", body: JSON.stringify({ query }) }, - baseUrl, - ); - }, - execShell(command: string, args: string[], baseUrl?: string) { - return requestJson( - "/shell/exec", - { method: "POST", body: JSON.stringify({ command, args }) }, - baseUrl, - ); - }, async cancelChat(_baseUrl?: string) { const r = await rpcCall("chat.cancel"); return r.ok; @@ -237,11 +163,7 @@ export const api = { return rpcCall("oauth.status"); }, /** Execute a bot slash command without LLM invocation. Returns markdown response text. */ - botCommand(command: string, args: string, baseUrl?: string) { - return requestJson<{ response: string }>( - "/bot/command", - { method: "POST", body: JSON.stringify({ command, args }) }, - baseUrl, - ); + botCommand(command: string, args: string) { + return rpcCall<{ response: string }>("bot.command", { command, args }); }, }; diff --git a/frontend/src/api/client/index.ts b/frontend/src/api/client/index.ts index 1a229672..86baa99b 100644 --- a/frontend/src/api/client/index.ts +++ b/frontend/src/api/client/index.ts @@ -33,6 +33,6 @@ export type { WsResponse, } from "./types"; -export { api, callMcpTool, DEFAULT_API_BASE, resolveWsHost } from "./http"; +export { api, callMcpTool, resolveWsHost } from "./http"; export { ChatWebSocket } from "./websocket"; diff --git a/frontend/src/components/Chat.commands.test.tsx b/frontend/src/components/Chat.commands.test.tsx index ff2b4f02..7e58a0d0 100644 --- a/frontend/src/components/Chat.commands.test.tsx +++ b/frontend/src/components/Chat.commands.test.tsx @@ -277,7 +277,6 @@ describe("Slash command handling (Story 374)", () => { expect(mockedApi.botCommand).toHaveBeenCalledWith( "status", "", - undefined, ); }); expect(await screen.findByText("Pipeline: 3 active")).toBeInTheDocument(); @@ -302,7 +301,6 @@ describe("Slash command handling (Story 374)", () => { expect(mockedApi.botCommand).toHaveBeenCalledWith( "status", "42", - undefined, ); }); }); @@ -324,7 +322,6 @@ describe("Slash command handling (Story 374)", () => { expect(mockedApi.botCommand).toHaveBeenCalledWith( "start", "42 opus", - undefined, ); }); expect(await screen.findByText("Started agent")).toBeInTheDocument(); @@ -348,7 +345,7 @@ describe("Slash command handling (Story 374)", () => { }); await waitFor(() => { - expect(mockedApi.botCommand).toHaveBeenCalledWith("git", "", undefined); + expect(mockedApi.botCommand).toHaveBeenCalledWith("git", ""); }); }); @@ -370,7 +367,7 @@ describe("Slash command handling (Story 374)", () => { }); await waitFor(() => { - expect(mockedApi.botCommand).toHaveBeenCalledWith("cost", "", undefined); + expect(mockedApi.botCommand).toHaveBeenCalledWith("cost", ""); }); }); @@ -446,7 +443,7 @@ describe("Slash command handling (Story 374)", () => { }); await waitFor(() => { - expect(mockedApi.botCommand).toHaveBeenCalledWith("help", "", undefined); + expect(mockedApi.botCommand).toHaveBeenCalledWith("help", ""); }); expect(lastSendChatArgs).toBeNull(); }); diff --git a/frontend/src/components/SetupWizard.tsx b/frontend/src/components/SetupWizard.tsx index 28393c2c..224db3f0 100644 --- a/frontend/src/components/SetupWizard.tsx +++ b/frontend/src/components/SetupWizard.tsx @@ -1,7 +1,6 @@ import { useCallback, useState } from "react"; import type { WizardStateData, WizardStepInfo } from "../api/client"; - -const API_BASE = "/api"; +import { rpcCall } from "../api/rpc"; interface SetupWizardProps { wizardState: WizardStateData; @@ -50,27 +49,17 @@ function stepBorder(status: string, isActive: boolean): string { /** Messages sent to the chat to trigger agent generation for each step. */ const STEP_PROMPTS: Record = { context: - "Read the codebase and generate .huskies/specs/00_CONTEXT.md with a project context spec. Include High-Level Goal, Core Features, Domain Definition, and Glossary sections. Then call the wizard API to store the content: PUT /api/wizard/step/context/content", + "Read the codebase and generate .huskies/specs/00_CONTEXT.md with a project context spec. Include High-Level Goal, Core Features, Domain Definition, and Glossary sections. Then call the wizard MCP tool `wizard_generate` with step=context to store the content.", stack: - "Read the tech stack and generate .huskies/specs/tech/STACK.md with a tech stack spec. Include Core Stack, Coding Standards, Quality Gates, and Libraries sections. Then call the wizard API to store the content: PUT /api/wizard/step/stack/content", + "Read the tech stack and generate .huskies/specs/tech/STACK.md with a tech stack spec. Include Core Stack, Coding Standards, Quality Gates, and Libraries sections. Then call the wizard MCP tool `wizard_generate` with step=stack to store the content.", test_script: - "Read the project structure and create script/test — a bash script that runs the project's actual test suite. Then call the wizard API: PUT /api/wizard/step/test_script/content", + "Read the project structure and create script/test — a bash script that runs the project's actual test suite. Then call the wizard MCP tool `wizard_generate` with step=test_script to store the content.", release_script: - "Read the project's deployment setup and create script/release tailored to the project. Then call the wizard API: PUT /api/wizard/step/release_script/content", + "Read the project's deployment setup and create script/release tailored to the project. Then call the wizard MCP tool `wizard_generate` with step=release_script to store the content.", test_coverage: - "If the stack supports coverage reporting, create script/test_coverage. Then call the wizard API: PUT /api/wizard/step/test_coverage/content", + "If the stack supports coverage reporting, create script/test_coverage. Then call the wizard MCP tool `wizard_generate` with step=test_coverage to store the content.", }; -async function apiPost(path: string): Promise { - try { - const resp = await fetch(`${API_BASE}${path}`, { method: "POST" }); - if (!resp.ok) return null; - return (await resp.json()) as WizardStateData; - } catch { - return null; - } -} - function StepCard({ step, isActive, @@ -272,10 +261,14 @@ export default function SetupWizard({ const handleConfirm = useCallback( async (step: WizardStepInfo) => { - const result = await apiPost(`/wizard/step/${step.step}/confirm`); - if (result) { + try { + const result = await rpcCall("wizard.confirm_step", { + step: step.step, + }); onWizardUpdate(result); setRefreshKey((k) => k + 1); + } catch { + // ignore — state remains unchanged } }, [onWizardUpdate], @@ -283,10 +276,14 @@ export default function SetupWizard({ const handleSkip = useCallback( async (step: WizardStepInfo) => { - const result = await apiPost(`/wizard/step/${step.step}/skip`); - if (result) { + try { + const result = await rpcCall("wizard.skip_step", { + step: step.step, + }); onWizardUpdate(result); setRefreshKey((k) => k + 1); + } catch { + // ignore — state remains unchanged } }, [onWizardUpdate], diff --git a/frontend/src/hooks/useChatSend.ts b/frontend/src/hooks/useChatSend.ts index ae0bbf0b..f4b2d151 100644 --- a/frontend/src/hooks/useChatSend.ts +++ b/frontend/src/hooks/useChatSend.ts @@ -125,7 +125,7 @@ export function useChatSend({ { role: "user", content: messageText }, ]); try { - const result = await api.botCommand(cmd, args, undefined); + const result = await api.botCommand(cmd, args); setMessages((prev: Message[]) => [ ...prev, { role: "assistant", content: result.response }, diff --git a/server/Cargo.toml b/server/Cargo.toml index a5a53aab..d61d5158 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -17,7 +17,6 @@ ignore = { workspace = true } mime_guess = { workspace = true } notify = { workspace = true } poem = { workspace = true, features = ["websocket"] } -poem-openapi = { workspace = true, features = ["swagger-ui"] } portable-pty = { workspace = true } reqwest = { workspace = true, features = ["json", "stream", "form"] } rust-embed = { workspace = true } diff --git a/server/src/crdt_sync/mod.rs b/server/src/crdt_sync/mod.rs index 33abd1dd..1d960212 100644 --- a/server/src/crdt_sync/mod.rs +++ b/server/src/crdt_sync/mod.rs @@ -70,8 +70,8 @@ mod wire; pub use auth::{add_join_token, init_token_auth, init_trusted_keys}; pub(crate) use client::connect_and_sync; pub use client::{RENDEZVOUS_ERROR_THRESHOLD, spawn_rendezvous_client}; -pub use rpc::init_rpc_context; pub(crate) use rpc::try_handle_rpc_text; +pub use rpc::{init_rpc_agents, init_rpc_context}; pub use server::crdt_sync_handler; // Test-only re-export used by `crdt_snapshot` tests. diff --git a/server/src/crdt_sync/rpc.rs b/server/src/crdt_sync/rpc.rs index e7cb4983..d7c2f2a1 100644 --- a/server/src/crdt_sync/rpc.rs +++ b/server/src/crdt_sync/rpc.rs @@ -37,6 +37,7 @@ use super::rpc_contract::{ SetAnthropicApiKeyParams, SetModelPreferenceParams, }; use super::wire::RpcFrame; +use crate::agents::AgentPool; use crate::state::SessionState; use crate::store::JsonFileStore; use crate::workflow::WorkflowState; @@ -57,6 +58,9 @@ pub struct RpcState { /// Global RPC context, initialised once at server startup via [`init_rpc_context`]. static RPC_CTX: OnceLock = OnceLock::new(); +/// Global agent pool, registered once at startup via [`init_rpc_agents`]. +static RPC_AGENTS: OnceLock> = OnceLock::new(); + /// Register the global RPC context. /// /// Must be called before any handler that accesses project state is invoked. @@ -73,6 +77,14 @@ pub fn init_rpc_context( }); } +/// Register the agent pool for use in RPC handlers. +/// +/// Must be called after [`AgentPool`] is constructed (after `init_rpc_context`). +/// Subsequent calls are silently ignored (OnceLock semantics). +pub fn init_rpc_agents(agents: Arc) { + let _ = RPC_AGENTS.set(agents); +} + /// Static registry mapping method names to handlers. /// /// Add new handlers here. The registry is a plain slice — linear scan is @@ -145,6 +157,18 @@ static HANDLERS: &[(&str, Handler)] = &[ ("project.forget", |p| Box::pin(handle_project_forget(p))), ("bot_config.save", |p| Box::pin(handle_bot_config_save(p))), ("chat.cancel", |p| Box::pin(handle_chat_cancel(p))), + // ── formerly REST-only endpoints, now RPC ──────────────────────────────── + ("io.read_file", |p| Box::pin(handle_io_read_file(p))), + ("io.list_directory_absolute", |p| { + Box::pin(handle_io_list_directory_absolute(p)) + }), + ("bot.command", |p| Box::pin(handle_bot_command(p))), + ("agents.start", |p| Box::pin(handle_agents_start(p))), + ("agents.stop", |p| Box::pin(handle_agents_stop(p))), + ("wizard.confirm_step", |p| { + Box::pin(handle_wizard_confirm_step(p)) + }), + ("wizard.skip_step", |p| Box::pin(handle_wizard_skip_step(p))), ]; // ── typed-write helper macros ─────────────────────────────────────────────── @@ -778,6 +802,194 @@ async fn handle_chat_cancel(_params: Value) -> Value { } } +// ── formerly REST-only handlers ────────────────────────────────────────────── + +/// Handler for `io.read_file`. Reads a project-scoped file and returns its content. +/// +/// Parameters: `{ "path": string }`. +async fn handle_io_read_file(params: Value) -> Value { + let Some(ctx) = RPC_CTX.get() else { + return err_json("RPC context not initialised"); + }; + let Some(path) = params.get("path").and_then(|v| v.as_str()) else { + return err_json("missing path"); + }; + match crate::service::file_io::read_file(path.to_string(), &ctx.state).await { + Ok(content) => Value::String(content), + Err(e) => err_json(e.to_string()), + } +} + +/// Handler for `io.list_directory_absolute`. Lists entries at an absolute path. +/// +/// Parameters: `{ "path": string }`. +async fn handle_io_list_directory_absolute(params: Value) -> Value { + let Some(path) = params.get("path").and_then(|v| v.as_str()) else { + return err_json("missing path"); + }; + match crate::service::file_io::list_directory_absolute(path.to_string()).await { + Ok(entries) => serde_json::to_value(entries).unwrap_or(Value::Array(vec![])), + Err(e) => err_json(e.to_string()), + } +} + +/// Handler for `bot.command`. Dispatches a slash command and returns markdown output. +/// +/// Parameters: `{ "command": string, "args"?: string }`. +async fn handle_bot_command(params: Value) -> Value { + let Some(ctx) = RPC_CTX.get() else { + return err_json("RPC context not initialised"); + }; + let Some(command) = params.get("command").and_then(|v| v.as_str()) else { + return err_json("missing command"); + }; + let args = params.get("args").and_then(|v| v.as_str()).unwrap_or(""); + let Ok(root) = ctx.state.get_project_root() else { + return err_json("No project open"); + }; + let Some(agents) = RPC_AGENTS.get() else { + return err_json("Agent pool not initialised"); + }; + match crate::service::bot_command::execute(command, args, &root, agents).await { + Ok(response) => serde_json::json!({"response": response}), + Err(e) => err_json(e.to_string()), + } +} + +/// Handler for `agents.start`. Starts an agent for a story. +/// +/// Parameters: `{ "story_id": string, "agent_name"?: string }`. +async fn handle_agents_start(params: Value) -> Value { + let Some(ctx) = RPC_CTX.get() else { + return err_json("RPC context not initialised"); + }; + let Some(story_id) = params.get("story_id").and_then(|v| v.as_str()) else { + return err_json("missing story_id"); + }; + let agent_name = params.get("agent_name").and_then(|v| v.as_str()); + let Ok(root) = ctx.state.get_project_root() else { + return err_json("No project open"); + }; + let Some(agents) = RPC_AGENTS.get() else { + return err_json("Agent pool not initialised"); + }; + match crate::service::agents::start_agent(agents, &root, story_id, agent_name, None, None).await + { + Ok(info) => serde_json::json!({ + "story_id": info.story_id, + "agent_name": info.agent_name, + "status": info.status.to_string(), + "session_id": info.session_id, + "worktree_path": info.worktree_path, + "base_branch": info.base_branch, + "log_session_id": info.log_session_id, + }), + Err(e) => err_json(e.to_string()), + } +} + +/// Handler for `agents.stop`. Stops a running agent. +/// +/// Parameters: `{ "story_id": string, "agent_name": string }`. +async fn handle_agents_stop(params: Value) -> Value { + let Some(ctx) = RPC_CTX.get() else { + return err_json("RPC context not initialised"); + }; + let Some(story_id) = params.get("story_id").and_then(|v| v.as_str()) else { + return err_json("missing story_id"); + }; + let Some(agent_name) = params.get("agent_name").and_then(|v| v.as_str()) else { + return err_json("missing agent_name"); + }; + let Ok(root) = ctx.state.get_project_root() else { + return err_json("No project open"); + }; + let Some(agents) = RPC_AGENTS.get() else { + return err_json("Agent pool not initialised"); + }; + match crate::service::agents::stop_agent(agents, &root, story_id, agent_name).await { + Ok(()) => Value::Bool(true), + Err(e) => err_json(e.to_string()), + } +} + +/// Serialise a [`crate::io::wizard::WizardState`] into the frontend's expected JSON shape. +fn wizard_state_to_value(state: &crate::io::wizard::WizardState) -> Value { + let steps: Vec = state + .steps + .iter() + .map(|s| { + let step_str = serde_json::to_value(s.step) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_default(); + let status_str = serde_json::to_value(&s.status) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_default(); + serde_json::json!({ + "step": step_str, + "label": s.step.label(), + "status": status_str, + "content": s.content, + }) + }) + .collect(); + serde_json::json!({ + "steps": steps, + "current_step_index": state.current_step_index(), + "completed": state.completed, + }) +} + +/// Handler for `wizard.confirm_step`. Confirms the current wizard step. +/// +/// Parameters: `{ "step": string }`. +async fn handle_wizard_confirm_step(params: Value) -> Value { + let Some(ctx) = RPC_CTX.get() else { + return err_json("RPC context not initialised"); + }; + let Some(step_str) = params.get("step").and_then(|v| v.as_str()) else { + return err_json("missing step"); + }; + let Ok(root) = ctx.state.get_project_root() else { + return err_json("No project open"); + }; + let quoted = format!("\"{step_str}\""); + let step = match serde_json::from_str::("ed) { + Ok(s) => s, + Err(_) => return err_json(format!("Unknown wizard step: {step_str}")), + }; + match crate::service::wizard::mark_step_confirmed(&root, step) { + Ok(state) => wizard_state_to_value(&state), + Err(e) => err_json(e.to_string()), + } +} + +/// Handler for `wizard.skip_step`. Skips the current wizard step. +/// +/// Parameters: `{ "step": string }`. +async fn handle_wizard_skip_step(params: Value) -> Value { + let Some(ctx) = RPC_CTX.get() else { + return err_json("RPC context not initialised"); + }; + let Some(step_str) = params.get("step").and_then(|v| v.as_str()) else { + return err_json("missing step"); + }; + let Ok(root) = ctx.state.get_project_root() else { + return err_json("No project open"); + }; + let quoted = format!("\"{step_str}\""); + let step = match serde_json::from_str::("ed) { + Ok(s) => s, + Err(_) => return err_json(format!("Unknown wizard step: {step_str}")), + }; + match crate::service::wizard::mark_step_skipped(&root, step) { + Ok(state) => wizard_state_to_value(&state), + Err(e) => err_json(e.to_string()), + } +} + // ── dispatch ────────────────────────────────────────────────────────────────── /// Dispatch an incoming RPC method call to the registered handler. diff --git a/server/src/http/agents/mod.rs b/server/src/http/agents/mod.rs deleted file mode 100644 index 41b37158..00000000 --- a/server/src/http/agents/mod.rs +++ /dev/null @@ -1,551 +0,0 @@ -//! HTTP agent endpoints — thin adapters over `service::agents`. -//! -//! Each handler: extracts payload → calls `service::agents::X` → shapes -//! response DTO → returns HTTP result. No filesystem access, no inline -//! validation, no process invocations. -use crate::http::context::{AppContext, OpenApiResult, bad_request, not_found}; -use crate::service::agents::{self as svc, AgentConfigEntry, WorkItemContent}; -use crate::workflow::{StoryTestResults, TestCaseResult, TestStatus}; -use poem::http::StatusCode; -use poem_openapi::{Object, OpenApi, Tags, param::Path, payload::Json}; -use serde::Serialize; -use std::sync::Arc; - -#[derive(Tags)] -enum AgentsTags { - Agents, -} - -#[derive(Object)] -struct StartAgentPayload { - story_id: String, - agent_name: Option, -} - -#[derive(Object)] -struct StopAgentPayload { - story_id: String, - agent_name: String, -} - -#[derive(Object, Serialize)] -struct AgentInfoResponse { - story_id: String, - agent_name: String, - status: String, - session_id: Option, - worktree_path: Option, -} - -#[derive(Object, Serialize)] -struct AgentConfigInfoResponse { - name: String, - role: String, - stage: Option, - model: Option, - allowed_tools: Option>, - max_turns: Option, - max_budget_usd: Option, -} - -impl From for AgentConfigInfoResponse { - fn from(e: AgentConfigEntry) -> Self { - Self { - name: e.name, - role: e.role, - stage: e.stage, - model: e.model, - allowed_tools: e.allowed_tools, - max_turns: e.max_turns, - max_budget_usd: e.max_budget_usd, - } - } -} - -#[derive(Object)] -struct CreateWorktreePayload { - story_id: String, -} - -#[derive(Object, Serialize)] -struct WorktreeInfoResponse { - story_id: String, - worktree_path: String, - branch: String, - base_branch: String, -} - -#[derive(Object, Serialize)] -struct WorktreeListEntry { - story_id: String, - path: String, -} - -/// Response for the work item content endpoint. -#[derive(Object, Serialize)] -struct WorkItemContentResponse { - content: String, - stage: String, - name: Option, - agent: Option, -} - -impl From for WorkItemContentResponse { - fn from(w: WorkItemContent) -> Self { - use crate::pipeline_state::Stage; - // Frozen items report "frozen" so the UI can render them distinctly; - // otherwise we emit the canonical clean stage directory name. - let stage = if w.frozen { - "frozen".to_string() - } else { - match &w.stage { - Stage::Coding => "current".to_string(), - other => other.dir_name().to_string(), - } - }; - Self { - content: w.content, - stage, - name: w.name, - agent: w.agent, - } - } -} - -/// A single test case result for the OpenAPI response. -#[derive(Object, Serialize)] -struct TestCaseResultResponse { - name: String, - status: String, - details: Option, -} - -/// Response for the work item test results endpoint. -#[derive(Object, Serialize)] -struct TestResultsResponse { - unit: Vec, - integration: Vec, -} - -impl TestResultsResponse { - fn from_story_results(results: &StoryTestResults) -> Self { - Self { - unit: results.unit.iter().map(Self::map_case).collect(), - integration: results.integration.iter().map(Self::map_case).collect(), - } - } - - fn map_case(tc: &TestCaseResult) -> TestCaseResultResponse { - TestCaseResultResponse { - name: tc.name.clone(), - status: match tc.status { - TestStatus::Pass => "pass".to_string(), - TestStatus::Fail => "fail".to_string(), - }, - details: tc.details.clone(), - } - } -} - -/// Response for the agent output endpoint. -#[derive(Object, Serialize)] -struct AgentOutputResponse { - output: String, -} - -/// Per-agent cost breakdown entry for the token cost endpoint. -#[derive(Object, Serialize)] -struct AgentCostEntry { - agent_name: String, - model: Option, - input_tokens: u64, - output_tokens: u64, - cache_creation_input_tokens: u64, - cache_read_input_tokens: u64, - total_cost_usd: f64, -} - -/// Response for the work item token cost endpoint. -#[derive(Object, Serialize)] -struct TokenCostResponse { - total_cost_usd: f64, - agents: Vec, -} - -/// A single token usage record in the all-usage response. -#[derive(Object, Serialize)] -struct TokenUsageRecordResponse { - story_id: String, - agent_name: String, - model: Option, - timestamp: String, - input_tokens: u64, - output_tokens: u64, - cache_creation_input_tokens: u64, - cache_read_input_tokens: u64, - total_cost_usd: f64, -} - -/// Response for the all token usage endpoint. -#[derive(Object, Serialize)] -struct AllTokenUsageResponse { - records: Vec, -} - -/// Map a `service::agents::Error` to a Poem HTTP error with the correct status. -fn map_svc_error(err: svc::Error) -> poem::Error { - match err { - svc::Error::AgentNotFound(_) => { - poem::Error::from_string(err.to_string(), StatusCode::NOT_FOUND) - } - svc::Error::WorkItemNotFound(_) => { - poem::Error::from_string(err.to_string(), StatusCode::NOT_FOUND) - } - svc::Error::Worktree(_) => { - poem::Error::from_string(err.to_string(), StatusCode::BAD_REQUEST) - } - svc::Error::Config(_) => poem::Error::from_string(err.to_string(), StatusCode::BAD_REQUEST), - svc::Error::Io(_) => { - poem::Error::from_string(err.to_string(), StatusCode::INTERNAL_SERVER_ERROR) - } - } -} - -/// OpenAPI endpoint group for agent management (start, stop, list, inspect). -pub struct AgentsApi { - pub ctx: Arc, -} - -#[OpenApi(tag = "AgentsTags::Agents")] -impl AgentsApi { - /// Start an agent for a given story (creates worktree, runs setup, spawns agent). - /// If agent_name is omitted, the first configured agent is used. - #[oai(path = "/agents/start", method = "post")] - async fn start_agent( - &self, - payload: Json, - ) -> OpenApiResult> { - let project_root = self - .ctx - .services - .agents - .get_project_root(&self.ctx.state) - .map_err(bad_request)?; - - let info = svc::start_agent( - &self.ctx.services.agents, - &project_root, - &payload.0.story_id, - payload.0.agent_name.as_deref(), - None, - None, - ) - .await - .map_err(map_svc_error)?; - - Ok(Json(AgentInfoResponse { - story_id: info.story_id, - agent_name: info.agent_name, - status: info.status.to_string(), - session_id: info.session_id, - worktree_path: info.worktree_path, - })) - } - - /// Stop a running agent and clean up its worktree. - #[oai(path = "/agents/stop", method = "post")] - async fn stop_agent(&self, payload: Json) -> OpenApiResult> { - let project_root = self - .ctx - .services - .agents - .get_project_root(&self.ctx.state) - .map_err(bad_request)?; - - svc::stop_agent( - &self.ctx.services.agents, - &project_root, - &payload.0.story_id, - &payload.0.agent_name, - ) - .await - .map_err(map_svc_error)?; - - Ok(Json(true)) - } - - /// Get the configured agent roster from project.toml. - #[oai(path = "/agents/config", method = "get")] - async fn get_agent_config(&self) -> OpenApiResult>> { - let project_root = self - .ctx - .services - .agents - .get_project_root(&self.ctx.state) - .map_err(bad_request)?; - - let entries = svc::get_agent_config(&project_root).map_err(map_svc_error)?; - Ok(Json( - entries - .into_iter() - .map(AgentConfigInfoResponse::from) - .collect(), - )) - } - - /// Reload project config and return the updated agent roster. - #[oai(path = "/agents/config/reload", method = "post")] - async fn reload_config(&self) -> OpenApiResult>> { - let project_root = self - .ctx - .services - .agents - .get_project_root(&self.ctx.state) - .map_err(bad_request)?; - - let entries = svc::reload_config(&project_root).map_err(map_svc_error)?; - Ok(Json( - entries - .into_iter() - .map(AgentConfigInfoResponse::from) - .collect(), - )) - } - - /// Create a git worktree for a story under .huskies/worktrees/{story_id}. - #[oai(path = "/agents/worktrees", method = "post")] - async fn create_worktree( - &self, - payload: Json, - ) -> OpenApiResult> { - let project_root = self - .ctx - .services - .agents - .get_project_root(&self.ctx.state) - .map_err(bad_request)?; - - let info = svc::create_worktree( - &self.ctx.services.agents, - &project_root, - &payload.0.story_id, - ) - .await - .map_err(map_svc_error)?; - - Ok(Json(WorktreeInfoResponse { - story_id: payload.0.story_id, - worktree_path: info.path.to_string_lossy().to_string(), - branch: info.branch, - base_branch: info.base_branch, - })) - } - - /// List all worktrees under .huskies/worktrees/. - #[oai(path = "/agents/worktrees", method = "get")] - async fn list_worktrees(&self) -> OpenApiResult>> { - let project_root = self - .ctx - .services - .agents - .get_project_root(&self.ctx.state) - .map_err(bad_request)?; - - let entries = svc::list_worktrees(&project_root).map_err(map_svc_error)?; - - Ok(Json( - entries - .into_iter() - .map(|e| WorktreeListEntry { - story_id: e.story_id, - path: e.path.to_string_lossy().to_string(), - }) - .collect(), - )) - } - - /// Get the markdown content of a work item by its story_id. - /// - /// Searches all active pipeline stages for the file and returns its content - /// along with the stage it was found in. - #[oai(path = "/work-items/:story_id", method = "get")] - async fn get_work_item_content( - &self, - story_id: Path, - ) -> OpenApiResult> { - let project_root = self - .ctx - .services - .agents - .get_project_root(&self.ctx.state) - .map_err(bad_request)?; - - let item = svc::get_work_item_content(&project_root, &story_id.0).map_err(|e| match e { - svc::Error::WorkItemNotFound(_) => not_found(e.to_string()), - other => map_svc_error(other), - })?; - - Ok(Json(WorkItemContentResponse::from(item))) - } - - /// Get test results for a work item by its story_id. - /// - /// Returns unit and integration test results. Checks in-memory workflow - /// state first, then falls back to results persisted in the story file. - #[oai(path = "/work-items/:story_id/test-results", method = "get")] - async fn get_test_results( - &self, - story_id: Path, - ) -> OpenApiResult>> { - // Fast path: return from in-memory state without requiring project_root. - let in_memory = { - let workflow = self - .ctx - .workflow - .lock() - .map_err(|e| bad_request(format!("Lock error: {e}")))?; - workflow.results.get(&story_id.0).cloned() - }; - if let Some(results) = in_memory { - return Ok(Json(Some(TestResultsResponse::from_story_results( - &results, - )))); - } - - // Slow path: fall back to results persisted in the story file. - let project_root = self - .ctx - .services - .agents - .get_project_root(&self.ctx.state) - .map_err(bad_request)?; - - let workflow = self - .ctx - .workflow - .lock() - .map_err(|e| bad_request(format!("Lock error: {e}")))?; - - let results = svc::get_test_results(&project_root, &story_id.0, &workflow); - Ok(Json( - results.map(|r| TestResultsResponse::from_story_results(&r)), - )) - } - - /// Get the historical output text for an agent session. - /// - /// Reads the most recent persistent log file for the given story+agent and - /// returns all `output` events concatenated as a single string. Returns an - /// empty string if no log file exists yet. - #[oai(path = "/agents/:story_id/:agent_name/output", method = "get")] - async fn get_agent_output( - &self, - story_id: Path, - agent_name: Path, - ) -> OpenApiResult> { - let project_root = self - .ctx - .services - .agents - .get_project_root(&self.ctx.state) - .map_err(bad_request)?; - - let output = svc::get_agent_output(&project_root, &story_id.0, &agent_name.0) - .map_err(map_svc_error)?; - - Ok(Json(AgentOutputResponse { output })) - } - - /// Remove a git worktree and its feature branch for a story. - #[oai(path = "/agents/worktrees/:story_id", method = "delete")] - async fn remove_worktree(&self, story_id: Path) -> OpenApiResult> { - let project_root = self - .ctx - .services - .agents - .get_project_root(&self.ctx.state) - .map_err(bad_request)?; - - svc::remove_worktree(&project_root, &story_id.0) - .await - .map_err(map_svc_error)?; - - Ok(Json(true)) - } - - /// Get the total token cost and per-agent breakdown for a work item. - /// - /// Returns the sum of all recorded token usage for the given story_id. - /// If no usage has been recorded, returns zero cost with an empty agents list. - #[oai(path = "/work-items/:story_id/token-cost", method = "get")] - async fn get_work_item_token_cost( - &self, - story_id: Path, - ) -> OpenApiResult> { - let project_root = self - .ctx - .services - .agents - .get_project_root(&self.ctx.state) - .map_err(bad_request)?; - - let summary = - svc::get_work_item_token_cost(&project_root, &story_id.0).map_err(map_svc_error)?; - - let agents = summary - .agents - .into_iter() - .map(|a| AgentCostEntry { - agent_name: a.agent_name, - model: a.model, - input_tokens: a.input_tokens, - output_tokens: a.output_tokens, - cache_creation_input_tokens: a.cache_creation_input_tokens, - cache_read_input_tokens: a.cache_read_input_tokens, - total_cost_usd: a.total_cost_usd, - }) - .collect(); - - Ok(Json(TokenCostResponse { - total_cost_usd: summary.total_cost_usd, - agents, - })) - } - - /// Get all token usage records across all stories. - /// - /// Returns the full history from the persistent token_usage.jsonl log. - #[oai(path = "/token-usage", method = "get")] - async fn get_all_token_usage(&self) -> OpenApiResult> { - let project_root = self - .ctx - .services - .agents - .get_project_root(&self.ctx.state) - .map_err(bad_request)?; - - let records = svc::get_all_token_usage(&project_root).map_err(map_svc_error)?; - - let response_records: Vec = records - .into_iter() - .map(|r| TokenUsageRecordResponse { - story_id: r.story_id, - agent_name: r.agent_name, - model: r.model, - timestamp: r.timestamp, - input_tokens: r.usage.input_tokens, - output_tokens: r.usage.output_tokens, - cache_creation_input_tokens: r.usage.cache_creation_input_tokens, - cache_read_input_tokens: r.usage.cache_read_input_tokens, - total_cost_usd: r.usage.total_cost_usd, - }) - .collect(); - - Ok(Json(AllTokenUsageResponse { - records: response_records, - })) - } -} - -#[cfg(test)] -mod tests; diff --git a/server/src/http/agents/tests.rs b/server/src/http/agents/tests.rs deleted file mode 100644 index 2391fce3..00000000 --- a/server/src/http/agents/tests.rs +++ /dev/null @@ -1,651 +0,0 @@ -//! Tests for the HTTP agent endpoints. -use super::*; -use crate::agents::AgentStatus; -use std::path; -use tempfile::TempDir; - -fn make_work_dirs(tmp: &TempDir) -> path::PathBuf { - let root = tmp.path().to_path_buf(); - for stage in &["5_done", "6_archived"] { - std::fs::create_dir_all(root.join(".huskies").join("work").join(stage)).unwrap(); - } - root -} - -#[test] -fn story_is_archived_false_when_file_absent() { - let tmp = TempDir::new().unwrap(); - let root = make_work_dirs(&tmp); - assert!(!svc::is_archived(&root, "79_story_foo")); -} - -#[test] -fn story_is_archived_true_when_file_in_5_done() { - let tmp = TempDir::new().unwrap(); - let root = make_work_dirs(&tmp); - std::fs::write( - root.join(".huskies/work/5_done/79_story_foo.md"), - "---\nname: test\n---\n", - ) - .unwrap(); - assert!(svc::is_archived(&root, "79_story_foo")); -} - -#[test] -fn story_is_archived_true_when_file_in_6_archived() { - let tmp = TempDir::new().unwrap(); - let root = make_work_dirs(&tmp); - std::fs::write( - root.join(".huskies/work/6_archived/79_story_foo.md"), - "---\nname: test\n---\n", - ) - .unwrap(); - assert!(svc::is_archived(&root, "79_story_foo")); -} - -fn make_project_toml(root: &path::Path, content: &str) { - let sk_dir = root.join(".huskies"); - std::fs::create_dir_all(&sk_dir).unwrap(); - std::fs::write(sk_dir.join("project.toml"), content).unwrap(); -} - -// --- get_agent_config tests --- - -#[tokio::test] -async fn get_agent_config_returns_default_when_no_toml() { - let tmp = TempDir::new().unwrap(); - let ctx = AppContext::new_test(tmp.path().to_path_buf()); - let api = AgentsApi { ctx: Arc::new(ctx) }; - let result = api.get_agent_config().await.unwrap().0; - // Default config has one agent named "default" - assert_eq!(result.len(), 1); - assert_eq!(result[0].name, "default"); -} - -#[tokio::test] -async fn get_agent_config_returns_configured_agents() { - let tmp = TempDir::new().unwrap(); - make_project_toml( - tmp.path(), - r#" -[[agent]] -name = "coder-1" -role = "Full-stack engineer" -model = "sonnet" -max_turns = 30 -max_budget_usd = 5.0 - -[[agent]] -name = "qa" -role = "QA reviewer" -model = "haiku" -"#, - ); - let ctx = AppContext::new_test(tmp.path().to_path_buf()); - let api = AgentsApi { ctx: Arc::new(ctx) }; - let result = api.get_agent_config().await.unwrap().0; - assert_eq!(result.len(), 2); - assert_eq!(result[0].name, "coder-1"); - assert_eq!(result[0].role, "Full-stack engineer"); - assert_eq!(result[0].model, Some("sonnet".to_string())); - assert_eq!(result[0].max_turns, Some(30)); - assert_eq!(result[0].max_budget_usd, Some(5.0)); - assert_eq!(result[1].name, "qa"); - assert_eq!(result[1].model, Some("haiku".to_string())); -} - -#[tokio::test] -async fn get_agent_config_returns_error_when_no_project_root() { - let tmp = TempDir::new().unwrap(); - let ctx = AppContext::new_test(tmp.path().to_path_buf()); - *ctx.state.project_root.lock().unwrap() = None; - let api = AgentsApi { ctx: Arc::new(ctx) }; - let result = api.get_agent_config().await; - assert!(result.is_err()); -} - -// --- reload_config tests --- - -#[tokio::test] -async fn reload_config_returns_default_when_no_toml() { - let tmp = TempDir::new().unwrap(); - let ctx = AppContext::new_test(tmp.path().to_path_buf()); - let api = AgentsApi { ctx: Arc::new(ctx) }; - let result = api.reload_config().await.unwrap().0; - assert_eq!(result.len(), 1); - assert_eq!(result[0].name, "default"); -} - -#[tokio::test] -async fn reload_config_returns_configured_agents() { - let tmp = TempDir::new().unwrap(); - make_project_toml( - tmp.path(), - r#" -[[agent]] -name = "supervisor" -role = "Coordinator" -model = "opus" -allowed_tools = ["Read", "Bash"] -"#, - ); - let ctx = AppContext::new_test(tmp.path().to_path_buf()); - let api = AgentsApi { ctx: Arc::new(ctx) }; - let result = api.reload_config().await.unwrap().0; - assert_eq!(result.len(), 1); - assert_eq!(result[0].name, "supervisor"); - assert_eq!(result[0].role, "Coordinator"); - assert_eq!(result[0].model, Some("opus".to_string())); - assert_eq!( - result[0].allowed_tools, - Some(vec!["Read".to_string(), "Bash".to_string()]) - ); -} - -#[tokio::test] -async fn reload_config_returns_error_when_no_project_root() { - let tmp = TempDir::new().unwrap(); - let ctx = AppContext::new_test(tmp.path().to_path_buf()); - *ctx.state.project_root.lock().unwrap() = None; - let api = AgentsApi { ctx: Arc::new(ctx) }; - let result = api.reload_config().await; - assert!(result.is_err()); -} - -// --- list_worktrees tests --- - -#[tokio::test] -async fn list_worktrees_returns_empty_when_no_worktree_dir() { - let tmp = TempDir::new().unwrap(); - let ctx = AppContext::new_test(tmp.path().to_path_buf()); - let api = AgentsApi { ctx: Arc::new(ctx) }; - let result = api.list_worktrees().await.unwrap().0; - assert!(result.is_empty()); -} - -#[tokio::test] -async fn list_worktrees_returns_entries_from_dir() { - let tmp = TempDir::new().unwrap(); - let worktrees_dir = tmp.path().join(".huskies").join("worktrees"); - std::fs::create_dir_all(worktrees_dir.join("42_story_foo")).unwrap(); - std::fs::create_dir_all(worktrees_dir.join("43_story_bar")).unwrap(); - - let ctx = AppContext::new_test(tmp.path().to_path_buf()); - let api = AgentsApi { ctx: Arc::new(ctx) }; - let mut result = api.list_worktrees().await.unwrap().0; - result.sort_by(|a, b| a.story_id.cmp(&b.story_id)); - - assert_eq!(result.len(), 2); - assert_eq!(result[0].story_id, "42_story_foo"); - assert_eq!(result[1].story_id, "43_story_bar"); -} - -#[tokio::test] -async fn list_worktrees_returns_error_when_no_project_root() { - let tmp = TempDir::new().unwrap(); - let ctx = AppContext::new_test(tmp.path().to_path_buf()); - *ctx.state.project_root.lock().unwrap() = None; - let api = AgentsApi { ctx: Arc::new(ctx) }; - let result = api.list_worktrees().await; - assert!(result.is_err()); -} - -// --- stop_agent tests --- - -#[tokio::test] -async fn stop_agent_returns_error_when_no_project_root() { - let tmp = TempDir::new().unwrap(); - let ctx = AppContext::new_test(tmp.path().to_path_buf()); - *ctx.state.project_root.lock().unwrap() = None; - let api = AgentsApi { ctx: Arc::new(ctx) }; - let result = api - .stop_agent(Json(StopAgentPayload { - story_id: "42_story_foo".to_string(), - agent_name: "coder-1".to_string(), - })) - .await; - assert!(result.is_err()); -} - -#[tokio::test] -async fn stop_agent_returns_error_when_agent_not_found() { - let tmp = TempDir::new().unwrap(); - let ctx = AppContext::new_test(tmp.path().to_path_buf()); - let api = AgentsApi { ctx: Arc::new(ctx) }; - let result = api - .stop_agent(Json(StopAgentPayload { - story_id: "nonexistent_story".to_string(), - agent_name: "coder-1".to_string(), - })) - .await; - assert!(result.is_err()); -} - -#[tokio::test] -async fn stop_agent_succeeds_with_running_agent() { - let tmp = TempDir::new().unwrap(); - let ctx = AppContext::new_test(tmp.path().to_path_buf()); - ctx.services - .agents - .inject_test_agent("42_story_foo", "coder-1", AgentStatus::Running); - let api = AgentsApi { ctx: Arc::new(ctx) }; - let result = api - .stop_agent(Json(StopAgentPayload { - story_id: "42_story_foo".to_string(), - agent_name: "coder-1".to_string(), - })) - .await - .unwrap() - .0; - assert!(result); -} - -// --- start_agent error path --- - -#[tokio::test] -async fn start_agent_returns_error_when_no_project_root() { - let tmp = TempDir::new().unwrap(); - let ctx = AppContext::new_test(tmp.path().to_path_buf()); - *ctx.state.project_root.lock().unwrap() = None; - let api = AgentsApi { ctx: Arc::new(ctx) }; - let result = api - .start_agent(Json(StartAgentPayload { - story_id: "42_story_foo".to_string(), - agent_name: None, - })) - .await; - assert!(result.is_err()); -} - -// --- get_work_item_content tests --- - -fn make_stage_dir(root: &path::Path, stage: &str) { - std::fs::create_dir_all(root.join(".huskies").join("work").join(stage)).unwrap(); -} - -#[tokio::test] -async fn get_work_item_content_returns_content_from_backlog() { - crate::crdt_state::init_for_test(); - let tmp = TempDir::new().unwrap(); - let root = tmp.path(); - make_stage_dir(root, "1_backlog"); - std::fs::write( - root.join(".huskies/work/1_backlog/42_story_foo.md"), - "---\nname: \"Foo Story\"\n---\n\n# Story 42: Foo Story\n\nSome content.", - ) - .unwrap(); - // Story 929: name lives in the typed CRDT register, not in YAML on disk. - crate::crdt_state::write_item_str( - "42_story_foo", - "1_backlog", - Some("Foo Story"), - None, - None, - None, - None, - None, - None, - ); - let ctx = AppContext::new_test(root.to_path_buf()); - let api = AgentsApi { ctx: Arc::new(ctx) }; - let result = api - .get_work_item_content(Path("42_story_foo".to_string())) - .await - .unwrap() - .0; - assert!(result.content.contains("Some content.")); - assert_eq!(result.stage, "backlog"); - assert_eq!(result.name, Some("Foo Story".to_string())); -} - -#[tokio::test] -async fn get_work_item_content_returns_content_from_current() { - crate::crdt_state::init_for_test(); - let tmp = TempDir::new().unwrap(); - let root = tmp.path(); - make_stage_dir(root, "2_current"); - std::fs::write( - root.join(".huskies/work/2_current/43_story_bar.md"), - "---\nname: \"Bar Story\"\n---\n\nBar content.", - ) - .unwrap(); - crate::crdt_state::write_item_str( - "43_story_bar", - "2_current", - Some("Bar Story"), - None, - None, - None, - None, - None, - None, - ); - let ctx = AppContext::new_test(root.to_path_buf()); - let api = AgentsApi { ctx: Arc::new(ctx) }; - let result = api - .get_work_item_content(Path("43_story_bar".to_string())) - .await - .unwrap() - .0; - assert_eq!(result.stage, "current"); - assert_eq!(result.name, Some("Bar Story".to_string())); -} - -#[tokio::test] -async fn get_work_item_content_returns_not_found_when_absent() { - let tmp = TempDir::new().unwrap(); - let ctx = AppContext::new_test(tmp.path().to_path_buf()); - let api = AgentsApi { ctx: Arc::new(ctx) }; - let result = api - .get_work_item_content(Path("99_story_nonexistent".to_string())) - .await; - assert!(result.is_err()); -} - -#[tokio::test] -async fn get_work_item_content_falls_back_to_crdt_when_no_file() { - let tmp = TempDir::new().unwrap(); - let root = tmp.path().to_path_buf(); - // Seed content + CRDT with no .md file on disk. - crate::db::write_item_with_content( - "44_story_crdt_only", - "1_backlog", - "---\nname: \"CRDT Only\"\n---\n\nCRDT content.", - crate::db::ItemMeta::named("CRDT Only"), - ); - let ctx = AppContext::new_test(root); - let api = AgentsApi { ctx: Arc::new(ctx) }; - let result = api - .get_work_item_content(Path("44_story_crdt_only".to_string())) - .await - .unwrap() - .0; - assert!(result.content.contains("CRDT content.")); - assert_eq!(result.stage, "backlog"); - assert_eq!(result.name, Some("CRDT Only".to_string())); -} - -#[tokio::test] -async fn get_work_item_content_crdt_fallback_with_current_stage() { - let tmp = TempDir::new().unwrap(); - let root = tmp.path().to_path_buf(); - // Seed a CRDT-only story in the coding/current stage. - crate::db::write_item_with_content( - "45_story_crdt_current", - "2_current", - "---\nname: \"Current CRDT\"\n---\n\nIn progress.", - crate::db::ItemMeta::named("Current CRDT"), - ); - let ctx = AppContext::new_test(root); - let api = AgentsApi { ctx: Arc::new(ctx) }; - let result = api - .get_work_item_content(Path("45_story_crdt_current".to_string())) - .await - .unwrap() - .0; - assert!(result.content.contains("In progress.")); - assert_eq!(result.stage, "current"); - assert_eq!(result.name, Some("Current CRDT".to_string())); -} - -#[tokio::test] -async fn get_work_item_content_returns_error_when_no_project_root() { - let tmp = TempDir::new().unwrap(); - let ctx = AppContext::new_test(tmp.path().to_path_buf()); - *ctx.state.project_root.lock().unwrap() = None; - let api = AgentsApi { ctx: Arc::new(ctx) }; - let result = api - .get_work_item_content(Path("42_story_foo".to_string())) - .await; - assert!(result.is_err()); -} - -// --- get_agent_output tests --- - -#[tokio::test] -async fn get_agent_output_returns_empty_when_no_log_exists() { - let tmp = TempDir::new().unwrap(); - let ctx = AppContext::new_test(tmp.path().to_path_buf()); - let api = AgentsApi { ctx: Arc::new(ctx) }; - let result = api - .get_agent_output( - Path("42_story_foo".to_string()), - Path("coder-1".to_string()), - ) - .await - .unwrap() - .0; - assert_eq!(result.output, ""); -} - -#[tokio::test] -async fn get_agent_output_returns_concatenated_output_events() { - use crate::agent_log::AgentLogWriter; - use crate::agents::AgentEvent; - - let tmp = TempDir::new().unwrap(); - let root = tmp.path(); - - let mut writer = AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-test").unwrap(); - - writer - .write_event(&AgentEvent::Status { - story_id: "42_story_foo".to_string(), - agent_name: "coder-1".to_string(), - status: "running".to_string(), - }) - .unwrap(); - writer - .write_event(&AgentEvent::Output { - story_id: "42_story_foo".to_string(), - agent_name: "coder-1".to_string(), - text: "Hello ".to_string(), - }) - .unwrap(); - writer - .write_event(&AgentEvent::Output { - story_id: "42_story_foo".to_string(), - agent_name: "coder-1".to_string(), - text: "world\n".to_string(), - }) - .unwrap(); - writer - .write_event(&AgentEvent::Done { - story_id: "42_story_foo".to_string(), - agent_name: "coder-1".to_string(), - session_id: None, - }) - .unwrap(); - - let ctx = AppContext::new_test(root.to_path_buf()); - let api = AgentsApi { ctx: Arc::new(ctx) }; - let result = api - .get_agent_output( - Path("42_story_foo".to_string()), - Path("coder-1".to_string()), - ) - .await - .unwrap() - .0; - - // Only output event texts should be concatenated; status and done are excluded. - assert_eq!(result.output, "Hello world\n"); -} - -#[tokio::test] -async fn get_agent_output_returns_error_when_no_project_root() { - let tmp = TempDir::new().unwrap(); - let ctx = AppContext::new_test(tmp.path().to_path_buf()); - *ctx.state.project_root.lock().unwrap() = None; - let api = AgentsApi { ctx: Arc::new(ctx) }; - let result = api - .get_agent_output( - Path("42_story_foo".to_string()), - Path("coder-1".to_string()), - ) - .await; - assert!(result.is_err()); -} - -// --- create_worktree error path --- - -#[tokio::test] -async fn create_worktree_returns_error_when_no_project_root() { - let tmp = TempDir::new().unwrap(); - let ctx = AppContext::new_test(tmp.path().to_path_buf()); - *ctx.state.project_root.lock().unwrap() = None; - let api = AgentsApi { ctx: Arc::new(ctx) }; - let result = api - .create_worktree(Json(CreateWorktreePayload { - story_id: "42_story_foo".to_string(), - })) - .await; - assert!(result.is_err()); -} - -#[tokio::test] -async fn create_worktree_returns_error_when_not_a_git_repo() { - let tmp = TempDir::new().unwrap(); - // project_root is set but has no git repo — git worktree add will fail - let ctx = AppContext::new_test(tmp.path().to_path_buf()); - let api = AgentsApi { ctx: Arc::new(ctx) }; - let result = api - .create_worktree(Json(CreateWorktreePayload { - story_id: "42_story_foo".to_string(), - })) - .await; - assert!(result.is_err()); -} - -// --- remove_worktree error paths --- - -#[tokio::test] -async fn remove_worktree_returns_error_when_no_project_root() { - let tmp = TempDir::new().unwrap(); - let ctx = AppContext::new_test(tmp.path().to_path_buf()); - *ctx.state.project_root.lock().unwrap() = None; - let api = AgentsApi { ctx: Arc::new(ctx) }; - let result = api.remove_worktree(Path("42_story_foo".to_string())).await; - assert!(result.is_err()); -} - -#[tokio::test] -async fn remove_worktree_returns_error_when_worktree_not_found() { - let tmp = TempDir::new().unwrap(); - // project_root is set but no worktree exists for this story_id - let ctx = AppContext::new_test(tmp.path().to_path_buf()); - let api = AgentsApi { ctx: Arc::new(ctx) }; - let result = api - .remove_worktree(Path("nonexistent_story".to_string())) - .await; - assert!(result.is_err()); -} - -// --- get_test_results tests --- - -#[tokio::test] -async fn get_test_results_returns_none_when_no_results() { - let tmp = TempDir::new().unwrap(); - let root = make_work_dirs(&tmp); - let ctx = AppContext::new_test(root); - let api = AgentsApi { ctx: Arc::new(ctx) }; - let result = api - .get_test_results(Path("42_story_foo".to_string())) - .await - .unwrap() - .0; - assert!(result.is_none()); -} - -#[tokio::test] -async fn get_test_results_returns_in_memory_results() { - let tmp = TempDir::new().unwrap(); - let root = make_work_dirs(&tmp); - let ctx = AppContext::new_test(root); - - // Record test results in-memory. - { - let mut workflow = ctx.workflow.lock().unwrap(); - workflow - .record_test_results_validated( - "42_story_foo".to_string(), - vec![crate::workflow::TestCaseResult { - name: "unit_test_1".to_string(), - status: crate::workflow::TestStatus::Pass, - details: None, - }], - vec![crate::workflow::TestCaseResult { - name: "int_test_1".to_string(), - status: crate::workflow::TestStatus::Fail, - details: Some("assertion failed".to_string()), - }], - ) - .unwrap(); - } - - let api = AgentsApi { ctx: Arc::new(ctx) }; - let result = api - .get_test_results(Path("42_story_foo".to_string())) - .await - .unwrap() - .0 - .expect("should have test results"); - - assert_eq!(result.unit.len(), 1); - assert_eq!(result.unit[0].name, "unit_test_1"); - assert_eq!(result.unit[0].status, "pass"); - assert!(result.unit[0].details.is_none()); - - assert_eq!(result.integration.len(), 1); - assert_eq!(result.integration[0].name, "int_test_1"); - assert_eq!(result.integration[0].status, "fail"); - assert_eq!( - result.integration[0].details.as_deref(), - Some("assertion failed") - ); -} - -#[tokio::test] -async fn get_test_results_falls_back_to_file_persisted_results() { - let tmp = TempDir::new().unwrap(); - let root = tmp.path().to_path_buf(); - // Create work dirs including 2_current for the story file. - for stage in &["1_backlog", "2_current", "5_done", "6_archived"] { - std::fs::create_dir_all(root.join(".huskies").join("work").join(stage)).unwrap(); - } - - // Use a unique high-numbered story ID to avoid collisions with the - // "42_story_foo" entry used by get_test_results_returns_none_when_no_results. - let story_content = r#"--- -name: "Test story" ---- -# Test story - -## Test Results - - -"#; - std::fs::write( - root.join(".huskies/work/2_current/9906_story_persisted_results.md"), - story_content, - ) - .unwrap(); - // Also write to the content store so read_story_content returns this - // test's content even when another test left a stale entry in the - // global content store. - crate::db::ensure_content_store(); - crate::db::write_content("9906_story_persisted_results", story_content); - - let ctx = AppContext::new_test(root); - let api = AgentsApi { ctx: Arc::new(ctx) }; - let result = api - .get_test_results(Path("9906_story_persisted_results".to_string())) - .await - .unwrap() - .0 - .expect("should fall back to file results"); - - assert_eq!(result.unit.len(), 1); - assert_eq!(result.unit[0].name, "from_file"); - assert_eq!(result.unit[0].status, "pass"); -} diff --git a/server/src/http/anthropic.rs b/server/src/http/anthropic.rs deleted file mode 100644 index 1aa8d09b..00000000 --- a/server/src/http/anthropic.rs +++ /dev/null @@ -1,268 +0,0 @@ -//! Anthropic API proxy — thin adapter over `service::anthropic`. -use crate::http::context::{AppContext, OpenApiResult, bad_request}; -use crate::service::anthropic::{self as svc, ModelSummary}; -use poem_openapi::{Object, OpenApi, Tags, payload::Json}; -use serde::Deserialize; -use std::sync::Arc; - -#[derive(Deserialize, Object)] -struct ApiKeyPayload { - api_key: String, -} - -#[derive(Tags)] -enum AnthropicTags { - Anthropic, -} - -/// OpenAPI endpoint group for Anthropic API key and model operations. -pub struct AnthropicApi { - ctx: Arc, -} - -impl AnthropicApi { - /// Create a new `AnthropicApi` bound to the given application context. - pub fn new(ctx: Arc) -> Self { - Self { ctx } - } -} - -#[cfg(test)] -impl From> for AnthropicApi { - fn from(ctx: Arc) -> Self { - Self::new(ctx) - } -} - -#[OpenApi(tag = "AnthropicTags::Anthropic")] -impl AnthropicApi { - /// Check whether an Anthropic API key is stored. - /// - /// Returns `true` if a non-empty key is present, otherwise `false`. - #[oai(path = "/anthropic/key/exists", method = "get")] - async fn get_anthropic_api_key_exists(&self) -> OpenApiResult> { - let exists = svc::get_api_key_exists(self.ctx.store.as_ref()) - .map_err(|e| bad_request(e.to_string()))?; - Ok(Json(exists)) - } - - /// Store or update the Anthropic API key used for requests. - /// - /// Returns `true` when the key is saved successfully. - #[oai(path = "/anthropic/key", method = "post")] - async fn set_anthropic_api_key( - &self, - payload: Json, - ) -> OpenApiResult> { - svc::set_api_key(self.ctx.store.as_ref(), payload.0.api_key) - .map_err(|e| bad_request(e.to_string()))?; - Ok(Json(true)) - } - - /// List available Anthropic models. - #[oai(path = "/anthropic/models", method = "get")] - async fn list_anthropic_models(&self) -> OpenApiResult>> { - let models = svc::list_models(self.ctx.store.as_ref()) - .await - .map_err(|e| bad_request(e.to_string()))?; - Ok(Json(models)) - } -} - -#[cfg(test)] -impl AnthropicApi { - /// List models from an injectable URL (used in tests to avoid real network calls). - async fn list_anthropic_models_from( - &self, - url: &str, - ) -> OpenApiResult>> { - let models = svc::list_models_from(self.ctx.store.as_ref(), url) - .await - .map_err(|e| bad_request(e.to_string()))?; - Ok(Json(models)) - } -} - -// Private helper retained for backward compatibility with tests that call it directly. -#[cfg(test)] -fn get_anthropic_api_key(ctx: &AppContext) -> Result { - svc::get_api_key(ctx.store.as_ref()).map_err(|e| e.to_string()) -} - -// Private types retained so existing tests that deserialise them directly continue to compile. -#[cfg(test)] -#[derive(serde::Deserialize)] -struct AnthropicModelsResponse { - data: Vec, -} - -#[cfg(test)] -#[derive(serde::Deserialize)] -struct AnthropicModelInfo { - id: String, - context_window: u64, -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::http::context::AppContext; - use crate::http::test_helpers::{make_api, test_ctx}; - use crate::store::StoreOps; - const KEY_ANTHROPIC_API_KEY: &str = "anthropic_api_key"; - use serde_json::json; - use tempfile::TempDir; - - // -- get_anthropic_api_key (private helper) -- - - #[test] - fn get_api_key_returns_err_when_not_set() { - let dir = TempDir::new().unwrap(); - let ctx = test_ctx(dir.path()); - let result = get_anthropic_api_key(&ctx); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("not found")); - } - - #[test] - fn get_api_key_returns_err_when_empty() { - let dir = TempDir::new().unwrap(); - let ctx = test_ctx(dir.path()); - ctx.store.set(KEY_ANTHROPIC_API_KEY, json!("")); - let result = get_anthropic_api_key(&ctx); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("empty")); - } - - #[test] - fn get_api_key_returns_err_when_not_string() { - let dir = TempDir::new().unwrap(); - let ctx = test_ctx(dir.path()); - ctx.store.set(KEY_ANTHROPIC_API_KEY, json!(12345)); - let result = get_anthropic_api_key(&ctx); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("not a string")); - } - - #[test] - fn get_api_key_returns_key_when_set() { - let dir = TempDir::new().unwrap(); - let ctx = test_ctx(dir.path()); - ctx.store - .set(KEY_ANTHROPIC_API_KEY, json!("sk-ant-test123")); - let result = get_anthropic_api_key(&ctx); - assert_eq!(result.unwrap(), "sk-ant-test123"); - } - - // -- get_anthropic_api_key_exists endpoint -- - - #[tokio::test] - async fn key_exists_returns_false_when_not_set() { - let dir = TempDir::new().unwrap(); - let api = make_api::(&dir); - let result = api.get_anthropic_api_key_exists().await.unwrap(); - assert!(!result.0); - } - - #[tokio::test] - async fn key_exists_returns_true_when_set() { - let dir = TempDir::new().unwrap(); - let ctx = AppContext::new_test(dir.path().to_path_buf()); - ctx.store - .set(KEY_ANTHROPIC_API_KEY, json!("sk-ant-test123")); - let api = AnthropicApi::new(Arc::new(ctx)); - let result = api.get_anthropic_api_key_exists().await.unwrap(); - assert!(result.0); - } - - // -- set_anthropic_api_key endpoint -- - - #[tokio::test] - async fn set_api_key_returns_true() { - let dir = TempDir::new().unwrap(); - let api = make_api::(&dir); - let payload = Json(ApiKeyPayload { - api_key: "sk-ant-test123".to_string(), - }); - let result = api.set_anthropic_api_key(payload).await.unwrap(); - assert!(result.0); - } - - #[tokio::test] - async fn set_then_exists_returns_true() { - let dir = TempDir::new().unwrap(); - let ctx = Arc::new(AppContext::new_test(dir.path().to_path_buf())); - let api = AnthropicApi::new(ctx); - api.set_anthropic_api_key(Json(ApiKeyPayload { - api_key: "sk-ant-test123".to_string(), - })) - .await - .unwrap(); - let result = api.get_anthropic_api_key_exists().await.unwrap(); - assert!(result.0); - } - - // -- list_anthropic_models endpoint -- - - #[tokio::test] - async fn list_models_fails_when_no_key() { - let dir = TempDir::new().unwrap(); - let api = make_api::(&dir); - let result = api.list_anthropic_models().await; - assert!(result.is_err()); - } - - #[tokio::test] - async fn list_models_fails_with_invalid_header_value() { - let dir = TempDir::new().unwrap(); - let ctx = AppContext::new_test(dir.path().to_path_buf()); - // A header value containing a newline is invalid - ctx.store.set(KEY_ANTHROPIC_API_KEY, json!("bad\nvalue")); - let api = AnthropicApi::new(Arc::new(ctx)); - let result = api.list_anthropic_models_from("http://127.0.0.1:1").await; - assert!(result.is_err()); - } - - #[tokio::test] - async fn list_models_fails_when_server_unreachable() { - let dir = TempDir::new().unwrap(); - let ctx = AppContext::new_test(dir.path().to_path_buf()); - ctx.store - .set(KEY_ANTHROPIC_API_KEY, json!("sk-ant-test123")); - let api = AnthropicApi::new(Arc::new(ctx)); - // Port 1 is reserved and should immediately refuse the connection - let result = api.list_anthropic_models_from("http://127.0.0.1:1").await; - assert!(result.is_err()); - } - - #[test] - fn new_creates_api_instance() { - let dir = TempDir::new().unwrap(); - let _api = make_api::(&dir); - } - - #[test] - fn anthropic_model_info_deserializes_context_window() { - let json = json!({ - "id": "claude-opus-4-5", - "context_window": 200000 - }); - let info: AnthropicModelInfo = serde_json::from_value(json).unwrap(); - assert_eq!(info.id, "claude-opus-4-5"); - assert_eq!(info.context_window, 200000); - } - - #[test] - fn anthropic_models_response_deserializes_multiple_models() { - let json = json!({ - "data": [ - { "id": "claude-opus-4-5", "context_window": 200000 }, - { "id": "claude-haiku-4-5-20251001", "context_window": 100000 } - ] - }); - let response: AnthropicModelsResponse = serde_json::from_value(json).unwrap(); - assert_eq!(response.data.len(), 2); - assert_eq!(response.data[0].context_window, 200000); - assert_eq!(response.data[1].context_window, 100000); - } -} diff --git a/server/src/http/bot_command.rs b/server/src/http/bot_command.rs deleted file mode 100644 index 64957b0b..00000000 --- a/server/src/http/bot_command.rs +++ /dev/null @@ -1,331 +0,0 @@ -//! Bot command HTTP endpoint. -//! -//! `POST /api/bot/command` lets the web UI invoke the same deterministic bot -//! commands available in Matrix without going through the LLM. -//! -//! Dispatches to [`crate::service::bot_command::execute`], which owns all -//! parsing and routing logic. This handler is a thin OpenAPI adapter: it -//! receives JSON, calls the service, and maps typed errors to HTTP status codes. - -use crate::http::context::{AppContext, OpenApiResult}; -use crate::service::bot_command as svc; -use poem::http::StatusCode; -use poem_openapi::{Object, OpenApi, Tags, payload::Json}; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; - -#[derive(Tags)] -enum BotCommandTags { - BotCommand, -} - -/// Body for `POST /api/bot/command`. -#[derive(Object, Deserialize)] -struct BotCommandRequest { - /// The command keyword without the leading slash (e.g. `"status"`, `"start"`). - command: String, - /// Any text after the command keyword, trimmed (may be empty). - #[oai(default)] - args: String, -} - -/// Response body for `POST /api/bot/command`. -#[derive(Object, Serialize)] -struct BotCommandResponse { - /// Markdown-formatted response text. - response: String, -} - -/// OpenAPI endpoint group for bot slash-command execution. -pub struct BotCommandApi { - pub ctx: Arc, -} - -#[OpenApi(tag = "BotCommandTags::BotCommand")] -impl BotCommandApi { - /// Execute a slash command without LLM invocation. - /// - /// Dispatches to the same handlers used by the Matrix and Slack bots. - /// Returns a markdown-formatted response that the frontend can display - /// directly in the chat panel. - /// - /// # Errors - /// - `400 Bad Request` — project root not set, or invalid command arguments. - /// - `404 Not Found` — unrecognised command keyword. - /// - `500 Internal Server Error` — command execution failed. - #[oai(path = "/bot/command", method = "post")] - async fn run_command( - &self, - body: Json, - ) -> OpenApiResult> { - let project_root = self - .ctx - .state - .get_project_root() - .map_err(|e| poem::Error::from_string(e, StatusCode::BAD_REQUEST))?; - - let cmd = body.command.trim().to_ascii_lowercase(); - let args = body.args.trim(); - - let response = svc::execute(&cmd, args, &project_root, &self.ctx.services.agents) - .await - .map_err(|e| match e { - svc::Error::UnknownCommand(msg) => { - poem::Error::from_string(msg, StatusCode::NOT_FOUND) - } - svc::Error::BadArgs(msg) => poem::Error::from_string(msg, StatusCode::BAD_REQUEST), - svc::Error::CommandFailed(msg) => { - poem::Error::from_string(msg, StatusCode::INTERNAL_SERVER_ERROR) - } - })?; - - Ok(Json(BotCommandResponse { response })) - } -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - fn test_api(dir: &TempDir) -> BotCommandApi { - BotCommandApi { - ctx: Arc::new(AppContext::new_test(dir.path().to_path_buf())), - } - } - - #[tokio::test] - async fn help_command_returns_response() { - let dir = TempDir::new().unwrap(); - let api = test_api(&dir); - let body = BotCommandRequest { - command: "help".to_string(), - args: String::new(), - }; - let result = api.run_command(Json(body)).await; - assert!(result.is_ok()); - let resp = result.unwrap().0; - assert!(!resp.response.is_empty()); - } - - #[tokio::test] - async fn unknown_command_returns_error_message() { - let dir = TempDir::new().unwrap(); - let api = test_api(&dir); - let body = BotCommandRequest { - command: "nonexistent_xyz".to_string(), - args: String::new(), - }; - let result = api.run_command(Json(body)).await; - assert!(result.is_err(), "unknown command should return HTTP 404"); - } - - #[tokio::test] - async fn start_without_number_returns_usage() { - let dir = TempDir::new().unwrap(); - let api = test_api(&dir); - let body = BotCommandRequest { - command: "start".to_string(), - args: String::new(), - }; - let result = api.run_command(Json(body)).await; - assert!(result.is_err(), "start with no args should return HTTP 400"); - } - - #[tokio::test] - async fn delete_without_number_returns_usage() { - let dir = TempDir::new().unwrap(); - let api = test_api(&dir); - let body = BotCommandRequest { - command: "delete".to_string(), - args: String::new(), - }; - let result = api.run_command(Json(body)).await; - assert!( - result.is_err(), - "delete with no args should return HTTP 400" - ); - } - - #[tokio::test] - async fn git_command_returns_response() { - let dir = TempDir::new().unwrap(); - // Initialise a bare git repo so the git command has something to query. - std::process::Command::new("git") - .args(["init"]) - .current_dir(dir.path()) - .output() - .ok(); - let api = test_api(&dir); - let body = BotCommandRequest { - command: "git".to_string(), - args: String::new(), - }; - let result = api.run_command(Json(body)).await; - assert!(result.is_ok()); - } - - #[tokio::test] - async fn timer_list_returns_response_not_unknown_command() { - let dir = TempDir::new().unwrap(); - let api = test_api(&dir); - let body = BotCommandRequest { - command: "timer".to_string(), - args: "list".to_string(), - }; - let result = api.run_command(Json(body)).await; - assert!( - result.is_ok(), - "timer list should succeed, got err: {:?}", - result.err().map(|e| e.to_string()) - ); - let resp = result.unwrap().0; - assert!( - !resp.response.contains("Unknown command"), - "timer list should not return 'Unknown command': {}", - resp.response - ); - } - - // -- htop (web-UI slash-command path) ------------------------------------ - - #[tokio::test] - async fn htop_returns_dashboard_not_unknown_command() { - let dir = TempDir::new().unwrap(); - let api = test_api(&dir); - let body = BotCommandRequest { - command: "htop".to_string(), - args: String::new(), - }; - let result = api.run_command(Json(body)).await; - assert!(result.is_ok()); - let resp = result.unwrap().0; - assert!( - !resp.response.contains("Unknown command"), - "htop should not return 'Unknown command': {}", - resp.response - ); - assert!( - resp.response.contains("htop"), - "htop response should contain 'htop': {}", - resp.response - ); - } - - #[tokio::test] - async fn htop_with_duration_returns_dashboard() { - let dir = TempDir::new().unwrap(); - let api = test_api(&dir); - let body = BotCommandRequest { - command: "htop".to_string(), - args: "10m".to_string(), - }; - let result = api.run_command(Json(body)).await; - assert!(result.is_ok()); - let resp = result.unwrap().0; - assert!( - !resp.response.contains("Unknown command"), - "htop 10m should not return 'Unknown command': {}", - resp.response - ); - } - - #[tokio::test] - async fn htop_stop_returns_response_not_unknown_command() { - let dir = TempDir::new().unwrap(); - let api = test_api(&dir); - let body = BotCommandRequest { - command: "htop".to_string(), - args: "stop".to_string(), - }; - let result = api.run_command(Json(body)).await; - assert!(result.is_ok()); - let resp = result.unwrap().0; - assert!( - !resp.response.contains("Unknown command"), - "htop stop should not return 'Unknown command': {}", - resp.response - ); - } - - // -- rmtree ---------------------------------------------------------------- - - #[tokio::test] - async fn rmtree_without_number_returns_usage() { - let dir = TempDir::new().unwrap(); - let api = test_api(&dir); - let body = BotCommandRequest { - command: "rmtree".to_string(), - args: String::new(), - }; - let result = api.run_command(Json(body)).await; - assert!( - result.is_err(), - "rmtree with no args should return HTTP 400" - ); - } - - #[tokio::test] - async fn rmtree_with_non_numeric_arg_returns_usage() { - let dir = TempDir::new().unwrap(); - let api = test_api(&dir); - let body = BotCommandRequest { - command: "rmtree".to_string(), - args: "foo".to_string(), - }; - let result = api.run_command(Json(body)).await; - assert!( - result.is_err(), - "rmtree with non-numeric arg should return HTTP 400" - ); - } - - #[tokio::test] - async fn rmtree_does_not_return_unknown_command() { - let dir = TempDir::new().unwrap(); - let api = test_api(&dir); - let body = BotCommandRequest { - command: "rmtree".to_string(), - args: "999".to_string(), - }; - let result = api.run_command(Json(body)).await; - assert!(result.is_ok()); - let resp = result.unwrap().0; - assert!( - !resp.response.contains("Unknown command"), - "/rmtree should not return 'Unknown command': {}", - resp.response - ); - } - - // -- htop bot-command path (regression: htop must remain in command registry) -- - - #[test] - fn htop_is_registered_in_bot_command_registry() { - let commands = crate::chat::commands::commands(); - assert!( - commands.iter().any(|c| c.name == "htop"), - "htop must be registered in the bot command registry so /help lists it" - ); - } - - #[tokio::test] - async fn run_command_requires_project_root() { - // Create a context with no project root set. - let dir = TempDir::new().unwrap(); - let ctx = AppContext::new_test(dir.path().to_path_buf()); - // Clear the project root. - *ctx.state.project_root.lock().unwrap() = None; - let api = BotCommandApi { ctx: Arc::new(ctx) }; - let body = BotCommandRequest { - command: "status".to_string(), - args: String::new(), - }; - let result = api.run_command(Json(body)).await; - assert!(result.is_err(), "should fail when no project root is set"); - } -} diff --git a/server/src/http/bot_config.rs b/server/src/http/bot_config.rs deleted file mode 100644 index a73a05cd..00000000 --- a/server/src/http/bot_config.rs +++ /dev/null @@ -1,56 +0,0 @@ -//! Bot configuration endpoints — GET/PUT for .huskies/bot.toml credentials. -use crate::http::context::{AppContext, OpenApiResult, bad_request}; -use poem_openapi::{Object, OpenApi, Tags, payload::Json}; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; - -#[derive(Tags)] -enum BotConfigTags { - BotConfig, -} - -#[derive(Object, Serialize, Deserialize, Default)] -struct BotConfigPayload { - pub transport: Option, - pub enabled: Option, - pub homeserver: Option, - pub username: Option, - pub password: Option, - pub room_ids: Option>, - pub slack_bot_token: Option, - pub slack_signing_secret: Option, - pub slack_channel_ids: Option>, -} - -/// OpenAPI endpoint group for reading and writing bot configuration. -pub struct BotConfigApi { - pub ctx: Arc, -} - -#[OpenApi(tag = "BotConfigTags::BotConfig")] -impl BotConfigApi { - /// Read current bot credentials from .huskies/bot.toml. - #[oai(path = "/bot/config", method = "get")] - async fn get_config(&self) -> OpenApiResult> { - let root = self.ctx.state.get_project_root().map_err(bad_request)?; - let path = root.join(".huskies").join("bot.toml"); - let config: BotConfigPayload = std::fs::read_to_string(&path) - .ok() - .and_then(|s| toml::from_str(&s).ok()) - .unwrap_or_default(); - Ok(Json(config)) - } - - /// Persist bot credentials to .huskies/bot.toml. - #[oai(path = "/bot/config", method = "put")] - async fn put_config( - &self, - payload: Json, - ) -> OpenApiResult> { - let root = self.ctx.state.get_project_root().map_err(bad_request)?; - let path = root.join(".huskies").join("bot.toml"); - let content = toml::to_string(&payload.0).map_err(|e| bad_request(e.to_string()))?; - std::fs::write(&path, content).map_err(|e| bad_request(e.to_string()))?; - Ok(payload) - } -} diff --git a/server/src/http/chat.rs b/server/src/http/chat.rs deleted file mode 100644 index 182d4545..00000000 --- a/server/src/http/chat.rs +++ /dev/null @@ -1,60 +0,0 @@ -//! HTTP chat endpoints — REST API for the LLM-powered chat interface. -use crate::http::context::{AppContext, OpenApiResult, bad_request}; -use crate::llm::chat; -use poem_openapi::{OpenApi, Tags, payload::Json}; -use std::sync::Arc; - -#[derive(Tags)] -enum ChatTags { - Chat, -} - -/// OpenAPI endpoint group for the LLM-powered chat interface. -pub struct ChatApi { - pub ctx: Arc, -} - -#[OpenApi(tag = "ChatTags::Chat")] -impl ChatApi { - /// Cancel the currently running chat stream, if any. - /// - /// Returns `true` once the cancellation signal is issued. - #[oai(path = "/chat/cancel", method = "post")] - async fn cancel_chat(&self) -> OpenApiResult> { - chat::cancel_chat(&self.ctx.state).map_err(bad_request)?; - Ok(Json(true)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - fn test_api(dir: &TempDir) -> ChatApi { - ChatApi { - ctx: Arc::new(AppContext::new_test(dir.path().to_path_buf())), - } - } - - #[tokio::test] - async fn cancel_chat_returns_true() { - let dir = TempDir::new().unwrap(); - let api = test_api(&dir); - let result = api.cancel_chat().await; - assert!(result.is_ok()); - assert!(result.unwrap().0); - } - - #[tokio::test] - async fn cancel_chat_sends_cancel_signal() { - let dir = TempDir::new().unwrap(); - let api = test_api(&dir); - let mut cancel_rx = api.ctx.state.cancel_rx.clone(); - cancel_rx.borrow_and_update(); - - api.cancel_chat().await.unwrap(); - - assert!(*cancel_rx.borrow()); - } -} diff --git a/server/src/http/context.rs b/server/src/http/context.rs index 71951e9a..be4ad0b8 100644 --- a/server/src/http/context.rs +++ b/server/src/http/context.rs @@ -7,7 +7,6 @@ use crate::services::Services; use crate::state::SessionState; use crate::store::JsonFileStore; use crate::workflow::WorkflowState; -use poem::http::StatusCode; use std::sync::Arc; use tokio::sync::{broadcast, mpsc, oneshot}; @@ -121,35 +120,10 @@ impl AppContext { } } -/// Alias for `poem::Result` used by OpenAPI handler return types. -pub type OpenApiResult = poem::Result; - -/// Return a 400 Bad Request error with the given message. -pub fn bad_request(message: String) -> poem::Error { - poem::Error::from_string(message, StatusCode::BAD_REQUEST) -} - -/// Return a 404 Not Found error with the given message. -pub fn not_found(message: String) -> poem::Error { - poem::Error::from_string(message, StatusCode::NOT_FOUND) -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn bad_request_returns_400_status() { - let err = bad_request("something went wrong".to_string()); - assert_eq!(err.status(), StatusCode::BAD_REQUEST); - } - - #[test] - fn bad_request_accepts_empty_message() { - let err = bad_request(String::new()); - assert_eq!(err.status(), StatusCode::BAD_REQUEST); - } - #[test] fn permission_decision_equality() { assert_eq!(PermissionDecision::Deny, PermissionDecision::Deny); @@ -161,10 +135,4 @@ mod tests { assert_ne!(PermissionDecision::Deny, PermissionDecision::Approve); assert_ne!(PermissionDecision::Approve, PermissionDecision::AlwaysAllow); } - - #[test] - fn not_found_returns_404_status() { - let err = not_found("item not found".to_string()); - assert_eq!(err.status(), StatusCode::NOT_FOUND); - } } diff --git a/server/src/http/io.rs b/server/src/http/io.rs deleted file mode 100644 index f8743e78..00000000 --- a/server/src/http/io.rs +++ /dev/null @@ -1,418 +0,0 @@ -//! HTTP I/O endpoints — thin adapters over `service::file_io`. -use crate::http::context::{AppContext, OpenApiResult, bad_request}; -use crate::service::file_io::{self as svc, FileEntry}; -use poem_openapi::{Object, OpenApi, Tags, payload::Json}; -use serde::Deserialize; -use std::sync::Arc; - -#[derive(Tags)] -enum IoTags { - Io, -} - -#[derive(Deserialize, Object)] -struct FilePathPayload { - pub path: String, -} - -#[derive(Deserialize, Object)] -struct WriteFilePayload { - pub path: String, - pub content: String, -} - -#[derive(Deserialize, Object)] -struct SearchPayload { - query: String, -} - -#[derive(Deserialize, Object)] -struct CreateDirectoryPayload { - pub path: String, -} - -#[derive(Deserialize, Object)] -struct ExecShellPayload { - pub command: String, - pub args: Vec, -} - -/// OpenAPI endpoint group for filesystem I/O operations (read, write, list, search). -pub struct IoApi { - pub ctx: Arc, -} - -#[OpenApi(tag = "IoTags::Io")] -impl IoApi { - /// Read a file from the currently open project and return its contents. - #[oai(path = "/io/fs/read", method = "post")] - async fn read_file(&self, payload: Json) -> OpenApiResult> { - let content = svc::read_file(payload.0.path, &self.ctx.state) - .await - .map_err(|e| bad_request(e.to_string()))?; - Ok(Json(content)) - } - - /// Write a file to the currently open project, creating parent directories if needed. - #[oai(path = "/io/fs/write", method = "post")] - async fn write_file(&self, payload: Json) -> OpenApiResult> { - svc::write_file(payload.0.path, payload.0.content, &self.ctx.state) - .await - .map_err(|e| bad_request(e.to_string()))?; - Ok(Json(true)) - } - - /// List files and folders in a directory within the currently open project. - #[oai(path = "/io/fs/list", method = "post")] - async fn list_directory( - &self, - payload: Json, - ) -> OpenApiResult>> { - let entries = svc::list_directory(payload.0.path, &self.ctx.state) - .await - .map_err(|e| bad_request(e.to_string()))?; - Ok(Json(entries)) - } - - /// List files and folders at an absolute path (not scoped to the project root). - #[oai(path = "/io/fs/list/absolute", method = "post")] - async fn list_directory_absolute( - &self, - payload: Json, - ) -> OpenApiResult>> { - let entries = svc::list_directory_absolute(payload.0.path) - .await - .map_err(|e| bad_request(e.to_string()))?; - Ok(Json(entries)) - } - - /// Create a directory at an absolute path. - #[oai(path = "/io/fs/create/absolute", method = "post")] - async fn create_directory_absolute( - &self, - payload: Json, - ) -> OpenApiResult> { - svc::create_directory_absolute(payload.0.path) - .await - .map_err(|e| bad_request(e.to_string()))?; - Ok(Json(true)) - } - - /// Get the user's home directory. - #[oai(path = "/io/fs/home", method = "get")] - async fn get_home_directory(&self) -> OpenApiResult> { - let home = svc::get_home_directory().map_err(|e| bad_request(e.to_string()))?; - Ok(Json(home)) - } - - /// List all files in the project recursively, respecting .gitignore. - #[oai(path = "/io/fs/files", method = "get")] - async fn list_project_files(&self) -> OpenApiResult>> { - let files = svc::list_project_files(&self.ctx.state) - .await - .map_err(|e| bad_request(e.to_string()))?; - Ok(Json(files)) - } - - /// Search the currently open project for files containing the provided query string. - #[oai(path = "/io/search", method = "post")] - async fn search_files( - &self, - payload: Json, - ) -> OpenApiResult>> { - let results = svc::search_files(payload.0.query, &self.ctx.state) - .await - .map_err(|e| bad_request(e.to_string()))?; - Ok(Json(results)) - } - - /// Execute an allowlisted shell command in the currently open project. - #[oai(path = "/io/shell/exec", method = "post")] - async fn exec_shell( - &self, - payload: Json, - ) -> OpenApiResult> { - let output = svc::exec_shell(payload.0.command, payload.0.args, &self.ctx.state) - .await - .map_err(|e| bad_request(e.to_string()))?; - Ok(Json(output)) - } -} - -#[cfg(test)] -impl From> for IoApi { - fn from(ctx: std::sync::Arc) -> Self { - Self { ctx } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::http::test_helpers::make_api; - use tempfile::TempDir; - - // --- list_directory_absolute --- - - #[tokio::test] - async fn list_directory_absolute_returns_entries_for_valid_path() { - let dir = TempDir::new().unwrap(); - std::fs::create_dir(dir.path().join("subdir")).unwrap(); - std::fs::write(dir.path().join("file.txt"), "content").unwrap(); - - let api = make_api::(&dir); - let payload = Json(FilePathPayload { - path: dir.path().to_string_lossy().to_string(), - }); - let result = api.list_directory_absolute(payload).await.unwrap(); - let entries = &result.0; - - assert!(entries.len() >= 2); - assert!( - entries - .iter() - .any(|e| e.name == "subdir" && e.kind == "dir") - ); - assert!( - entries - .iter() - .any(|e| e.name == "file.txt" && e.kind == "file") - ); - } - - #[tokio::test] - async fn list_directory_absolute_returns_empty_for_empty_dir() { - let dir = TempDir::new().unwrap(); - let empty = dir.path().join("empty"); - std::fs::create_dir(&empty).unwrap(); - - let api = make_api::(&dir); - let payload = Json(FilePathPayload { - path: empty.to_string_lossy().to_string(), - }); - let result = api.list_directory_absolute(payload).await.unwrap(); - assert!(result.0.is_empty()); - } - - #[tokio::test] - async fn list_directory_absolute_errors_on_nonexistent_path() { - let dir = TempDir::new().unwrap(); - let api = make_api::(&dir); - let payload = Json(FilePathPayload { - path: dir.path().join("nonexistent").to_string_lossy().to_string(), - }); - let result = api.list_directory_absolute(payload).await; - assert!(result.is_err()); - } - - #[tokio::test] - async fn list_directory_absolute_errors_on_file_path() { - let dir = TempDir::new().unwrap(); - let file = dir.path().join("not_a_dir.txt"); - std::fs::write(&file, "content").unwrap(); - - let api = make_api::(&dir); - let payload = Json(FilePathPayload { - path: file.to_string_lossy().to_string(), - }); - let result = api.list_directory_absolute(payload).await; - assert!(result.is_err()); - } - - // --- create_directory_absolute --- - - #[tokio::test] - async fn create_directory_absolute_creates_new_dir() { - let dir = TempDir::new().unwrap(); - let new_dir = dir.path().join("new_dir"); - - let api = make_api::(&dir); - let payload = Json(CreateDirectoryPayload { - path: new_dir.to_string_lossy().to_string(), - }); - let result = api.create_directory_absolute(payload).await.unwrap(); - assert!(result.0); - assert!(new_dir.is_dir()); - } - - #[tokio::test] - async fn create_directory_absolute_succeeds_for_existing_dir() { - let dir = TempDir::new().unwrap(); - let existing = dir.path().join("existing"); - std::fs::create_dir(&existing).unwrap(); - - let api = make_api::(&dir); - let payload = Json(CreateDirectoryPayload { - path: existing.to_string_lossy().to_string(), - }); - let result = api.create_directory_absolute(payload).await.unwrap(); - assert!(result.0); - } - - #[tokio::test] - async fn create_directory_absolute_creates_nested_dirs() { - let dir = TempDir::new().unwrap(); - let nested = dir.path().join("a").join("b").join("c"); - - let api = make_api::(&dir); - let payload = Json(CreateDirectoryPayload { - path: nested.to_string_lossy().to_string(), - }); - let result = api.create_directory_absolute(payload).await.unwrap(); - assert!(result.0); - assert!(nested.is_dir()); - } - - // --- get_home_directory --- - - #[tokio::test] - async fn get_home_directory_returns_a_path() { - let dir = TempDir::new().unwrap(); - let api = make_api::(&dir); - let result = api.get_home_directory().await.unwrap(); - let home = &result.0; - assert!(!home.is_empty()); - assert!(std::path::Path::new(home).is_absolute()); - } - - // --- read_file (project-scoped) --- - - #[tokio::test] - async fn read_file_returns_content() { - let dir = TempDir::new().unwrap(); - std::fs::write(dir.path().join("hello.txt"), "hello world").unwrap(); - - let api = make_api::(&dir); - let payload = Json(FilePathPayload { - path: "hello.txt".to_string(), - }); - let result = api.read_file(payload).await.unwrap(); - assert_eq!(result.0, "hello world"); - } - - #[tokio::test] - async fn read_file_errors_on_missing_file() { - let dir = TempDir::new().unwrap(); - let api = make_api::(&dir); - let payload = Json(FilePathPayload { - path: "nonexistent.txt".to_string(), - }); - let result = api.read_file(payload).await; - assert!(result.is_err()); - } - - // --- write_file (project-scoped) --- - - #[tokio::test] - async fn write_file_creates_file() { - let dir = TempDir::new().unwrap(); - let api = make_api::(&dir); - let payload = Json(WriteFilePayload { - path: "output.txt".to_string(), - content: "written content".to_string(), - }); - let result = api.write_file(payload).await.unwrap(); - assert!(result.0); - assert_eq!( - std::fs::read_to_string(dir.path().join("output.txt")).unwrap(), - "written content" - ); - } - - #[tokio::test] - async fn write_file_creates_parent_dirs() { - let dir = TempDir::new().unwrap(); - let api = make_api::(&dir); - let payload = Json(WriteFilePayload { - path: "sub/dir/file.txt".to_string(), - content: "nested".to_string(), - }); - let result = api.write_file(payload).await.unwrap(); - assert!(result.0); - assert_eq!( - std::fs::read_to_string(dir.path().join("sub/dir/file.txt")).unwrap(), - "nested" - ); - } - - // --- list_project_files --- - - #[tokio::test] - async fn list_project_files_returns_file_paths() { - let dir = TempDir::new().unwrap(); - std::fs::create_dir(dir.path().join("src")).unwrap(); - std::fs::write(dir.path().join("src/main.rs"), "fn main() {}").unwrap(); - std::fs::write(dir.path().join("README.md"), "# readme").unwrap(); - - let api = make_api::(&dir); - let result = api.list_project_files().await.unwrap(); - let files = &result.0; - - assert!(files.contains(&"README.md".to_string())); - assert!(files.contains(&"src/main.rs".to_string())); - } - - #[tokio::test] - async fn list_project_files_excludes_directories() { - let dir = TempDir::new().unwrap(); - std::fs::create_dir(dir.path().join("subdir")).unwrap(); - std::fs::write(dir.path().join("file.txt"), "").unwrap(); - - let api = make_api::(&dir); - let result = api.list_project_files().await.unwrap(); - let files = &result.0; - - assert!(files.contains(&"file.txt".to_string())); - // Directories should not appear - assert!(!files.iter().any(|f| f == "subdir")); - } - - #[tokio::test] - async fn list_project_files_returns_sorted_paths() { - let dir = TempDir::new().unwrap(); - std::fs::write(dir.path().join("z_last.txt"), "").unwrap(); - std::fs::write(dir.path().join("a_first.txt"), "").unwrap(); - - let api = make_api::(&dir); - let result = api.list_project_files().await.unwrap(); - let files = &result.0; - - let a_idx = files.iter().position(|f| f == "a_first.txt").unwrap(); - let z_idx = files.iter().position(|f| f == "z_last.txt").unwrap(); - assert!(a_idx < z_idx); - } - - // --- list_directory (project-scoped) --- - - #[tokio::test] - async fn list_directory_returns_entries() { - let dir = TempDir::new().unwrap(); - std::fs::create_dir(dir.path().join("adir")).unwrap(); - std::fs::write(dir.path().join("bfile.txt"), "").unwrap(); - - let api = make_api::(&dir); - let payload = Json(FilePathPayload { - path: ".".to_string(), - }); - let result = api.list_directory(payload).await.unwrap(); - let entries = &result.0; - - assert!(entries.iter().any(|e| e.name == "adir" && e.kind == "dir")); - assert!( - entries - .iter() - .any(|e| e.name == "bfile.txt" && e.kind == "file") - ); - } - - #[tokio::test] - async fn list_directory_errors_on_nonexistent() { - let dir = TempDir::new().unwrap(); - let api = make_api::(&dir); - let payload = Json(FilePathPayload { - path: "nonexistent_dir".to_string(), - }); - let result = api.list_directory(payload).await; - assert!(result.is_err()); - } -} diff --git a/server/src/http/mod.rs b/server/src/http/mod.rs index a6db6065..a554bf27 100644 --- a/server/src/http/mod.rs +++ b/server/src/http/mod.rs @@ -1,34 +1,18 @@ //! HTTP server — module declarations for all REST, MCP, WebSocket, and SSE endpoints. -/// Agent management HTTP endpoints. -pub mod agents; /// Server-sent event stream for real-time agent output. pub mod agents_sse; -/// Anthropic API key management endpoints. -pub mod anthropic; /// Static asset serving (embedded frontend files). pub mod assets; -/// Bot slash-command HTTP endpoint. -pub mod bot_command; -/// Bot configuration read/write endpoints. -pub mod bot_config; -/// Chat session HTTP endpoints. -pub mod chat; /// Shared application context threaded through handlers. pub mod context; /// Server-sent event stream for pipeline/watcher events. pub mod events; /// Node identity endpoint (public key, node ID). pub mod identity; -/// Filesystem I/O HTTP endpoints (read, write, list, search). -pub mod io; /// Model Context Protocol (MCP) HTTP endpoint and tool modules. pub mod mcp; -/// LLM model selection and listing endpoints. -pub mod model; /// OAuth 2.0 PKCE flow endpoints for Anthropic authentication. pub mod oauth; -/// Project settings HTTP endpoints. -pub mod settings; #[cfg(test)] pub(crate) mod test_helpers; /// Workflow helpers for story/bug file operations. @@ -36,26 +20,13 @@ pub mod workflow; /// Gateway-mode HTTP endpoints for multi-project proxy. pub mod gateway; -/// Project open/close/list HTTP endpoints. -pub mod project; -/// Setup wizard HTTP endpoints. -pub mod wizard; /// WebSocket handler for real-time frontend communication. pub mod ws; -use agents::AgentsApi; -use anthropic::AnthropicApi; -use bot_command::BotCommandApi; -use bot_config::BotConfigApi; -use chat::ChatApi; use context::AppContext; -use io::IoApi; -use model::ModelApi; use poem::EndpointExt; +use poem::http::StatusCode; use poem::{Route, get, post}; -use poem_openapi::OpenApiService; -use project::ProjectApi; -use settings::SettingsApi; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -88,7 +59,13 @@ pub fn remove_port_file(path: &Path) { let _ = std::fs::remove_file(path); } -/// Assemble the full Poem route tree (API, WebSocket, MCP, OAuth, assets). +/// Liveness probe — always returns 200 OK. +#[poem::handler] +pub fn health_handler() -> poem::Response { + poem::Response::builder().status(StatusCode::OK).body("ok") +} + +/// Assemble the full Poem route tree (WebSocket, MCP, OAuth, assets, webhooks). pub fn build_routes( ctx: AppContext, whatsapp_ctx: Option>, @@ -98,13 +75,10 @@ pub fn build_routes( ) -> impl poem::Endpoint { let ctx_arc = std::sync::Arc::new(ctx); - let (api_service, docs_service) = build_openapi_service(ctx_arc.clone()); - let oauth_state = Arc::new(oauth::OAuthState::new(port)); let mut route = Route::new() - .nest("/api", api_service) - .nest("/docs", docs_service.swagger_ui()) + .at("/health", get(health_handler)) .at("/ws", get(ws::ws_handler)) .at("/crdt-sync", get(crate::crdt_sync::crdt_sync_handler)) .at("/rpc", post(rpc_http_handler)) @@ -240,58 +214,6 @@ pub fn debug_crdt_handler(req: &poem::Request) -> poem::Response { .body(serde_json::to_string_pretty(&body).unwrap_or_default()) } -type ApiTuple = ( - ProjectApi, - ModelApi, - AnthropicApi, - IoApi, - ChatApi, - AgentsApi, - SettingsApi, - BotCommandApi, - wizard::WizardApi, - BotConfigApi, -); - -type ApiService = OpenApiService; - -/// All HTTP methods are documented by OpenAPI at /docs -pub fn build_openapi_service(ctx: Arc) -> (ApiService, ApiService) { - let api = ( - ProjectApi { ctx: ctx.clone() }, - ModelApi { ctx: ctx.clone() }, - AnthropicApi::new(ctx.clone()), - IoApi { ctx: ctx.clone() }, - ChatApi { ctx: ctx.clone() }, - AgentsApi { ctx: ctx.clone() }, - SettingsApi { ctx: ctx.clone() }, - BotCommandApi { ctx: ctx.clone() }, - wizard::WizardApi { ctx: ctx.clone() }, - BotConfigApi { ctx: ctx.clone() }, - ); - - let api_service = - OpenApiService::new(api, "Huskies API", "1.0").server("http://127.0.0.1:3001/api"); - - let docs_api = ( - ProjectApi { ctx: ctx.clone() }, - ModelApi { ctx: ctx.clone() }, - AnthropicApi::new(ctx.clone()), - IoApi { ctx: ctx.clone() }, - ChatApi { ctx: ctx.clone() }, - AgentsApi { ctx: ctx.clone() }, - SettingsApi { ctx: ctx.clone() }, - BotCommandApi { ctx: ctx.clone() }, - wizard::WizardApi { ctx: ctx.clone() }, - BotConfigApi { ctx }, - ); - - let docs_service = - OpenApiService::new(docs_api, "Huskies API", "1.0").server("http://127.0.0.1:3001/api"); - - (api_service, docs_service) -} - #[cfg(test)] mod tests { use super::*; @@ -341,13 +263,6 @@ mod tests { assert!(port > 0); } - #[test] - fn build_openapi_service_constructs_without_panic() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = Arc::new(context::AppContext::new_test(tmp.path().to_path_buf())); - let (_api_service, _docs_service) = build_openapi_service(ctx); - } - #[test] fn build_routes_constructs_without_panic() { let tmp = tempfile::tempdir().unwrap(); diff --git a/server/src/http/model.rs b/server/src/http/model.rs deleted file mode 100644 index 1415f7f1..00000000 --- a/server/src/http/model.rs +++ /dev/null @@ -1,132 +0,0 @@ -//! HTTP model endpoints — REST API for model selection and LLM provider management. -use crate::http::context::{AppContext, OpenApiResult, bad_request}; -use crate::io::fs; -use crate::llm::chat; -use poem_openapi::{Object, OpenApi, Tags, param::Query, payload::Json}; -use serde::Deserialize; -use std::sync::Arc; - -#[derive(Tags)] -enum ModelTags { - Model, -} - -#[derive(Deserialize, Object)] -struct ModelPayload { - model: String, -} - -/// OpenAPI endpoint group for LLM model selection and listing. -pub struct ModelApi { - pub ctx: Arc, -} - -#[OpenApi(tag = "ModelTags::Model")] -impl ModelApi { - /// Get the currently selected model preference, if any. - #[oai(path = "/model", method = "get")] - async fn get_model_preference(&self) -> OpenApiResult>> { - let result = fs::get_model_preference(self.ctx.store.as_ref()).map_err(bad_request)?; - Ok(Json(result)) - } - - /// Persist the selected model preference. - #[oai(path = "/model", method = "post")] - async fn set_model_preference(&self, payload: Json) -> OpenApiResult> { - fs::set_model_preference(payload.0.model, self.ctx.store.as_ref()).map_err(bad_request)?; - Ok(Json(true)) - } - - /// Fetch available model names from an Ollama server. - /// Optionally override the base URL via query string. - /// Returns an empty list when Ollama is unreachable so the UI stays functional. - #[oai(path = "/ollama/models", method = "get")] - async fn get_ollama_models( - &self, - base_url: Query>, - ) -> OpenApiResult>> { - let models = chat::get_ollama_models(base_url.0) - .await - .unwrap_or_default(); - Ok(Json(models)) - } -} - -#[cfg(test)] -impl From> for ModelApi { - fn from(ctx: std::sync::Arc) -> Self { - Self { ctx } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::http::test_helpers::make_api; - use tempfile::TempDir; - - #[tokio::test] - async fn get_model_preference_returns_none_when_unset() { - let dir = TempDir::new().unwrap(); - let api = make_api::(&dir); - let result = api.get_model_preference().await.unwrap(); - assert!(result.0.is_none()); - } - - #[tokio::test] - async fn set_model_preference_returns_true() { - let dir = TempDir::new().unwrap(); - let api = make_api::(&dir); - let payload = Json(ModelPayload { - model: "claude-3-sonnet".to_string(), - }); - let result = api.set_model_preference(payload).await.unwrap(); - assert!(result.0); - } - - #[tokio::test] - async fn get_model_preference_returns_value_after_set() { - let dir = TempDir::new().unwrap(); - let api = make_api::(&dir); - - let payload = Json(ModelPayload { - model: "claude-3-sonnet".to_string(), - }); - api.set_model_preference(payload).await.unwrap(); - - let result = api.get_model_preference().await.unwrap(); - assert_eq!(result.0, Some("claude-3-sonnet".to_string())); - } - - #[tokio::test] - async fn set_model_preference_overwrites_previous_value() { - let dir = TempDir::new().unwrap(); - let api = make_api::(&dir); - - api.set_model_preference(Json(ModelPayload { - model: "model-a".to_string(), - })) - .await - .unwrap(); - - api.set_model_preference(Json(ModelPayload { - model: "model-b".to_string(), - })) - .await - .unwrap(); - - let result = api.get_model_preference().await.unwrap(); - assert_eq!(result.0, Some("model-b".to_string())); - } - - #[tokio::test] - async fn get_ollama_models_returns_empty_list_for_unreachable_url() { - let dir = TempDir::new().unwrap(); - let api = make_api::(&dir); - // Port 1 is reserved and should immediately refuse the connection. - let base_url = Query(Some("http://127.0.0.1:1".to_string())); - let result = api.get_ollama_models(base_url).await; - assert!(result.is_ok()); - assert_eq!(result.unwrap().0, Vec::::new()); - } -} diff --git a/server/src/http/project.rs b/server/src/http/project.rs deleted file mode 100644 index 069e696a..00000000 --- a/server/src/http/project.rs +++ /dev/null @@ -1,231 +0,0 @@ -//! HTTP project endpoints — thin adapters over `service::project`. -use crate::http::context::{AppContext, OpenApiResult, bad_request, not_found}; -use crate::service::project::{self as svc, Error as ProjectError}; -use poem::http::StatusCode; -use poem_openapi::{Object, OpenApi, Tags, payload::Json}; -use serde::Deserialize; -use std::sync::Arc; - -#[derive(Tags)] -enum ProjectTags { - Project, -} - -#[derive(Deserialize, Object)] -struct PathPayload { - path: String, -} - -/// Map a typed [`ProjectError`] to a `poem::Error` with the appropriate HTTP status. -fn map_project_error(e: ProjectError) -> poem::Error { - match e { - ProjectError::PathNotFound(msg) => not_found(msg), - ProjectError::NotADirectory(msg) => bad_request(msg), - ProjectError::Internal(msg) => { - poem::Error::from_string(msg, StatusCode::INTERNAL_SERVER_ERROR) - } - } -} - -/// OpenAPI endpoint group for project open, close, and listing operations. -pub struct ProjectApi { - pub ctx: Arc, -} - -#[OpenApi(tag = "ProjectTags::Project")] -impl ProjectApi { - /// Get the currently open project path (if any). - /// - /// Returns null when no project is open. - #[oai(path = "/project", method = "get")] - async fn get_current_project(&self) -> OpenApiResult>> { - let result = svc::get_current_project(&self.ctx.state, self.ctx.store.as_ref()) - .map_err(map_project_error)?; - Ok(Json(result)) - } - - /// Open a project and set it as the current project. - /// - /// Persists the selected path for later sessions. - #[oai(path = "/project", method = "post")] - async fn open_project(&self, payload: Json) -> OpenApiResult> { - let confirmed = svc::open_project( - payload.0.path, - &self.ctx.state, - self.ctx.store.as_ref(), - self.ctx.services.agents.port(), - ) - .await - .map_err(map_project_error)?; - Ok(Json(confirmed)) - } - - /// Close the current project and clear the stored selection. - #[oai(path = "/project", method = "delete")] - async fn close_project(&self) -> OpenApiResult> { - // TRACE:MERGE-DEBUG — remove once root cause is found - crate::slog_error!( - "[MERGE-DEBUG] DELETE /project called! \ - Backtrace: this is the only code path that clears project_root." - ); - svc::close_project(&self.ctx.state, self.ctx.store.as_ref()).map_err(map_project_error)?; - Ok(Json(true)) - } - - /// List known projects from the store. - #[oai(path = "/projects", method = "get")] - async fn list_known_projects(&self) -> OpenApiResult>> { - let projects = - svc::get_known_projects(self.ctx.store.as_ref()).map_err(map_project_error)?; - Ok(Json(projects)) - } - - /// Forget a known project path. - #[oai(path = "/projects/forget", method = "post")] - async fn forget_known_project(&self, payload: Json) -> OpenApiResult> { - svc::forget_known_project(payload.0.path, self.ctx.store.as_ref()) - .map_err(map_project_error)?; - Ok(Json(true)) - } -} - -#[cfg(test)] -impl From> for ProjectApi { - fn from(ctx: std::sync::Arc) -> Self { - Self { ctx } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::http::test_helpers::make_api; - use tempfile::TempDir; - - #[tokio::test] - async fn get_current_project_returns_none_when_unset() { - let dir = TempDir::new().unwrap(); - let api = make_api::(&dir); - // Clear the project root that new_test sets - api.close_project().await.unwrap(); - let result = api.get_current_project().await.unwrap(); - assert!(result.0.is_none()); - } - - #[tokio::test] - async fn get_current_project_returns_path_from_state() { - let dir = TempDir::new().unwrap(); - let api = make_api::(&dir); - let result = api.get_current_project().await.unwrap(); - assert_eq!(result.0, Some(dir.path().to_string_lossy().to_string())); - } - - #[tokio::test] - async fn open_project_succeeds_with_valid_directory() { - let dir = TempDir::new().unwrap(); - let api = make_api::(&dir); - let path = dir.path().to_string_lossy().to_string(); - let payload = Json(PathPayload { path: path.clone() }); - let result = api.open_project(payload).await.unwrap(); - assert_eq!(result.0, path); - } - - #[tokio::test] - async fn open_project_fails_with_nonexistent_file_path() { - let dir = TempDir::new().unwrap(); - let api = make_api::(&dir); - // Create a file (not a directory) to trigger validation error - let file_path = dir.path().join("not_a_dir.txt"); - std::fs::write(&file_path, "content").unwrap(); - let payload = Json(PathPayload { - path: file_path.to_string_lossy().to_string(), - }); - let result = api.open_project(payload).await; - assert!(result.is_err()); - } - - #[tokio::test] - async fn close_project_returns_true() { - let dir = TempDir::new().unwrap(); - let api = make_api::(&dir); - let result = api.close_project().await.unwrap(); - assert!(result.0); - } - - #[tokio::test] - async fn close_project_clears_current_project() { - let dir = TempDir::new().unwrap(); - let api = make_api::(&dir); - - // Verify project is set initially - let before = api.get_current_project().await.unwrap(); - assert!(before.0.is_some()); - - // Close the project - api.close_project().await.unwrap(); - - // Verify project is now None - let after = api.get_current_project().await.unwrap(); - assert!(after.0.is_none()); - } - - #[tokio::test] - async fn list_known_projects_returns_empty_initially() { - let dir = TempDir::new().unwrap(); - let api = make_api::(&dir); - // Close the project so the store has no known projects - api.close_project().await.unwrap(); - let result = api.list_known_projects().await.unwrap(); - assert!(result.0.is_empty()); - } - - #[tokio::test] - async fn list_known_projects_returns_project_after_open() { - let dir = TempDir::new().unwrap(); - let api = make_api::(&dir); - let path = dir.path().to_string_lossy().to_string(); - - api.open_project(Json(PathPayload { path: path.clone() })) - .await - .unwrap(); - - let result = api.list_known_projects().await.unwrap(); - assert!(result.0.contains(&path)); - } - - #[tokio::test] - async fn forget_known_project_removes_project() { - let dir = TempDir::new().unwrap(); - let api = make_api::(&dir); - let path = dir.path().to_string_lossy().to_string(); - - api.open_project(Json(PathPayload { path: path.clone() })) - .await - .unwrap(); - - let before = api.list_known_projects().await.unwrap(); - assert!(before.0.contains(&path)); - - let result = api - .forget_known_project(Json(PathPayload { path: path.clone() })) - .await - .unwrap(); - assert!(result.0); - - let after = api.list_known_projects().await.unwrap(); - assert!(!after.0.contains(&path)); - } - - #[tokio::test] - async fn forget_known_project_returns_true_for_nonexistent_path() { - let dir = TempDir::new().unwrap(); - let api = make_api::(&dir); - let result = api - .forget_known_project(Json(PathPayload { - path: "/some/unknown/path".to_string(), - })) - .await - .unwrap(); - assert!(result.0); - } -} diff --git a/server/src/http/settings.rs b/server/src/http/settings.rs deleted file mode 100644 index a3ada552..00000000 --- a/server/src/http/settings.rs +++ /dev/null @@ -1,615 +0,0 @@ -//! HTTP settings endpoints — REST API for user preferences and editor configuration. -use crate::http::context::{AppContext, OpenApiResult, bad_request}; -use crate::service::settings as svc; -use crate::store::StoreOps; -use poem_openapi::{Object, OpenApi, Tags, param::Query, payload::Json}; -use serde::Serialize; -use serde_json::json; -#[cfg(test)] -use std::path::Path; -use std::sync::Arc; - -// Re-export service types so the test module (which does `use super::*`) can -// access them without modification. -pub use svc::EDITOR_COMMAND_KEY; -pub use svc::ProjectSettings; -#[cfg(test)] -pub use svc::settings_from_config; - -/// Thin wrapper — delegates to [`svc::validate_project_settings`] and maps -/// the typed error to `String` so existing tests calling `.unwrap_err()` can -/// call `.contains()` directly. -fn validate_project_settings(s: &ProjectSettings) -> Result<(), String> { - svc::validate_project_settings(s).map_err(|e| e.to_string()) -} - -/// Thin wrapper — delegates to [`svc::write_project_settings`] and maps the -/// typed error to `String` so existing tests can call `.unwrap()` unchanged. -#[cfg(test)] -fn write_project_settings(project_root: &Path, s: &ProjectSettings) -> Result<(), String> { - svc::write_project_settings(project_root, s).map_err(|e| e.to_string()) -} - -/// Return the configured editor command from the store, or `None` if not set. -pub fn get_editor_command_from_store(ctx: &AppContext) -> Option { - svc::get_editor_command(&*ctx.store) -} - -#[derive(Tags)] -enum SettingsTags { - Settings, -} - -#[derive(Object)] -struct EditorCommandPayload { - editor_command: Option, -} - -#[derive(Object, Serialize)] -struct EditorCommandResponse { - editor_command: Option, -} - -#[derive(Debug, Object, Serialize)] -struct OpenFileResponse { - success: bool, -} - -/// OpenAPI endpoint group for user preferences and editor configuration. -pub struct SettingsApi { - pub ctx: Arc, -} - -#[OpenApi(tag = "SettingsTags::Settings")] -impl SettingsApi { - /// Get the configured editor command (e.g. "zed", "code", "cursor"), or null if not set. - #[oai(path = "/settings/editor", method = "get")] - async fn get_editor(&self) -> OpenApiResult> { - let editor_command = get_editor_command_from_store(&self.ctx); - Ok(Json(EditorCommandResponse { editor_command })) - } - - /// Open a file in the configured editor at the given line number. - /// - /// Invokes the stored editor CLI (e.g. "zed", "code") with `path:line` as the argument. - /// Returns an error if no editor is configured or if the process fails to spawn. - #[oai(path = "/settings/open-file", method = "post")] - async fn open_file( - &self, - path: Query, - line: Query>, - ) -> OpenApiResult> { - svc::open_file_in_editor(&*self.ctx.store, &path.0, line.0) - .map_err(|e| bad_request(e.to_string()))?; - Ok(Json(OpenFileResponse { success: true })) - } - - /// Get current project.toml scalar settings as JSON. - #[oai(path = "/settings", method = "get")] - async fn get_settings(&self) -> OpenApiResult> { - let project_root = self.ctx.state.get_project_root().map_err(bad_request)?; - let s = - svc::load_project_settings(&project_root).map_err(|e| bad_request(e.to_string()))?; - Ok(Json(s)) - } - - /// Update project.toml scalar settings. Array sections (component, agent) are preserved. - /// - /// Returns 400 if the input fails validation (e.g. unknown qa mode, negative max_retries). - #[oai(path = "/settings", method = "put")] - async fn put_settings( - &self, - payload: Json, - ) -> OpenApiResult> { - validate_project_settings(&payload.0).map_err(bad_request)?; - let project_root = self.ctx.state.get_project_root().map_err(bad_request)?; - svc::write_project_settings(&project_root, &payload.0) - .map_err(|e| bad_request(e.to_string()))?; - // Re-read to confirm what was written - let s = - svc::load_project_settings(&project_root).map_err(|e| bad_request(e.to_string()))?; - Ok(Json(s)) - } - - /// Set the preferred editor command (e.g. "zed", "code", "cursor"). - /// Pass null or empty string to clear the preference. - #[oai(path = "/settings/editor", method = "put")] - async fn set_editor( - &self, - payload: Json, - ) -> OpenApiResult> { - let editor_command = payload.0.editor_command; - let trimmed = editor_command - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()); - match trimmed { - Some(cmd) => { - self.ctx.store.set(EDITOR_COMMAND_KEY, json!(cmd)); - self.ctx.store.save().map_err(bad_request)?; - Ok(Json(EditorCommandResponse { - editor_command: Some(cmd.to_string()), - })) - } - None => { - self.ctx.store.delete(EDITOR_COMMAND_KEY); - self.ctx.store.save().map_err(bad_request)?; - Ok(Json(EditorCommandResponse { - editor_command: None, - })) - } - } - } -} - -#[cfg(test)] -impl From> for SettingsApi { - fn from(ctx: std::sync::Arc) -> Self { - Self { ctx } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::http::test_helpers::{make_api, test_ctx}; - use tempfile::TempDir; - - #[tokio::test] - async fn get_editor_returns_none_when_unset() { - let dir = TempDir::new().unwrap(); - let api = make_api::(&dir); - let result = api.get_editor().await.unwrap(); - assert!(result.0.editor_command.is_none()); - } - - #[tokio::test] - async fn set_editor_stores_command() { - let dir = TempDir::new().unwrap(); - let api = make_api::(&dir); - let payload = Json(EditorCommandPayload { - editor_command: Some("zed".to_string()), - }); - let result = api.set_editor(payload).await.unwrap(); - assert_eq!(result.0.editor_command, Some("zed".to_string())); - } - - #[tokio::test] - async fn set_editor_clears_command_on_null() { - let dir = TempDir::new().unwrap(); - let api = make_api::(&dir); - api.set_editor(Json(EditorCommandPayload { - editor_command: Some("zed".to_string()), - })) - .await - .unwrap(); - let result = api - .set_editor(Json(EditorCommandPayload { - editor_command: None, - })) - .await - .unwrap(); - assert!(result.0.editor_command.is_none()); - } - - #[tokio::test] - async fn set_editor_clears_command_on_empty_string() { - let dir = TempDir::new().unwrap(); - let api = make_api::(&dir); - let result = api - .set_editor(Json(EditorCommandPayload { - editor_command: Some(String::new()), - })) - .await - .unwrap(); - assert!(result.0.editor_command.is_none()); - } - - #[tokio::test] - async fn set_editor_trims_whitespace_only() { - let dir = TempDir::new().unwrap(); - let api = make_api::(&dir); - let result = api - .set_editor(Json(EditorCommandPayload { - editor_command: Some(" ".to_string()), - })) - .await - .unwrap(); - assert!(result.0.editor_command.is_none()); - } - - #[tokio::test] - async fn get_editor_returns_value_after_set() { - let dir = TempDir::new().unwrap(); - let api = make_api::(&dir); - api.set_editor(Json(EditorCommandPayload { - editor_command: Some("cursor".to_string()), - })) - .await - .unwrap(); - let result = api.get_editor().await.unwrap(); - assert_eq!(result.0.editor_command, Some("cursor".to_string())); - } - - #[test] - fn editor_command_defaults_to_null() { - let dir = TempDir::new().unwrap(); - let ctx = test_ctx(dir.path()); - let result = get_editor_command_from_store(&ctx); - assert!(result.is_none()); - } - - #[test] - fn set_editor_command_persists_in_store() { - let dir = TempDir::new().unwrap(); - let ctx = test_ctx(dir.path()); - - ctx.store.set(EDITOR_COMMAND_KEY, json!("zed")); - ctx.store.save().unwrap(); - - let result = get_editor_command_from_store(&ctx); - assert_eq!(result, Some("zed".to_string())); - } - - #[test] - fn get_editor_command_from_store_returns_value() { - let dir = TempDir::new().unwrap(); - let ctx = test_ctx(dir.path()); - - ctx.store.set(EDITOR_COMMAND_KEY, json!("code")); - let result = get_editor_command_from_store(&ctx); - assert_eq!(result, Some("code".to_string())); - } - - #[test] - fn delete_editor_command_returns_none() { - let dir = TempDir::new().unwrap(); - let ctx = test_ctx(dir.path()); - - ctx.store.set(EDITOR_COMMAND_KEY, json!("cursor")); - ctx.store.delete(EDITOR_COMMAND_KEY); - let result = get_editor_command_from_store(&ctx); - assert!(result.is_none()); - } - - #[test] - fn editor_command_survives_reload() { - let dir = TempDir::new().unwrap(); - let store_path = dir.path().join(".huskies_store.json"); - - { - let ctx = AppContext::new_test(dir.path().to_path_buf()); - ctx.store.set(EDITOR_COMMAND_KEY, json!("zed")); - ctx.store.save().unwrap(); - } - - // Reload from disk - let store2 = crate::store::JsonFileStore::new(store_path).unwrap(); - let val = store2.get(EDITOR_COMMAND_KEY); - assert_eq!(val, Some(json!("zed"))); - } - - #[tokio::test] - async fn get_editor_http_handler_returns_null_when_not_set() { - let dir = TempDir::new().unwrap(); - let ctx = test_ctx(dir.path()); - let api = SettingsApi { ctx: Arc::new(ctx) }; - let result = api.get_editor().await.unwrap().0; - assert!(result.editor_command.is_none()); - } - - #[tokio::test] - async fn set_editor_http_handler_stores_value() { - let dir = TempDir::new().unwrap(); - let ctx = test_ctx(dir.path()); - let api = SettingsApi { ctx: Arc::new(ctx) }; - let result = api - .set_editor(Json(EditorCommandPayload { - editor_command: Some("zed".to_string()), - })) - .await - .unwrap() - .0; - assert_eq!(result.editor_command, Some("zed".to_string())); - } - - #[tokio::test] - async fn set_editor_http_handler_clears_value_when_null() { - let dir = TempDir::new().unwrap(); - let ctx = test_ctx(dir.path()); - let api = SettingsApi { ctx: Arc::new(ctx) }; - // First set a value - api.set_editor(Json(EditorCommandPayload { - editor_command: Some("code".to_string()), - })) - .await - .unwrap(); - // Now clear it - let result = api - .set_editor(Json(EditorCommandPayload { - editor_command: None, - })) - .await - .unwrap() - .0; - assert!(result.editor_command.is_none()); - } - - #[tokio::test] - async fn open_file_returns_error_when_no_editor_configured() { - let dir = TempDir::new().unwrap(); - let api = make_api::(&dir); - let result = api - .open_file(Query("src/main.rs".to_string()), Query(Some(42))) - .await; - assert!(result.is_err()); - let err = result.unwrap_err(); - assert_eq!(err.status(), poem::http::StatusCode::BAD_REQUEST); - } - - #[tokio::test] - async fn open_file_spawns_editor_with_path_and_line() { - let dir = TempDir::new().unwrap(); - let api = make_api::(&dir); - // Configure the editor to "echo" which is a safe no-op command - api.set_editor(Json(EditorCommandPayload { - editor_command: Some("echo".to_string()), - })) - .await - .unwrap(); - let result = api - .open_file(Query("src/main.rs".to_string()), Query(Some(42))) - .await - .unwrap(); - assert!(result.0.success); - } - - #[tokio::test] - async fn open_file_spawns_editor_with_path_only_when_no_line() { - let dir = TempDir::new().unwrap(); - let api = make_api::(&dir); - api.set_editor(Json(EditorCommandPayload { - editor_command: Some("echo".to_string()), - })) - .await - .unwrap(); - let result = api - .open_file(Query("src/lib.rs".to_string()), Query(None)) - .await - .unwrap(); - assert!(result.0.success); - } - - #[tokio::test] - async fn open_file_returns_error_for_nonexistent_editor() { - let dir = TempDir::new().unwrap(); - let api = make_api::(&dir); - api.set_editor(Json(EditorCommandPayload { - editor_command: Some("this_editor_does_not_exist_xyz_abc".to_string()), - })) - .await - .unwrap(); - let result = api - .open_file(Query("src/main.rs".to_string()), Query(Some(1))) - .await; - assert!(result.is_err()); - } - - // ── /api/settings GET/PUT ────────────────────────────────────────────── - - fn default_project_settings() -> ProjectSettings { - let cfg = crate::config::ProjectConfig::default(); - settings_from_config(&cfg) - } - - #[tokio::test] - async fn get_settings_returns_defaults_when_no_project_toml() { - let dir = TempDir::new().unwrap(); - // Create .huskies dir so project root detection works but no project.toml - std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); - let ctx = AppContext::new_test(dir.path().to_path_buf()); - let api = SettingsApi { ctx: Arc::new(ctx) }; - let result = api.get_settings().await.unwrap().0; - assert_eq!(result.default_qa, "server"); - assert_eq!(result.max_retries, 2); - assert!(result.rate_limit_notifications); - } - - #[tokio::test] - async fn put_settings_writes_and_returns_settings() { - let dir = TempDir::new().unwrap(); - std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); - let ctx = AppContext::new_test(dir.path().to_path_buf()); - let api = SettingsApi { ctx: Arc::new(ctx) }; - - let mut s = default_project_settings(); - s.default_qa = "agent".to_string(); - s.max_retries = 5; - s.rate_limit_notifications = false; - - let result = api.put_settings(Json(s)).await.unwrap().0; - assert_eq!(result.default_qa, "agent"); - assert_eq!(result.max_retries, 5); - assert!(!result.rate_limit_notifications); - } - - #[tokio::test] - async fn put_settings_preserves_agent_sections() { - let dir = TempDir::new().unwrap(); - let huskies_dir = dir.path().join(".huskies"); - std::fs::create_dir_all(&huskies_dir).unwrap(); - - // Write a project.toml with agent sections - std::fs::write( - huskies_dir.join("project.toml"), - r#" -[[agent]] -name = "coder-1" -model = "sonnet" -stage = "coder" - -[[component]] -name = "server" -path = "." -"#, - ) - .unwrap(); - - let ctx = AppContext::new_test(dir.path().to_path_buf()); - let api = SettingsApi { ctx: Arc::new(ctx) }; - - let mut s = default_project_settings(); - s.default_qa = "human".to_string(); - api.put_settings(Json(s)).await.unwrap(); - - // Re-read the file and verify agent/component sections are still there - let written = std::fs::read_to_string(huskies_dir.join("project.toml")).unwrap(); - assert!( - written.contains("coder-1"), - "agent section should be preserved" - ); - assert!( - written.contains("server"), - "component section should be preserved" - ); - assert!(written.contains("human"), "new setting should be written"); - } - - #[tokio::test] - async fn put_settings_rejects_invalid_qa_mode() { - let dir = TempDir::new().unwrap(); - std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); - let ctx = AppContext::new_test(dir.path().to_path_buf()); - let api = SettingsApi { ctx: Arc::new(ctx) }; - - let mut s = default_project_settings(); - s.default_qa = "invalid_mode".to_string(); - - let result = api.put_settings(Json(s)).await; - assert!(result.is_err()); - let err = result.unwrap_err(); - assert_eq!(err.status(), poem::http::StatusCode::BAD_REQUEST); - } - - #[test] - fn validate_project_settings_accepts_valid_qa_modes() { - for mode in &["server", "agent", "human"] { - let s = ProjectSettings { - default_qa: mode.to_string(), - default_coder_model: None, - max_coders: None, - max_retries: 2, - base_branch: None, - rate_limit_notifications: true, - timezone: None, - rendezvous: None, - watcher_sweep_interval_secs: 60, - watcher_done_retention_secs: 14400, - }; - assert!( - validate_project_settings(&s).is_ok(), - "qa mode '{mode}' should be valid" - ); - } - } - - #[test] - fn validate_project_settings_rejects_unknown_qa_mode() { - let s = ProjectSettings { - default_qa: "robot".to_string(), - default_coder_model: None, - max_coders: None, - max_retries: 2, - base_branch: None, - rate_limit_notifications: true, - timezone: None, - rendezvous: None, - watcher_sweep_interval_secs: 60, - watcher_done_retention_secs: 14400, - }; - let err = validate_project_settings(&s).unwrap_err(); - assert!(err.contains("robot")); - } - - #[test] - fn write_and_read_project_settings_roundtrip() { - let dir = TempDir::new().unwrap(); - std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); - - let s = ProjectSettings { - default_qa: "agent".to_string(), - default_coder_model: Some("opus".to_string()), - max_coders: Some(2), - max_retries: 3, - base_branch: Some("main".to_string()), - rate_limit_notifications: false, - timezone: Some("America/New_York".to_string()), - rendezvous: Some("ws://host:3001/crdt-sync".to_string()), - watcher_sweep_interval_secs: 30, - watcher_done_retention_secs: 7200, - }; - - write_project_settings(dir.path(), &s).unwrap(); - - let config = crate::config::ProjectConfig::load(dir.path()).unwrap(); - let loaded = settings_from_config(&config); - - assert_eq!(loaded.default_qa, "agent"); - assert_eq!(loaded.default_coder_model, Some("opus".to_string())); - assert_eq!(loaded.max_coders, Some(2)); - assert_eq!(loaded.max_retries, 3); - assert_eq!(loaded.base_branch, Some("main".to_string())); - assert!(!loaded.rate_limit_notifications); - assert_eq!(loaded.timezone, Some("America/New_York".to_string())); - assert_eq!( - loaded.rendezvous, - Some("ws://host:3001/crdt-sync".to_string()) - ); - assert_eq!(loaded.watcher_sweep_interval_secs, 30); - assert_eq!(loaded.watcher_done_retention_secs, 7200); - } - - #[test] - fn write_project_settings_clears_optional_fields_when_none() { - let dir = TempDir::new().unwrap(); - let huskies_dir = dir.path().join(".huskies"); - std::fs::create_dir_all(&huskies_dir).unwrap(); - - // First write with optional fields set - let s_with = ProjectSettings { - default_qa: "server".to_string(), - default_coder_model: Some("sonnet".to_string()), - max_coders: Some(3), - max_retries: 2, - base_branch: Some("master".to_string()), - rate_limit_notifications: true, - timezone: Some("UTC".to_string()), - rendezvous: None, - watcher_sweep_interval_secs: 60, - watcher_done_retention_secs: 14400, - }; - write_project_settings(dir.path(), &s_with).unwrap(); - - // Then write with optional fields cleared - let s_clear = ProjectSettings { - default_qa: "server".to_string(), - default_coder_model: None, - max_coders: None, - max_retries: 2, - base_branch: None, - rate_limit_notifications: true, - timezone: None, - rendezvous: None, - watcher_sweep_interval_secs: 60, - watcher_done_retention_secs: 14400, - }; - write_project_settings(dir.path(), &s_clear).unwrap(); - - let config = crate::config::ProjectConfig::load(dir.path()).unwrap(); - let loaded = settings_from_config(&config); - assert!(loaded.default_coder_model.is_none()); - assert!(loaded.max_coders.is_none()); - assert!(loaded.base_branch.is_none()); - assert!(loaded.timezone.is_none()); - } -} diff --git a/server/src/http/test_helpers.rs b/server/src/http/test_helpers.rs index c4a5651f..c719f1eb 100644 --- a/server/src/http/test_helpers.rs +++ b/server/src/http/test_helpers.rs @@ -1,21 +1,9 @@ //! Shared test utilities for HTTP handler tests. -//! -//! Import with `use crate::http::test_helpers::{make_api, test_ctx};` use crate::http::context::AppContext; use std::path::Path; -use std::sync::Arc; -use tempfile::TempDir; /// Build an [`AppContext`] rooted at `dir` for use in tests. pub(crate) fn test_ctx(dir: &Path) -> AppContext { AppContext::new_test(dir.to_path_buf()) } - -/// Build an API struct rooted in `dir` for use in tests. -/// -/// Requires the API type to implement `From>`. Add a -/// `#[cfg(test)]` impl block to each API struct to opt in. -pub(crate) fn make_api>>(dir: &TempDir) -> T { - Arc::new(test_ctx(dir.path())).into() -} diff --git a/server/src/http/wizard.rs b/server/src/http/wizard.rs deleted file mode 100644 index a997d9d1..00000000 --- a/server/src/http/wizard.rs +++ /dev/null @@ -1,287 +0,0 @@ -//! HTTP wizard endpoints — REST API for the project setup wizard. -use crate::http::context::{AppContext, OpenApiResult, bad_request, not_found}; -use crate::io::wizard::{WizardState, WizardStep}; -use crate::service::wizard as svc; -use poem_openapi::{Object, OpenApi, Tags, param::Path, payload::Json}; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; - -#[derive(Tags)] -enum WizardTags { - Wizard, -} - -/// Response for a single wizard step. -#[derive(Serialize, Object)] -struct StepResponse { - step: String, - label: String, - status: String, - #[oai(skip_serializing_if = "Option::is_none")] - content: Option, -} - -/// Full wizard state response. -#[derive(Serialize, Object)] -struct WizardResponse { - steps: Vec, - current_step_index: usize, - completed: bool, -} - -/// Request body for confirming/skipping a step or submitting content. -#[derive(Deserialize, Object)] -struct StepActionPayload { - /// Optional content to store for the step (e.g., generated spec). - #[oai(skip_serializing_if = "Option::is_none")] - content: Option, -} - -impl From<&WizardState> for WizardResponse { - fn from(state: &WizardState) -> Self { - WizardResponse { - steps: state - .steps - .iter() - .map(|s| StepResponse { - step: serde_json::to_value(s.step) - .ok() - .and_then(|v| v.as_str().map(String::from)) - .unwrap_or_default(), - label: s.step.label().to_string(), - status: serde_json::to_value(&s.status) - .ok() - .and_then(|v| v.as_str().map(String::from)) - .unwrap_or_default(), - content: s.content.clone(), - }) - .collect(), - current_step_index: state.current_step_index(), - completed: state.completed, - } - } -} - -fn parse_step(step_str: &str) -> Result { - let quoted = format!("\"{step_str}\""); - serde_json::from_str::("ed) - .map_err(|_| not_found(format!("Unknown wizard step: {step_str}"))) -} - -/// OpenAPI endpoint group for the multi-step project setup wizard. -pub struct WizardApi { - pub ctx: Arc, -} - -#[OpenApi(tag = "WizardTags::Wizard")] -impl WizardApi { - /// Get the current wizard state. - /// - /// Returns the full setup wizard progress including all steps and their - /// statuses. Returns 404 if no wizard is active. - #[oai(path = "/wizard", method = "get")] - async fn get_wizard_state(&self) -> OpenApiResult> { - let root = self.ctx.state.get_project_root().map_err(bad_request)?; - let state = svc::get_state(&root).map_err(|_| not_found("No wizard active".to_string()))?; - Ok(Json(WizardResponse::from(&state))) - } - - /// Set a step's content and mark it as awaiting confirmation. - /// - /// Used after the agent generates content for a step. The content is - /// stored for preview and the step is marked as awaiting user confirmation. - #[oai(path = "/wizard/step/:step/content", method = "put")] - async fn set_step_content( - &self, - step: Path, - payload: Json, - ) -> OpenApiResult> { - let root = self.ctx.state.get_project_root().map_err(bad_request)?; - let wizard_step = parse_step(&step.0)?; - let state = svc::set_step_content(&root, wizard_step, payload.0.content) - .map_err(|e| bad_request(e.to_string()))?; - Ok(Json(WizardResponse::from(&state))) - } - - /// Confirm a step and advance to the next. - /// - /// The step must be the current active step. Returns the updated wizard state. - #[oai(path = "/wizard/step/:step/confirm", method = "post")] - async fn confirm_step(&self, step: Path) -> OpenApiResult> { - let root = self.ctx.state.get_project_root().map_err(bad_request)?; - let wizard_step = parse_step(&step.0)?; - let state = - svc::mark_step_confirmed(&root, wizard_step).map_err(|e| bad_request(e.to_string()))?; - Ok(Json(WizardResponse::from(&state))) - } - - /// Skip a step and advance to the next. - /// - /// The step must be the current active step. - #[oai(path = "/wizard/step/:step/skip", method = "post")] - async fn skip_step(&self, step: Path) -> OpenApiResult> { - let root = self.ctx.state.get_project_root().map_err(bad_request)?; - let wizard_step = parse_step(&step.0)?; - let state = - svc::mark_step_skipped(&root, wizard_step).map_err(|e| bad_request(e.to_string()))?; - Ok(Json(WizardResponse::from(&state))) - } - - /// Mark a step as generating (agent is working on it). - #[oai(path = "/wizard/step/:step/generating", method = "post")] - async fn mark_generating(&self, step: Path) -> OpenApiResult> { - let root = self.ctx.state.get_project_root().map_err(bad_request)?; - let wizard_step = parse_step(&step.0)?; - let state = svc::mark_step_generating(&root, wizard_step) - .map_err(|e| bad_request(e.to_string()))?; - Ok(Json(WizardResponse::from(&state))) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::http::context::AppContext; - use poem::http::StatusCode; - use poem::test::TestClient; - use poem_openapi::OpenApiService; - use tempfile::TempDir; - - fn setup() -> (TempDir, TestClient) { - let dir = TempDir::new().unwrap(); - let root = dir.path().to_path_buf(); - std::fs::create_dir_all(root.join(".huskies")).unwrap(); - - let ctx = Arc::new(AppContext::new_test(root.clone())); - let api = WizardApi { ctx }; - let service = OpenApiService::new(api, "test", "0.1.0"); - let client = TestClient::new(service); - (dir, client) - } - - #[tokio::test] - async fn get_wizard_returns_404_when_no_wizard() { - let (_dir, client) = setup(); - let resp = client.get("/wizard").send().await; - resp.assert_status(StatusCode::NOT_FOUND); - } - - #[tokio::test] - async fn get_wizard_returns_state_when_active() { - let (dir, client) = setup(); - WizardState::init_if_missing(dir.path()); - - let resp = client.get("/wizard").send().await; - resp.assert_status_is_ok(); - let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap(); - assert_eq!(body["current_step_index"], 1); - assert!(!body["completed"].as_bool().unwrap()); - assert_eq!(body["steps"].as_array().unwrap().len(), 8); - assert_eq!(body["steps"][0]["status"], "confirmed"); - } - - #[tokio::test] - async fn confirm_step_advances_wizard() { - let (dir, client) = setup(); - WizardState::init_if_missing(dir.path()); - - let resp = client.post("/wizard/step/context/confirm").send().await; - resp.assert_status_is_ok(); - let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap(); - assert_eq!(body["current_step_index"], 2); - assert_eq!(body["steps"][1]["status"], "confirmed"); - } - - #[tokio::test] - async fn confirm_wrong_step_returns_error() { - let (dir, client) = setup(); - WizardState::init_if_missing(dir.path()); - - // Try to confirm step 3 (stack) when current is step 2 (context) - let resp = client.post("/wizard/step/stack/confirm").send().await; - resp.assert_status(StatusCode::BAD_REQUEST); - } - - #[tokio::test] - async fn skip_step_advances_wizard() { - let (dir, client) = setup(); - WizardState::init_if_missing(dir.path()); - - let resp = client.post("/wizard/step/context/skip").send().await; - resp.assert_status_is_ok(); - let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap(); - assert_eq!(body["steps"][1]["status"], "skipped"); - assert_eq!(body["current_step_index"], 2); - } - - #[tokio::test] - async fn set_step_content_marks_awaiting_confirmation() { - let (dir, client) = setup(); - WizardState::init_if_missing(dir.path()); - - let resp = client - .put("/wizard/step/context/content") - .body_json(&serde_json::json!({ - "content": "# My Project\n\nA great project." - })) - .send() - .await; - resp.assert_status_is_ok(); - let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap(); - assert_eq!(body["steps"][1]["status"], "awaiting_confirmation"); - assert_eq!( - body["steps"][1]["content"], - "# My Project\n\nA great project." - ); - } - - #[tokio::test] - async fn mark_generating_updates_step() { - let (dir, client) = setup(); - WizardState::init_if_missing(dir.path()); - - let resp = client.post("/wizard/step/context/generating").send().await; - resp.assert_status_is_ok(); - let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap(); - assert_eq!(body["steps"][1]["status"], "generating"); - } - - #[tokio::test] - async fn unknown_step_returns_404() { - let (dir, client) = setup(); - WizardState::init_if_missing(dir.path()); - - let resp = client.post("/wizard/step/nonexistent/confirm").send().await; - resp.assert_status(StatusCode::NOT_FOUND); - } - - #[tokio::test] - async fn full_wizard_flow_completes() { - let (dir, client) = setup(); - WizardState::init_if_missing(dir.path()); - - // Steps 2-8 (scaffold is already confirmed) - let steps = [ - "context", - "stack", - "test_script", - "build_script", - "lint_script", - "release_script", - "test_coverage", - ]; - for step in steps { - let resp = client - .post(format!("/wizard/step/{step}/confirm")) - .send() - .await; - resp.assert_status_is_ok(); - } - - // Check final state - let resp = client.get("/wizard").send().await; - resp.assert_status_is_ok(); - let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap(); - assert!(body["completed"].as_bool().unwrap()); - } -} diff --git a/server/src/io/fs/files.rs b/server/src/io/fs/files.rs index 2e8d0873..7153e77e 100644 --- a/server/src/io/fs/files.rs +++ b/server/src/io/fs/files.rs @@ -38,7 +38,7 @@ pub async fn write_file(path: String, content: String, state: &SessionState) -> write_file_impl(full_path, content).await } -#[derive(Serialize, Debug, poem_openapi::Object)] +#[derive(Serialize, Debug)] /// A directory listing entry with its name and kind (file or directory). pub struct FileEntry { pub name: String, diff --git a/server/src/io/search.rs b/server/src/io/search.rs index 0ef0130e..ec94de33 100644 --- a/server/src/io/search.rs +++ b/server/src/io/search.rs @@ -6,7 +6,7 @@ use serde::Serialize; use std::fs; use std::path::PathBuf; -#[derive(Serialize, Debug, poem_openapi::Object)] +#[derive(Serialize, Debug)] /// A single file that matched a text search, with its match count. pub struct SearchResult { pub path: String, diff --git a/server/src/io/shell.rs b/server/src/io/shell.rs index c86dfab5..e83a8c1c 100644 --- a/server/src/io/shell.rs +++ b/server/src/io/shell.rs @@ -5,7 +5,7 @@ use std::path::PathBuf; use std::process::Command; /// Output captured from a shell command: stdout, stderr, and exit code. -#[derive(Serialize, Debug, poem_openapi::Object)] +#[derive(Serialize, Debug)] pub struct CommandOutput { pub stdout: String, pub stderr: String, diff --git a/server/src/main.rs b/server/src/main.rs index 1b61ac3f..e4528464 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -181,6 +181,7 @@ async fn main() -> Result<(), std::io::Error> { // Event bus: broadcast channel for pipeline lifecycle events. let (watcher_tx, _) = broadcast::channel::(1024); let agents = Arc::new(AgentPool::new(port, watcher_tx.clone())); + crate::crdt_sync::init_rpc_agents(Arc::clone(&agents)); // Filesystem watcher: watches config files for hot-reload. if let Some(ref root) = *app_state.project_root.lock().unwrap() { diff --git a/server/src/service/agents/io.rs b/server/src/service/agents/io.rs index 25acc1e2..44441daa 100644 --- a/server/src/service/agents/io.rs +++ b/server/src/service/agents/io.rs @@ -7,7 +7,6 @@ use crate::agent_log::{self, LogEntry}; use crate::agents::token_usage::{self, TokenUsageRecord}; use crate::config::ProjectConfig; -use crate::worktree::{self, WorktreeListEntry}; use std::path::Path; use super::Error; @@ -48,22 +47,6 @@ pub fn load_config(project_root: &Path) -> Result { ProjectConfig::load(project_root).map_err(Error::Config) } -/// List all worktrees under `.huskies/worktrees/`. -pub fn list_worktrees(project_root: &Path) -> Result, Error> { - worktree::list_worktrees(project_root).map_err(Error::Io) -} - -/// Remove the git worktree for a story by ID. -/// -/// Loads the project config to honour teardown commands. Returns an error if -/// the worktree directory does not exist. -pub async fn remove_worktree(project_root: &Path, story_id: &str) -> Result<(), Error> { - let config = load_config(project_root)?; - worktree::remove_worktree_by_story_id(project_root, story_id, &config) - .await - .map_err(Error::Worktree) -} - /// Read test results persisted in a story's markdown file. /// /// Returns `None` when the story has no test results section. @@ -208,26 +191,4 @@ mod tests { assert_eq!(config.agent.len(), 1); assert_eq!(config.agent[0].name, "default"); } - - // ── list_worktrees ──────────────────────────────────────────────────────── - - #[test] - fn list_worktrees_empty_when_no_dir() { - let tmp = TempDir::new().unwrap(); - let entries = list_worktrees(tmp.path()).unwrap(); - assert!(entries.is_empty()); - } - - #[test] - fn list_worktrees_returns_subdirs() { - let tmp = TempDir::new().unwrap(); - let wt_dir = tmp.path().join(".huskies").join("worktrees"); - std::fs::create_dir_all(wt_dir.join("42_story_foo")).unwrap(); - std::fs::create_dir_all(wt_dir.join("43_story_bar")).unwrap(); - let mut entries = list_worktrees(tmp.path()).unwrap(); - entries.sort_by(|a, b| a.story_id.cmp(&b.story_id)); - assert_eq!(entries.len(), 2); - assert_eq!(entries[0].story_id, "42_story_foo"); - assert_eq!(entries[1].story_id, "43_story_bar"); - } } diff --git a/server/src/service/agents/mod.rs b/server/src/service/agents/mod.rs index e7161474..b5fd2795 100644 --- a/server/src/service/agents/mod.rs +++ b/server/src/service/agents/mod.rs @@ -17,7 +17,6 @@ use crate::agents::AgentPool; use crate::agents::token_usage::TokenUsageRecord; use crate::config::ProjectConfig; use crate::workflow::StoryTestResults; -use crate::worktree::{WorktreeInfo, WorktreeListEntry}; use std::path::Path; pub use io::is_archived; @@ -35,8 +34,6 @@ pub enum Error { AgentNotFound(String), /// No work item found for the requested story ID. WorkItemNotFound(String), - /// A worktree operation failed. - Worktree(String), /// Project configuration could not be loaded. Config(String), /// A filesystem or I/O operation failed. @@ -48,7 +45,6 @@ impl std::fmt::Display for Error { match self { Self::AgentNotFound(msg) => write!(f, "Agent not found: {msg}"), Self::WorkItemNotFound(msg) => write!(f, "Work item not found: {msg}"), - Self::Worktree(msg) => write!(f, "Worktree error: {msg}"), Self::Config(msg) => write!(f, "Config error: {msg}"), Self::Io(msg) => write!(f, "I/O error: {msg}"), } @@ -62,8 +58,6 @@ impl std::fmt::Display for Error { pub struct WorkItemContent { pub content: String, pub stage: crate::pipeline_state::Stage, - /// Whether the item is frozen — orthogonal to [`Self::stage`]. - pub frozen: bool, pub name: Option, pub agent: Option, } @@ -117,41 +111,12 @@ pub async fn stop_agent( .map_err(Error::AgentNotFound) } -/// Create a git worktree for a story. -pub async fn create_worktree( - pool: &AgentPool, - project_root: &Path, - story_id: &str, -) -> Result { - pool.create_worktree(project_root, story_id) - .await - .map_err(Error::Worktree) -} - -/// List all worktrees under `.huskies/worktrees/`. -pub fn list_worktrees(project_root: &Path) -> Result, Error> { - io::list_worktrees(project_root) -} - -/// Remove the git worktree for a story. -pub async fn remove_worktree(project_root: &Path, story_id: &str) -> Result<(), Error> { - io::remove_worktree(project_root, story_id).await -} - /// Get the configured agent roster from `project.toml`. pub fn get_agent_config(project_root: &Path) -> Result, Error> { let config = io::load_config(project_root)?; Ok(config_to_entries(&config)) } -/// Reload and return the project's agent configuration. -/// -/// Semantically identical to `get_agent_config`; provided as a distinct -/// function so callers can express intent (UI "Reload" button). -pub fn reload_config(project_root: &Path) -> Result, Error> { - get_agent_config(project_root) -} - /// Get the concatenated output text for an agent's most recent session. /// /// Returns an empty string when no log file exists yet. @@ -207,7 +172,6 @@ pub fn get_work_item_content( return Ok(WorkItemContent { content, stage: stage.clone(), - frozen: false, name: crdt_name.clone(), agent: crdt_agent.clone(), }); @@ -218,14 +182,13 @@ pub fn get_work_item_content( if let Some(content) = crate::db::read_content(story_id) { let item = crate::pipeline_state::read_typed(story_id) .map_err(|e| Error::Io(format!("Pipeline read error: {e}")))?; - let (stage, frozen) = match item.as_ref() { - Some(i) => (i.stage.clone(), i.is_frozen()), - None => (Stage::Upcoming, false), + let stage = match item.as_ref() { + Some(i) => i.stage.clone(), + None => Stage::Upcoming, }; return Ok(WorkItemContent { content, stage, - frozen, name: crdt_name, agent: crdt_agent, }); @@ -359,7 +322,6 @@ max_budget_usd = 5.0 let item = get_work_item_content(tmp.path(), "42_story_foo").unwrap(); assert!(item.content.contains("Some content.")); assert_eq!(item.stage, crate::pipeline_state::Stage::Backlog); - assert!(!item.frozen); assert_eq!(item.name, Some("Foo Story".to_string())); } diff --git a/server/src/service/anthropic/mod.rs b/server/src/service/anthropic/mod.rs index 25c56b70..5f4ef6e4 100644 --- a/server/src/service/anthropic/mod.rs +++ b/server/src/service/anthropic/mod.rs @@ -44,7 +44,7 @@ impl std::fmt::Display for Error { // ── Types ───────────────────────────────────────────────────────────────────── /// A summary of an Anthropic model as returned by the `/v1/models` endpoint. -#[derive(Serialize, Deserialize, Debug, PartialEq, poem_openapi::Object)] +#[derive(Serialize, Deserialize, Debug, PartialEq)] pub struct ModelSummary { pub id: String, pub context_window: u64, diff --git a/server/src/service/file_io/io.rs b/server/src/service/file_io/io.rs index c6c41c66..f777f1a9 100644 --- a/server/src/service/file_io/io.rs +++ b/server/src/service/file_io/io.rs @@ -16,6 +16,7 @@ pub(super) async fn read_file(path: String, state: &SessionState) -> Result Result Result<(), Error> { crate::io::fs::create_directory_absolute(path) .await @@ -58,6 +61,7 @@ pub(super) async fn list_project_files(state: &SessionState) -> Result, diff --git a/server/src/service/file_io/mod.rs b/server/src/service/file_io/mod.rs index 52f5479c..29101d55 100644 --- a/server/src/service/file_io/mod.rs +++ b/server/src/service/file_io/mod.rs @@ -65,12 +65,14 @@ pub async fn read_file(path: String, state: &SessionState) -> Result Result<(), Error> { validate_path(&path)?; io::write_file(path, content, state).await } /// List directory entries at a project-relative path. +#[allow(dead_code)] pub async fn list_directory(path: String, state: &SessionState) -> Result, Error> { io::list_directory(path, state).await } @@ -81,6 +83,7 @@ pub async fn list_directory_absolute(path: String) -> Result, Err } /// Create a directory (and all parents) at an absolute path. +#[allow(dead_code)] pub async fn create_directory_absolute(path: String) -> Result<(), Error> { io::create_directory_absolute(path).await } @@ -96,11 +99,13 @@ pub async fn list_project_files(state: &SessionState) -> Result, Err } /// Search the project for files whose contents contain `query`. +#[allow(dead_code)] pub async fn search_files(query: String, state: &SessionState) -> Result, Error> { io::search_files(query, state).await } /// Execute an allowlisted shell command in the project root directory. +#[allow(dead_code)] pub async fn exec_shell( command: String, args: Vec, diff --git a/server/src/service/settings/project.rs b/server/src/service/settings/project.rs index d19ada7b..6d04dc5a 100644 --- a/server/src/service/settings/project.rs +++ b/server/src/service/settings/project.rs @@ -6,7 +6,6 @@ //! write path in `mod.rs` + `io.rs`). use crate::config::ProjectConfig; -use poem_openapi::Object; use serde::{Deserialize, Serialize}; /// Project-level settings exposed via `GET /api/settings` and `PUT /api/settings`. @@ -14,7 +13,7 @@ use serde::{Deserialize, Serialize}; /// Only contains the scalar fields of `ProjectConfig` — array sections /// (`[[component]]`, `[[agent]]`, `[watcher]`) are preserved in the TOML file /// and are not editable through this API. -#[derive(Debug, Object, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct ProjectSettings { /// Project-wide default QA mode: "server", "agent", or "human". Default: "server". pub default_qa: String, diff --git a/server/src/service/wizard/mod.rs b/server/src/service/wizard/mod.rs index 7349adfa..2dbbbea4 100644 --- a/server/src/service/wizard/mod.rs +++ b/server/src/service/wizard/mod.rs @@ -52,35 +52,6 @@ impl std::fmt::Display for Error { } } -// ── Public API — used by HTTP handlers ──────────────────────────────────────── - -/// Load and return the current wizard state. -/// -/// # Errors -/// - [`Error::NotActive`] if `wizard_state.json` does not exist. -pub fn get_state(root: &Path) -> Result { - io::load(root).ok_or(Error::NotActive) -} - -/// Set content for `step` and mark it as awaiting confirmation. -/// -/// Content is staged in `wizard_state.json` but **not** written to disk until -/// [`confirm`] is called. -/// -/// # Errors -/// - [`Error::NotActive`] if no wizard is active. -/// - [`Error::PersistenceFailure`] if saving state fails. -pub fn set_step_content( - root: &Path, - step: WizardStep, - content: Option, -) -> Result { - let mut state = io::load(root).ok_or(Error::NotActive)?; - state.set_step_status(step, StepStatus::AwaitingConfirmation, content); - io::save(&state, root)?; - Ok(state) -} - /// Mark `step` as confirmed and advance the wizard. /// /// Enforces sequential ordering — only the current step may be confirmed. @@ -113,18 +84,6 @@ pub fn mark_step_skipped(root: &Path, step: WizardStep) -> Result Result { - let mut state = io::load(root).ok_or(Error::NotActive)?; - state.set_step_status(step, StepStatus::Generating, None); - io::save(&state, root)?; - Ok(state) -} - // ── Public API — used by MCP tool handlers ───────────────────────────────── /// Return the current wizard state as a human-readable summary. @@ -300,6 +259,34 @@ pub fn retry(root: &Path) -> Result { )) } +/// Return the current wizard state. +/// +/// # Errors +/// - [`Error::NotActive`] if no wizard is active. +#[cfg(test)] +pub fn get_state(root: &Path) -> Result { + io::load(root).ok_or(Error::NotActive) +} + +/// Stage `content` for `step` and transition its status to `AwaitingConfirmation`. +/// +/// Content is not written to disk until [`confirm`] is called. +/// +/// # Errors +/// - [`Error::NotActive`] if no wizard is active. +/// - [`Error::PersistenceFailure`] if saving state fails. +#[cfg(test)] +pub fn set_step_content( + root: &Path, + step: WizardStep, + content: Option, +) -> Result { + let mut state = io::load(root).ok_or(Error::NotActive)?; + state.set_step_status(step, StepStatus::AwaitingConfirmation, content); + io::save(&state, root)?; + Ok(state) +} + /// Write `content` to `path` if no real content already exists there. /// /// Thin public wrapper around `io::write_step_file` for use by HTTP/chat