diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 4a86f5c2..a65da9f6 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "desktop", - "version": "0.5.0", + "version": "0.6.0", "description": "ClosedLoop Desktop", "author": "ClosedLoop AI ", "private": true, diff --git a/apps/desktop/src/main/job-store.ts b/apps/desktop/src/main/job-store.ts index ae40eaed..1bbaeeb5 100644 --- a/apps/desktop/src/main/job-store.ts +++ b/apps/desktop/src/main/job-store.ts @@ -14,7 +14,7 @@ export type LocalJobStatus = export type LocalJobKind = "SYMPHONY_LOOP"; -export type LocalJobCommand = "PLAN" | "EXECUTE" | "REQUEST_CHANGES" | "DECOMPOSE"; +export type LocalJobCommand = "PLAN" | "EXECUTE" | "REQUEST_CHANGES" | "DECOMPOSE" | "GENERATE_PRD"; export type TaskProgress = { pending: number; diff --git a/apps/desktop/src/server/operations/symphony-loop.ts b/apps/desktop/src/server/operations/symphony-loop.ts index 5645a9a5..f30ecc81 100644 --- a/apps/desktop/src/server/operations/symphony-loop.ts +++ b/apps/desktop/src/server/operations/symphony-loop.ts @@ -21,9 +21,9 @@ import { // Types // --------------------------------------------------------------------------- -type LoopCommand = "PLAN" | "EXECUTE" | "REQUEST_CHANGES" | "DECOMPOSE"; +type LoopCommand = "PLAN" | "EXECUTE" | "REQUEST_CHANGES" | "DECOMPOSE" | "GENERATE_PRD"; -const VALID_COMMANDS = new Set(["PLAN", "EXECUTE", "REQUEST_CHANGES", "DECOMPOSE"]); +const VALID_COMMANDS = new Set(["PLAN", "EXECUTE", "REQUEST_CHANGES", "DECOMPOSE", "GENERATE_PRD"]); interface LoopArtifact { id?: string; @@ -369,6 +369,35 @@ function findWorktreeForBranch( // PLAN always creates a fresh worktree. EXECUTE/REQUEST_CHANGES reuse via // findWorktreeForBranch(parentBranchName) which matches the specific parent. +/** + * Remove a GENERATE_PRD worktree via git worktree remove, falling back to + * fs.rm + git worktree prune. Used from both handleProcessCompletion and + * early-return cleanup in handleLoopRequest. + */ +async function cleanupGeneratePrdWorktree( + worktreeDir: string, + expandedRepoPath: string, + loopId?: string +): Promise { + try { + execSync(`git worktree remove --force ${shellEscape(worktreeDir)}`, { + cwd: expandedRepoPath, + stdio: "pipe", + timeout: 15_000, + }); + } catch { + if (loopId) { + loopLog(loopId, `git worktree remove failed for GENERATE_PRD, falling back to fs.rm`); + } + await fs.rm(worktreeDir, { recursive: true, force: true }); + try { + execSync("git worktree prune", { cwd: expandedRepoPath, stdio: "pipe", timeout: 10_000 }); + } catch { + // Best-effort + } + } +} + // --------------------------------------------------------------------------- // Per-command artifact writing // --------------------------------------------------------------------------- @@ -468,6 +497,44 @@ async function writeArtifactsForDecompose( } } +/** + * Write context pack files for GENERATE_PRD command. + * Mirrors writeContextPackFiles in harness-agent.mjs (lines 744-816). + * Files go under worktreeDir/.claude/context/ (NOT claudeWorkDir). + */ +async function writeArtifactsForGeneratePrd( + worktreeDir: string, + artifacts: LoopArtifact[], + prompt: string, + repo?: unknown +): Promise { + const contextDir = path.join(worktreeDir, ".claude", "context"); + const artifactsDir = path.join(contextDir, "artifacts"); + await fs.mkdir(artifactsDir, { recursive: true }); + + // Write prompt + await fs.writeFile(path.join(contextDir, "prompt.md"), prompt); + + // Write repo-info.json when present + if (repo) { + await fs.writeFile( + path.join(contextDir, "repo-info.json"), + JSON.stringify(repo, null, 2) + ); + } + + // Write each artifact + for (const artifact of artifacts) { + const safeName = artifact.type.toLowerCase().replace(/[^a-z0-9_-]/g, "_"); + const safeId = (artifact.id ?? "unknown").replace(/[^a-zA-Z0-9_-]/g, "_"); + const header = `# ${artifact.title ?? "Untitled"}\n\n`; + await fs.writeFile( + path.join(artifactsDir, `${safeName}-${safeId}.md`), + header + artifact.content + ); + } +} + // --------------------------------------------------------------------------- // Per-command output reading // --------------------------------------------------------------------------- @@ -527,6 +594,11 @@ function readDecomposeOutputs(workDir: string): Record { return { features: features ?? undefined }; } +function readGeneratePrdOutputs(worktreeDir: string): Record { + const prdContent = readTextFile(path.join(worktreeDir, "prd.md")); + return { prd: prdContent ? { content: prdContent } : undefined }; +} + /** Parse token usage from claude-output.jsonl (JSONL stream output). */ function parseTokenUsage(claudeWorkDir: string): { input: number; output: number } { const totals = { input: 0, output: 0 }; @@ -684,6 +756,7 @@ async function handleProcessCompletion( apiBaseUrl: string, worktreeDir: string | null, claudeWorkDir: string, + expandedRepoPath: string | null, jobStore?: JobStore ): Promise { const { loopId, command, closedLoopAuthToken, committer } = body; @@ -713,6 +786,9 @@ async function handleProcessCompletion( }); } } + if (command === "GENERATE_PRD" && worktreeDir && expandedRepoPath) { + await cleanupGeneratePrdWorktree(worktreeDir, expandedRepoPath, loopId); + } return; } @@ -749,6 +825,8 @@ async function handleProcessCompletion( } } else if (command === "DECOMPOSE") { artifacts = readDecomposeOutputs(claudeWorkDir); + } else if (command === "GENERATE_PRD") { + artifacts = readGeneratePrdOutputs(worktreeDir ?? claudeWorkDir); } // Read session ID if available @@ -834,6 +912,8 @@ async function handleProcessCompletion( // Clean up DECOMPOSE temp directory after all reads and uploads are complete if (command === "DECOMPOSE") { fs.rm(claudeWorkDir, { recursive: true, force: true }).catch(() => {}); + } else if (command === "GENERATE_PRD" && worktreeDir && expandedRepoPath) { + await cleanupGeneratePrdWorktree(worktreeDir, expandedRepoPath, loopId); } } @@ -886,6 +966,11 @@ async function handleLoopRequest( return; } + if (body.command === "GENERATE_PRD" && (typeof body.prompt !== "string" || !body.prompt.trim())) { + json(context, 400, { error: "No prompt found for GENERATE_PRD" }); + return; + } + if (runningLoops.has(body.loopId)) { json(context, 409, { error: "Loop is already running on this machine" }); return; @@ -943,7 +1028,7 @@ async function handleLoopRequest( await writeArtifactsForDecompose(claudeWorkDir, body.artifacts, body.prompt); } else if (!expandedRepoPath) { json(context, 400, { - error: "Repository required for PLAN, EXECUTE, and REQUEST_CHANGES commands", + error: "Repository required for PLAN, EXECUTE, REQUEST_CHANGES, and GENERATE_PRD commands", }); return; } else if (body.command === "PLAN" || body.command === "EXECUTE" || body.command === "REQUEST_CHANGES") { @@ -1044,6 +1129,51 @@ async function handleLoopRequest( body.prompt ); } + } else if (body.command === "GENERATE_PRD") { + // Use a dedicated branch namespace to avoid collisions with PLAN/EXECUTE worktrees. + // GENERATE_PRD always starts fresh -- it must not inherit a prior PLAN worktree. + const sanitizedSlug = body.artifactSlug + ? slugifyLoopId(body.artifactSlug) + : null; + const worktreeKey = sanitizedSlug ?? pickStableId(body); + const branchName = sanitizedSlug + ? `symphony/generate-prd-${sanitizedSlug}` + : `symphony/generate-prd-${pickStableId(body)}`; + + worktreeDir = resolveLoopWorktreeDir(expandedRepoPath, `generate-prd-${worktreeKey}`); + + // Always start fresh: remove any stale worktree for this branch before creation. + const staleWorktree = findWorktreeForBranch(expandedRepoPath, branchName); + if (staleWorktree) { + loopLog(body.loopId, `Removing stale worktree for fresh GENERATE_PRD: ${staleWorktree}`); + await cleanupGeneratePrdWorktree(staleWorktree, expandedRepoPath, body.loopId); + } + + await ensureWorktree( + expandedRepoPath, + worktreeDir, + branchName, + body.repo?.branch ?? "main" + ); + loopLog(body.loopId, `Created worktree for GENERATE_PRD: ${worktreeDir} (branch: ${branchName})`); + + try { + assertPathAllowed(worktreeDir, allowedDirs); + } catch (e) { + if (e instanceof DirectoryNotAllowedError) { + await cleanupGeneratePrdWorktree(worktreeDir, expandedRepoPath, body.loopId); + json(context, 403, { error: `Worktree path not allowed: ${worktreeDir}` }); + return; + } + throw e; + } + + // claudeWorkDir is a separate operational dir inside the worktree (same pattern as PLAN/EXECUTE). + // Spawn uses cwd: worktreeDir so Claude writes prd.md to the repo root. + // Logs, PID, and prompt file go to claudeWorkDir, not the repo root. + claudeWorkDir = path.join(worktreeDir, ".claude", "work"); + await fs.mkdir(claudeWorkDir, { recursive: true }); + await writeArtifactsForGeneratePrd(worktreeDir, body.artifacts, body.prompt!, body.repo); } else { json(context, 400, { error: `Unknown command: ${body.command}` }); return; @@ -1052,7 +1182,7 @@ async function handleLoopRequest( // Pre-flight: verify required binary exists BEFORE posting 'started' event. // PLAN and EXECUTE use run-loop.sh; REQUEST_CHANGES and DECOMPOSE use claude CLI directly. const usesRunLoop = body.command === "PLAN" || body.command === "EXECUTE"; - const usesClaude = body.command === "REQUEST_CHANGES" || body.command === "DECOMPOSE"; + const usesClaude = body.command === "REQUEST_CHANGES" || body.command === "DECOMPOSE" || body.command === "GENERATE_PRD"; let scriptPath: string | null = null; if (usesClaude) { @@ -1068,6 +1198,9 @@ async function handleLoopRequest( code: "BINARY_NOT_FOUND", message: "claude CLI not found in PATH", } ); + if (body.command === "GENERATE_PRD" && worktreeDir && expandedRepoPath) { + await cleanupGeneratePrdWorktree(worktreeDir, expandedRepoPath, body.loopId); + } json(context, 500, { error: "claude CLI not found in PATH" }); return; } @@ -1108,6 +1241,9 @@ async function handleLoopRequest( type: "error", code: "SPAWN_FAILED", message: `Cannot open log file: ${msg}`, }); + if (body.command === "GENERATE_PRD" && worktreeDir && expandedRepoPath) { + await cleanupGeneratePrdWorktree(worktreeDir, expandedRepoPath, body.loopId); + } json(context, 500, { error: `Cannot open log file: ${msg}` }); return; } @@ -1184,6 +1320,26 @@ async function handleLoopRequest( env: spawnEnv, }); child.unref(); + } else if (body.command === "GENERATE_PRD") { + const promptFile = path.join(claudeWorkDir, "generate-prd-prompt.txt"); + await fs.writeFile(promptFile, body.prompt!); + + const claudeArgs = [ + "-p", "-", + "--output-format", "stream-json", + "--verbose", + "--allowedTools", + "Bash,Glob,Grep,Read,Write,Edit,Task,Skill,SlashCommand,TodoWrite", + "--max-turns", "200", + ]; + const pipeline = buildClaudePipeline(claudeArgs, claudeWorkDir, promptFile); + child = spawn(pipeline.cmd, pipeline.args, { + cwd: worktreeDir!, + detached: true, + stdio: ["ignore", logFd, logFd], + env: spawnEnv, + }); + child.unref(); } else { // PLAN, EXECUTE: spawn run-loop.sh // Build args matching ECS harness-agent's buildRunLoopArgs(): @@ -1215,6 +1371,9 @@ async function handleLoopRequest( type: "error", code: "SPAWN_FAILED", message: msg, }); + if (body.command === "GENERATE_PRD" && worktreeDir && expandedRepoPath) { + await cleanupGeneratePrdWorktree(worktreeDir, expandedRepoPath, body.loopId); + } json(context, 500, { error: `Failed to spawn process: ${msg}` }); return; } @@ -1228,7 +1387,7 @@ async function handleLoopRequest( } completionHandled = true; loopLog(body.loopId, `onceComplete fired, code=${code}`); - handleProcessCompletion(code, body, apiBaseUrl, worktreeDir, claudeWorkDir, jobStore).catch( + handleProcessCompletion(code, body, apiBaseUrl, worktreeDir, claudeWorkDir, expandedRepoPath, jobStore).catch( (err) => loopError(body.loopId, "Completion handler error:", err) ); }; diff --git a/apps/desktop/test/symphony-loop-generate-prd.test.ts b/apps/desktop/test/symphony-loop-generate-prd.test.ts new file mode 100644 index 00000000..a1aa8b2a --- /dev/null +++ b/apps/desktop/test/symphony-loop-generate-prd.test.ts @@ -0,0 +1,709 @@ +/** + * Integration tests for the GENERATE_PRD loop command. + * + * Uses a fake claude binary, a mock API server to record event/upload calls, + * and real git repos with worktrees. + */ +import assert from "node:assert/strict"; +import { execFile, execSync } from "node:child_process"; +import fs from "node:fs/promises"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, test } from "node:test"; +import { promisify } from "node:util"; +import { DesktopGatewayServer } from "../src/server/server.js"; +import { EMPTY_CAPABILITIES, PORT_PROBE_ORDER } from "../src/shared/contracts.js"; + +const execFileAsync = promisify(execFile); + +// --------------------------------------------------------------------------- +// Shared state and cleanup +// --------------------------------------------------------------------------- + +const serversToClose: DesktopGatewayServer[] = []; +const mockServersToClose: http.Server[] = []; +const tempPathsToClean: string[] = []; +const originalSymphonyWorktreeParentDir = process.env.SYMPHONY_WORKTREE_PARENT_DIR; +const originalPath = process.env.PATH; +const originalHome = process.env.HOME; + +afterEach(async () => { + if (originalSymphonyWorktreeParentDir === undefined) { + delete process.env.SYMPHONY_WORKTREE_PARENT_DIR; + } else { + process.env.SYMPHONY_WORKTREE_PARENT_DIR = originalSymphonyWorktreeParentDir; + } + + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + + for (const server of serversToClose.splice(0)) { + await server.stop(); + } + + for (const ms of mockServersToClose.splice(0)) { + await new Promise((resolve, reject) => { + ms.close((err) => (err ? reject(err) : resolve())); + }); + } + + for (const tempPath of tempPathsToClean.splice(0)) { + await fs.rm(tempPath, { recursive: true, force: true }); + } +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function initGitRepo(repoPath: string): Promise { + await execFileAsync("git", ["init", "-b", "main", repoPath]); + await execFileAsync("git", ["-C", repoPath, "config", "user.email", "test@test.com"]); + await execFileAsync("git", ["-C", repoPath, "config", "user.name", "Test"]); + await fs.writeFile(path.join(repoPath, "README.md"), "# initial\n"); + await execFileAsync("git", ["-C", repoPath, "add", "."]); + await execFileAsync("git", ["-C", repoPath, "commit", "-m", "initial"]); +} + +type RecordedRequest = { method: string; url: string; body: string }; + +async function startMockApiServer(): Promise<{ + server: http.Server; + port: number; + requests: RecordedRequest[]; + waitForRequest: (urlSubstring: string, timeoutMs?: number) => Promise; +}> { + const requests: RecordedRequest[] = []; + const waiters: Array<{ urlSubstring: string; resolve: (r: RecordedRequest) => void }> = []; + + const server = http.createServer((req, res) => { + void (async () => { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); + } + const recorded: RecordedRequest = { + method: req.method ?? "", + url: req.url ?? "", + body: Buffer.concat(chunks).toString("utf-8"), + }; + requests.push(recorded); + + // Resolve any waiters matching this URL + for (let i = waiters.length - 1; i >= 0; i--) { + if (recorded.url.includes(waiters[i].urlSubstring)) { + waiters[i].resolve(recorded); + waiters.splice(i, 1); + } + } + + res.statusCode = 200; + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ success: true })); + })(); + }); + + await new Promise((resolve, reject) => { + server.listen(0, "127.0.0.1", () => resolve()); + server.once("error", reject); + }); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("failed to bind mock API server"); + } + + function waitForRequest(urlSubstring: string, timeoutMs = 15_000): Promise { + // Check if already received + const existing = requests.find((r) => r.url.includes(urlSubstring)); + if (existing) { + return Promise.resolve(existing); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`Timed out waiting for request matching "${urlSubstring}" after ${timeoutMs}ms`)); + }, timeoutMs); + waiters.push({ + urlSubstring, + resolve: (r) => { + clearTimeout(timer); + resolve(r); + }, + }); + }); + } + + return { server, port: address.port, requests, waitForRequest }; +} + +const LOOP_UUID = "00000000-0000-0000-0000-000000000099"; + +// --------------------------------------------------------------------------- +// Test 1: Repo-required rejection +// --------------------------------------------------------------------------- + +test("GENERATE_PRD: rejects with 400 when no repo configured", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "genprd-norepo-")); + tempPathsToClean.push(tmpDir); + + const mock = await startMockApiServer(); + mockServersToClose.push(mock.server); + + const server = new DesktopGatewayServer({ + host: "127.0.0.1", + preferredPort: PORT_PROBE_ORDER[0], + fallbackPorts: PORT_PROBE_ORDER.slice(1), + webAppOrigin: "https://app.symphony.com", + getAllowedDirectories: () => [tmpDir], + machineName: "genprd-norepo-machine", + version: "0.1.0-test", + capabilities: EMPTY_CAPABILITIES, + discoveryFilePath: path.join(tmpDir, "electron-port"), + getApiOrigin: () => `http://127.0.0.1:${mock.port}`, + }); + serversToClose.push(server); + await server.start(); + + const response = await fetch( + `http://127.0.0.1:${server.getActivePort()}/api/engineer/symphony/loop`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + loopId: LOOP_UUID, + command: "GENERATE_PRD", + closedLoopAuthToken: "tok", + artifacts: [], + prompt: "Generate a PRD", + // No repo + }), + } + ); + + assert.equal(response.status, 400); + const body = await response.json(); + assert.ok( + (body as { error: string }).error.includes("GENERATE_PRD"), + `Error should mention GENERATE_PRD: ${(body as { error: string }).error}` + ); +}); + +// --------------------------------------------------------------------------- +// Test 2: Command acceptance +// --------------------------------------------------------------------------- + +test("GENERATE_PRD: accepts valid command and responds 200", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "genprd-accept-")); + tempPathsToClean.push(tmpDir); + + const repoPath = path.join(tmpDir, "repo-accept"); + await initGitRepo(repoPath); + + const worktreeParent = path.join(tmpDir, "worktrees"); + await fs.mkdir(worktreeParent, { recursive: true }); + + const fakeBin = path.join(tmpDir, "fake-bin"); + await fs.mkdir(fakeBin, { recursive: true }); + // Output a JSON line so the buildClaudePipeline grep/tee/formatter pipeline succeeds + await fs.writeFile(path.join(fakeBin, "claude"), '#!/bin/sh\necho \'{"type":"result"}\'\nexit 0\n', { mode: 0o755 }); + + process.env.SYMPHONY_WORKTREE_PARENT_DIR = worktreeParent; + process.env.HOME = tmpDir; // Prevents findStreamFormatter from finding real formatter + process.env.PATH = `${fakeBin}:/usr/bin:/bin`; + + const mock = await startMockApiServer(); + mockServersToClose.push(mock.server); + + const server = new DesktopGatewayServer({ + host: "127.0.0.1", + preferredPort: PORT_PROBE_ORDER[0], + fallbackPorts: PORT_PROBE_ORDER.slice(1), + webAppOrigin: "https://app.symphony.com", + getAllowedDirectories: () => [tmpDir], + machineName: "genprd-accept-machine", + version: "0.1.0-test", + capabilities: EMPTY_CAPABILITIES, + discoveryFilePath: path.join(tmpDir, "electron-port"), + getApiOrigin: () => `http://127.0.0.1:${mock.port}`, + }); + serversToClose.push(server); + await server.start(); + + const response = await fetch( + `http://127.0.0.1:${server.getActivePort()}/api/engineer/symphony/loop`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + loopId: "00000000-0000-0000-0000-000000000010", + command: "GENERATE_PRD", + closedLoopAuthToken: "tok", + artifacts: [], + prompt: "Generate a PRD for this project", + repo: { fullName: "org/repo-accept", branch: "main" }, + }), + } + ); + + assert.equal(response.status, 200, `Expected 200 but got ${response.status}: ${await response.text().catch(() => "")}`); +}); + +// --------------------------------------------------------------------------- +// Test 3: Missing-prompt rejection +// --------------------------------------------------------------------------- + +test("GENERATE_PRD: rejects with 400 when prompt is missing", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "genprd-noprompt-")); + tempPathsToClean.push(tmpDir); + + const repoPath = path.join(tmpDir, "repo-noprompt"); + await initGitRepo(repoPath); + + const mock = await startMockApiServer(); + mockServersToClose.push(mock.server); + + const server = new DesktopGatewayServer({ + host: "127.0.0.1", + preferredPort: PORT_PROBE_ORDER[0], + fallbackPorts: PORT_PROBE_ORDER.slice(1), + webAppOrigin: "https://app.symphony.com", + getAllowedDirectories: () => [tmpDir], + machineName: "genprd-noprompt-machine", + version: "0.1.0-test", + capabilities: EMPTY_CAPABILITIES, + discoveryFilePath: path.join(tmpDir, "electron-port"), + getApiOrigin: () => `http://127.0.0.1:${mock.port}`, + }); + serversToClose.push(server); + await server.start(); + + // Test with no prompt field + const response1 = await fetch( + `http://127.0.0.1:${server.getActivePort()}/api/engineer/symphony/loop`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + loopId: "00000000-0000-0000-0000-000000000020", + command: "GENERATE_PRD", + closedLoopAuthToken: "tok", + artifacts: [], + repo: { fullName: "org/repo-noprompt", branch: "main" }, + }), + } + ); + + assert.equal(response1.status, 400); + const body1 = await response1.json(); + assert.ok( + (body1 as { error: string }).error.includes("No prompt found for GENERATE_PRD"), + `Expected specific error message, got: ${(body1 as { error: string }).error}` + ); + + // Test with empty string prompt + const response2 = await fetch( + `http://127.0.0.1:${server.getActivePort()}/api/engineer/symphony/loop`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + loopId: "00000000-0000-0000-0000-000000000021", + command: "GENERATE_PRD", + closedLoopAuthToken: "tok", + artifacts: [], + prompt: "", + repo: { fullName: "org/repo-noprompt", branch: "main" }, + }), + } + ); + + assert.equal(response2.status, 400); + const body2 = await response2.json(); + assert.ok( + (body2 as { error: string }).error.includes("No prompt found for GENERATE_PRD"), + `Expected specific error message for empty prompt, got: ${(body2 as { error: string }).error}` + ); +}); + +// --------------------------------------------------------------------------- +// Test 4: Spawn cwd, context-pack layout, and no --add-dir +// --------------------------------------------------------------------------- + +test("GENERATE_PRD: spawns with worktree cwd, writes context pack, no --add-dir", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "genprd-layout-")); + tempPathsToClean.push(tmpDir); + + const repoPath = path.join(tmpDir, "repo-layout"); + await initGitRepo(repoPath); + + const worktreeParent = path.join(tmpDir, "worktrees"); + await fs.mkdir(worktreeParent, { recursive: true }); + + // Capture file outside the worktree (which gets cleaned up) + const captureFile = path.join(tmpDir, "capture.txt"); + + const fakeBin = path.join(tmpDir, "fake-bin"); + await fs.mkdir(fakeBin, { recursive: true }); + + // Fake claude spy script: captures cwd, context files, and args, then outputs JSON for the pipeline + const spyScript = [ + "#!/bin/sh", + `echo "CWD=$(pwd)" > ${JSON.stringify(captureFile)}`, + `echo "PROMPT_MD=$(cat .claude/context/prompt.md 2>/dev/null || echo MISSING)" >> ${JSON.stringify(captureFile)}`, + `echo "REPO_INFO_EXISTS=$(test -f .claude/context/repo-info.json && echo yes || echo no)" >> ${JSON.stringify(captureFile)}`, + `echo "ARTIFACTS=$(find .claude/context/artifacts -maxdepth 1 -type f 2>/dev/null | sort | tr '\\n' ',')" >> ${JSON.stringify(captureFile)}`, + `echo "ARGS=$*" >> ${JSON.stringify(captureFile)}`, + // Check that operational files are NOT at worktree root + `echo "ROOT_LOG=$(test -f symphony-loop.log && echo present || echo absent)" >> ${JSON.stringify(captureFile)}`, + `echo "ROOT_PROMPT_TXT=$(test -f generate-prd-prompt.txt && echo present || echo absent)" >> ${JSON.stringify(captureFile)}`, + `echo "ROOT_PID=$(test -f process.pid && echo present || echo absent)" >> ${JSON.stringify(captureFile)}`, + // Output JSON so the buildClaudePipeline grep/tee/formatter pipeline succeeds + 'echo \'{"type":"result"}\'', + "exit 0", + ].join("\n"); + await fs.writeFile(path.join(fakeBin, "claude"), spyScript, { mode: 0o755 }); + + process.env.SYMPHONY_WORKTREE_PARENT_DIR = worktreeParent; + process.env.HOME = tmpDir; // Prevents findStreamFormatter from finding real formatter + process.env.PATH = `${fakeBin}:/usr/bin:/bin`; + + const mock = await startMockApiServer(); + mockServersToClose.push(mock.server); + + const server = new DesktopGatewayServer({ + host: "127.0.0.1", + preferredPort: PORT_PROBE_ORDER[0], + fallbackPorts: PORT_PROBE_ORDER.slice(1), + webAppOrigin: "https://app.symphony.com", + getAllowedDirectories: () => [tmpDir], + machineName: "genprd-layout-machine", + version: "0.1.0-test", + capabilities: EMPTY_CAPABILITIES, + discoveryFilePath: path.join(tmpDir, "electron-port"), + getApiOrigin: () => `http://127.0.0.1:${mock.port}`, + }); + serversToClose.push(server); + await server.start(); + + const loopId = "00000000-0000-0000-0000-000000000030"; + const response = await fetch( + `http://127.0.0.1:${server.getActivePort()}/api/engineer/symphony/loop`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + loopId, + command: "GENERATE_PRD", + closedLoopAuthToken: "tok", + artifacts: [ + { id: "art-1", type: "TEMPLATE", title: "PRD Template", content: "Template content here" }, + { id: "art-2", type: "prd", title: "Existing PRD", content: "Existing PRD content" }, + ], + prompt: "Generate a comprehensive PRD", + repo: { fullName: "org/repo-layout", branch: "main" }, + }), + } + ); + + assert.equal(response.status, 200); + + // Wait for the upload call (indicates process completed) + await mock.waitForRequest("upload-artifacts"); + + // Read the captured data + const captured = await fs.readFile(captureFile, "utf-8"); + const lines = captured.split("\n"); + const getValue = (prefix: string) => { + const line = lines.find((l) => l.startsWith(prefix)); + return line ? line.slice(prefix.length) : undefined; + }; + + // cwd should be the worktree, not the bare repo checkout + const cwd = getValue("CWD="); + assert.ok(cwd, "CWD should be captured"); + assert.ok(cwd!.includes("worktrees"), `CWD should be in worktrees dir, got: ${cwd}`); + assert.ok(cwd!.includes("generate-prd"), `CWD should be a generate-prd worktree, got: ${cwd}`); + assert.ok(!cwd!.endsWith(repoPath), `CWD should not be the bare repo path, got: ${cwd}`); + + // prompt.md should match + const promptMd = getValue("PROMPT_MD="); + assert.equal(promptMd, "Generate a comprehensive PRD"); + + // repo-info.json should exist + const repoInfoExists = getValue("REPO_INFO_EXISTS="); + assert.equal(repoInfoExists, "yes"); + + // Artifacts should be present with correct naming + const artifactsRaw = getValue("ARTIFACTS="); + assert.ok(artifactsRaw, "Artifacts listing should be captured"); + assert.ok(artifactsRaw!.includes("template-art-1.md"), `Should contain template artifact: ${artifactsRaw}`); + assert.ok(artifactsRaw!.includes("prd-art-2.md"), `Should contain prd artifact: ${artifactsRaw}`); + + // Args should NOT contain --add-dir + const args = getValue("ARGS="); + assert.ok(args !== undefined, "ARGS should be captured"); + assert.ok(!args!.includes("--add-dir"), `Args should not contain --add-dir: ${args}`); + + // Operational files should NOT be at worktree root + assert.equal(getValue("ROOT_LOG="), "absent", "symphony-loop.log should not be at worktree root"); + assert.equal(getValue("ROOT_PROMPT_TXT="), "absent", "generate-prd-prompt.txt should not be at worktree root"); + assert.equal(getValue("ROOT_PID="), "absent", "process.pid should not be at worktree root"); +}); + +// --------------------------------------------------------------------------- +// Test 5: Uploaded payload shape +// --------------------------------------------------------------------------- + +test("GENERATE_PRD: uploads { prd: { content } } when prd.md is written", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "genprd-upload-")); + tempPathsToClean.push(tmpDir); + + const repoPath = path.join(tmpDir, "repo-upload"); + await initGitRepo(repoPath); + + const worktreeParent = path.join(tmpDir, "worktrees"); + await fs.mkdir(worktreeParent, { recursive: true }); + + const fakeBin = path.join(tmpDir, "fake-bin"); + await fs.mkdir(fakeBin, { recursive: true }); + + // Fake claude that writes prd.md to cwd, outputs JSON for the pipeline, and exits 0 + const fakeScript = [ + "#!/bin/sh", + 'printf "# Generated PRD\\n\\nContent here." > prd.md', + 'echo \'{"type":"result"}\'', + "exit 0", + ].join("\n"); + await fs.writeFile(path.join(fakeBin, "claude"), fakeScript, { mode: 0o755 }); + + process.env.SYMPHONY_WORKTREE_PARENT_DIR = worktreeParent; + process.env.HOME = tmpDir; // Prevents findStreamFormatter from finding real formatter + process.env.PATH = `${fakeBin}:/usr/bin:/bin`; + + const mock = await startMockApiServer(); + mockServersToClose.push(mock.server); + + const server = new DesktopGatewayServer({ + host: "127.0.0.1", + preferredPort: PORT_PROBE_ORDER[0], + fallbackPorts: PORT_PROBE_ORDER.slice(1), + webAppOrigin: "https://app.symphony.com", + getAllowedDirectories: () => [tmpDir], + machineName: "genprd-upload-machine", + version: "0.1.0-test", + capabilities: EMPTY_CAPABILITIES, + discoveryFilePath: path.join(tmpDir, "electron-port"), + getApiOrigin: () => `http://127.0.0.1:${mock.port}`, + }); + serversToClose.push(server); + await server.start(); + + const loopId = "00000000-0000-0000-0000-000000000040"; + const response = await fetch( + `http://127.0.0.1:${server.getActivePort()}/api/engineer/symphony/loop`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + loopId, + command: "GENERATE_PRD", + closedLoopAuthToken: "tok", + artifacts: [], + prompt: "Generate a PRD", + repo: { fullName: "org/repo-upload", branch: "main" }, + }), + } + ); + + assert.equal(response.status, 200); + + // Wait for upload call + const uploadReq = await mock.waitForRequest("upload-artifacts"); + const uploadBody = JSON.parse(uploadReq.body) as { + artifacts: { prd?: { content: string } }; + metadata: Record; + }; + + assert.ok(uploadBody.artifacts.prd, "Upload should contain prd artifact"); + assert.equal(uploadBody.artifacts.prd!.content, "# Generated PRD\n\nContent here."); + assert.ok(uploadBody.metadata !== undefined, "Upload should contain metadata"); +}); + +// --------------------------------------------------------------------------- +// Test 6: No-output path (Claude exits 0 without writing prd.md) +// --------------------------------------------------------------------------- + +test("GENERATE_PRD: uploads empty artifacts when prd.md is not written", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "genprd-noout-")); + tempPathsToClean.push(tmpDir); + + const repoPath = path.join(tmpDir, "repo-noout"); + await initGitRepo(repoPath); + + const worktreeParent = path.join(tmpDir, "worktrees"); + await fs.mkdir(worktreeParent, { recursive: true }); + + const fakeBin = path.join(tmpDir, "fake-bin"); + await fs.mkdir(fakeBin, { recursive: true }); + // Output JSON for pipeline but do NOT write prd.md + await fs.writeFile(path.join(fakeBin, "claude"), '#!/bin/sh\necho \'{"type":"result"}\'\nexit 0\n', { mode: 0o755 }); + + process.env.SYMPHONY_WORKTREE_PARENT_DIR = worktreeParent; + process.env.HOME = tmpDir; // Prevents findStreamFormatter from finding real formatter + process.env.PATH = `${fakeBin}:/usr/bin:/bin`; + + const mock = await startMockApiServer(); + mockServersToClose.push(mock.server); + + const server = new DesktopGatewayServer({ + host: "127.0.0.1", + preferredPort: PORT_PROBE_ORDER[0], + fallbackPorts: PORT_PROBE_ORDER.slice(1), + webAppOrigin: "https://app.symphony.com", + getAllowedDirectories: () => [tmpDir], + machineName: "genprd-noout-machine", + version: "0.1.0-test", + capabilities: EMPTY_CAPABILITIES, + discoveryFilePath: path.join(tmpDir, "electron-port"), + getApiOrigin: () => `http://127.0.0.1:${mock.port}`, + }); + serversToClose.push(server); + await server.start(); + + const loopId = "00000000-0000-0000-0000-000000000050"; + const response = await fetch( + `http://127.0.0.1:${server.getActivePort()}/api/engineer/symphony/loop`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + loopId, + command: "GENERATE_PRD", + closedLoopAuthToken: "tok", + artifacts: [], + prompt: "Generate a PRD", + repo: { fullName: "org/repo-noout", branch: "main" }, + }), + } + ); + + assert.equal(response.status, 200); + + // Wait for upload call + const uploadReq = await mock.waitForRequest("upload-artifacts"); + const uploadBody = JSON.parse(uploadReq.body) as { + artifacts: Record; + metadata: Record; + }; + + // prd should be undefined (no prd.md written) + assert.equal(uploadBody.artifacts.prd, undefined, "prd should be undefined when not written"); +}); + +// --------------------------------------------------------------------------- +// Test 7: Cleanup leaves no stale git worktree entry on failure +// --------------------------------------------------------------------------- + +test("GENERATE_PRD: cleans up worktree on failure (exit code 1)", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "genprd-cleanup-")); + tempPathsToClean.push(tmpDir); + + const repoPath = path.join(tmpDir, "repo-cleanup"); + await initGitRepo(repoPath); + + const worktreeParent = path.join(tmpDir, "worktrees"); + await fs.mkdir(worktreeParent, { recursive: true }); + + const fakeBin = path.join(tmpDir, "fake-bin"); + await fs.mkdir(fakeBin, { recursive: true }); + // Fake claude that exits with error + await fs.writeFile(path.join(fakeBin, "claude"), "#!/bin/sh\nexit 1\n", { mode: 0o755 }); + + process.env.SYMPHONY_WORKTREE_PARENT_DIR = worktreeParent; + process.env.HOME = tmpDir; // Prevents findStreamFormatter from finding real formatter + process.env.PATH = `${fakeBin}:/usr/bin:/bin`; + + const mock = await startMockApiServer(); + mockServersToClose.push(mock.server); + + const server = new DesktopGatewayServer({ + host: "127.0.0.1", + preferredPort: PORT_PROBE_ORDER[0], + fallbackPorts: PORT_PROBE_ORDER.slice(1), + webAppOrigin: "https://app.symphony.com", + getAllowedDirectories: () => [tmpDir], + machineName: "genprd-cleanup-machine", + version: "0.1.0-test", + capabilities: EMPTY_CAPABILITIES, + discoveryFilePath: path.join(tmpDir, "electron-port"), + getApiOrigin: () => `http://127.0.0.1:${mock.port}`, + }); + serversToClose.push(server); + await server.start(); + + const loopId = "00000000-0000-0000-0000-000000000060"; + const response = await fetch( + `http://127.0.0.1:${server.getActivePort()}/api/engineer/symphony/loop`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + loopId, + command: "GENERATE_PRD", + closedLoopAuthToken: "tok", + artifacts: [], + prompt: "Generate a PRD", + repo: { fullName: "org/repo-cleanup", branch: "main" }, + }), + } + ); + + assert.equal(response.status, 200); + + // Poll until cleanup completes (worktree directory removed) instead of relying + // on a fragile waitForRequest("events") + fixed sleep -- the first "events" match + // is the "started" event, not the "error" event, so cleanup hasn't run yet. + const pollDeadline = Date.now() + 15_000; + while (Date.now() < pollDeadline) { + const entries = await fs.readdir(worktreeParent).catch(() => []); + if (!entries.some((e) => e.includes("generate-prd"))) break; + await new Promise((resolve) => setTimeout(resolve, 200)); + } + + // Verify no stale worktree entries remain + const worktreeList = execSync("git worktree list --porcelain", { + cwd: repoPath, + encoding: "utf-8", + stdio: "pipe", + timeout: 10_000, + }); + + // Check that no worktree path points into the worktrees dir for generate-prd + const worktreeLines = worktreeList.split("\n").filter((l) => l.startsWith("worktree ")); + for (const line of worktreeLines) { + const wtPath = line.slice("worktree ".length); + assert.ok( + !wtPath.includes("generate-prd"), + `Stale worktree entry found: ${wtPath}` + ); + } + + // Verify the directory itself is removed + const worktreeEntries = await fs.readdir(worktreeParent).catch(() => []); + const generatePrdEntries = worktreeEntries.filter((e) => e.includes("generate-prd")); + assert.equal( + generatePrdEntries.length, + 0, + `Worktree directory should be cleaned up, found: ${generatePrdEntries.join(", ")}` + ); +});