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..16680b7 --- /dev/null +++ b/src/core/spawn.ts @@ -0,0 +1,227 @@ +import { loadConfig, resolveAgentConfig } from './config.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'; +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 manifest = await requireManifest(projectRoot); + const sessionName = manifest.sessionName; + + 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) + 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 new file mode 100644 index 0000000..c84fc82 --- /dev/null +++ b/src/server/routes/spawn.test.ts @@ -0,0 +1,353 @@ +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/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', + }, + ], + }), + resolvePromptText: vi.fn().mockResolvedValue('Fix the bug'), +})); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const PROJECT_ROOT = '/fake/project'; + +async function buildApp(): Promise { + const app = Fastify(); + await app.register(spawnRoute, { projectRoot: PROJECT_ROOT }); + 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(); + 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', + 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).toBe('ag-agent001'); + expect(body.agents[0].tmuxTarget).toBe('ppg-test:my-task'); + expect(body.agents[0].sessionId).toBe('sess-uuid-001'); + }); + + test('given all options, should pass them to spawnNewWorktree', async () => { + const { spawnNewWorktree } = await import('../../core/spawn.js'); + + await postSpawn(app, { + name: 'my-task', + prompt: 'Fix the bug', + agent: 'codex', + base: 'develop', + count: 3, + vars: { ISSUE: '42' }, + }); + + expect(vi.mocked(spawnNewWorktree)).toHaveBeenCalledWith({ + projectRoot: PROJECT_ROOT, + name: 'my-task', + promptText: 'Fix the bug', + userVars: { ISSUE: '42' }, + agentName: 'codex', + baseBranch: 'develop', + count: 3, + }); + }); + + test('given template name, should resolve prompt via resolvePromptText', async () => { + const { resolvePromptText } = await import('../../core/spawn.js'); + + await postSpawn(app, { + name: 'my-task', + template: 'review', + }); + + expect(vi.mocked(resolvePromptText)).toHaveBeenCalledWith( + { prompt: undefined, template: 'review' }, + PROJECT_ROOT, + ); + }); + + 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: 'Inline prompt', + template: 'review', + }); + + // 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 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(spawnNewWorktree)).toHaveBeenCalledWith( + expect.objectContaining({ userVars: undefined }), + ); + }); + + // ─── 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 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); + }); + + // ─── Input Sanitization ───────────────────────────────────────────────────── + + 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(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 400 INVALID_ARGS', async () => { + const res = await postSpawn(app, { + name: 'my-task', + prompt: 'Fix the bug', + vars: { 'KEY;rm': 'value' }, + }); + + 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 () => { + const res = await postSpawn(app, { + name: 'my-task', + prompt: 'Fix the bug', + vars: { CMD: '`whoami`' }, + }); + + 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 () => { + const { spawnNewWorktree } = await import('../../core/spawn.js'); + + const res = await postSpawn(app, { + name: 'my-task', + prompt: 'Fix the bug', + vars: { ISSUE: '42', REPO: 'ppg-cli', TAG: 'v1.0.0' }, + }); + + expect(res.statusCode).toBe(201); + expect(vi.mocked(spawnNewWorktree)).toHaveBeenCalledWith( + expect.objectContaining({ + userVars: { ISSUE: '42', REPO: 'ppg-cli', TAG: 'v1.0.0' }, + }), + ); + }); + + // ─── Error Paths ──────────────────────────────────────────────────────────── + + 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( + new PpgError('Either "prompt" or "template" is required', 'INVALID_ARGS'), + ); + + const res = await postSpawn(app, { + name: 'my-task', + }); + + 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 () => { + 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 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'); + 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 it', + }); + + expect(vi.mocked(spawnNewWorktree)).toHaveBeenCalledWith( + expect.objectContaining({ projectRoot: '/fake/project' }), + ); + }); +}); diff --git a/src/server/routes/spawn.ts b/src/server/routes/spawn.ts new file mode 100644 index 0000000..587a17d --- /dev/null +++ b/src/server/routes/spawn.ts @@ -0,0 +1,141 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { spawnNewWorktree, resolvePromptText } from '../../core/spawn.js'; +import { PpgError } from '../../lib/errors.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, +}; + +// 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', + ); + } + } +} + +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; +} + +export default async function spawnRoute( + app: FastifyInstance, + opts: SpawnRouteOptions, +): Promise { + const { projectRoot } = opts; + + app.post( + '/api/spawn', + { schema: { body: spawnBodySchema } }, + async ( + request: FastifyRequest<{ Body: SpawnRequestBody }>, + reply: FastifyReply, + ) => { + try { + const body = request.body; + + // 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 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, + })), + }; + + 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(500).send({ + message: err instanceof Error ? err.message : 'Internal server error', + }); + } + }, + ); +}