Files
Codewalkers/apps/server/trpc/routers/errand.ts
Lukas May 28521e1c20 chore: merge main into cw/small-change-flow
Integrates main branch changes (headquarters dashboard, task retry count,
agent prompt persistence, remote sync improvements) with the initiative's
errand agent feature. Both features coexist in the merged result.

Key resolutions:
- Schema: take main's errands table (nullable projectId, no conflictFiles,
  with errandsRelations); migrate to 0035_faulty_human_fly
- Router: keep both errandProcedures and headquartersProcedures
- Errand prompt: take main's simpler version (no question-asking flow)
- Manager: take main's status check (running|idle only, no waiting_for_input)
- Tests: update to match removed conflictFiles field and undefined vs null
2026-03-06 16:48:12 +01:00

443 lines
17 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 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 { join } from 'node:path';
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
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({
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(),
}))
.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)
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;
}),
}),
};
}