huskies: merge 950

This commit is contained in:
dave
2026-05-13 08:41:57 +00:00
parent 7491eec257
commit 4a8ed4348b
38 changed files with 354 additions and 4329 deletions
Generated
+4 -126
View File
@@ -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",
-1
View File
@@ -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"
+37 -106
View File
@@ -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)",
);
});
});
});
+12 -57
View File
@@ -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<T>(
path: string,
options: RequestInit = {},
baseUrl = DEFAULT_API_BASE,
): Promise<T> {
const res = await fetch(buildApiUrl(path, baseUrl), {
headers: {
"Content-Type": "application/json",
...(options.headers ?? {}),
},
...options,
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `Request failed (${res.status})`);
}
return res.json() as Promise<T>;
}
export const agentsApi = {
startAgent(storyId: string, agentName?: string, baseUrl?: string) {
return requestJson<AgentInfo>(
"/agents/start",
{
method: "POST",
body: JSON.stringify({
story_id: storyId,
agent_name: agentName,
}),
},
baseUrl,
);
startAgent(storyId: string, agentName?: string) {
return rpcCall<AgentInfo>("agents.start", {
story_id: storyId,
agent_name: agentName,
});
},
stopAgent(storyId: string, agentName: string, baseUrl?: string) {
return requestJson<boolean>(
"/agents/stop",
{
method: "POST",
body: JSON.stringify({
story_id: storyId,
agent_name: agentName,
}),
},
baseUrl,
);
stopAgent(storyId: string, agentName: string) {
return rpcCall<boolean>("agents.stop", {
story_id: storyId,
agent_name: agentName,
});
},
listAgents(_baseUrl?: string) {
@@ -104,12 +63,8 @@ export const agentsApi = {
return rpcCall<AgentConfigInfo[]>("agent_config.list");
},
reloadConfig(baseUrl?: string) {
return requestJson<AgentConfigInfo[]>(
"/agents/config/reload",
{ method: "POST" },
baseUrl,
);
reloadConfig() {
return rpcCall<AgentConfigInfo[]>("agent_config.list");
},
getAgentOutput(storyId: string, agentName: string, _baseUrl?: string) {
-56
View File
@@ -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", () => {
+9 -87
View File
@@ -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<T>(
path: string,
options: RequestInit = {},
baseUrl = DEFAULT_API_BASE,
): Promise<T> {
const res = await fetch(buildApiUrl(path, baseUrl), {
headers: {
"Content-Type": "application/json",
...(options.headers ?? {}),
},
...options,
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `Request failed (${res.status})`);
}
return res.json() as Promise<T>;
}
/**
* 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<string | null>("project.current");
@@ -137,40 +106,11 @@ export const api = {
const r = await rpcCall<OkResult>("anthropic.set_api_key", params);
return r.ok;
},
readFile(path: string, baseUrl?: string) {
return requestJson<string>(
"/fs/read",
{ method: "POST", body: JSON.stringify({ path }) },
baseUrl,
);
readFile(path: string) {
return rpcCall<string>("io.read_file", { path });
},
writeFile(path: string, content: string, baseUrl?: string) {
return requestJson<boolean>(
"/fs/write",
{ method: "POST", body: JSON.stringify({ path, content }) },
baseUrl,
);
},
listDirectory(path: string, baseUrl?: string) {
return requestJson<FileEntry[]>(
"/fs/list",
{ method: "POST", body: JSON.stringify({ path }) },
baseUrl,
);
},
listDirectoryAbsolute(path: string, baseUrl?: string) {
return requestJson<FileEntry[]>(
"/io/fs/list/absolute",
{ method: "POST", body: JSON.stringify({ path }) },
baseUrl,
);
},
createDirectoryAbsolute(path: string, baseUrl?: string) {
return requestJson<boolean>(
"/io/fs/create/absolute",
{ method: "POST", body: JSON.stringify({ path }) },
baseUrl,
);
listDirectoryAbsolute(path: string) {
return rpcCall<FileEntry[]>("io.list_directory_absolute", { path });
},
getHomeDirectory(_baseUrl?: string) {
return rpcCall<string>("io.home_directory");
@@ -178,20 +118,6 @@ export const api = {
listProjectFiles(_baseUrl?: string) {
return rpcCall<string[]>("io.list_project_files");
},
searchFiles(query: string, baseUrl?: string) {
return requestJson<SearchResult[]>(
"/fs/search",
{ method: "POST", body: JSON.stringify({ query }) },
baseUrl,
);
},
execShell(command: string, args: string[], baseUrl?: string) {
return requestJson<CommandOutput>(
"/shell/exec",
{ method: "POST", body: JSON.stringify({ command, args }) },
baseUrl,
);
},
async cancelChat(_baseUrl?: string) {
const r = await rpcCall<OkResult>("chat.cancel");
return r.ok;
@@ -237,11 +163,7 @@ export const api = {
return rpcCall<OAuthStatus>("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 });
},
};
+1 -1
View File
@@ -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";
@@ -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();
});
+18 -21
View File
@@ -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<string, string> = {
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<WizardStateData | null> {
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<WizardStateData>("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<WizardStateData>("wizard.skip_step", {
step: step.step,
});
onWizardUpdate(result);
setRefreshKey((k) => k + 1);
} catch {
// ignore — state remains unchanged
}
},
[onWizardUpdate],
+1 -1
View File
@@ -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 },
-1
View File
@@ -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 }
+1 -1
View File
@@ -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.
+212
View File
@@ -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<RpcState> = OnceLock::new();
/// Global agent pool, registered once at startup via [`init_rpc_agents`].
static RPC_AGENTS: OnceLock<Arc<AgentPool>> = 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<AgentPool>) {
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<Value> = 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::<crate::io::wizard::WizardStep>(&quoted) {
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::<crate::io::wizard::WizardStep>(&quoted) {
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.
-551
View File
@@ -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<String>,
}
#[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<String>,
worktree_path: Option<String>,
}
#[derive(Object, Serialize)]
struct AgentConfigInfoResponse {
name: String,
role: String,
stage: Option<String>,
model: Option<String>,
allowed_tools: Option<Vec<String>>,
max_turns: Option<u32>,
max_budget_usd: Option<f64>,
}
impl From<AgentConfigEntry> 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<String>,
agent: Option<String>,
}
impl From<WorkItemContent> 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<String>,
}
/// Response for the work item test results endpoint.
#[derive(Object, Serialize)]
struct TestResultsResponse {
unit: Vec<TestCaseResultResponse>,
integration: Vec<TestCaseResultResponse>,
}
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<String>,
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<AgentCostEntry>,
}
/// A single token usage record in the all-usage response.
#[derive(Object, Serialize)]
struct TokenUsageRecordResponse {
story_id: String,
agent_name: String,
model: Option<String>,
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<TokenUsageRecordResponse>,
}
/// 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<AppContext>,
}
#[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<StartAgentPayload>,
) -> OpenApiResult<Json<AgentInfoResponse>> {
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<StopAgentPayload>) -> OpenApiResult<Json<bool>> {
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<Json<Vec<AgentConfigInfoResponse>>> {
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<Json<Vec<AgentConfigInfoResponse>>> {
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<CreateWorktreePayload>,
) -> OpenApiResult<Json<WorktreeInfoResponse>> {
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<Json<Vec<WorktreeListEntry>>> {
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<String>,
) -> OpenApiResult<Json<WorkItemContentResponse>> {
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<String>,
) -> OpenApiResult<Json<Option<TestResultsResponse>>> {
// 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<String>,
agent_name: Path<String>,
) -> OpenApiResult<Json<AgentOutputResponse>> {
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<String>) -> OpenApiResult<Json<bool>> {
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<String>,
) -> OpenApiResult<Json<TokenCostResponse>> {
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<Json<AllTokenUsageResponse>> {
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<TokenUsageRecordResponse> = 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;
-651
View File
@@ -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
<!-- huskies-test-results: {"unit":[{"name":"from_file","status":"pass","details":null}],"integration":[]} -->
"#;
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");
}
-268
View File
@@ -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<AppContext>,
}
impl AnthropicApi {
/// Create a new `AnthropicApi` bound to the given application context.
pub fn new(ctx: Arc<AppContext>) -> Self {
Self { ctx }
}
}
#[cfg(test)]
impl From<Arc<AppContext>> for AnthropicApi {
fn from(ctx: Arc<AppContext>) -> 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<Json<bool>> {
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<ApiKeyPayload>,
) -> OpenApiResult<Json<bool>> {
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<Json<Vec<ModelSummary>>> {
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<Json<Vec<ModelSummary>>> {
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<String, String> {
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<AnthropicModelInfo>,
}
#[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::<AnthropicApi>(&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::<AnthropicApi>(&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::<AnthropicApi>(&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::<AnthropicApi>(&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);
}
}
-331
View File
@@ -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<AppContext>,
}
#[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<BotCommandRequest>,
) -> OpenApiResult<Json<BotCommandResponse>> {
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");
}
}
-56
View File
@@ -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<String>,
pub enabled: Option<bool>,
pub homeserver: Option<String>,
pub username: Option<String>,
pub password: Option<String>,
pub room_ids: Option<Vec<String>>,
pub slack_bot_token: Option<String>,
pub slack_signing_secret: Option<String>,
pub slack_channel_ids: Option<Vec<String>>,
}
/// OpenAPI endpoint group for reading and writing bot configuration.
pub struct BotConfigApi {
pub ctx: Arc<AppContext>,
}
#[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<Json<BotConfigPayload>> {
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<BotConfigPayload>,
) -> OpenApiResult<Json<BotConfigPayload>> {
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)
}
}
-60
View File
@@ -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<AppContext>,
}
#[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<Json<bool>> {
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());
}
}
-32
View File
@@ -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<T>` used by OpenAPI handler return types.
pub type OpenApiResult<T> = poem::Result<T>;
/// 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);
}
}
-418
View File
@@ -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<String>,
}
/// OpenAPI endpoint group for filesystem I/O operations (read, write, list, search).
pub struct IoApi {
pub ctx: Arc<AppContext>,
}
#[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<FilePathPayload>) -> OpenApiResult<Json<String>> {
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<WriteFilePayload>) -> OpenApiResult<Json<bool>> {
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<FilePathPayload>,
) -> OpenApiResult<Json<Vec<FileEntry>>> {
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<FilePathPayload>,
) -> OpenApiResult<Json<Vec<FileEntry>>> {
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<CreateDirectoryPayload>,
) -> OpenApiResult<Json<bool>> {
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<Json<String>> {
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<Json<Vec<String>>> {
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<SearchPayload>,
) -> OpenApiResult<Json<Vec<crate::service::file_io::SearchResult>>> {
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<ExecShellPayload>,
) -> OpenApiResult<Json<crate::service::file_io::CommandOutput>> {
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<std::sync::Arc<AppContext>> for IoApi {
fn from(ctx: std::sync::Arc<AppContext>) -> 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::<IoApi>(&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::<IoApi>(&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::<IoApi>(&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::<IoApi>(&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::<IoApi>(&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::<IoApi>(&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::<IoApi>(&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::<IoApi>(&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::<IoApi>(&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::<IoApi>(&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::<IoApi>(&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::<IoApi>(&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::<IoApi>(&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::<IoApi>(&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::<IoApi>(&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::<IoApi>(&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::<IoApi>(&dir);
let payload = Json(FilePathPayload {
path: "nonexistent_dir".to_string(),
});
let result = api.list_directory(payload).await;
assert!(result.is_err());
}
}
+9 -94
View File
@@ -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<Arc<WhatsAppWebhookContext>>,
@@ -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<ApiTuple, ()>;
/// All HTTP methods are documented by OpenAPI at /docs
pub fn build_openapi_service(ctx: Arc<AppContext>) -> (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();
-132
View File
@@ -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<AppContext>,
}
#[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<Json<Option<String>>> {
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<ModelPayload>) -> OpenApiResult<Json<bool>> {
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<Option<String>>,
) -> OpenApiResult<Json<Vec<String>>> {
let models = chat::get_ollama_models(base_url.0)
.await
.unwrap_or_default();
Ok(Json(models))
}
}
#[cfg(test)]
impl From<std::sync::Arc<AppContext>> for ModelApi {
fn from(ctx: std::sync::Arc<AppContext>) -> 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::<ModelApi>(&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::<ModelApi>(&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::<ModelApi>(&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::<ModelApi>(&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::<ModelApi>(&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::<String>::new());
}
}
-231
View File
@@ -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<AppContext>,
}
#[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<Json<Option<String>>> {
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<PathPayload>) -> OpenApiResult<Json<String>> {
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<Json<bool>> {
// 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<Json<Vec<String>>> {
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<PathPayload>) -> OpenApiResult<Json<bool>> {
svc::forget_known_project(payload.0.path, self.ctx.store.as_ref())
.map_err(map_project_error)?;
Ok(Json(true))
}
}
#[cfg(test)]
impl From<std::sync::Arc<AppContext>> for ProjectApi {
fn from(ctx: std::sync::Arc<AppContext>) -> 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::<ProjectApi>(&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::<ProjectApi>(&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::<ProjectApi>(&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::<ProjectApi>(&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::<ProjectApi>(&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::<ProjectApi>(&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::<ProjectApi>(&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::<ProjectApi>(&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::<ProjectApi>(&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::<ProjectApi>(&dir);
let result = api
.forget_known_project(Json(PathPayload {
path: "/some/unknown/path".to_string(),
}))
.await
.unwrap();
assert!(result.0);
}
}
-615
View File
@@ -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<String> {
svc::get_editor_command(&*ctx.store)
}
#[derive(Tags)]
enum SettingsTags {
Settings,
}
#[derive(Object)]
struct EditorCommandPayload {
editor_command: Option<String>,
}
#[derive(Object, Serialize)]
struct EditorCommandResponse {
editor_command: Option<String>,
}
#[derive(Debug, Object, Serialize)]
struct OpenFileResponse {
success: bool,
}
/// OpenAPI endpoint group for user preferences and editor configuration.
pub struct SettingsApi {
pub ctx: Arc<AppContext>,
}
#[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<Json<EditorCommandResponse>> {
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<String>,
line: Query<Option<u32>>,
) -> OpenApiResult<Json<OpenFileResponse>> {
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<Json<ProjectSettings>> {
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<ProjectSettings>,
) -> OpenApiResult<Json<ProjectSettings>> {
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<EditorCommandPayload>,
) -> OpenApiResult<Json<EditorCommandResponse>> {
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<std::sync::Arc<AppContext>> for SettingsApi {
fn from(ctx: std::sync::Arc<AppContext>) -> 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::<SettingsApi>(&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::<SettingsApi>(&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::<SettingsApi>(&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::<SettingsApi>(&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::<SettingsApi>(&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::<SettingsApi>(&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::<SettingsApi>(&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::<SettingsApi>(&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::<SettingsApi>(&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::<SettingsApi>(&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());
}
}
-12
View File
@@ -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<Arc<AppContext>>`. Add a
/// `#[cfg(test)]` impl block to each API struct to opt in.
pub(crate) fn make_api<T: From<Arc<AppContext>>>(dir: &TempDir) -> T {
Arc::new(test_ctx(dir.path())).into()
}
-287
View File
@@ -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<String>,
}
/// Full wizard state response.
#[derive(Serialize, Object)]
struct WizardResponse {
steps: Vec<StepResponse>,
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<String>,
}
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<WizardStep, poem::Error> {
let quoted = format!("\"{step_str}\"");
serde_json::from_str::<WizardStep>(&quoted)
.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<AppContext>,
}
#[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<Json<WizardResponse>> {
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<String>,
payload: Json<StepActionPayload>,
) -> OpenApiResult<Json<WizardResponse>> {
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<String>) -> OpenApiResult<Json<WizardResponse>> {
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<String>) -> OpenApiResult<Json<WizardResponse>> {
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<String>) -> OpenApiResult<Json<WizardResponse>> {
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<impl poem::Endpoint>) {
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());
}
}
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+1
View File
@@ -181,6 +181,7 @@ async fn main() -> Result<(), std::io::Error> {
// Event bus: broadcast channel for pipeline lifecycle events.
let (watcher_tx, _) = broadcast::channel::<io::watcher::WatcherEvent>(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() {
-39
View File
@@ -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, Error> {
ProjectConfig::load(project_root).map_err(Error::Config)
}
/// List all worktrees under `.huskies/worktrees/`.
pub fn list_worktrees(project_root: &Path) -> Result<Vec<WorktreeListEntry>, 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");
}
}
+3 -41
View File
@@ -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<String>,
pub agent: Option<String>,
}
@@ -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<WorktreeInfo, Error> {
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<Vec<WorktreeListEntry>, 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<Vec<AgentConfigEntry>, 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<Vec<AgentConfigEntry>, 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()));
}
+1 -1
View File
@@ -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,
+5
View File
@@ -16,6 +16,7 @@ pub(super) async fn read_file(path: String, state: &SessionState) -> Result<Stri
.map_err(Error::Filesystem)
}
#[allow(dead_code)]
pub(super) async fn write_file(
path: String,
content: String,
@@ -26,6 +27,7 @@ pub(super) async fn write_file(
.map_err(Error::Filesystem)
}
#[allow(dead_code)]
pub(super) async fn list_directory(
path: String,
state: &SessionState,
@@ -41,6 +43,7 @@ pub(super) async fn list_directory_absolute(path: String) -> Result<Vec<FileEntr
.map_err(Error::Filesystem)
}
#[allow(dead_code)]
pub(super) async fn create_directory_absolute(path: String) -> Result<(), Error> {
crate::io::fs::create_directory_absolute(path)
.await
@@ -58,6 +61,7 @@ pub(super) async fn list_project_files(state: &SessionState) -> Result<Vec<Strin
.map_err(Error::Filesystem)
}
#[allow(dead_code)]
pub(super) async fn search_files(
query: String,
state: &SessionState,
@@ -67,6 +71,7 @@ pub(super) async fn search_files(
.map_err(Error::Filesystem)
}
#[allow(dead_code)]
pub(super) async fn exec_shell(
command: String,
args: Vec<String>,
+5
View File
@@ -65,12 +65,14 @@ pub async fn read_file(path: String, state: &SessionState) -> Result<String, Err
}
/// Write a file to the project root, creating parent directories as needed.
#[allow(dead_code)]
pub async fn write_file(path: String, content: String, state: &SessionState) -> 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<Vec<FileEntry>, Error> {
io::list_directory(path, state).await
}
@@ -81,6 +83,7 @@ pub async fn list_directory_absolute(path: String) -> Result<Vec<FileEntry>, 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<Vec<String>, Err
}
/// Search the project for files whose contents contain `query`.
#[allow(dead_code)]
pub async fn search_files(query: String, state: &SessionState) -> Result<Vec<SearchResult>, 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<String>,
+1 -2
View File
@@ -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,
+28 -41
View File
@@ -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<WizardState, Error> {
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<String>,
) -> Result<WizardState, Error> {
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<WizardState, E
Ok(state)
}
/// Mark `step` as generating (agent is working on it).
///
/// # Errors
/// - [`Error::NotActive`] if no wizard is active.
/// - [`Error::PersistenceFailure`] if saving state fails.
pub fn mark_step_generating(root: &Path, step: WizardStep) -> Result<WizardState, Error> {
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<String, Error> {
))
}
/// Return the current wizard state.
///
/// # Errors
/// - [`Error::NotActive`] if no wizard is active.
#[cfg(test)]
pub fn get_state(root: &Path) -> Result<WizardState, Error> {
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<String>,
) -> Result<WizardState, Error> {
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