feat: Add errand tRPC router with all 9 procedures and comprehensive tests

Implements the errand workflow for small isolated changes that spawn a
dedicated agent in a git worktree:
- errand.create: branch + worktree + DB record + agent spawn
- errand.list / errand.get / errand.diff: read procedures
- errand.complete: transitions active→pending_review, stops agent
- errand.merge: merges branch, handles conflicts with conflictFiles
- errand.delete / errand.abandon: cleanup worktree, branch, agent
- errand.sendMessage: delivers user message directly to running agent

Supporting changes:
- Add 'errand' to AgentMode union and agents.mode enum
- Add sendUserMessage() to AgentManager interface and MockAgentManager
- MockAgentManager now accepts optional agentRepository to persist agents
  to the DB (required for FK constraint satisfaction in tests)
- Add ORDER BY createdAt DESC, id DESC to errand findAll
- Fix dispatch/manager.test.ts missing sendUserMessage mock

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas May
2026-03-06 16:21:01 +01:00
parent 3a328d2b1c
commit 377e8de5e9
9 changed files with 1226 additions and 7 deletions

View File

@@ -0,0 +1,430 @@
/**
* Errand Router
*
* All 9 errand procedures: create, list, get, diff, complete, merge, delete, sendMessage, abandon.
* 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 } from '../../agent/prompts/index.js';
import { SimpleGitWorktreeManager } from '../../git/manager.js';
import { ensureProjectClone } 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<string> {
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';
// 45. Compute branch name with unique suffix
const branchName = `cw/errand/${slug}-${nanoid().slice(0, 8)}`;
// 6. Resolve base branch
const baseBranch = input.baseBranch ?? 'main';
// 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({
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(),
}))
.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' });
}
return {
...errand,
conflictFiles: errand.conflictFiles ? (JSON.parse(errand.conflictFiles) as string[]) : null,
};
}),
// -----------------------------------------------------------------------
// 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' });
}
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;
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', conflictFiles: null });
return { status: 'merged' };
} else {
// Conflict — persist conflict files and throw
const conflictFilesList = result.conflicts ?? [];
await repo.update(input.id, {
status: 'conflict',
conflictFiles: JSON.stringify(conflictFilesList),
});
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)
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})`,
});
}
await agentManager.sendUserMessage(errand.agentId, input.message);
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)
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;
}),
}),
};
}