/** * Errand Router * * All 10 errand procedures: create, list, get, diff, complete, merge, delete, sendMessage, abandon, requestChanges. * Errands are small isolated changes that spawn a dedicated agent in a git worktree. */ import { z } from 'zod'; import { TRPCError } from '@trpc/server'; import { nanoid } from 'nanoid'; import { router } from '../trpc.js'; import type { ProcedureBuilder } from '../trpc.js'; import { requireErrandRepository, requireProjectRepository, requireAgentManager, requireBranchManager, } from './_helpers.js'; import { writeErrandManifest } from '../../agent/file-io.js'; import { buildErrandPrompt, buildErrandRevisionPrompt } from '../../agent/prompts/index.js'; import { join } from 'node:path'; import { existsSync, rmSync } from 'node:fs'; import { SimpleGitWorktreeManager } from '../../git/manager.js'; import { ensureProjectClone, getProjectCloneDir } from '../../git/project-clones.js'; import type { TRPCContext } from '../context.js'; // ErrandStatus values for input validation const ErrandStatusValues = ['active', 'pending_review', 'conflict', 'merged', 'abandoned'] as const; /** * Resolve the project's local clone path. * Throws INTERNAL_SERVER_ERROR if workspaceRoot is not available. */ async function resolveClonePath( project: { id: string; name: string; url: string }, ctx: TRPCContext, ): Promise { if (!ctx.workspaceRoot) { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Workspace root not configured', }); } return ensureProjectClone(project, ctx.workspaceRoot); } export function errandProcedures(publicProcedure: ProcedureBuilder) { return { errand: router({ // ----------------------------------------------------------------------- // errand.create // ----------------------------------------------------------------------- create: publicProcedure .input(z.object({ description: z.string(), projectId: z.string().min(1), baseBranch: z.string().optional(), })) .mutation(async ({ ctx, input }) => { // 1. Validate description length if (input.description.length > 200) { throw new TRPCError({ code: 'BAD_REQUEST', message: `description must be ≤200 characters (${input.description.length} given)`, }); } // 2. Look up project const project = await requireProjectRepository(ctx).findById(input.projectId); if (!project) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' }); } // 3. Generate slug let slug = input.description .toLowerCase() .replace(/\s+/g, '-') .replace(/[^a-z0-9-]/g, '') .slice(0, 50); if (!slug) slug = 'errand'; // 4–5. Compute branch name with unique suffix const branchName = `cw/errand/${slug}-${nanoid().slice(0, 8)}`; // 6. Resolve base branch (respect project's default branch) const baseBranch = input.baseBranch ?? project.defaultBranch; // 7. Get project clone path and create branch const clonePath = await resolveClonePath(project, ctx); const branchManager = requireBranchManager(ctx); try { await branchManager.ensureBranch(clonePath, branchName, baseBranch); } catch (err) { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: err instanceof Error ? err.message : String(err), }); } // 7.5. Create DB record early (agentId null) to get a stable ID for the worktree const repo = requireErrandRepository(ctx); let errand; try { errand = await repo.create({ id: nanoid(), description: input.description, branch: branchName, baseBranch, agentId: null, projectId: input.projectId, status: 'active', }); } catch (err) { try { await branchManager.deleteBranch(clonePath, branchName); } catch { /* no-op */ } throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: err instanceof Error ? err.message : String(err), }); } const errandId = errand.id; // 8. Create worktree using the DB-assigned errand ID const worktreeManager = new SimpleGitWorktreeManager(clonePath); let worktree; try { worktree = await worktreeManager.create(errandId, branchName, baseBranch); } catch (err) { // Clean up DB record and branch on worktree failure try { await repo.delete(errandId); } catch { /* no-op */ } try { await branchManager.deleteBranch(clonePath, branchName); } catch { /* no-op */ } throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: err instanceof Error ? err.message : String(err), }); } // 9. Build prompt const prompt = buildErrandPrompt(input.description); // 10. Spawn agent const agentManager = requireAgentManager(ctx); let agent; try { agent = await agentManager.spawn({ prompt, mode: 'errand', cwd: worktree.path, provider: undefined, }); } catch (err) { // Clean up worktree, DB record, and branch on spawn failure try { await worktreeManager.remove(errandId); } catch { /* no-op */ } try { await repo.delete(errandId); } catch { /* no-op */ } try { await branchManager.deleteBranch(clonePath, branchName); } catch { /* no-op */ } throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: err instanceof Error ? err.message : String(err), }); } // 11. Write errand manifest files await writeErrandManifest({ agentWorkdir: worktree.path, errandId, description: input.description, branch: branchName, projectName: project.name, agentId: agent.id, agentName: agent.name, }); // 12. Update DB record with agent ID await repo.update(errandId, { agentId: agent.id }); // 13. Return result return { id: errandId, branch: branchName, agentId: agent.id }; }), // ----------------------------------------------------------------------- // errand.list // ----------------------------------------------------------------------- list: publicProcedure .input(z.object({ projectId: z.string().optional(), status: z.enum(ErrandStatusValues).optional(), }).optional()) .query(async ({ ctx, input }) => { return requireErrandRepository(ctx).findAll({ projectId: input?.projectId, status: input?.status, }); }), // ----------------------------------------------------------------------- // errand.get // ----------------------------------------------------------------------- get: publicProcedure .input(z.object({ id: z.string().min(1) })) .query(async ({ ctx, input }) => { const errand = await requireErrandRepository(ctx).findById(input.id); if (!errand) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Errand not found' }); } // Compute project clone path for cw errand resolve let projectPath: string | null = null; if (errand.projectId && ctx.workspaceRoot) { const project = await requireProjectRepository(ctx).findById(errand.projectId); if (project) { projectPath = join(ctx.workspaceRoot, getProjectCloneDir(project.name, project.id)); } } return { ...errand, projectPath }; }), // ----------------------------------------------------------------------- // errand.diff // ----------------------------------------------------------------------- diff: publicProcedure .input(z.object({ id: z.string().min(1) })) .query(async ({ ctx, input }) => { const errand = await requireErrandRepository(ctx).findById(input.id); if (!errand) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Errand not found' }); } if (!errand.projectId) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Errand has no project' }); } const project = await requireProjectRepository(ctx).findById(errand.projectId); if (!project) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' }); } const clonePath = await resolveClonePath(project, ctx); const diff = await requireBranchManager(ctx).diffBranches( clonePath, errand.baseBranch, errand.branch, ); return { diff }; }), // ----------------------------------------------------------------------- // errand.complete // ----------------------------------------------------------------------- complete: publicProcedure .input(z.object({ id: z.string().min(1) })) .mutation(async ({ ctx, input }) => { const repo = requireErrandRepository(ctx); const errand = await repo.findById(input.id); if (!errand) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Errand not found' }); } if (errand.status !== 'active') { throw new TRPCError({ code: 'BAD_REQUEST', message: `Cannot complete an errand with status '${errand.status}'`, }); } // Stop agent if present if (errand.agentId) { try { await requireAgentManager(ctx).stop(errand.agentId); } catch { /* no-op if already stopped */ } } const updated = await repo.update(input.id, { status: 'pending_review' }); return updated; }), // ----------------------------------------------------------------------- // errand.merge // ----------------------------------------------------------------------- merge: publicProcedure .input(z.object({ id: z.string().min(1), target: z.string().optional(), })) .mutation(async ({ ctx, input }) => { const repo = requireErrandRepository(ctx); const errand = await repo.findById(input.id); if (!errand) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Errand not found' }); } if (errand.status !== 'pending_review' && errand.status !== 'conflict') { throw new TRPCError({ code: 'BAD_REQUEST', message: `Cannot merge an errand with status '${errand.status}'`, }); } const targetBranch = input.target ?? errand.baseBranch; if (!errand.projectId) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Errand has no project' }); } const project = await requireProjectRepository(ctx).findById(errand.projectId); if (!project) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' }); } const clonePath = await resolveClonePath(project, ctx); const result = await requireBranchManager(ctx).mergeBranch( clonePath, errand.branch, targetBranch, ); if (result.success) { // Clean merge — remove worktree and mark merged const worktreeManager = new SimpleGitWorktreeManager(clonePath); try { await worktreeManager.remove(errand.id); } catch { /* no-op */ } await repo.update(input.id, { status: 'merged' }); return { status: 'merged' }; } else { // Conflict — update status and throw const conflictFilesList = result.conflicts ?? []; await repo.update(input.id, { status: 'conflict' }); throw new TRPCError({ code: 'BAD_REQUEST', message: `Merge conflict in ${conflictFilesList.length} file(s)`, cause: { conflictFiles: conflictFilesList }, }); } }), // ----------------------------------------------------------------------- // errand.delete // ----------------------------------------------------------------------- delete: publicProcedure .input(z.object({ id: z.string().min(1) })) .mutation(async ({ ctx, input }) => { const repo = requireErrandRepository(ctx); const errand = await repo.findById(input.id); if (!errand) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Errand not found' }); } const agentManager = requireAgentManager(ctx); // Stop agent if active if (errand.status === 'active' && errand.agentId) { try { await agentManager.stop(errand.agentId); } catch { /* no-op */ } } // Remove worktree and branch (best-effort) if (errand.projectId) { const project = await requireProjectRepository(ctx).findById(errand.projectId); if (project) { const clonePath = await resolveClonePath(project, ctx); const worktreeManager = new SimpleGitWorktreeManager(clonePath); try { await worktreeManager.remove(errand.id); } catch { /* no-op if already gone */ } try { await requireBranchManager(ctx).deleteBranch(clonePath, errand.branch); } catch { /* no-op */ } } } await repo.delete(errand.id); return { success: true }; }), // ----------------------------------------------------------------------- // errand.sendMessage // ----------------------------------------------------------------------- sendMessage: publicProcedure .input(z.object({ id: z.string().min(1), message: z.string().min(1), })) .mutation(async ({ ctx, input }) => { const errand = await requireErrandRepository(ctx).findById(input.id); if (!errand) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Errand not found' }); } if (errand.status !== 'active') { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Errand is not active' }); } if (!errand.agentId) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Errand has no associated agent' }); } const agentManager = requireAgentManager(ctx); const agent = await agentManager.get(errand.agentId); if (!agent || agent.status === 'stopped' || agent.status === 'crashed') { const status = agent?.status ?? 'unknown'; throw new TRPCError({ code: 'BAD_REQUEST', message: `Agent is not running (status: ${status})`, }); } // Resolve errand worktree path — errand agents don't use agent-workdirs/ let worktreePath: string | undefined; if (errand.projectId) { const project = await requireProjectRepository(ctx).findById(errand.projectId); if (project) { try { const clonePath = await resolveClonePath(project, ctx); const wm = new SimpleGitWorktreeManager(clonePath); const wt = await wm.get(errand.id); if (wt) worktreePath = wt.path; } catch { // Fall through — sendUserMessage will use default path } } } await agentManager.sendUserMessage(errand.agentId, input.message, worktreePath); return { success: true }; }), // ----------------------------------------------------------------------- // errand.abandon // ----------------------------------------------------------------------- abandon: publicProcedure .input(z.object({ id: z.string().min(1) })) .mutation(async ({ ctx, input }) => { const repo = requireErrandRepository(ctx); const errand = await repo.findById(input.id); if (!errand) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Errand not found' }); } if (errand.status === 'merged' || errand.status === 'abandoned') { throw new TRPCError({ code: 'BAD_REQUEST', message: `Cannot abandon an errand with status '${errand.status}'`, }); } const agentManager = requireAgentManager(ctx); const branchManager = requireBranchManager(ctx); // Stop agent if active if (errand.status === 'active' && errand.agentId) { try { await agentManager.stop(errand.agentId); } catch { /* no-op */ } } // Remove worktree and branch (best-effort) if (errand.projectId) { const project = await requireProjectRepository(ctx).findById(errand.projectId); if (project) { const clonePath = await resolveClonePath(project, ctx); const worktreeManager = new SimpleGitWorktreeManager(clonePath); try { await worktreeManager.remove(errand.id); } catch { /* no-op if already gone */ } try { await branchManager.deleteBranch(clonePath, errand.branch); } catch { /* no-op */ } } } const updated = await repo.update(input.id, { status: 'abandoned' }); return updated; }), // ----------------------------------------------------------------------- // errand.requestChanges // ----------------------------------------------------------------------- requestChanges: publicProcedure .input(z.object({ id: z.string().min(1), feedback: z.string().min(1), })) .mutation(async ({ ctx, input }) => { const repo = requireErrandRepository(ctx); const errand = await repo.findById(input.id); if (!errand) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Errand not found' }); } if (errand.status !== 'pending_review' && errand.status !== 'conflict') { throw new TRPCError({ code: 'BAD_REQUEST', message: `Cannot request changes on an errand with status '${errand.status}'`, }); } if (!errand.projectId) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Errand has no project' }); } const project = await requireProjectRepository(ctx).findById(errand.projectId); if (!project) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' }); } // Resolve clone path and verify worktree still exists const clonePath = await resolveClonePath(project, ctx); const worktreeManager = new SimpleGitWorktreeManager(clonePath); let worktree; try { worktree = await worktreeManager.get(errand.id); } catch { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Worktree no longer exists — cannot request changes. Delete and re-create the errand.', }); } if (!worktree) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Worktree no longer exists — cannot request changes. Delete and re-create the errand.', }); } // Clean up stale signal.json to prevent false completion detection const signalPath = join(worktree.path, '.cw', 'output', 'signal.json'); if (existsSync(signalPath)) { rmSync(signalPath); } // Build revision prompt and spawn new agent in existing worktree const prompt = buildErrandRevisionPrompt(errand.description, input.feedback); const agentManager = requireAgentManager(ctx); let agent; try { agent = await agentManager.spawn({ prompt, mode: 'errand', cwd: worktree.path, provider: undefined, }); } catch (err) { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: err instanceof Error ? err.message : String(err), }); } // Update manifest files await writeErrandManifest({ agentWorkdir: worktree.path, errandId: errand.id, description: errand.description, branch: errand.branch, projectName: project.name, agentId: agent.id, agentName: agent.name, }); // Transition back to active with new agent await repo.update(errand.id, { status: 'active', agentId: agent.id }); return { id: errand.id, agentId: agent.id }; }), }), }; }