diff --git a/.seeds/issues.jsonl b/.seeds/issues.jsonl index b86c4e8a..81bc9410 100644 --- a/.seeds/issues.jsonl +++ b/.seeds/issues.jsonl @@ -757,7 +757,7 @@ {"id":"warren-882e","title":"ratchetwatch tightening: 2026-06-16","status":"open","type":"task","priority":3,"createdAt":"2026-06-16T10:44:01.390Z","updatedAt":"2026-06-16T10:56:50.995Z","labels":["audit","ratchetwatch"],"plan_id":"pl-2b5e"} {"id":"warren-7a15","title":"Decompose src/diagnostics/checks.test.ts (653 lines) below the 500-line global limit. Extract the shared helper captureSpawnCalls plus the imports it needs (SpawnFn from ../projects/clone.ts) into a new sibling src/diagnostics/checks.test-helpers.ts and export it; import it from each resulting test file (keep ONE copy — do NOT duplicate, or jscpd check:dups will flag it). Split the 10 top-level describe blocks along their existing seams into sibling files under src/diagnostics/ so every resulting file is < 500 lines: keep checks.test.ts for checkBwrap/checkCanopyClone/checkCanopyClean; create checks.config.test.ts for checkWarrenConfig/checkWarrenConfigDeprecations/checkWarrenDb/checkDatabaseReachable; create checks.preview.test.ts for checkPreviewPortAllocator/checkPreviewMaxLive/checkPreviewAuthStrength. Each file imports the checks under test from ./checks.ts and the shared helper from ./checks.test-helpers.ts. Do not alter any test body or assertion. Verify: each of `wc -l src/diagnostics/checks.test.ts src/diagnostics/checks.config.test.ts src/diagnostics/checks.preview.test.ts src/diagnostics/checks.test-helpers.ts` shows < 500 AND `bun test src/diagnostics/checks.test.ts src/diagnostics/checks.config.test.ts src/diagnostics/checks.preview.test.ts` reports 53 pass / 0 fail (same total as before) AND `bun run typecheck` is clean AND `bun run check:dups` exits 0.","status":"closed","type":"task","priority":2,"plan_step_index":0,"description":"\nStep 1 of plan pl-2b5e.\n\nParent seed: warren-882e — ratchetwatch tightening: 2026-06-16\nPlan template: refactor\nPlan approach: Split src/diagnostics/checks.test.ts (653 lines, 10 top-level describe blocks, 53 tests) along its existing describe seams into sibling test files under src/diagnostics/, each comfortably under the 500-line threshold. Hoist the single…\n\nRun `sd plan show pl-2b5e` for the full plan (context, alternatives, sibling steps, acceptance criteria).\n","createdAt":"2026-06-16T10:44:38.043Z","updatedAt":"2026-06-16T10:52:14.841Z","labels":["ratchetwatch"],"plan_id":"pl-2b5e","blocks":["warren-2c73","warren-882e"],"extensions":{"role":"pi","lastRunId":"run_h8cb2nadsngr","lastRunAt":"2026-06-16T10:46:58.027Z"},"closedAt":"2026-06-16T10:52:14.841Z"} {"id":"warren-2c73","title":"Remove the \"src/diagnostics/checks.test.ts\": 653 entry from scripts/file-size-budgets.json (delete that single line). Do NOT add budget entries for the new sibling files (checks.config.test.ts, checks.preview.test.ts, checks.test-helpers.ts) — they must default-pass under the 500-line threshold (confirm `wc -l` for each shows < 500). Per docs/CONSTITUTION.md Article VI, before declaring done run a repo-wide search for the old path across ALL file types — `grep -rn \"diagnostics/checks.test\" --include='*' . | grep -v node_modules` (or `git grep -n \"diagnostics/checks.test\"`) PLUS an explicit sweep of Dockerfile, docker-compose.yml, .github/workflows/*.yml, src/supervisor/ spawn/config strings, scripts/acceptance/, and docs/ — and fix any stale reference (file moves have broken production here before; encode the check, do not assume it). Verify: `bun run check:size` exits 0 AND `bun run check:all` is fully green (every gate stays green; the removed entry leaves all resulting files default-passing under the 500-line threshold).","status":"closed","type":"task","priority":2,"plan_step_index":1,"description":"\nStep 2 of plan pl-2b5e.\n\nParent seed: warren-882e — ratchetwatch tightening: 2026-06-16\nPlan template: refactor\nPlan approach: Split src/diagnostics/checks.test.ts (653 lines, 10 top-level describe blocks, 53 tests) along its existing describe seams into sibling test files under src/diagnostics/, each comfortably under the 500-line threshold. Hoist the single…\n\nRun `sd plan show pl-2b5e` for the full plan (context, alternatives, sibling steps, acceptance criteria).\n","createdAt":"2026-06-16T10:44:38.043Z","updatedAt":"2026-06-16T10:56:50.995Z","labels":["ratchetwatch"],"plan_id":"pl-2b5e","blocks":["warren-882e"],"closedAt":"2026-06-16T10:56:50.995Z"} -{"id":"warren-1c0d","title":"ratchetwatch tightening: 2026-06-17","status":"open","type":"task","priority":3,"createdAt":"2026-06-17T10:45:50.559Z","updatedAt":"2026-06-17T10:46:21.709Z","description":"Per docs/CONSTITUTION.md Article II (ratchets only tighten). Patrol 2026-06-17 measurements:\n- Coverage: functions 88.96% (floor 88.60%, slack 0.36pt), lines 91.74% (floor 91.54%, slack 0.20pt) — both under the 0.75pt threshold; no floor raise.\n- Bundle: net +460 B gzip JS over trailing 7d (raises summed ~2.6KB); under ~20KB; no finding.\n- Debt-marker allowlist: empty (last touched 2026-05-27); no finding.\n- File-size grandfather list: no entry has dropped below the 500-line global limit (no removals); no new entry in last 24h (only removal of checks.test.ts in 4e157b16). bridge.test.ts grandfather-at-birth already tracked (warren-889a).\n- Decomposition (one per patrol): src/server/handlers/plot-plan-runs.test.ts at 640 lines is the furthest-over grandfathered file NOT already covered by an open seed (pr.ts 659 is covered by warren-db9a/warren-70d7). Plan below decomposes it and removes its budget entry.","labels":["audit","ratchetwatch"],"plan_id":"pl-7c4f","blockedBy":["warren-59db","warren-e304"]} -{"id":"warren-59db","title":"Decompose src/server/handlers/plot-plan-runs.test.ts (640 lines, furthest-over grandfathered file not under an open seed) below the 500-line global limit. Extract the file-local helper/fixture group into a new sibling src/server/handlers/plot-plan-runs.test-helpers.ts and export each symbol: silentLogger, stubFetch, jsonRes, poolFor, makeSdSpawn, planShowResult, seedShowResult, makeAttachment, makePlotReader, makePlotResolver, makeSynthesizer, depsFor, tcpUrl, plus their helper-local types (SdCall, SynthesizeCall, BuildDepsInput). Carry the imports those helpers need into the helpers file (Attachment from @os-eco/plot-cli, BurrowClient/BurrowClientPool, openDatabase/WarrenDb, createRepos/Repos, ProjectRow, the plan-run/plot/synthesizer types, SpawnFn/SpawnOptions/SpawnResult, RunEventBroker, NO_AUTH, createBridgeRegistry, startServer, server types). Keep ONE copy of every helper and import it — do NOT duplicate, or jscpd check:dups will flag it. Split the 9 tests in the single describe(\"POST /plot-plan-runs\") block along theme seams into two sibling test files, each importing from ./plot-plan-runs.test-helpers.ts: keep plot-plan-runs.test.ts for the happy-path + filter tests ('happy path: synthesizes plan + persists plan-run + emits Plot dispatch event' and 'filters closed seeds + sd_plan attachments before synthesis'); create plot-plan-runs.validation.test.ts for the 7 validation/error tests (malformed plot_id 400, no .plot/ 400, no .seeds/ 400, plot_id not in project 400, zero dispatchable attachments 400, 404 project missing, synthesizer error 500). Do not alter any test body, assertion, stub, or fixture. Mirror the precedent of src/diagnostics/checks.test-helpers.ts (warren-7a15) and src/server/handlers/projects.test-helpers.ts (warren-a715). Verify: each of `wc -l src/server/handlers/plot-plan-runs.test.ts src/server/handlers/plot-plan-runs.validation.test.ts src/server/handlers/plot-plan-runs.test-helpers.ts` shows < 500 AND `bun test src/server/handlers/plot-plan-runs.test.ts src/server/handlers/plot-plan-runs.validation.test.ts` reports 9 pass / 0 fail (same total as before) AND `bun run typecheck` is clean AND `bun run check:dups` exits 0.","status":"open","type":"task","priority":2,"plan_step_index":0,"description":"\nStep 1 of plan pl-7c4f.\n\nParent seed: warren-1c0d — ratchetwatch tightening: 2026-06-17\nPlan template: refactor\nPlan approach: Extract the file-local helper/fixture block (the ~16 helpers spanning silentLogger, stubFetch, jsonRes, poolFor, makeSdSpawn, planShowResult, seedShowResult, makeAttachment, makePlotReader, makePlotResolver, makeSynthesizer, depsFor,…\n\nRun `sd plan show pl-7c4f` for the full plan (context, alternatives, sibling steps, acceptance criteria).\n","createdAt":"2026-06-17T10:46:21.709Z","updatedAt":"2026-06-17T10:46:21.709Z","labels":["ratchetwatch"],"plan_id":"pl-7c4f","blocks":["warren-e304","warren-1c0d"]} -{"id":"warren-e304","title":"Remove the \"src/server/handlers/plot-plan-runs.test.ts\": 640 entry from scripts/file-size-budgets.json (delete that single line). Do NOT add budget entries for the new sibling files (plot-plan-runs.validation.test.ts, plot-plan-runs.test-helpers.ts) — they must default-pass under the 500-line threshold (confirm `wc -l` for each shows < 500). Per docs/CONSTITUTION.md Article VI, before declaring done run a repo-wide search for the old path across ALL file types — `rg -n \"plot-plan-runs.test\" --hidden -g '!node_modules' .` (or `git grep -n \"plot-plan-runs.test\"`) PLUS an explicit sweep of Dockerfile, docker-compose.yml, .github/workflows/*.yml, src/supervisor/ spawn/config strings, scripts/acceptance/, and docs/ — and fix any stale reference (file moves have broken production here before; encode the check, do not assume it). Verify: `bun run check:size` exits 0 AND `bun run check:all` is fully green (every gate stays green; the removed entry leaves all resulting files default-passing under the 500-line threshold).","status":"open","type":"task","priority":2,"plan_step_index":1,"description":"\nStep 2 of plan pl-7c4f.\n\nParent seed: warren-1c0d — ratchetwatch tightening: 2026-06-17\nPlan template: refactor\nPlan approach: Extract the file-local helper/fixture block (the ~16 helpers spanning silentLogger, stubFetch, jsonRes, poolFor, makeSdSpawn, planShowResult, seedShowResult, makeAttachment, makePlotReader, makePlotResolver, makeSynthesizer, depsFor,…\n\nRun `sd plan show pl-7c4f` for the full plan (context, alternatives, sibling steps, acceptance criteria).\n","createdAt":"2026-06-17T10:46:21.709Z","updatedAt":"2026-06-17T10:46:21.709Z","labels":["ratchetwatch"],"plan_id":"pl-7c4f","blockedBy":["warren-59db"],"blocks":["warren-1c0d"]} +{"id":"warren-1c0d","title":"ratchetwatch tightening: 2026-06-17","status":"open","type":"task","priority":3,"createdAt":"2026-06-17T10:45:50.559Z","updatedAt":"2026-06-17T10:54:35.710Z","description":"Per docs/CONSTITUTION.md Article II (ratchets only tighten). Patrol 2026-06-17 measurements:\n- Coverage: functions 88.96% (floor 88.60%, slack 0.36pt), lines 91.74% (floor 91.54%, slack 0.20pt) — both under the 0.75pt threshold; no floor raise.\n- Bundle: net +460 B gzip JS over trailing 7d (raises summed ~2.6KB); under ~20KB; no finding.\n- Debt-marker allowlist: empty (last touched 2026-05-27); no finding.\n- File-size grandfather list: no entry has dropped below the 500-line global limit (no removals); no new entry in last 24h (only removal of checks.test.ts in 4e157b16). bridge.test.ts grandfather-at-birth already tracked (warren-889a).\n- Decomposition (one per patrol): src/server/handlers/plot-plan-runs.test.ts at 640 lines is the furthest-over grandfathered file NOT already covered by an open seed (pr.ts 659 is covered by warren-db9a/warren-70d7). Plan below decomposes it and removes its budget entry.","labels":["audit","ratchetwatch"],"plan_id":"pl-7c4f","blockedBy":["warren-e304"]} +{"id":"warren-59db","title":"Decompose src/server/handlers/plot-plan-runs.test.ts (640 lines, furthest-over grandfathered file not under an open seed) below the 500-line global limit. Extract the file-local helper/fixture group into a new sibling src/server/handlers/plot-plan-runs.test-helpers.ts and export each symbol: silentLogger, stubFetch, jsonRes, poolFor, makeSdSpawn, planShowResult, seedShowResult, makeAttachment, makePlotReader, makePlotResolver, makeSynthesizer, depsFor, tcpUrl, plus their helper-local types (SdCall, SynthesizeCall, BuildDepsInput). Carry the imports those helpers need into the helpers file (Attachment from @os-eco/plot-cli, BurrowClient/BurrowClientPool, openDatabase/WarrenDb, createRepos/Repos, ProjectRow, the plan-run/plot/synthesizer types, SpawnFn/SpawnOptions/SpawnResult, RunEventBroker, NO_AUTH, createBridgeRegistry, startServer, server types). Keep ONE copy of every helper and import it — do NOT duplicate, or jscpd check:dups will flag it. Split the 9 tests in the single describe(\"POST /plot-plan-runs\") block along theme seams into two sibling test files, each importing from ./plot-plan-runs.test-helpers.ts: keep plot-plan-runs.test.ts for the happy-path + filter tests ('happy path: synthesizes plan + persists plan-run + emits Plot dispatch event' and 'filters closed seeds + sd_plan attachments before synthesis'); create plot-plan-runs.validation.test.ts for the 7 validation/error tests (malformed plot_id 400, no .plot/ 400, no .seeds/ 400, plot_id not in project 400, zero dispatchable attachments 400, 404 project missing, synthesizer error 500). Do not alter any test body, assertion, stub, or fixture. Mirror the precedent of src/diagnostics/checks.test-helpers.ts (warren-7a15) and src/server/handlers/projects.test-helpers.ts (warren-a715). Verify: each of `wc -l src/server/handlers/plot-plan-runs.test.ts src/server/handlers/plot-plan-runs.validation.test.ts src/server/handlers/plot-plan-runs.test-helpers.ts` shows < 500 AND `bun test src/server/handlers/plot-plan-runs.test.ts src/server/handlers/plot-plan-runs.validation.test.ts` reports 9 pass / 0 fail (same total as before) AND `bun run typecheck` is clean AND `bun run check:dups` exits 0.","status":"closed","type":"task","priority":2,"plan_step_index":0,"description":"\nStep 1 of plan pl-7c4f.\n\nParent seed: warren-1c0d — ratchetwatch tightening: 2026-06-17\nPlan template: refactor\nPlan approach: Extract the file-local helper/fixture block (the ~16 helpers spanning silentLogger, stubFetch, jsonRes, poolFor, makeSdSpawn, planShowResult, seedShowResult, makeAttachment, makePlotReader, makePlotResolver, makeSynthesizer, depsFor,…\n\nRun `sd plan show pl-7c4f` for the full plan (context, alternatives, sibling steps, acceptance criteria).\n","createdAt":"2026-06-17T10:46:21.709Z","updatedAt":"2026-06-17T10:54:35.710Z","labels":["ratchetwatch"],"plan_id":"pl-7c4f","blocks":["warren-e304","warren-1c0d"],"extensions":{"role":"pi","lastRunId":"run_gwekz26ek6vm","lastRunAt":"2026-06-17T10:49:16.505Z"},"closedAt":"2026-06-17T10:54:35.710Z"} +{"id":"warren-e304","title":"Remove the \"src/server/handlers/plot-plan-runs.test.ts\": 640 entry from scripts/file-size-budgets.json (delete that single line). Do NOT add budget entries for the new sibling files (plot-plan-runs.validation.test.ts, plot-plan-runs.test-helpers.ts) — they must default-pass under the 500-line threshold (confirm `wc -l` for each shows < 500). Per docs/CONSTITUTION.md Article VI, before declaring done run a repo-wide search for the old path across ALL file types — `rg -n \"plot-plan-runs.test\" --hidden -g '!node_modules' .` (or `git grep -n \"plot-plan-runs.test\"`) PLUS an explicit sweep of Dockerfile, docker-compose.yml, .github/workflows/*.yml, src/supervisor/ spawn/config strings, scripts/acceptance/, and docs/ — and fix any stale reference (file moves have broken production here before; encode the check, do not assume it). Verify: `bun run check:size` exits 0 AND `bun run check:all` is fully green (every gate stays green; the removed entry leaves all resulting files default-passing under the 500-line threshold).","status":"open","type":"task","priority":2,"plan_step_index":1,"description":"\nStep 2 of plan pl-7c4f.\n\nParent seed: warren-1c0d — ratchetwatch tightening: 2026-06-17\nPlan template: refactor\nPlan approach: Extract the file-local helper/fixture block (the ~16 helpers spanning silentLogger, stubFetch, jsonRes, poolFor, makeSdSpawn, planShowResult, seedShowResult, makeAttachment, makePlotReader, makePlotResolver, makeSynthesizer, depsFor,…\n\nRun `sd plan show pl-7c4f` for the full plan (context, alternatives, sibling steps, acceptance criteria).\n","createdAt":"2026-06-17T10:46:21.709Z","updatedAt":"2026-06-17T10:54:35.710Z","labels":["ratchetwatch"],"plan_id":"pl-7c4f","blocks":["warren-1c0d"]} {"id":"warren-f248","title":"Auditor sandboxes lack WARREN_API_TOKEN — warden-conversation delivery (POST /conversations/:id/messages) returns 401 for gatewatch/ratchetwatch/tastewatch","status":"open","type":"bug","priority":2,"createdAt":"2026-06-17T10:47:09.663Z","updatedAt":"2026-06-17T10:47:09.663Z","description":"Evidence (ratchetwatch patrol 2026-06-17):\n- The warren API is reachable from the auditor burrow at http://localhost:8080 (e.g. GET/POST /conversations responds), but every request returns: {\"error\":{\"code\":\"unauthorized\",\"message\":\"missing Authorization header\"}}.\n- src/server/auth.ts requires a bearer token from WARREN_API_TOKEN; that env var is NOT present in the auditor sandbox (env shows only ANTHROPIC_API_KEY and WARREN_QUALITY_GATE). No token file is mounted (~/.warren absent; /data unreadable; no *.token anywhere outside node_modules).\n- Result: the operating contract added by warren-7f62 (post each finding to the standing 'Audit Warden' conversation over POST /conversations/:id/messages, 202 steering channel) cannot be fulfilled by ANY auditor — gatewatch, ratchetwatch, tastewatch — because they cannot authenticate or even resolve the conversation id (GET /conversations is also 401-gated).\n- This is a runtime/provisioning gap, not a prompt gap: warren-7f62 wired the prompts but the auditor runs are not given a credential (or a loopback no-auth exemption, or a pre-resolved conversation id) to reach the channel.\nSuggested fix directions (for a human/operator to choose): inject a scoped WARREN_API_TOKEN into auditor burrow env via composeRunEnv, OR expose a loopback no-auth path for the conversation-message endpoint, OR pass the resolved warden conversation id + token through the rendered agent context. Until then, auditors file seeds/plans correctly (this patrol filed warren-1c0d + plan pl-7c4f) but the warden transcript stays empty.","labels":["audit","warden"]} diff --git a/src/server/handlers/plot-plan-runs.test-helpers.ts b/src/server/handlers/plot-plan-runs.test-helpers.ts new file mode 100644 index 00000000..0efa04a5 --- /dev/null +++ b/src/server/handlers/plot-plan-runs.test-helpers.ts @@ -0,0 +1,213 @@ +/** + * Shared helpers + fixtures for the `POST /plot-plan-runs` test suites + * (warren-59db / pl-7c4f). Extracted from plot-plan-runs.test.ts so the + * happy-path/filter tests (plot-plan-runs.test.ts) and the + * validation/error tests (plot-plan-runs.validation.test.ts) share a + * single copy of every stub/fixture. Mirrors the precedent of + * src/diagnostics/checks.test-helpers.ts (warren-7a15) and + * src/server/handlers/projects.test-helpers.ts (warren-a715). + */ + +import type { Attachment } from "@os-eco/plot-cli"; +import { BurrowClient, BurrowClientPool } from "../../burrow-client/index.ts"; +import type { Repos } from "../../db/repos/index.ts"; +import type { ProjectRow } from "../../db/schema.ts"; +import type { PlanRunPlotAppender } from "../../plan-runs/plot-appender.ts"; +import type { + PlanSynthesizer, + SynthesizePlanInput, + SynthesizePlanResult, +} from "../../plot-plan-runs/index.ts"; +import type { + PlotReader, + PlotResolver, + ReadPlotRequest, + ReadPlotResult, +} from "../../plots/index.ts"; +import type { SpawnFn, SpawnOptions, SpawnResult } from "../../projects/clone.ts"; +import { RunEventBroker } from "../../runs/index.ts"; +import { createBridgeRegistry } from "../bridges.ts"; +import type { BridgeRegistry, Logger, ServeHandle, ServerDeps } from "../types.ts"; + +export const silentLogger: Logger = { + info() {}, + warn() {}, + error() {}, +}; + +export function stubFetch( + impl: (input: URL | RequestInfo, init?: RequestInit) => Promise, +): typeof fetch { + return impl as unknown as typeof fetch; +} + +export function jsonRes(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +export async function poolFor(repos: Repos): Promise { + await repos.workers.upsert({ name: "local", url: "unix:///tmp/x.sock" }); + const pool = new BurrowClientPool({ repos }); + const client = new BurrowClient({ + config: { transport: { kind: "unix", path: "/tmp/x.sock" } }, + fetch: stubFetch(async () => jsonRes(404, { error: { code: "not_found", message: "stub" } })), + }); + pool.register("local", client); + return pool; +} + +export interface SdCall { + cmd: readonly string[]; +} + +export function makeSdSpawn( + calls: SdCall[], + responses: { match: (cmd: readonly string[]) => boolean; result: SpawnResult }[], +): SpawnFn { + return async (cmd: readonly string[], _opts: SpawnOptions): Promise => { + calls.push({ cmd }); + const matched = responses.find((r) => r.match(cmd)); + if (matched !== undefined) return matched.result; + return { stdout: "", stderr: `no stub for ${cmd.join(" ")}`, exitCode: 1 }; + }; +} + +export function planShowResult(planId: string, status: string, children: string[]): SpawnResult { + return { + stdout: JSON.stringify({ + success: true, + plan: { + id: planId, + status, + children, + sections: { steps: children.map((title) => ({ title, blocks: [] })) }, + }, + }), + stderr: "", + exitCode: 0, + }; +} + +export function seedShowResult(id: string, status: "open" | "closed"): SpawnResult { + return { + stdout: JSON.stringify({ + success: true, + issue: { id, status, blockedBy: [] }, + }), + stderr: "", + exitCode: 0, + }; +} + +export function makeAttachment( + id: string, + type: Attachment["type"], + ref: string, + role = "tracks", +): Attachment { + return { + id, + type, + ref, + role, + added_at: "2026-05-19T00:00:00.000Z", + added_by: "user:operator", + }; +} + +export function makePlotReader(envelope: ReadPlotResult): PlotReader { + return { + async read(_input: ReadPlotRequest) { + return envelope; + }, + }; +} + +export function makePlotResolver(map: Record): PlotResolver { + return { + async resolve(plotId) { + return map[plotId] ?? null; + }, + }; +} + +export interface SynthesizeCall extends SynthesizePlanInput {} + +export function makeSynthesizer(opts: { + calls?: SynthesizeCall[]; + result?: SynthesizePlanResult; + error?: Error; +}): PlanSynthesizer { + const calls = opts.calls ?? []; + return { + async synthesize(input) { + calls.push(input); + if (opts.error) throw opts.error; + return ( + opts.result ?? { + parentSeedId: "wa-syn", + planId: "pl-syn", + children: [...input.candidateSeedIds], + } + ); + }, + }; +} + +export interface BuildDepsInput { + repos: Repos; + sdSpawn: SpawnFn; + bridges?: BridgeRegistry; + planRunPlotAppender?: PlanRunPlotAppender; + planSynthesizer?: PlanSynthesizer; + plotReader?: PlotReader; + plotResolver?: PlotResolver; + logger?: Logger; +} + +export async function depsFor(input: BuildDepsInput): Promise { + const broker = new RunEventBroker(); + const pool = await poolFor(input.repos); + return { + repos: input.repos, + burrowClientPool: pool, + broker, + bridges: + input.bridges ?? + createBridgeRegistry({ + repos: input.repos, + broker, + burrowClientPool: pool, + bridge: async () => ({ written: 0, skipped: 0, errored: false }), + }), + projectsConfig: { root: "/tmp/projects", gitBinary: "git" }, + logger: input.logger ?? silentLogger, + uiDistDir: null, + seedsCli: { sdBinary: "sd", spawn: input.sdSpawn }, + ...(input.planRunPlotAppender !== undefined + ? { planRunPlotAppender: input.planRunPlotAppender } + : {}), + ...(input.planSynthesizer !== undefined ? { planSynthesizer: input.planSynthesizer } : {}), + ...(input.plotReader !== undefined ? { plotReader: input.plotReader } : {}), + ...(input.plotResolver !== undefined ? { plotResolver: input.plotResolver } : {}), + }; +} + +export function tcpUrl(handle: ServeHandle): string { + if (handle.transport.kind !== "tcp") throw new Error("expected tcp transport"); + return `http://${handle.transport.hostname}:${handle.transport.port}`; +} + +export function plotEnvelope(opts: { attachments: Attachment[]; id?: string }): ReadPlotResult { + return { + id: opts.id ?? "plot-deadbeef", + name: "Test Plot", + status: "active", + intent: { goal: "", non_goals: [], constraints: [], success_criteria: [] }, + attachments: opts.attachments, + event_log: [], + }; +} diff --git a/src/server/handlers/plot-plan-runs.test.ts b/src/server/handlers/plot-plan-runs.test.ts index d616f6a5..5a7346ba 100644 --- a/src/server/handlers/plot-plan-runs.test.ts +++ b/src/server/handlers/plot-plan-runs.test.ts @@ -1,223 +1,47 @@ /** - * Tests for `POST /plot-plan-runs` (warren-99b2 / pl-f404 step 3 / - * SPEC §11.Q). The handler composes plot_id validation, project + - * .plot/ + .seeds/ gates, PlotResolver existence check, PlotReader - * attachment fetch + candidate filter, per-candidate `sd show` status - * probe, plan synthesis via the `planSynthesizer` seam, `sd plan show` - * re-read, and PlanRun persistence + Plot append (mirrors POST - * /plan-runs). Stubs layer at each seam — PlotResolver / PlotReader, + * Happy-path + filter tests for `POST /plot-plan-runs` (warren-99b2 / + * pl-f404 step 3 / SPEC §11.Q). The handler composes plot_id + * validation, project + .plot/ + .seeds/ gates, PlotResolver existence + * check, PlotReader attachment fetch + candidate filter, per-candidate + * `sd show` status probe, plan synthesis via the `planSynthesizer` seam, + * `sd plan show` re-read, and PlanRun persistence + Plot append (mirrors + * POST /plan-runs). Stubs layer at each seam — PlotResolver / PlotReader, * `planSynthesizer`, `sdSpawn` for `sd show` + `sd plan show`, - * `planRunPlotAppender` for the Plot mirror — so no real `sd` binary - * or disk read happens. + * `planRunPlotAppender` for the Plot mirror — so no real `sd` binary or + * disk read happens. Validation/error tests live in the sibling + * plot-plan-runs.validation.test.ts; shared stubs/fixtures live in + * ./plot-plan-runs.test-helpers.ts (warren-59db / pl-7c4f). */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import type { Attachment } from "@os-eco/plot-cli"; -import { BurrowClient, BurrowClientPool } from "../../burrow-client/index.ts"; import { openDatabase, type WarrenDb } from "../../db/client.ts"; import { createRepos, type Repos } from "../../db/repos/index.ts"; import type { ProjectRow } from "../../db/schema.ts"; -import type { - AppendPlanRunDispatchedInput, - PlanRunPlotAppender, -} from "../../plan-runs/plot-appender.ts"; -import type { - PlanSynthesizer, - SynthesizePlanInput, - SynthesizePlanResult, -} from "../../plot-plan-runs/index.ts"; -import type { - PlotReader, - PlotResolver, - ReadPlotRequest, - ReadPlotResult, -} from "../../plots/index.ts"; -import type { SpawnFn, SpawnOptions, SpawnResult } from "../../projects/clone.ts"; -import { RunEventBroker } from "../../runs/index.ts"; +import type { AppendPlanRunDispatchedInput } from "../../plan-runs/plot-appender.ts"; import { NO_AUTH } from "../auth.ts"; -import { createBridgeRegistry } from "../bridges.ts"; import { startServer } from "../server.ts"; -import type { BridgeRegistry, Logger, ServeHandle, ServerDeps } from "../types.ts"; - -const silentLogger: Logger = { - info() {}, - warn() {}, - error() {}, -}; - -function stubFetch( - impl: (input: URL | RequestInfo, init?: RequestInit) => Promise, -): typeof fetch { - return impl as unknown as typeof fetch; -} - -function jsonRes(status: number, body: unknown): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); -} - -async function poolFor(repos: Repos): Promise { - await repos.workers.upsert({ name: "local", url: "unix:///tmp/x.sock" }); - const pool = new BurrowClientPool({ repos }); - const client = new BurrowClient({ - config: { transport: { kind: "unix", path: "/tmp/x.sock" } }, - fetch: stubFetch(async () => jsonRes(404, { error: { code: "not_found", message: "stub" } })), - }); - pool.register("local", client); - return pool; -} - -interface SdCall { - cmd: readonly string[]; -} - -function makeSdSpawn( - calls: SdCall[], - responses: { match: (cmd: readonly string[]) => boolean; result: SpawnResult }[], -): SpawnFn { - return async (cmd: readonly string[], _opts: SpawnOptions): Promise => { - calls.push({ cmd }); - const matched = responses.find((r) => r.match(cmd)); - if (matched !== undefined) return matched.result; - return { stdout: "", stderr: `no stub for ${cmd.join(" ")}`, exitCode: 1 }; - }; -} - -function planShowResult(planId: string, status: string, children: string[]): SpawnResult { - return { - stdout: JSON.stringify({ - success: true, - plan: { - id: planId, - status, - children, - sections: { steps: children.map((title) => ({ title, blocks: [] })) }, - }, - }), - stderr: "", - exitCode: 0, - }; -} - -function seedShowResult(id: string, status: "open" | "closed"): SpawnResult { - return { - stdout: JSON.stringify({ - success: true, - issue: { id, status, blockedBy: [] }, - }), - stderr: "", - exitCode: 0, - }; -} - -function makeAttachment( - id: string, - type: Attachment["type"], - ref: string, - role = "tracks", -): Attachment { - return { - id, - type, - ref, - role, - added_at: "2026-05-19T00:00:00.000Z", - added_by: "user:operator", - }; -} - -function makePlotReader(envelope: ReadPlotResult): PlotReader { - return { - async read(_input: ReadPlotRequest) { - return envelope; - }, - }; -} - -function makePlotResolver(map: Record): PlotResolver { - return { - async resolve(plotId) { - return map[plotId] ?? null; - }, - }; -} - -interface SynthesizeCall extends SynthesizePlanInput {} - -function makeSynthesizer(opts: { - calls?: SynthesizeCall[]; - result?: SynthesizePlanResult; - error?: Error; -}): PlanSynthesizer { - const calls = opts.calls ?? []; - return { - async synthesize(input) { - calls.push(input); - if (opts.error) throw opts.error; - return ( - opts.result ?? { - parentSeedId: "wa-syn", - planId: "pl-syn", - children: [...input.candidateSeedIds], - } - ); - }, - }; -} - -interface BuildDepsInput { - repos: Repos; - sdSpawn: SpawnFn; - bridges?: BridgeRegistry; - planRunPlotAppender?: PlanRunPlotAppender; - planSynthesizer?: PlanSynthesizer; - plotReader?: PlotReader; - plotResolver?: PlotResolver; - logger?: Logger; -} - -async function depsFor(input: BuildDepsInput): Promise { - const broker = new RunEventBroker(); - const pool = await poolFor(input.repos); - return { - repos: input.repos, - burrowClientPool: pool, - broker, - bridges: - input.bridges ?? - createBridgeRegistry({ - repos: input.repos, - broker, - burrowClientPool: pool, - bridge: async () => ({ written: 0, skipped: 0, errored: false }), - }), - projectsConfig: { root: "/tmp/projects", gitBinary: "git" }, - logger: input.logger ?? silentLogger, - uiDistDir: null, - seedsCli: { sdBinary: "sd", spawn: input.sdSpawn }, - ...(input.planRunPlotAppender !== undefined - ? { planRunPlotAppender: input.planRunPlotAppender } - : {}), - ...(input.planSynthesizer !== undefined ? { planSynthesizer: input.planSynthesizer } : {}), - ...(input.plotReader !== undefined ? { plotReader: input.plotReader } : {}), - ...(input.plotResolver !== undefined ? { plotResolver: input.plotResolver } : {}), - }; -} - -function tcpUrl(handle: ServeHandle): string { - if (handle.transport.kind !== "tcp") throw new Error("expected tcp transport"); - return `http://${handle.transport.hostname}:${handle.transport.port}`; -} +import type { ServeHandle } from "../types.ts"; +import { + depsFor, + makeAttachment, + makePlotReader, + makePlotResolver, + makeSdSpawn, + makeSynthesizer, + planShowResult, + plotEnvelope, + type SdCall, + type SynthesizeCall, + seedShowResult, + silentLogger, + tcpUrl, +} from "./plot-plan-runs.test-helpers.ts"; describe("POST /plot-plan-runs", () => { let db: WarrenDb; let repos: Repos; let handle: ServeHandle | null = null; let plottedProject: ProjectRow; - let seedyOnlyProject: ProjectRow; - let bareProject: ProjectRow; beforeEach(async () => { db = await openDatabase({ path: ":memory:" }); @@ -241,20 +65,6 @@ describe("POST /plot-plan-runs", () => { hasSeeds: true, hasPlot: true, }); - seedyOnlyProject = await repos.projects.create({ - gitUrl: "https://github.com/x/seedy.git", - localPath: "/tmp/seedy", - defaultBranch: "main", - hasSeeds: true, - hasPlot: false, - }); - bareProject = await repos.projects.create({ - gitUrl: "https://github.com/x/bare.git", - localPath: "/tmp/bare", - defaultBranch: "main", - hasSeeds: false, - hasPlot: true, - }); }); afterEach(async () => { @@ -265,17 +75,6 @@ describe("POST /plot-plan-runs", () => { await db.close(); }); - function plotEnvelope(opts: { attachments: Attachment[]; id?: string }): ReadPlotResult { - return { - id: opts.id ?? "plot-deadbeef", - name: "Test Plot", - status: "active", - intent: { goal: "", non_goals: [], constraints: [], success_criteria: [] }, - attachments: opts.attachments, - event_log: [], - }; - } - test("happy path: synthesizes plan + persists plan-run + emits Plot dispatch event", async () => { const sdCalls: SdCall[] = []; const sdSpawn = makeSdSpawn(sdCalls, [ @@ -423,218 +222,4 @@ describe("POST /plot-plan-runs", () => { expect(res.status).toBe(201); expect(synthesizeCalls[0]?.candidateSeedIds).toEqual(["warren-a"]); }); - - test("rejects malformed plot_id with 400 plot_id_invalid (warren-bae5)", async () => { - const sdSpawn = makeSdSpawn([], []); - const deps = await depsFor({ repos, sdSpawn }); - handle = startServer(deps, { - transport: { kind: "tcp", hostname: "127.0.0.1", port: 0 }, - auth: NO_AUTH, - logger: silentLogger, - }); - - const res = await fetch(`${tcpUrl(handle)}/plot-plan-runs`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - plot_id: "plot_id=plot-3e72876d", - project_id: plottedProject.id, - agent_name: "claude-code", - }), - }); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe("plot_id_invalid"); - }); - - test("rejects project without .plot/ with 400 project_lacks_plot", async () => { - const sdSpawn = makeSdSpawn([], []); - const deps = await depsFor({ repos, sdSpawn }); - handle = startServer(deps, { - transport: { kind: "tcp", hostname: "127.0.0.1", port: 0 }, - auth: NO_AUTH, - logger: silentLogger, - }); - - const res = await fetch(`${tcpUrl(handle)}/plot-plan-runs`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - plot_id: "plot-deadbeef", - project_id: seedyOnlyProject.id, - agent_name: "claude-code", - }), - }); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe("project_lacks_plot"); - }); - - test("rejects project without .seeds/ with 400 project_lacks_seeds", async () => { - const sdSpawn = makeSdSpawn([], []); - const deps = await depsFor({ repos, sdSpawn }); - handle = startServer(deps, { - transport: { kind: "tcp", hostname: "127.0.0.1", port: 0 }, - auth: NO_AUTH, - logger: silentLogger, - }); - - const res = await fetch(`${tcpUrl(handle)}/plot-plan-runs`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - plot_id: "plot-deadbeef", - project_id: bareProject.id, - agent_name: "claude-code", - }), - }); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe("project_lacks_seeds"); - }); - - test("rejects plot_id not in this project with 400 plot_id_not_found", async () => { - const sdSpawn = makeSdSpawn([], []); - const deps = await depsFor({ - repos, - sdSpawn, - // resolver returns null for any plot_id - plotResolver: makePlotResolver({}), - }); - handle = startServer(deps, { - transport: { kind: "tcp", hostname: "127.0.0.1", port: 0 }, - auth: NO_AUTH, - logger: silentLogger, - }); - - const res = await fetch(`${tcpUrl(handle)}/plot-plan-runs`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - plot_id: "plot-orphan", - project_id: plottedProject.id, - agent_name: "claude-code", - }), - }); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe("plot_id_not_found"); - }); - - test("rejects Plot with zero dispatchable attachments with 400 no_dispatchable_seeds", async () => { - const sdSpawn = makeSdSpawn( - [], - [ - { - match: (cmd) => cmd[1] === "show" && cmd[2] === "warren-closed", - result: seedShowResult("warren-closed", "closed"), - }, - ], - ); - const deps = await depsFor({ - repos, - sdSpawn, - plotReader: makePlotReader( - plotEnvelope({ - attachments: [ - makeAttachment("att-001", "seeds_issue", "pl-99999"), - makeAttachment("att-002", "seeds_issue", "warren-closed"), - makeAttachment("att-003", "mulch_record", "mx-deadbeef"), - ], - }), - ), - plotResolver: makePlotResolver({ "plot-deadbeef": plottedProject }), - }); - handle = startServer(deps, { - transport: { kind: "tcp", hostname: "127.0.0.1", port: 0 }, - auth: NO_AUTH, - logger: silentLogger, - }); - - const res = await fetch(`${tcpUrl(handle)}/plot-plan-runs`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - plot_id: "plot-deadbeef", - project_id: plottedProject.id, - agent_name: "claude-code", - }), - }); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: { code: string; hint?: string } }; - expect(body.error.code).toBe("no_dispatchable_seeds"); - expect(body.error.hint).toContain("attach open seeds_issue items"); - }); - - test("404 when project doesn't exist", async () => { - const sdSpawn = makeSdSpawn([], []); - const deps = await depsFor({ repos, sdSpawn }); - handle = startServer(deps, { - transport: { kind: "tcp", hostname: "127.0.0.1", port: 0 }, - auth: NO_AUTH, - logger: silentLogger, - }); - - const res = await fetch(`${tcpUrl(handle)}/plot-plan-runs`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - plot_id: "plot-deadbeef", - project_id: "prj_does_not_exist", - agent_name: "claude-code", - }), - }); - expect(res.status).toBe(404); - }); - - test("synthesizer error surfaces as 500 sd_plan_synthesis_error", async () => { - const { SdPlanSynthesisError } = await import("../../plot-plan-runs/index.ts"); - const sdSpawn = makeSdSpawn( - [], - [ - { - match: (cmd) => cmd[1] === "show" && cmd[2] === "warren-a", - result: seedShowResult("warren-a", "open"), - }, - { - match: (cmd) => cmd[1] === "show" && cmd[2] === "warren-b", - result: seedShowResult("warren-b", "open"), - }, - ], - ); - const deps = await depsFor({ - repos, - sdSpawn, - planSynthesizer: makeSynthesizer({ - error: new SdPlanSynthesisError("sd plan submit exited 1: validation error"), - }), - plotReader: makePlotReader( - plotEnvelope({ - attachments: [ - makeAttachment("att-001", "seeds_issue", "warren-a"), - makeAttachment("att-002", "seeds_issue", "warren-b"), - ], - }), - ), - plotResolver: makePlotResolver({ "plot-deadbeef": plottedProject }), - }); - handle = startServer(deps, { - transport: { kind: "tcp", hostname: "127.0.0.1", port: 0 }, - auth: NO_AUTH, - logger: silentLogger, - }); - - const res = await fetch(`${tcpUrl(handle)}/plot-plan-runs`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - plot_id: "plot-deadbeef", - project_id: plottedProject.id, - agent_name: "claude-code", - }), - }); - expect(res.status).toBe(500); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe("sd_plan_synthesis_error"); - }); }); diff --git a/src/server/handlers/plot-plan-runs.validation.test.ts b/src/server/handlers/plot-plan-runs.validation.test.ts new file mode 100644 index 00000000..c2018a64 --- /dev/null +++ b/src/server/handlers/plot-plan-runs.validation.test.ts @@ -0,0 +1,298 @@ +/** + * Validation/error tests for `POST /plot-plan-runs` (warren-99b2 / + * pl-f404 step 3 / SPEC §11.Q). Covers the 400/404/500 gate paths: + * malformed plot_id, missing .plot/, missing .seeds/, plot_id not in + * project, zero dispatchable attachments, absent project, and a + * synthesizer failure. The happy-path + filter tests live in the + * sibling plot-plan-runs.test.ts; shared stubs/fixtures live in + * ./plot-plan-runs.test-helpers.ts (warren-59db / pl-7c4f). + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { openDatabase, type WarrenDb } from "../../db/client.ts"; +import { createRepos, type Repos } from "../../db/repos/index.ts"; +import type { ProjectRow } from "../../db/schema.ts"; +import { NO_AUTH } from "../auth.ts"; +import { startServer } from "../server.ts"; +import type { ServeHandle } from "../types.ts"; +import { + depsFor, + makeAttachment, + makePlotReader, + makePlotResolver, + makeSdSpawn, + makeSynthesizer, + plotEnvelope, + seedShowResult, + silentLogger, + tcpUrl, +} from "./plot-plan-runs.test-helpers.ts"; + +describe("POST /plot-plan-runs", () => { + let db: WarrenDb; + let repos: Repos; + let handle: ServeHandle | null = null; + let plottedProject: ProjectRow; + let seedyOnlyProject: ProjectRow; + let bareProject: ProjectRow; + + beforeEach(async () => { + db = await openDatabase({ path: ":memory:" }); + repos = createRepos(db); + + await repos.agents.upsert({ + name: "claude-code", + renderedJson: { + name: "claude-code", + version: 1, + sections: { system: "you are claude" }, + resolvedFrom: [], + frontmatter: {}, + }, + }); + + plottedProject = await repos.projects.create({ + gitUrl: "https://github.com/x/plotted.git", + localPath: "/tmp/plotted", + defaultBranch: "main", + hasSeeds: true, + hasPlot: true, + }); + seedyOnlyProject = await repos.projects.create({ + gitUrl: "https://github.com/x/seedy.git", + localPath: "/tmp/seedy", + defaultBranch: "main", + hasSeeds: true, + hasPlot: false, + }); + bareProject = await repos.projects.create({ + gitUrl: "https://github.com/x/bare.git", + localPath: "/tmp/bare", + defaultBranch: "main", + hasSeeds: false, + hasPlot: true, + }); + }); + + afterEach(async () => { + if (handle) { + await handle.stop(); + handle = null; + } + await db.close(); + }); + + test("rejects malformed plot_id with 400 plot_id_invalid (warren-bae5)", async () => { + const sdSpawn = makeSdSpawn([], []); + const deps = await depsFor({ repos, sdSpawn }); + handle = startServer(deps, { + transport: { kind: "tcp", hostname: "127.0.0.1", port: 0 }, + auth: NO_AUTH, + logger: silentLogger, + }); + + const res = await fetch(`${tcpUrl(handle)}/plot-plan-runs`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + plot_id: "plot_id=plot-3e72876d", + project_id: plottedProject.id, + agent_name: "claude-code", + }), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe("plot_id_invalid"); + }); + + test("rejects project without .plot/ with 400 project_lacks_plot", async () => { + const sdSpawn = makeSdSpawn([], []); + const deps = await depsFor({ repos, sdSpawn }); + handle = startServer(deps, { + transport: { kind: "tcp", hostname: "127.0.0.1", port: 0 }, + auth: NO_AUTH, + logger: silentLogger, + }); + + const res = await fetch(`${tcpUrl(handle)}/plot-plan-runs`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + plot_id: "plot-deadbeef", + project_id: seedyOnlyProject.id, + agent_name: "claude-code", + }), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe("project_lacks_plot"); + }); + + test("rejects project without .seeds/ with 400 project_lacks_seeds", async () => { + const sdSpawn = makeSdSpawn([], []); + const deps = await depsFor({ repos, sdSpawn }); + handle = startServer(deps, { + transport: { kind: "tcp", hostname: "127.0.0.1", port: 0 }, + auth: NO_AUTH, + logger: silentLogger, + }); + + const res = await fetch(`${tcpUrl(handle)}/plot-plan-runs`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + plot_id: "plot-deadbeef", + project_id: bareProject.id, + agent_name: "claude-code", + }), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe("project_lacks_seeds"); + }); + + test("rejects plot_id not in this project with 400 plot_id_not_found", async () => { + const sdSpawn = makeSdSpawn([], []); + const deps = await depsFor({ + repos, + sdSpawn, + // resolver returns null for any plot_id + plotResolver: makePlotResolver({}), + }); + handle = startServer(deps, { + transport: { kind: "tcp", hostname: "127.0.0.1", port: 0 }, + auth: NO_AUTH, + logger: silentLogger, + }); + + const res = await fetch(`${tcpUrl(handle)}/plot-plan-runs`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + plot_id: "plot-orphan", + project_id: plottedProject.id, + agent_name: "claude-code", + }), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe("plot_id_not_found"); + }); + + test("rejects Plot with zero dispatchable attachments with 400 no_dispatchable_seeds", async () => { + const sdSpawn = makeSdSpawn( + [], + [ + { + match: (cmd) => cmd[1] === "show" && cmd[2] === "warren-closed", + result: seedShowResult("warren-closed", "closed"), + }, + ], + ); + const deps = await depsFor({ + repos, + sdSpawn, + plotReader: makePlotReader( + plotEnvelope({ + attachments: [ + makeAttachment("att-001", "seeds_issue", "pl-99999"), + makeAttachment("att-002", "seeds_issue", "warren-closed"), + makeAttachment("att-003", "mulch_record", "mx-deadbeef"), + ], + }), + ), + plotResolver: makePlotResolver({ "plot-deadbeef": plottedProject }), + }); + handle = startServer(deps, { + transport: { kind: "tcp", hostname: "127.0.0.1", port: 0 }, + auth: NO_AUTH, + logger: silentLogger, + }); + + const res = await fetch(`${tcpUrl(handle)}/plot-plan-runs`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + plot_id: "plot-deadbeef", + project_id: plottedProject.id, + agent_name: "claude-code", + }), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: { code: string; hint?: string } }; + expect(body.error.code).toBe("no_dispatchable_seeds"); + expect(body.error.hint).toContain("attach open seeds_issue items"); + }); + + test("404 when project doesn't exist", async () => { + const sdSpawn = makeSdSpawn([], []); + const deps = await depsFor({ repos, sdSpawn }); + handle = startServer(deps, { + transport: { kind: "tcp", hostname: "127.0.0.1", port: 0 }, + auth: NO_AUTH, + logger: silentLogger, + }); + + const res = await fetch(`${tcpUrl(handle)}/plot-plan-runs`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + plot_id: "plot-deadbeef", + project_id: "prj_does_not_exist", + agent_name: "claude-code", + }), + }); + expect(res.status).toBe(404); + }); + + test("synthesizer error surfaces as 500 sd_plan_synthesis_error", async () => { + const { SdPlanSynthesisError } = await import("../../plot-plan-runs/index.ts"); + const sdSpawn = makeSdSpawn( + [], + [ + { + match: (cmd) => cmd[1] === "show" && cmd[2] === "warren-a", + result: seedShowResult("warren-a", "open"), + }, + { + match: (cmd) => cmd[1] === "show" && cmd[2] === "warren-b", + result: seedShowResult("warren-b", "open"), + }, + ], + ); + const deps = await depsFor({ + repos, + sdSpawn, + planSynthesizer: makeSynthesizer({ + error: new SdPlanSynthesisError("sd plan submit exited 1: validation error"), + }), + plotReader: makePlotReader( + plotEnvelope({ + attachments: [ + makeAttachment("att-001", "seeds_issue", "warren-a"), + makeAttachment("att-002", "seeds_issue", "warren-b"), + ], + }), + ), + plotResolver: makePlotResolver({ "plot-deadbeef": plottedProject }), + }); + handle = startServer(deps, { + transport: { kind: "tcp", hostname: "127.0.0.1", port: 0 }, + auth: NO_AUTH, + logger: silentLogger, + }); + + const res = await fetch(`${tcpUrl(handle)}/plot-plan-runs`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + plot_id: "plot-deadbeef", + project_id: plottedProject.id, + agent_name: "claude-code", + }), + }); + expect(res.status).toBe(500); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe("sd_plan_synthesis_error"); + }); +});