Files
Codewalkers/apps/server/trpc/routers/errand.ts
Lukas May c52fa86542 refactor: unify errand worktree paths to use agent-workdirs/<alias>/
Errands now create worktrees via ProcessManager.createWorktreesForProjects()
into agent-workdirs/<alias>/<project.name>/ instead of repos/<project>/.cw-worktrees/<errandId>.
This makes getAgentWorkdir + resolveAgentCwd work correctly for all agent types.

Key changes:
- Extract createWorktreesForProjects() from createProjectWorktrees() in ProcessManager
- Add resolveAgentCwd() to ProcessManager (probes for .cw/output in subdirs)
- Add projectId to SpawnAgentOptions for single-project agents (errands)
- Skip auto-cleanup for errand agents (worktrees persist for merge/abandon)
- Errand router uses agentManager.delete() for cleanup instead of SimpleGitWorktreeManager
- Remove cwd parameter from sendUserMessage (resolves via worktreeId)
- Add pruneProjectRepos() to CleanupManager for errand worktree refs
2026-03-07 00:02:27 +01:00

506 lines
19 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 { 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)
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. Build prompt
const prompt = buildErrandPrompt(input.description);
// 9. Spawn agent — worktree created via projectId in agent-workdirs/<alias>/<project.name>/
const agentManager = requireAgentManager(ctx);
let agent;
try {
agent = await agentManager.spawn({
prompt,
mode: 'errand',
projectId: input.projectId,
branchName,
baseBranch,
});
} catch (err) {
// Clean up DB record and branch on spawn 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),
});
}
// 10. Write errand manifest files
const agentWorkdir = join(ctx.workspaceRoot!, 'agent-workdirs', agent.name, project.name);
await writeErrandManifest({
agentWorkdir,
errandId,
description: input.description,
branch: branchName,
projectName: project.name,
agentId: agent.id,
agentName: agent.name,
});
// 11. Update DB record with agent ID
await repo.update(errandId, { agentId: agent.id });
// 12. 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 — delete agent (removes worktree) and mark merged
const agentManager = requireAgentManager(ctx);
if (errand.agentId) {
try { await agentManager.delete(errand.agentId); } 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 and delete agent (removes worktree + logs)
if (errand.agentId) {
try { await agentManager.stop(errand.agentId); } catch { /* no-op */ }
try { await agentManager.delete(errand.agentId); } catch { /* no-op */ }
}
// Delete branch (agent doesn't own the branch name)
if (errand.projectId) {
const project = await requireProjectRepository(ctx).findById(errand.projectId);
if (project) {
const clonePath = await resolveClonePath(project, ctx);
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 and delete agent (removes worktree + logs)
if (errand.agentId) {
try { await agentManager.stop(errand.agentId); } catch { /* no-op */ }
try { await agentManager.delete(errand.agentId); } catch { /* no-op */ }
}
// Delete branch
if (errand.projectId) {
const project = await requireProjectRepository(ctx).findById(errand.projectId);
if (project) {
const clonePath = await resolveClonePath(project, ctx);
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' });
}
const agentManager = requireAgentManager(ctx);
// Stop and delete old agent — committed work is preserved on the branch
if (errand.agentId) {
try { await agentManager.stop(errand.agentId); } catch { /* no-op */ }
try { await agentManager.delete(errand.agentId); } catch { /* no-op */ }
}
// Spawn fresh agent on the same branch (all committed work is preserved)
const prompt = buildErrandRevisionPrompt(errand.description, input.feedback);
let agent;
try {
agent = await agentManager.spawn({
prompt,
mode: 'errand',
projectId: errand.projectId!,
branchName: errand.branch,
baseBranch: errand.baseBranch,
});
} catch (err) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: err instanceof Error ? err.message : String(err),
});
}
// Update manifest files
const agentWorkdir = join(ctx.workspaceRoot!, 'agent-workdirs', agent.name, project.name);
await writeErrandManifest({
agentWorkdir,
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 };
}),
}),
};
}