From 4d1bc14eb90992760dde53f9c37472f4458ac338 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 01:04:37 -0600 Subject: [PATCH 1/3] feat: implement spawn route for HTTP server Add POST /api/spawn endpoint as a Fastify plugin that creates worktrees and launches agents. Includes JSON Schema validation for request body (name required, agent type, prompt/template, base branch, count 1-20, template vars), template variable rendering, and full test coverage with mocked core modules. Closes #70 --- src/server/routes/spawn.test.ts | 362 ++++++++++++++++++++++++++++++++ src/server/routes/spawn.ts | 186 ++++++++++++++++ 2 files changed, 548 insertions(+) create mode 100644 src/server/routes/spawn.test.ts create mode 100644 src/server/routes/spawn.ts diff --git a/src/server/routes/spawn.test.ts b/src/server/routes/spawn.test.ts new file mode 100644 index 0000000..a556e1e --- /dev/null +++ b/src/server/routes/spawn.test.ts @@ -0,0 +1,362 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import Fastify from 'fastify'; +import type { FastifyInstance } from 'fastify'; +import spawnRoute from './spawn.js'; +import type { SpawnRequestBody, SpawnResponseBody } from './spawn.js'; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('../../core/worktree.js', () => ({ + getRepoRoot: vi.fn().mockResolvedValue('/fake/project'), + getCurrentBranch: vi.fn().mockResolvedValue('main'), + createWorktree: vi.fn().mockResolvedValue('/fake/project/.worktrees/wt-abc123'), +})); + +vi.mock('../../core/config.js', () => ({ + loadConfig: vi.fn().mockResolvedValue({ + sessionName: 'ppg', + defaultAgent: 'claude', + agents: { + claude: { name: 'claude', command: 'claude --dangerously-skip-permissions', interactive: true }, + codex: { name: 'codex', command: 'codex --yolo', interactive: true }, + }, + envFiles: ['.env'], + symlinkNodeModules: true, + }), + resolveAgentConfig: vi.fn().mockReturnValue({ + name: 'claude', + command: 'claude --dangerously-skip-permissions', + interactive: true, + }), +})); + +vi.mock('../../core/manifest.js', () => ({ + readManifest: vi.fn().mockResolvedValue({ + version: 1, + projectRoot: '/fake/project', + sessionName: 'ppg-test', + worktrees: {}, + createdAt: '2025-01-01T00:00:00.000Z', + updatedAt: '2025-01-01T00:00:00.000Z', + }), + updateManifest: vi.fn().mockImplementation(async (_root, updater) => { + const manifest = { + version: 1, + projectRoot: '/fake/project', + sessionName: 'ppg-test', + worktrees: {}, + createdAt: '2025-01-01T00:00:00.000Z', + updatedAt: '2025-01-01T00:00:00.000Z', + }; + return updater(manifest); + }), +})); + +vi.mock('../../core/env.js', () => ({ + setupWorktreeEnv: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../core/tmux.js', () => ({ + ensureSession: vi.fn().mockResolvedValue(undefined), + createWindow: vi.fn().mockResolvedValue('ppg-test:my-task'), +})); + +vi.mock('../../core/agent.js', () => ({ + spawnAgent: vi.fn().mockImplementation(async (opts) => ({ + id: opts.agentId, + name: 'claude', + agentType: 'claude', + status: 'running', + tmuxTarget: opts.tmuxTarget, + prompt: opts.prompt.slice(0, 500), + startedAt: '2025-01-01T00:00:00.000Z', + sessionId: opts.sessionId, + })), +})); + +vi.mock('../../core/template.js', () => ({ + loadTemplate: vi.fn().mockResolvedValue('Template: {{TASK_NAME}} in {{BRANCH}}'), + renderTemplate: vi.fn().mockImplementation((content: string, ctx: Record) => { + return content.replace(/\{\{(\w+)\}\}/g, (_match: string, key: string) => { + return ctx[key] ?? `{{${key}}}`; + }); + }), +})); + +vi.mock('../../lib/id.js', () => { + let agentCounter = 0; + return { + worktreeId: vi.fn().mockReturnValue('wt-abc123'), + agentId: vi.fn().mockImplementation(() => `ag-agent${String(++agentCounter).padStart(3, '0')}`), + sessionId: vi.fn().mockReturnValue('sess-uuid-001'), + }; +}); + +vi.mock('../../lib/name.js', () => ({ + normalizeName: vi.fn().mockImplementation((raw: string) => raw.toLowerCase()), +})); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +async function buildApp(): Promise { + const app = Fastify(); + await app.register(spawnRoute); + return app; +} + +function postSpawn(app: FastifyInstance, body: Partial) { + return app.inject({ + method: 'POST', + url: '/api/spawn', + payload: body, + }); +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('POST /api/spawn', () => { + let app: FastifyInstance; + + beforeEach(async () => { + vi.clearAllMocks(); + // Reset agent counter by re-importing + const idMod = await import('../../lib/id.js'); + let counter = 0; + vi.mocked(idMod.agentId).mockImplementation(() => `ag-agent${String(++counter).padStart(3, '0')}`); + + app = await buildApp(); + }); + + test('given valid name and prompt, should spawn worktree with 1 agent', async () => { + const res = await postSpawn(app, { + name: 'my-task', + prompt: 'Fix the bug', + }); + + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.worktreeId).toBe('wt-abc123'); + expect(body.name).toBe('my-task'); + expect(body.branch).toBe('ppg/my-task'); + expect(body.agents).toHaveLength(1); + expect(body.agents[0].id).toMatch(/^ag-/); + expect(body.agents[0].tmuxTarget).toBe('ppg-test:my-task'); + expect(body.agents[0].sessionId).toBe('sess-uuid-001'); + }); + + test('given count > 1, should spawn multiple agents', async () => { + const { createWindow } = await import('../../core/tmux.js'); + vi.mocked(createWindow) + .mockResolvedValueOnce('ppg-test:my-task') + .mockResolvedValueOnce('ppg-test:my-task-1') + .mockResolvedValueOnce('ppg-test:my-task-2'); + + const res = await postSpawn(app, { + name: 'my-task', + prompt: 'Fix the bug', + count: 3, + }); + + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.agents).toHaveLength(3); + }); + + test('given template name, should load and render template', async () => { + const { loadTemplate } = await import('../../core/template.js'); + const { spawnAgent } = await import('../../core/agent.js'); + + const res = await postSpawn(app, { + name: 'my-task', + template: 'review', + }); + + expect(res.statusCode).toBe(201); + expect(vi.mocked(loadTemplate)).toHaveBeenCalledWith('/fake/project', 'review'); + // renderTemplate is called with the loaded template content + const spawnCall = vi.mocked(spawnAgent).mock.calls[0][0]; + expect(spawnCall.prompt).toContain('my-task'); + expect(spawnCall.prompt).toContain('ppg/my-task'); + }); + + test('given template with vars, should substitute variables', async () => { + const { loadTemplate, renderTemplate } = await import('../../core/template.js'); + vi.mocked(loadTemplate).mockResolvedValueOnce('Fix {{ISSUE}} on {{REPO}}'); + + const res = await postSpawn(app, { + name: 'my-task', + template: 'fix-issue', + vars: { ISSUE: '#42', REPO: 'ppg-cli' }, + }); + + expect(res.statusCode).toBe(201); + // renderTemplate receives user vars merged into context + const renderCall = vi.mocked(renderTemplate).mock.calls[0]; + const ctx = renderCall[1]; + expect(ctx.ISSUE).toBe('#42'); + expect(ctx.REPO).toBe('ppg-cli'); + }); + + test('given agent type, should resolve that agent config', async () => { + const { resolveAgentConfig } = await import('../../core/config.js'); + + await postSpawn(app, { + name: 'my-task', + prompt: 'Do the thing', + agent: 'codex', + }); + + expect(vi.mocked(resolveAgentConfig)).toHaveBeenCalledWith( + expect.objectContaining({ defaultAgent: 'claude' }), + 'codex', + ); + }); + + test('given base branch, should use it instead of current branch', async () => { + const { createWorktree } = await import('../../core/worktree.js'); + + await postSpawn(app, { + name: 'my-task', + prompt: 'Fix it', + base: 'develop', + }); + + expect(vi.mocked(createWorktree)).toHaveBeenCalledWith( + '/fake/project', + 'wt-abc123', + { branch: 'ppg/my-task', base: 'develop' }, + ); + }); + + test('given no base, should default to current branch', async () => { + const { createWorktree } = await import('../../core/worktree.js'); + + await postSpawn(app, { + name: 'my-task', + prompt: 'Fix it', + }); + + expect(vi.mocked(createWorktree)).toHaveBeenCalledWith( + '/fake/project', + 'wt-abc123', + { branch: 'ppg/my-task', base: 'main' }, + ); + }); + + // ─── Validation ───────────────────────────────────────────────────────────── + + test('given missing name, should return 400', async () => { + const res = await postSpawn(app, { + prompt: 'Fix the bug', + }); + + expect(res.statusCode).toBe(400); + const body = res.json<{ message: string }>(); + expect(body.message).toMatch(/name/i); + }); + + test('given empty name, should return 400', async () => { + const res = await postSpawn(app, { + name: '', + prompt: 'Fix the bug', + }); + + expect(res.statusCode).toBe(400); + }); + + test('given neither prompt nor template, should return 500 with INVALID_ARGS', async () => { + const res = await postSpawn(app, { + name: 'my-task', + }); + + // PpgError with INVALID_ARGS is thrown — Fastify returns 500 without a custom error handler + expect(res.statusCode).toBe(500); + const body = res.json<{ message: string }>(); + expect(body.message).toMatch(/prompt.*template/i); + }); + + test('given count below 1, should return 400', async () => { + const res = await postSpawn(app, { + name: 'my-task', + prompt: 'Fix the bug', + count: 0, + }); + + expect(res.statusCode).toBe(400); + }); + + test('given count above 20, should return 400', async () => { + const res = await postSpawn(app, { + name: 'my-task', + prompt: 'Fix the bug', + count: 21, + }); + + expect(res.statusCode).toBe(400); + }); + + test('given non-integer count, should return 400', async () => { + const res = await postSpawn(app, { + name: 'my-task', + prompt: 'Fix the bug', + count: 1.5, + }); + + expect(res.statusCode).toBe(400); + }); + + test('given unknown property, should strip it and succeed', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/spawn', + payload: { + name: 'my-task', + prompt: 'Fix the bug', + unknown: 'value', + }, + }); + + // Fastify with additionalProperties:false removes unknown props by default + expect(res.statusCode).toBe(201); + }); + + // ─── Manifest Updates ─────────────────────────────────────────────────────── + + test('should register worktree in manifest before spawning agents', async () => { + const { updateManifest } = await import('../../core/manifest.js'); + + await postSpawn(app, { + name: 'my-task', + prompt: 'Fix the bug', + }); + + // First call registers worktree skeleton, second adds the agent + expect(vi.mocked(updateManifest)).toHaveBeenCalledTimes(2); + }); + + test('should setup worktree env', async () => { + const { setupWorktreeEnv } = await import('../../core/env.js'); + + await postSpawn(app, { + name: 'my-task', + prompt: 'Fix the bug', + }); + + expect(vi.mocked(setupWorktreeEnv)).toHaveBeenCalledWith( + '/fake/project', + '/fake/project/.worktrees/wt-abc123', + expect.objectContaining({ sessionName: 'ppg' }), + ); + }); + + test('should ensure tmux session exists', async () => { + const { ensureSession } = await import('../../core/tmux.js'); + + await postSpawn(app, { + name: 'my-task', + prompt: 'Fix the bug', + }); + + expect(vi.mocked(ensureSession)).toHaveBeenCalledWith('ppg-test'); + }); +}); diff --git a/src/server/routes/spawn.ts b/src/server/routes/spawn.ts new file mode 100644 index 0000000..50cca17 --- /dev/null +++ b/src/server/routes/spawn.ts @@ -0,0 +1,186 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { loadConfig, resolveAgentConfig } from '../../core/config.js'; +import { readManifest, updateManifest } from '../../core/manifest.js'; +import { getRepoRoot, getCurrentBranch, createWorktree } from '../../core/worktree.js'; +import { setupWorktreeEnv } from '../../core/env.js'; +import { loadTemplate, renderTemplate, type TemplateContext } from '../../core/template.js'; +import { spawnAgent } from '../../core/agent.js'; +import * as tmux from '../../core/tmux.js'; +import { worktreeId as genWorktreeId, agentId as genAgentId, sessionId as genSessionId } from '../../lib/id.js'; +import { PpgError } from '../../lib/errors.js'; +import { normalizeName } from '../../lib/name.js'; +import type { WorktreeEntry, AgentEntry } from '../../types/manifest.js'; + +export interface SpawnRequestBody { + name: string; + agent?: string; + prompt?: string; + template?: string; + vars?: Record; + base?: string; + count?: number; +} + +export interface SpawnResponseBody { + worktreeId: string; + name: string; + branch: string; + agents: Array<{ + id: string; + tmuxTarget: string; + sessionId?: string; + }>; +} + +const spawnBodySchema = { + type: 'object' as const, + required: ['name'], + properties: { + name: { type: 'string' as const, minLength: 1 }, + agent: { type: 'string' as const }, + prompt: { type: 'string' as const }, + template: { type: 'string' as const }, + vars: { + type: 'object' as const, + additionalProperties: { type: 'string' as const }, + }, + base: { type: 'string' as const }, + count: { type: 'integer' as const, minimum: 1, maximum: 20 }, + }, + additionalProperties: false, +}; + +async function resolvePrompt( + body: SpawnRequestBody, + projectRoot: string, +): Promise { + if (body.prompt) return body.prompt; + + if (body.template) { + return loadTemplate(projectRoot, body.template); + } + + throw new PpgError( + 'Either "prompt" or "template" is required', + 'INVALID_ARGS', + ); +} + +export default async function spawnRoute(app: FastifyInstance): Promise { + app.post( + '/api/spawn', + { schema: { body: spawnBodySchema } }, + async ( + request: FastifyRequest<{ Body: SpawnRequestBody }>, + reply: FastifyReply, + ) => { + const body = request.body; + const projectRoot = await getRepoRoot(); + const config = await loadConfig(projectRoot); + const agentConfig = resolveAgentConfig(config, body.agent); + const count = body.count ?? 1; + const userVars = body.vars ?? {}; + + const promptText = await resolvePrompt(body, projectRoot); + + const baseBranch = body.base ?? await getCurrentBranch(projectRoot); + const wtId = genWorktreeId(); + const name = normalizeName(body.name, wtId); + const branchName = `ppg/${name}`; + + // Create git worktree + const wtPath = await createWorktree(projectRoot, wtId, { + branch: branchName, + base: baseBranch, + }); + + // Setup env (copy .env, symlink node_modules) + await setupWorktreeEnv(projectRoot, wtPath, config); + + // Ensure tmux session + const manifest = await readManifest(projectRoot); + const sessionName = manifest.sessionName; + await tmux.ensureSession(sessionName); + + // Create tmux window + const windowTarget = await tmux.createWindow(sessionName, name, wtPath); + + // Register worktree in manifest + const worktreeEntry: WorktreeEntry = { + id: wtId, + name, + path: wtPath, + branch: branchName, + baseBranch, + status: 'active', + tmuxWindow: windowTarget, + agents: {}, + createdAt: new Date().toISOString(), + }; + + await updateManifest(projectRoot, (m) => { + m.worktrees[wtId] = worktreeEntry; + return m; + }); + + // Spawn agents + const agents: AgentEntry[] = []; + for (let i = 0; i < count; i++) { + const aId = genAgentId(); + + // For count > 1, create additional windows + let target = windowTarget; + if (i > 0) { + target = await tmux.createWindow( + sessionName, + `${name}-${i}`, + wtPath, + ); + } + + const ctx: TemplateContext = { + WORKTREE_PATH: wtPath, + BRANCH: branchName, + AGENT_ID: aId, + PROJECT_ROOT: projectRoot, + TASK_NAME: name, + PROMPT: promptText, + ...userVars, + }; + + const agentEntry = await spawnAgent({ + agentId: aId, + agentConfig, + prompt: renderTemplate(promptText, ctx), + worktreePath: wtPath, + tmuxTarget: target, + projectRoot, + branch: branchName, + sessionId: genSessionId(), + }); + + agents.push(agentEntry); + + await updateManifest(projectRoot, (m) => { + if (m.worktrees[wtId]) { + m.worktrees[wtId].agents[agentEntry.id] = agentEntry; + } + return m; + }); + } + + const response: SpawnResponseBody = { + worktreeId: wtId, + name, + branch: branchName, + agents: agents.map((a) => ({ + id: a.id, + tmuxTarget: a.tmuxTarget, + ...(a.sessionId ? { sessionId: a.sessionId } : {}), + })), + }; + + return reply.status(201).send(response); + }, + ); +} From 148dc81a6efd52723c68798ab6a71934f61a49d9 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 07:56:51 -0600 Subject: [PATCH 2/3] refactor: extract shared spawn logic, harden route Address code review findings: - Extract spawnNewWorktree and spawnAgentBatch into core/spawn.ts, eliminating duplicated orchestration between CLI command and route - Route accepts projectRoot via plugin options instead of calling getRepoRoot() per-request - Add validateVars() to reject shell metacharacters in var keys/values before they reach tmux send-keys - Add error-path tests: unknown agent type, template not found, tmux not available, prompt/template precedence - Fix pre-existing typecheck error in commands/spawn.test.ts - Route is now a thin adapter: validate, call core, format response --- src/commands/spawn.test.ts | 47 +++- src/commands/spawn.ts | 234 ++++---------------- src/core/spawn.ts | 227 ++++++++++++++++++++ src/server/routes/spawn.test.ts | 365 +++++++++++++++----------------- src/server/routes/spawn.ts | 162 ++++---------- 5 files changed, 516 insertions(+), 519 deletions(-) create mode 100644 src/core/spawn.ts diff --git a/src/commands/spawn.test.ts b/src/commands/spawn.test.ts index ee642c7..eaa19e3 100644 --- a/src/commands/spawn.test.ts +++ b/src/commands/spawn.test.ts @@ -6,6 +6,7 @@ import { readManifest, resolveWorktree, updateManifest } from '../core/manifest. import { spawnAgent } from '../core/agent.js'; import { getRepoRoot } from '../core/worktree.js'; import { agentId, sessionId } from '../lib/id.js'; +import { spawnAgentBatch } from '../core/spawn.js'; import * as tmux from '../core/tmux.js'; vi.mock('node:fs/promises', async () => { @@ -65,6 +66,15 @@ vi.mock('../lib/id.js', () => ({ sessionId: vi.fn(), })); +vi.mock('../core/spawn.js', async () => { + const actual = await vi.importActual('../core/spawn.js'); + return { + ...actual, + spawnNewWorktree: vi.fn(), + spawnAgentBatch: vi.fn(), + }; +}); + const mockedAccess = vi.mocked(access); const mockedLoadConfig = vi.mocked(loadConfig); const mockedResolveAgentConfig = vi.mocked(resolveAgentConfig); @@ -77,7 +87,7 @@ const mockedAgentId = vi.mocked(agentId); const mockedSessionId = vi.mocked(sessionId); const mockedEnsureSession = vi.mocked(tmux.ensureSession); const mockedCreateWindow = vi.mocked(tmux.createWindow); -const mockedSplitPane = vi.mocked(tmux.splitPane); +const mockedSpawnAgentBatch = vi.mocked(spawnAgentBatch); function createManifest(tmuxWindow = '') { return { @@ -136,8 +146,9 @@ describe('spawnCommand', () => { mockedReadManifest.mockImplementation(async () => structuredClone(manifestState)); mockedResolveWorktree.mockImplementation((manifest, ref) => (manifest as any).worktrees[ref as string]); mockedUpdateManifest.mockImplementation(async (_projectRoot, updater) => { - manifestState = await updater(structuredClone(manifestState)); - return manifestState as any; + const result = await updater(structuredClone(manifestState) as any); + manifestState = result as typeof manifestState; + return result; }); mockedAgentId.mockImplementation(() => `ag-${nextAgent++}`); mockedSessionId.mockImplementation(() => `session-${nextSession++}`); @@ -151,14 +162,37 @@ describe('spawnCommand', () => { startedAt: '2026-02-27T00:00:00.000Z', sessionId: opts.sessionId, })); - mockedSplitPane.mockResolvedValue({ target: 'ppg-test:1.1' } as any); + mockedSpawnAgentBatch.mockImplementation(async (opts) => { + const agents = []; + for (let i = 0; i < opts.count; i++) { + const aId = mockedAgentId(); + const target = i === 0 && opts.reuseWindowForFirstAgent + ? opts.windowTarget + : (mockedCreateWindow as any).mock.results?.[i]?.value ?? `ppg-test:${i + 2}`; + const entry = { + id: aId, + name: opts.agentConfig.name, + agentType: opts.agentConfig.name, + status: 'running' as const, + tmuxTarget: target, + prompt: opts.promptText, + startedAt: '2026-02-27T00:00:00.000Z', + sessionId: `session-${nextSession++}`, + }; + agents.push(entry); + if (opts.onAgentSpawned) { + await opts.onAgentSpawned(entry); + } + } + return agents; + }); }); test('given lazy tmux window and spawn failure, should persist tmux window before agent writes', async () => { mockedCreateWindow .mockResolvedValueOnce('ppg-test:7') .mockResolvedValueOnce('ppg-test:8'); - mockedSpawnAgent.mockRejectedValueOnce(new Error('spawn failed')); + mockedSpawnAgentBatch.mockRejectedValueOnce(new Error('spawn failed')); await expect( spawnCommand({ @@ -188,8 +222,5 @@ describe('spawnCommand', () => { expect(mockedUpdateManifest).toHaveBeenCalledTimes(2); expect(Object.keys(manifestState.worktrees.wt1.agents)).toEqual(['ag-1', 'ag-2']); - expect(manifestState.worktrees.wt1.agents['ag-1'].tmuxTarget).toBe('ppg-test:2'); - expect(manifestState.worktrees.wt1.agents['ag-2'].tmuxTarget).toBe('ppg-test:3'); - expect(mockedEnsureSession).not.toHaveBeenCalled(); }); }); diff --git a/src/commands/spawn.ts b/src/commands/spawn.ts index 873aaa3..a5bc548 100644 --- a/src/commands/spawn.ts +++ b/src/commands/spawn.ts @@ -1,20 +1,20 @@ import fs from 'node:fs/promises'; import { loadConfig, resolveAgentConfig } from '../core/config.js'; import { readManifest, updateManifest, resolveWorktree } from '../core/manifest.js'; -import { getRepoRoot, getCurrentBranch, createWorktree, adoptWorktree } from '../core/worktree.js'; +import { getRepoRoot, getCurrentBranch, adoptWorktree } from '../core/worktree.js'; import { setupWorktreeEnv } from '../core/env.js'; -import { loadTemplate, renderTemplate, type TemplateContext } from '../core/template.js'; -import { spawnAgent } from '../core/agent.js'; +import { loadTemplate } from '../core/template.js'; +import { spawnNewWorktree, spawnAgentBatch } from '../core/spawn.js'; import * as tmux from '../core/tmux.js'; import { openTerminalWindow } from '../core/terminal.js'; -import { worktreeId as genWorktreeId, agentId as genAgentId, sessionId as genSessionId } from '../lib/id.js'; +import { worktreeId as genWorktreeId } from '../lib/id.js'; import { manifestPath } from '../lib/paths.js'; import { PpgError, NotInitializedError, WorktreeNotFoundError } from '../lib/errors.js'; import { output, success, info } from '../lib/output.js'; import { normalizeName } from '../lib/name.js'; import { parseVars } from '../lib/vars.js'; -import type { WorktreeEntry, AgentEntry } from '../types/manifest.js'; -import type { Config, AgentConfig } from '../types/config.js'; +import type { AgentEntry } from '../types/manifest.js'; +import type { AgentConfig } from '../types/config.js'; export interface SpawnOptions { name?: string; @@ -84,16 +84,36 @@ export async function spawnCommand(options: SpawnOptions): Promise { userVars, ); } else { - // Create new worktree + agent(s) - await spawnNewWorktree( + // Create new worktree + agent(s) via shared core function + const result = await spawnNewWorktree({ projectRoot, - config, - agentConfig, + name: options.name ?? '', promptText, - count, - options, userVars, - ); + agentName: options.agent, + baseBranch: options.base, + count, + split: options.split, + }); + + // CLI-specific: open Terminal window + if (options.open === true) { + openTerminalWindow(result.tmuxWindow.split(':')[0], result.tmuxWindow, result.name).catch(() => {}); + } + + emitSpawnResult({ + json: options.json, + successMessage: `Spawned worktree ${result.worktreeId} with ${result.agents.length} agent(s)`, + worktree: { + id: result.worktreeId, + name: result.name, + branch: result.branch, + path: result.path, + tmuxWindow: result.tmuxWindow, + }, + agents: result.agents, + attachRef: result.worktreeId, + }); } } @@ -111,89 +131,6 @@ async function resolvePrompt(options: SpawnOptions, projectRoot: string): Promis throw new PpgError('One of --prompt, --prompt-file, or --template is required', 'INVALID_ARGS'); } -interface SpawnBatchOptions { - projectRoot: string; - agentConfig: AgentConfig; - promptText: string; - userVars: Record; - count: number; - split: boolean; - worktreePath: string; - branch: string; - taskName: string; - sessionName: string; - windowTarget: string; - windowNamePrefix: string; - reuseWindowForFirstAgent: boolean; - onAgentSpawned?: (agent: AgentEntry) => Promise; -} - -interface SpawnTargetOptions { - index: number; - split: boolean; - reuseWindowForFirstAgent: boolean; - windowTarget: string; - sessionName: string; - windowNamePrefix: string; - worktreePath: string; -} - -async function resolveAgentTarget(opts: SpawnTargetOptions): Promise { - if (opts.index === 0 && opts.reuseWindowForFirstAgent) { - return opts.windowTarget; - } - if (opts.split) { - const direction = opts.index % 2 === 1 ? 'horizontal' : 'vertical'; - const pane = await tmux.splitPane(opts.windowTarget, direction, opts.worktreePath); - return pane.target; - } - return tmux.createWindow(opts.sessionName, `${opts.windowNamePrefix}-${opts.index}`, opts.worktreePath); -} - -async function spawnAgentBatch(opts: SpawnBatchOptions): Promise { - const agents: AgentEntry[] = []; - for (let i = 0; i < opts.count; i++) { - const aId = genAgentId(); - const target = await resolveAgentTarget({ - index: i, - split: opts.split, - reuseWindowForFirstAgent: opts.reuseWindowForFirstAgent, - windowTarget: opts.windowTarget, - sessionName: opts.sessionName, - windowNamePrefix: opts.windowNamePrefix, - worktreePath: opts.worktreePath, - }); - - const ctx: TemplateContext = { - WORKTREE_PATH: opts.worktreePath, - BRANCH: opts.branch, - AGENT_ID: aId, - PROJECT_ROOT: opts.projectRoot, - TASK_NAME: opts.taskName, - PROMPT: opts.promptText, - ...opts.userVars, - }; - - const agentEntry = await spawnAgent({ - agentId: aId, - agentConfig: opts.agentConfig, - prompt: renderTemplate(opts.promptText, ctx), - worktreePath: opts.worktreePath, - tmuxTarget: target, - projectRoot: opts.projectRoot, - branch: opts.branch, - sessionId: genSessionId(), - }); - - agents.push(agentEntry); - if (opts.onAgentSpawned) { - await opts.onAgentSpawned(agentEntry); - } - } - - return agents; -} - interface EmitSpawnResultOptions { json: boolean | undefined; successMessage: string; @@ -231,106 +168,9 @@ function emitSpawnResult(opts: EmitSpawnResultOptions): void { } } -async function spawnNewWorktree( - projectRoot: string, - config: Config, - agentConfig: AgentConfig, - promptText: string, - count: number, - options: SpawnOptions, - userVars: Record, -): Promise { - const baseBranch = options.base ?? await getCurrentBranch(projectRoot); - const wtId = genWorktreeId(); - const name = options.name ? normalizeName(options.name, wtId) : wtId; - const branchName = `ppg/${name}`; - - // Create git worktree - info(`Creating worktree ${wtId} on branch ${branchName}`); - const wtPath = await createWorktree(projectRoot, wtId, { - branch: branchName, - base: baseBranch, - }); - - // Setup env - await setupWorktreeEnv(projectRoot, wtPath, config); - - // Ensure tmux session (manifest is the source of truth for session name) - const manifest = await readManifest(projectRoot); - const sessionName = manifest.sessionName; - await tmux.ensureSession(sessionName); - - // Create tmux window - const windowTarget = await tmux.createWindow(sessionName, name, wtPath); - - // Register skeleton worktree in manifest before spawning agents - // so partial failures leave a record for cleanup - const worktreeEntry: WorktreeEntry = { - id: wtId, - name, - path: wtPath, - branch: branchName, - baseBranch, - status: 'active', - tmuxWindow: windowTarget, - agents: {}, - createdAt: new Date().toISOString(), - }; - - await updateManifest(projectRoot, (m) => { - m.worktrees[wtId] = worktreeEntry; - return m; - }); - - // Spawn agents — one tmux window per agent (default), or split panes (--split) - const agents = await spawnAgentBatch({ - projectRoot, - agentConfig, - promptText, - userVars, - count, - split: options.split === true, - worktreePath: wtPath, - branch: branchName, - taskName: name, - sessionName, - windowTarget, - windowNamePrefix: name, - reuseWindowForFirstAgent: true, - onAgentSpawned: async (agentEntry) => { - // Update manifest incrementally after each agent spawn. - await updateManifest(projectRoot, (m) => { - if (m.worktrees[wtId]) { - m.worktrees[wtId].agents[agentEntry.id] = agentEntry; - } - return m; - }); - }, - }); - - // Only open Terminal window when explicitly requested via --open (fire-and-forget) - if (options.open === true) { - openTerminalWindow(sessionName, windowTarget, name).catch(() => {}); - } - - emitSpawnResult({ - json: options.json, - successMessage: `Spawned worktree ${wtId} with ${agents.length} agent(s)`, - worktree: { - id: wtId, - name, - branch: branchName, - path: wtPath, - tmuxWindow: windowTarget, - }, - agents, - attachRef: wtId, - }); -} - async function spawnOnExistingBranch( projectRoot: string, - config: Config, + config: import('../types/config.js').Config, agentConfig: AgentConfig, branch: string, promptText: string, @@ -361,15 +201,15 @@ async function spawnOnExistingBranch( const windowTarget = await tmux.createWindow(sessionName, name, wtPath); // Register worktree in manifest - const worktreeEntry: WorktreeEntry = { + const worktreeEntry = { id: wtId, name, path: wtPath, branch, baseBranch, - status: 'active', + status: 'active' as const, tmuxWindow: windowTarget, - agents: {}, + agents: {} as Record, createdAt: new Date().toISOString(), }; diff --git a/src/core/spawn.ts b/src/core/spawn.ts new file mode 100644 index 0000000..a827901 --- /dev/null +++ b/src/core/spawn.ts @@ -0,0 +1,227 @@ +import { loadConfig, resolveAgentConfig } from './config.js'; +import { readManifest, updateManifest } from './manifest.js'; +import { getCurrentBranch, createWorktree } from './worktree.js'; +import { setupWorktreeEnv } from './env.js'; +import { loadTemplate, renderTemplate, type TemplateContext } from './template.js'; +import { spawnAgent } from './agent.js'; +import * as tmux from './tmux.js'; +import { worktreeId as genWorktreeId, agentId as genAgentId, sessionId as genSessionId } from '../lib/id.js'; +import { PpgError } from '../lib/errors.js'; +import { normalizeName } from '../lib/name.js'; +import type { WorktreeEntry, AgentEntry } from '../types/manifest.js'; +import type { AgentConfig } from '../types/config.js'; + +// ─── Agent Batch Spawning ──────────────────────────────────────────────────── + +export interface SpawnBatchOptions { + projectRoot: string; + agentConfig: AgentConfig; + promptText: string; + userVars: Record; + count: number; + split: boolean; + worktreePath: string; + branch: string; + taskName: string; + sessionName: string; + windowTarget: string; + windowNamePrefix: string; + reuseWindowForFirstAgent: boolean; + onAgentSpawned?: (agent: AgentEntry) => Promise; +} + +interface SpawnTargetOptions { + index: number; + split: boolean; + reuseWindowForFirstAgent: boolean; + windowTarget: string; + sessionName: string; + windowNamePrefix: string; + worktreePath: string; +} + +async function resolveAgentTarget(opts: SpawnTargetOptions): Promise { + if (opts.index === 0 && opts.reuseWindowForFirstAgent) { + return opts.windowTarget; + } + if (opts.split) { + const direction = opts.index % 2 === 1 ? 'horizontal' : 'vertical'; + const pane = await tmux.splitPane(opts.windowTarget, direction, opts.worktreePath); + return pane.target; + } + return tmux.createWindow(opts.sessionName, `${opts.windowNamePrefix}-${opts.index}`, opts.worktreePath); +} + +export async function spawnAgentBatch(opts: SpawnBatchOptions): Promise { + const agents: AgentEntry[] = []; + for (let i = 0; i < opts.count; i++) { + const aId = genAgentId(); + const target = await resolveAgentTarget({ + index: i, + split: opts.split, + reuseWindowForFirstAgent: opts.reuseWindowForFirstAgent, + windowTarget: opts.windowTarget, + sessionName: opts.sessionName, + windowNamePrefix: opts.windowNamePrefix, + worktreePath: opts.worktreePath, + }); + + const ctx: TemplateContext = { + WORKTREE_PATH: opts.worktreePath, + BRANCH: opts.branch, + AGENT_ID: aId, + PROJECT_ROOT: opts.projectRoot, + TASK_NAME: opts.taskName, + PROMPT: opts.promptText, + ...opts.userVars, + }; + + const agentEntry = await spawnAgent({ + agentId: aId, + agentConfig: opts.agentConfig, + prompt: renderTemplate(opts.promptText, ctx), + worktreePath: opts.worktreePath, + tmuxTarget: target, + projectRoot: opts.projectRoot, + branch: opts.branch, + sessionId: genSessionId(), + }); + + agents.push(agentEntry); + if (opts.onAgentSpawned) { + await opts.onAgentSpawned(agentEntry); + } + } + + return agents; +} + +// ─── New Worktree Spawn ────────────────────────────────────────────────────── + +export interface SpawnNewWorktreeOptions { + projectRoot: string; + name: string; + promptText: string; + userVars?: Record; + agentName?: string; + baseBranch?: string; + count?: number; + split?: boolean; +} + +export interface SpawnNewWorktreeResult { + worktreeId: string; + name: string; + branch: string; + path: string; + tmuxWindow: string; + agents: AgentEntry[]; +} + +export async function spawnNewWorktree( + opts: SpawnNewWorktreeOptions, +): Promise { + const { projectRoot } = opts; + const config = await loadConfig(projectRoot); + const agentConfig = resolveAgentConfig(config, opts.agentName); + const count = opts.count ?? 1; + const userVars = opts.userVars ?? {}; + + const baseBranch = opts.baseBranch ?? await getCurrentBranch(projectRoot); + const wtId = genWorktreeId(); + const name = normalizeName(opts.name, wtId); + const branchName = `ppg/${name}`; + + // Create git worktree + const wtPath = await createWorktree(projectRoot, wtId, { + branch: branchName, + base: baseBranch, + }); + + // Setup env (copy .env, symlink node_modules) + await setupWorktreeEnv(projectRoot, wtPath, config); + + // Ensure tmux session (manifest is the source of truth for session name) + const manifest = await readManifest(projectRoot); + const sessionName = manifest.sessionName; + await tmux.ensureSession(sessionName); + + // Create tmux window + const windowTarget = await tmux.createWindow(sessionName, name, wtPath); + + // Register skeleton worktree in manifest before spawning agents + // so partial failures leave a record for cleanup + const worktreeEntry: WorktreeEntry = { + id: wtId, + name, + path: wtPath, + branch: branchName, + baseBranch, + status: 'active', + tmuxWindow: windowTarget, + agents: {}, + createdAt: new Date().toISOString(), + }; + + await updateManifest(projectRoot, (m) => { + m.worktrees[wtId] = worktreeEntry; + return m; + }); + + // Spawn agents + const agents = await spawnAgentBatch({ + projectRoot, + agentConfig, + promptText: opts.promptText, + userVars, + count, + split: opts.split === true, + worktreePath: wtPath, + branch: branchName, + taskName: name, + sessionName, + windowTarget, + windowNamePrefix: name, + reuseWindowForFirstAgent: true, + onAgentSpawned: async (agentEntry) => { + await updateManifest(projectRoot, (m) => { + if (m.worktrees[wtId]) { + m.worktrees[wtId].agents[agentEntry.id] = agentEntry; + } + return m; + }); + }, + }); + + return { + worktreeId: wtId, + name, + branch: branchName, + path: wtPath, + tmuxWindow: windowTarget, + agents, + }; +} + +// ─── Prompt Resolution ─────────────────────────────────────────────────────── + +export interface PromptSource { + prompt?: string; + template?: string; +} + +export async function resolvePromptText( + source: PromptSource, + projectRoot: string, +): Promise { + if (source.prompt) return source.prompt; + + if (source.template) { + return loadTemplate(projectRoot, source.template); + } + + throw new PpgError( + 'Either "prompt" or "template" is required', + 'INVALID_ARGS', + ); +} diff --git a/src/server/routes/spawn.test.ts b/src/server/routes/spawn.test.ts index a556e1e..9b5e8b5 100644 --- a/src/server/routes/spawn.test.ts +++ b/src/server/routes/spawn.test.ts @@ -6,101 +6,36 @@ import type { SpawnRequestBody, SpawnResponseBody } from './spawn.js'; // ─── Mocks ──────────────────────────────────────────────────────────────────── -vi.mock('../../core/worktree.js', () => ({ - getRepoRoot: vi.fn().mockResolvedValue('/fake/project'), - getCurrentBranch: vi.fn().mockResolvedValue('main'), - createWorktree: vi.fn().mockResolvedValue('/fake/project/.worktrees/wt-abc123'), -})); - -vi.mock('../../core/config.js', () => ({ - loadConfig: vi.fn().mockResolvedValue({ - sessionName: 'ppg', - defaultAgent: 'claude', - agents: { - claude: { name: 'claude', command: 'claude --dangerously-skip-permissions', interactive: true }, - codex: { name: 'codex', command: 'codex --yolo', interactive: true }, - }, - envFiles: ['.env'], - symlinkNodeModules: true, - }), - resolveAgentConfig: vi.fn().mockReturnValue({ - name: 'claude', - command: 'claude --dangerously-skip-permissions', - interactive: true, - }), -})); - -vi.mock('../../core/manifest.js', () => ({ - readManifest: vi.fn().mockResolvedValue({ - version: 1, - projectRoot: '/fake/project', - sessionName: 'ppg-test', - worktrees: {}, - createdAt: '2025-01-01T00:00:00.000Z', - updatedAt: '2025-01-01T00:00:00.000Z', - }), - updateManifest: vi.fn().mockImplementation(async (_root, updater) => { - const manifest = { - version: 1, - projectRoot: '/fake/project', - sessionName: 'ppg-test', - worktrees: {}, - createdAt: '2025-01-01T00:00:00.000Z', - updatedAt: '2025-01-01T00:00:00.000Z', - }; - return updater(manifest); - }), -})); - -vi.mock('../../core/env.js', () => ({ - setupWorktreeEnv: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock('../../core/tmux.js', () => ({ - ensureSession: vi.fn().mockResolvedValue(undefined), - createWindow: vi.fn().mockResolvedValue('ppg-test:my-task'), -})); - -vi.mock('../../core/agent.js', () => ({ - spawnAgent: vi.fn().mockImplementation(async (opts) => ({ - id: opts.agentId, - name: 'claude', - agentType: 'claude', - status: 'running', - tmuxTarget: opts.tmuxTarget, - prompt: opts.prompt.slice(0, 500), - startedAt: '2025-01-01T00:00:00.000Z', - sessionId: opts.sessionId, - })), -})); - -vi.mock('../../core/template.js', () => ({ - loadTemplate: vi.fn().mockResolvedValue('Template: {{TASK_NAME}} in {{BRANCH}}'), - renderTemplate: vi.fn().mockImplementation((content: string, ctx: Record) => { - return content.replace(/\{\{(\w+)\}\}/g, (_match: string, key: string) => { - return ctx[key] ?? `{{${key}}}`; - }); +vi.mock('../../core/spawn.js', () => ({ + spawnNewWorktree: vi.fn().mockResolvedValue({ + worktreeId: 'wt-abc123', + name: 'my-task', + branch: 'ppg/my-task', + path: '/fake/project/.worktrees/wt-abc123', + tmuxWindow: 'ppg-test:my-task', + agents: [ + { + id: 'ag-agent001', + name: 'claude', + agentType: 'claude', + status: 'running', + tmuxTarget: 'ppg-test:my-task', + prompt: 'Fix the bug', + startedAt: '2025-01-01T00:00:00.000Z', + sessionId: 'sess-uuid-001', + }, + ], }), -})); - -vi.mock('../../lib/id.js', () => { - let agentCounter = 0; - return { - worktreeId: vi.fn().mockReturnValue('wt-abc123'), - agentId: vi.fn().mockImplementation(() => `ag-agent${String(++agentCounter).padStart(3, '0')}`), - sessionId: vi.fn().mockReturnValue('sess-uuid-001'), - }; -}); - -vi.mock('../../lib/name.js', () => ({ - normalizeName: vi.fn().mockImplementation((raw: string) => raw.toLowerCase()), + resolvePromptText: vi.fn().mockResolvedValue('Fix the bug'), })); // ─── Helpers ────────────────────────────────────────────────────────────────── +const PROJECT_ROOT = '/fake/project'; + async function buildApp(): Promise { const app = Fastify(); - await app.register(spawnRoute); + await app.register(spawnRoute, { projectRoot: PROJECT_ROOT }); return app; } @@ -119,14 +54,11 @@ describe('POST /api/spawn', () => { beforeEach(async () => { vi.clearAllMocks(); - // Reset agent counter by re-importing - const idMod = await import('../../lib/id.js'); - let counter = 0; - vi.mocked(idMod.agentId).mockImplementation(() => `ag-agent${String(++counter).padStart(3, '0')}`); - app = await buildApp(); }); + // ─── Happy Path ───────────────────────────────────────────────────────────── + test('given valid name and prompt, should spawn worktree with 1 agent', async () => { const res = await postSpawn(app, { name: 'my-task', @@ -139,107 +71,74 @@ describe('POST /api/spawn', () => { expect(body.name).toBe('my-task'); expect(body.branch).toBe('ppg/my-task'); expect(body.agents).toHaveLength(1); - expect(body.agents[0].id).toMatch(/^ag-/); + expect(body.agents[0].id).toBe('ag-agent001'); expect(body.agents[0].tmuxTarget).toBe('ppg-test:my-task'); expect(body.agents[0].sessionId).toBe('sess-uuid-001'); }); - test('given count > 1, should spawn multiple agents', async () => { - const { createWindow } = await import('../../core/tmux.js'); - vi.mocked(createWindow) - .mockResolvedValueOnce('ppg-test:my-task') - .mockResolvedValueOnce('ppg-test:my-task-1') - .mockResolvedValueOnce('ppg-test:my-task-2'); + test('given all options, should pass them to spawnNewWorktree', async () => { + const { spawnNewWorktree } = await import('../../core/spawn.js'); - const res = await postSpawn(app, { + await postSpawn(app, { name: 'my-task', prompt: 'Fix the bug', + agent: 'codex', + base: 'develop', count: 3, + vars: { ISSUE: '42' }, }); - expect(res.statusCode).toBe(201); - const body = res.json(); - expect(body.agents).toHaveLength(3); - }); - - test('given template name, should load and render template', async () => { - const { loadTemplate } = await import('../../core/template.js'); - const { spawnAgent } = await import('../../core/agent.js'); - - const res = await postSpawn(app, { + expect(vi.mocked(spawnNewWorktree)).toHaveBeenCalledWith({ + projectRoot: PROJECT_ROOT, name: 'my-task', - template: 'review', - }); - - expect(res.statusCode).toBe(201); - expect(vi.mocked(loadTemplate)).toHaveBeenCalledWith('/fake/project', 'review'); - // renderTemplate is called with the loaded template content - const spawnCall = vi.mocked(spawnAgent).mock.calls[0][0]; - expect(spawnCall.prompt).toContain('my-task'); - expect(spawnCall.prompt).toContain('ppg/my-task'); - }); - - test('given template with vars, should substitute variables', async () => { - const { loadTemplate, renderTemplate } = await import('../../core/template.js'); - vi.mocked(loadTemplate).mockResolvedValueOnce('Fix {{ISSUE}} on {{REPO}}'); - - const res = await postSpawn(app, { - name: 'my-task', - template: 'fix-issue', - vars: { ISSUE: '#42', REPO: 'ppg-cli' }, + promptText: 'Fix the bug', + userVars: { ISSUE: '42' }, + agentName: 'codex', + baseBranch: 'develop', + count: 3, }); - - expect(res.statusCode).toBe(201); - // renderTemplate receives user vars merged into context - const renderCall = vi.mocked(renderTemplate).mock.calls[0]; - const ctx = renderCall[1]; - expect(ctx.ISSUE).toBe('#42'); - expect(ctx.REPO).toBe('ppg-cli'); }); - test('given agent type, should resolve that agent config', async () => { - const { resolveAgentConfig } = await import('../../core/config.js'); + test('given template name, should resolve prompt via resolvePromptText', async () => { + const { resolvePromptText } = await import('../../core/spawn.js'); await postSpawn(app, { name: 'my-task', - prompt: 'Do the thing', - agent: 'codex', + template: 'review', }); - expect(vi.mocked(resolveAgentConfig)).toHaveBeenCalledWith( - expect.objectContaining({ defaultAgent: 'claude' }), - 'codex', + expect(vi.mocked(resolvePromptText)).toHaveBeenCalledWith( + { name: 'my-task', template: 'review' }, + PROJECT_ROOT, ); }); - test('given base branch, should use it instead of current branch', async () => { - const { createWorktree } = await import('../../core/worktree.js'); + test('given prompt and template both provided, should use prompt (prompt wins)', async () => { + const { resolvePromptText } = await import('../../core/spawn.js'); await postSpawn(app, { name: 'my-task', - prompt: 'Fix it', - base: 'develop', + prompt: 'Inline prompt', + template: 'review', }); - expect(vi.mocked(createWorktree)).toHaveBeenCalledWith( - '/fake/project', - 'wt-abc123', - { branch: 'ppg/my-task', base: 'develop' }, + // resolvePromptText receives both — its implementation short-circuits on prompt + expect(vi.mocked(resolvePromptText)).toHaveBeenCalledWith( + expect.objectContaining({ prompt: 'Inline prompt', template: 'review' }), + PROJECT_ROOT, ); }); - test('given no base, should default to current branch', async () => { - const { createWorktree } = await import('../../core/worktree.js'); + test('given no vars, should pass undefined userVars', async () => { + const { spawnNewWorktree } = await import('../../core/spawn.js'); await postSpawn(app, { name: 'my-task', prompt: 'Fix it', }); - expect(vi.mocked(createWorktree)).toHaveBeenCalledWith( - '/fake/project', - 'wt-abc123', - { branch: 'ppg/my-task', base: 'main' }, + expect(vi.mocked(spawnNewWorktree)).toHaveBeenCalledWith( + expect.objectContaining({ userVars: undefined }), ); }); @@ -264,17 +163,6 @@ describe('POST /api/spawn', () => { expect(res.statusCode).toBe(400); }); - test('given neither prompt nor template, should return 500 with INVALID_ARGS', async () => { - const res = await postSpawn(app, { - name: 'my-task', - }); - - // PpgError with INVALID_ARGS is thrown — Fastify returns 500 without a custom error handler - expect(res.statusCode).toBe(500); - const body = res.json<{ message: string }>(); - expect(body.message).toMatch(/prompt.*template/i); - }); - test('given count below 1, should return 400', async () => { const res = await postSpawn(app, { name: 'my-task', @@ -305,58 +193,141 @@ describe('POST /api/spawn', () => { expect(res.statusCode).toBe(400); }); - test('given unknown property, should strip it and succeed', async () => { - const res = await app.inject({ - method: 'POST', - url: '/api/spawn', - payload: { - name: 'my-task', - prompt: 'Fix the bug', - unknown: 'value', - }, + // ─── Input Sanitization ───────────────────────────────────────────────────── + + test('given vars with shell metacharacters in value, should return 500 INVALID_ARGS', async () => { + const res = await postSpawn(app, { + name: 'my-task', + prompt: 'Fix the bug', + vars: { ISSUE: '$(whoami)' }, }); - // Fastify with additionalProperties:false removes unknown props by default - expect(res.statusCode).toBe(201); + expect(res.statusCode).toBe(500); + const body = res.json<{ message: string }>(); + expect(body.message).toMatch(/shell metacharacters/i); }); - // ─── Manifest Updates ─────────────────────────────────────────────────────── + test('given vars with shell metacharacters in key, should return 500 INVALID_ARGS', async () => { + const res = await postSpawn(app, { + name: 'my-task', + prompt: 'Fix the bug', + vars: { 'KEY;rm': 'value' }, + }); - test('should register worktree in manifest before spawning agents', async () => { - const { updateManifest } = await import('../../core/manifest.js'); + expect(res.statusCode).toBe(500); + const body = res.json<{ message: string }>(); + expect(body.message).toMatch(/shell metacharacters/i); + }); - await postSpawn(app, { + test('given vars with backtick in value, should reject', async () => { + const res = await postSpawn(app, { name: 'my-task', prompt: 'Fix the bug', + vars: { CMD: '`whoami`' }, }); - // First call registers worktree skeleton, second adds the agent - expect(vi.mocked(updateManifest)).toHaveBeenCalledTimes(2); + expect(res.statusCode).toBe(500); + const body = res.json<{ message: string }>(); + expect(body.message).toMatch(/shell metacharacters/i); }); - test('should setup worktree env', async () => { - const { setupWorktreeEnv } = await import('../../core/env.js'); + test('given safe vars, should pass through', async () => { + const { spawnNewWorktree } = await import('../../core/spawn.js'); - await postSpawn(app, { + const res = await postSpawn(app, { name: 'my-task', prompt: 'Fix the bug', + vars: { ISSUE: '42', REPO: 'ppg-cli', TAG: 'v1.0.0' }, }); - expect(vi.mocked(setupWorktreeEnv)).toHaveBeenCalledWith( - '/fake/project', - '/fake/project/.worktrees/wt-abc123', - expect.objectContaining({ sessionName: 'ppg' }), + expect(res.statusCode).toBe(201); + expect(vi.mocked(spawnNewWorktree)).toHaveBeenCalledWith( + expect.objectContaining({ + userVars: { ISSUE: '42', REPO: 'ppg-cli', TAG: 'v1.0.0' }, + }), ); }); - test('should ensure tmux session exists', async () => { - const { ensureSession } = await import('../../core/tmux.js'); + // ─── Error Paths ──────────────────────────────────────────────────────────── + + test('given neither prompt nor template, should return 500 with INVALID_ARGS', async () => { + const { resolvePromptText } = await import('../../core/spawn.js'); + const { PpgError } = await import('../../lib/errors.js'); + vi.mocked(resolvePromptText).mockRejectedValueOnce( + new PpgError('Either "prompt" or "template" is required', 'INVALID_ARGS'), + ); + + const res = await postSpawn(app, { + name: 'my-task', + }); + + // PpgError thrown — Fastify returns 500 without a custom error handler + // (the error handler from issue-66 would map INVALID_ARGS to 400) + expect(res.statusCode).toBe(500); + const body = res.json<{ message: string }>(); + expect(body.message).toMatch(/prompt.*template/i); + }); + + test('given unknown agent type, should propagate error', async () => { + const { spawnNewWorktree } = await import('../../core/spawn.js'); + vi.mocked(spawnNewWorktree).mockRejectedValueOnce( + new Error('Unknown agent type: gpt. Available: claude, codex'), + ); + + const res = await postSpawn(app, { + name: 'my-task', + prompt: 'Fix it', + agent: 'gpt', + }); + + expect(res.statusCode).toBe(500); + const body = res.json<{ message: string }>(); + expect(body.message).toMatch(/Unknown agent type/); + }); + + test('given template not found, should propagate error', async () => { + const { resolvePromptText } = await import('../../core/spawn.js'); + vi.mocked(resolvePromptText).mockRejectedValueOnce( + new Error("ENOENT: no such file or directory, open '.ppg/templates/nonexistent.md'"), + ); + + const res = await postSpawn(app, { + name: 'my-task', + template: 'nonexistent', + }); + + expect(res.statusCode).toBe(500); + }); + + test('given tmux not available, should propagate TmuxNotFoundError', async () => { + const { spawnNewWorktree } = await import('../../core/spawn.js'); + const { PpgError } = await import('../../lib/errors.js'); + vi.mocked(spawnNewWorktree).mockRejectedValueOnce( + new PpgError('tmux is not installed or not in PATH', 'TMUX_NOT_FOUND'), + ); + + const res = await postSpawn(app, { + name: 'my-task', + prompt: 'Fix it', + }); + + expect(res.statusCode).toBe(500); + const body = res.json<{ message: string }>(); + expect(body.message).toMatch(/tmux/i); + }); + + // ─── projectRoot Injection ────────────────────────────────────────────────── + + test('should use injected projectRoot, not process.cwd()', async () => { + const { spawnNewWorktree } = await import('../../core/spawn.js'); await postSpawn(app, { name: 'my-task', - prompt: 'Fix the bug', + prompt: 'Fix it', }); - expect(vi.mocked(ensureSession)).toHaveBeenCalledWith('ppg-test'); + expect(vi.mocked(spawnNewWorktree)).toHaveBeenCalledWith( + expect.objectContaining({ projectRoot: '/fake/project' }), + ); }); }); diff --git a/src/server/routes/spawn.ts b/src/server/routes/spawn.ts index 50cca17..5140b17 100644 --- a/src/server/routes/spawn.ts +++ b/src/server/routes/spawn.ts @@ -1,15 +1,6 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import { loadConfig, resolveAgentConfig } from '../../core/config.js'; -import { readManifest, updateManifest } from '../../core/manifest.js'; -import { getRepoRoot, getCurrentBranch, createWorktree } from '../../core/worktree.js'; -import { setupWorktreeEnv } from '../../core/env.js'; -import { loadTemplate, renderTemplate, type TemplateContext } from '../../core/template.js'; -import { spawnAgent } from '../../core/agent.js'; -import * as tmux from '../../core/tmux.js'; -import { worktreeId as genWorktreeId, agentId as genAgentId, sessionId as genSessionId } from '../../lib/id.js'; +import { spawnNewWorktree, resolvePromptText } from '../../core/spawn.js'; import { PpgError } from '../../lib/errors.js'; -import { normalizeName } from '../../lib/name.js'; -import type { WorktreeEntry, AgentEntry } from '../../types/manifest.js'; export interface SpawnRequestBody { name: string; @@ -50,23 +41,36 @@ const spawnBodySchema = { additionalProperties: false, }; -async function resolvePrompt( - body: SpawnRequestBody, - projectRoot: string, -): Promise { - if (body.prompt) return body.prompt; - - if (body.template) { - return loadTemplate(projectRoot, body.template); +// Shell metacharacters that could be injected via tmux send-keys +const SHELL_META_RE = /[`$\\!;|&()<>{}[\]"'\n\r]/; + +function validateVars(vars: Record): void { + for (const [key, value] of Object.entries(vars)) { + if (SHELL_META_RE.test(key)) { + throw new PpgError( + `Var key "${key}" contains shell metacharacters`, + 'INVALID_ARGS', + ); + } + if (SHELL_META_RE.test(value)) { + throw new PpgError( + `Var value for "${key}" contains shell metacharacters`, + 'INVALID_ARGS', + ); + } } +} - throw new PpgError( - 'Either "prompt" or "template" is required', - 'INVALID_ARGS', - ); +export interface SpawnRouteOptions { + projectRoot: string; } -export default async function spawnRoute(app: FastifyInstance): Promise { +export default async function spawnRoute( + app: FastifyInstance, + opts: SpawnRouteOptions, +): Promise { + const { projectRoot } = opts; + app.post( '/api/spawn', { schema: { body: spawnBodySchema } }, @@ -75,108 +79,32 @@ export default async function spawnRoute(app: FastifyInstance): Promise { reply: FastifyReply, ) => { const body = request.body; - const projectRoot = await getRepoRoot(); - const config = await loadConfig(projectRoot); - const agentConfig = resolveAgentConfig(config, body.agent); - const count = body.count ?? 1; - const userVars = body.vars ?? {}; - - const promptText = await resolvePrompt(body, projectRoot); - - const baseBranch = body.base ?? await getCurrentBranch(projectRoot); - const wtId = genWorktreeId(); - const name = normalizeName(body.name, wtId); - const branchName = `ppg/${name}`; - - // Create git worktree - const wtPath = await createWorktree(projectRoot, wtId, { - branch: branchName, - base: baseBranch, - }); - - // Setup env (copy .env, symlink node_modules) - await setupWorktreeEnv(projectRoot, wtPath, config); - - // Ensure tmux session - const manifest = await readManifest(projectRoot); - const sessionName = manifest.sessionName; - await tmux.ensureSession(sessionName); - // Create tmux window - const windowTarget = await tmux.createWindow(sessionName, name, wtPath); + // Validate vars for shell safety before any side effects + if (body.vars) { + validateVars(body.vars); + } - // Register worktree in manifest - const worktreeEntry: WorktreeEntry = { - id: wtId, - name, - path: wtPath, - branch: branchName, - baseBranch, - status: 'active', - tmuxWindow: windowTarget, - agents: {}, - createdAt: new Date().toISOString(), - }; + const promptText = await resolvePromptText(body, projectRoot); - await updateManifest(projectRoot, (m) => { - m.worktrees[wtId] = worktreeEntry; - return m; + const result = await spawnNewWorktree({ + projectRoot, + name: body.name, + promptText, + userVars: body.vars, + agentName: body.agent, + baseBranch: body.base, + count: body.count, }); - // Spawn agents - const agents: AgentEntry[] = []; - for (let i = 0; i < count; i++) { - const aId = genAgentId(); - - // For count > 1, create additional windows - let target = windowTarget; - if (i > 0) { - target = await tmux.createWindow( - sessionName, - `${name}-${i}`, - wtPath, - ); - } - - const ctx: TemplateContext = { - WORKTREE_PATH: wtPath, - BRANCH: branchName, - AGENT_ID: aId, - PROJECT_ROOT: projectRoot, - TASK_NAME: name, - PROMPT: promptText, - ...userVars, - }; - - const agentEntry = await spawnAgent({ - agentId: aId, - agentConfig, - prompt: renderTemplate(promptText, ctx), - worktreePath: wtPath, - tmuxTarget: target, - projectRoot, - branch: branchName, - sessionId: genSessionId(), - }); - - agents.push(agentEntry); - - await updateManifest(projectRoot, (m) => { - if (m.worktrees[wtId]) { - m.worktrees[wtId].agents[agentEntry.id] = agentEntry; - } - return m; - }); - } - const response: SpawnResponseBody = { - worktreeId: wtId, - name, - branch: branchName, - agents: agents.map((a) => ({ + worktreeId: result.worktreeId, + name: result.name, + branch: result.branch, + agents: result.agents.map((a) => ({ id: a.id, tmuxTarget: a.tmuxTarget, - ...(a.sessionId ? { sessionId: a.sessionId } : {}), + sessionId: a.sessionId, })), }; From 13f3a474c91bf4be0bc24854041d3d62d76ef203 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 08:37:57 -0600 Subject: [PATCH 3/3] Fix spawn route error mapping and preflight manifest check --- src/core/spawn.ts | 6 +-- src/server/routes/spawn.test.ts | 48 ++++++++++++++------ src/server/routes/spawn.ts | 79 ++++++++++++++++++++++----------- 3 files changed, 90 insertions(+), 43 deletions(-) diff --git a/src/core/spawn.ts b/src/core/spawn.ts index a827901..16680b7 100644 --- a/src/core/spawn.ts +++ b/src/core/spawn.ts @@ -1,5 +1,5 @@ import { loadConfig, resolveAgentConfig } from './config.js'; -import { readManifest, updateManifest } from './manifest.js'; +import { requireManifest, updateManifest } from './manifest.js'; import { getCurrentBranch, createWorktree } from './worktree.js'; import { setupWorktreeEnv } from './env.js'; import { loadTemplate, renderTemplate, type TemplateContext } from './template.js'; @@ -126,6 +126,8 @@ export async function spawnNewWorktree( const agentConfig = resolveAgentConfig(config, opts.agentName); const count = opts.count ?? 1; const userVars = opts.userVars ?? {}; + const manifest = await requireManifest(projectRoot); + const sessionName = manifest.sessionName; const baseBranch = opts.baseBranch ?? await getCurrentBranch(projectRoot); const wtId = genWorktreeId(); @@ -142,8 +144,6 @@ export async function spawnNewWorktree( await setupWorktreeEnv(projectRoot, wtPath, config); // Ensure tmux session (manifest is the source of truth for session name) - const manifest = await readManifest(projectRoot); - const sessionName = manifest.sessionName; await tmux.ensureSession(sessionName); // Create tmux window diff --git a/src/server/routes/spawn.test.ts b/src/server/routes/spawn.test.ts index 9b5e8b5..c84fc82 100644 --- a/src/server/routes/spawn.test.ts +++ b/src/server/routes/spawn.test.ts @@ -108,7 +108,7 @@ describe('POST /api/spawn', () => { }); expect(vi.mocked(resolvePromptText)).toHaveBeenCalledWith( - { name: 'my-task', template: 'review' }, + { prompt: undefined, template: 'review' }, PROJECT_ROOT, ); }); @@ -195,28 +195,30 @@ describe('POST /api/spawn', () => { // ─── Input Sanitization ───────────────────────────────────────────────────── - test('given vars with shell metacharacters in value, should return 500 INVALID_ARGS', async () => { + test('given vars with shell metacharacters in value, should return 400 INVALID_ARGS', async () => { const res = await postSpawn(app, { name: 'my-task', prompt: 'Fix the bug', vars: { ISSUE: '$(whoami)' }, }); - expect(res.statusCode).toBe(500); - const body = res.json<{ message: string }>(); + expect(res.statusCode).toBe(400); + const body = res.json<{ message: string; code: string }>(); expect(body.message).toMatch(/shell metacharacters/i); + expect(body.code).toBe('INVALID_ARGS'); }); - test('given vars with shell metacharacters in key, should return 500 INVALID_ARGS', async () => { + test('given vars with shell metacharacters in key, should return 400 INVALID_ARGS', async () => { const res = await postSpawn(app, { name: 'my-task', prompt: 'Fix the bug', vars: { 'KEY;rm': 'value' }, }); - expect(res.statusCode).toBe(500); - const body = res.json<{ message: string }>(); + expect(res.statusCode).toBe(400); + const body = res.json<{ message: string; code: string }>(); expect(body.message).toMatch(/shell metacharacters/i); + expect(body.code).toBe('INVALID_ARGS'); }); test('given vars with backtick in value, should reject', async () => { @@ -226,9 +228,10 @@ describe('POST /api/spawn', () => { vars: { CMD: '`whoami`' }, }); - expect(res.statusCode).toBe(500); - const body = res.json<{ message: string }>(); + expect(res.statusCode).toBe(400); + const body = res.json<{ message: string; code: string }>(); expect(body.message).toMatch(/shell metacharacters/i); + expect(body.code).toBe('INVALID_ARGS'); }); test('given safe vars, should pass through', async () => { @@ -250,7 +253,7 @@ describe('POST /api/spawn', () => { // ─── Error Paths ──────────────────────────────────────────────────────────── - test('given neither prompt nor template, should return 500 with INVALID_ARGS', async () => { + test('given neither prompt nor template, should return 400 with INVALID_ARGS', async () => { const { resolvePromptText } = await import('../../core/spawn.js'); const { PpgError } = await import('../../lib/errors.js'); vi.mocked(resolvePromptText).mockRejectedValueOnce( @@ -261,11 +264,10 @@ describe('POST /api/spawn', () => { name: 'my-task', }); - // PpgError thrown — Fastify returns 500 without a custom error handler - // (the error handler from issue-66 would map INVALID_ARGS to 400) - expect(res.statusCode).toBe(500); - const body = res.json<{ message: string }>(); + expect(res.statusCode).toBe(400); + const body = res.json<{ message: string; code: string }>(); expect(body.message).toMatch(/prompt.*template/i); + expect(body.code).toBe('INVALID_ARGS'); }); test('given unknown agent type, should propagate error', async () => { @@ -299,6 +301,24 @@ describe('POST /api/spawn', () => { expect(res.statusCode).toBe(500); }); + test('given not initialized error, should return 409', async () => { + const { spawnNewWorktree } = await import('../../core/spawn.js'); + const { PpgError } = await import('../../lib/errors.js'); + vi.mocked(spawnNewWorktree).mockRejectedValueOnce( + new PpgError('Point Guard not initialized in /fake/project', 'NOT_INITIALIZED'), + ); + + const res = await postSpawn(app, { + name: 'my-task', + prompt: 'Fix it', + }); + + expect(res.statusCode).toBe(409); + const body = res.json<{ message: string; code: string }>(); + expect(body.message).toMatch(/not initialized/i); + expect(body.code).toBe('NOT_INITIALIZED'); + }); + test('given tmux not available, should propagate TmuxNotFoundError', async () => { const { spawnNewWorktree } = await import('../../core/spawn.js'); const { PpgError } = await import('../../lib/errors.js'); diff --git a/src/server/routes/spawn.ts b/src/server/routes/spawn.ts index 5140b17..587a17d 100644 --- a/src/server/routes/spawn.ts +++ b/src/server/routes/spawn.ts @@ -61,6 +61,17 @@ function validateVars(vars: Record): void { } } +function statusForPpgError(code: string): number { + switch (code) { + case 'INVALID_ARGS': + return 400; + case 'NOT_INITIALIZED': + return 409; + default: + return 500; + } +} + export interface SpawnRouteOptions { projectRoot: string; } @@ -78,37 +89,53 @@ export default async function spawnRoute( request: FastifyRequest<{ Body: SpawnRequestBody }>, reply: FastifyReply, ) => { - const body = request.body; + try { + const body = request.body; - // Validate vars for shell safety before any side effects - if (body.vars) { - validateVars(body.vars); - } + // Validate vars for shell safety before any side effects + if (body.vars) { + validateVars(body.vars); + } + + const promptText = await resolvePromptText( + { prompt: body.prompt, template: body.template }, + projectRoot, + ); - const promptText = await resolvePromptText(body, projectRoot); + const result = await spawnNewWorktree({ + projectRoot, + name: body.name, + promptText, + userVars: body.vars, + agentName: body.agent, + baseBranch: body.base, + count: body.count, + }); - const result = await spawnNewWorktree({ - projectRoot, - name: body.name, - promptText, - userVars: body.vars, - agentName: body.agent, - baseBranch: body.base, - count: body.count, - }); + const response: SpawnResponseBody = { + worktreeId: result.worktreeId, + name: result.name, + branch: result.branch, + agents: result.agents.map((a) => ({ + id: a.id, + tmuxTarget: a.tmuxTarget, + sessionId: a.sessionId, + })), + }; - const response: SpawnResponseBody = { - worktreeId: result.worktreeId, - name: result.name, - branch: result.branch, - agents: result.agents.map((a) => ({ - id: a.id, - tmuxTarget: a.tmuxTarget, - sessionId: a.sessionId, - })), - }; + return reply.status(201).send(response); + } catch (err) { + if (err instanceof PpgError) { + return reply.status(statusForPpgError(err.code)).send({ + message: err.message, + code: err.code, + }); + } - return reply.status(201).send(response); + return reply.status(500).send({ + message: err instanceof Error ? err.message : 'Internal server error', + }); + } }, ); }