Files
Codewalkers/apps/server/trpc/routers/errand.ts
Lukas May b17c0a2b4f fix: resolve errand worktree path for sendMessage instead of using agent-workdirs
Errand agents are spawned in the errand's git worktree (managed by
SimpleGitWorktreeManager), not in agent-workdirs/<alias>/.
sendUserMessage was deriving the cwd from worktreeId which pointed to
the non-existent agent-workdirs path. Now the errand.sendMessage
procedure resolves the actual worktree path and passes it through.
2026-03-06 23:11:55 +01:00

554 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<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 (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 };
}),
}),
};
}