diff --git a/apps/server/agent/manager.test.ts b/apps/server/agent/manager.test.ts index 5781477..d9a751d 100644 --- a/apps/server/agent/manager.test.ts +++ b/apps/server/agent/manager.test.ts @@ -462,6 +462,31 @@ describe('MultiProviderAgentManager', () => { }); }); + describe('sendUserMessage', () => { + it('resumes errand agent in idle status', async () => { + mockRepository.findById = vi.fn().mockResolvedValue({ + ...mockAgent, + status: 'idle', + }); + + const mockChild = createMockChildProcess(); + mockSpawn.mockReturnValue(mockChild); + + await expect(manager.sendUserMessage(mockAgent.id, 'my answer')).resolves.not.toThrow(); + }); + + it('rejects if agent is stopped', async () => { + mockRepository.findById = vi.fn().mockResolvedValue({ + ...mockAgent, + status: 'stopped', + }); + + await expect(manager.sendUserMessage(mockAgent.id, 'message')).rejects.toThrow( + 'Agent is not running' + ); + }); + }); + describe('getResult', () => { it('returns null when agent has no result', async () => { const result = await manager.getResult('agent-123'); diff --git a/apps/server/agent/mock-manager.ts b/apps/server/agent/mock-manager.ts index 68d49be..529b769 100644 --- a/apps/server/agent/mock-manager.ts +++ b/apps/server/agent/mock-manager.ts @@ -26,6 +26,7 @@ import type { AgentDeletedEvent, AgentWaitingEvent, } from '../events/index.js'; +import type { AgentRepository } from '../db/repositories/agent-repository.js'; /** * Scenario configuration for mock agent behavior. @@ -83,10 +84,12 @@ export class MockAgentManager implements AgentManager { private scenarioOverrides: Map = new Map(); private defaultScenario: MockAgentScenario; private eventBus?: EventBus; + private agentRepository?: AgentRepository; - constructor(options?: { eventBus?: EventBus; defaultScenario?: MockAgentScenario }) { + constructor(options?: { eventBus?: EventBus; defaultScenario?: MockAgentScenario; agentRepository?: AgentRepository }) { this.eventBus = options?.eventBus; this.defaultScenario = options?.defaultScenario ?? DEFAULT_SCENARIO; + this.agentRepository = options?.agentRepository; } /** @@ -111,7 +114,7 @@ export class MockAgentManager implements AgentManager { * Completion happens async via setTimeout (even if delay=0). */ async spawn(options: SpawnAgentOptions): Promise { - const { taskId, prompt } = options; + const { taskId } = options; const name = options.name ?? `agent-${taskId?.slice(0, 6) ?? 'noTask'}`; // Check name uniqueness @@ -121,11 +124,29 @@ export class MockAgentManager implements AgentManager { } } - const agentId = randomUUID(); const sessionId = randomUUID(); const worktreeId = randomUUID(); const now = new Date(); + // Persist to agentRepository when provided (required for FK constraints in tests) + let agentId: string; + if (this.agentRepository) { + const dbAgent = await this.agentRepository.create({ + name, + worktreeId, + taskId: taskId ?? null, + initiativeId: options.initiativeId ?? null, + sessionId, + status: 'running', + mode: options.mode ?? 'execute', + provider: options.provider ?? 'claude', + accountId: null, + }); + agentId = dbAgent.id; + } else { + agentId = randomUUID(); + } + // Determine scenario (override takes precedence — use original name or generated) const scenario = this.scenarioOverrides.get(name) ?? this.defaultScenario; @@ -509,6 +530,18 @@ export class MockAgentManager implements AgentManager { return true; } + /** + * Deliver a user message to a running errand agent. + * Mock implementation: no-op (simulates message delivery without actual process interaction). + */ + async sendUserMessage(agentId: string, _message: string): Promise { + const record = this.agents.get(agentId); + if (!record) { + throw new Error(`Agent '${agentId}' not found`); + } + // Mock: succeed silently — message delivery is a no-op in tests + } + /** * Clear all agents and pending timers. * Useful for test cleanup. diff --git a/apps/server/agent/types.ts b/apps/server/agent/types.ts index 93ad9bc..0e26557 100644 --- a/apps/server/agent/types.ts +++ b/apps/server/agent/types.ts @@ -15,7 +15,7 @@ export type AgentStatus = 'idle' | 'running' | 'waiting_for_input' | 'stopped' | * - plan: Plan initiative into phases * - detail: Detail phase into individual tasks */ -export type AgentMode = 'execute' | 'discuss' | 'plan' | 'detail' | 'refine' | 'chat'; +export type AgentMode = 'execute' | 'discuss' | 'plan' | 'detail' | 'refine' | 'chat' | 'errand'; /** * Context data written as input files in agent workdir before spawn. @@ -263,4 +263,14 @@ export interface AgentManager { question: string, fromAgentId: string, ): Promise; + + /** + * Deliver a user message to a running errand agent. + * Does not use the conversations table — the message is injected directly + * into the agent's Claude Code session as a resume prompt. + * + * @param agentId - The errand agent to message + * @param message - The user's message text + */ + sendUserMessage(agentId: string, message: string): Promise; } diff --git a/apps/server/cli/errand.test.ts b/apps/server/cli/errand.test.ts new file mode 100644 index 0000000..0b2f5bb --- /dev/null +++ b/apps/server/cli/errand.test.ts @@ -0,0 +1,266 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createCli } from './index.js'; + +const mockClient = { + errand: { + create: { mutate: vi.fn() }, + list: { query: vi.fn() }, + get: { query: vi.fn() }, + diff: { query: vi.fn() }, + complete: { mutate: vi.fn() }, + merge: { mutate: vi.fn() }, + delete: { mutate: vi.fn() }, + sendMessage: { mutate: vi.fn() }, + abandon: { mutate: vi.fn() }, + }, +}; + +vi.mock('./trpc-client.js', () => ({ + createDefaultTrpcClient: () => mockClient, +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +async function runCli(args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const stdoutLines: string[] = []; + const stderrLines: string[] = []; + let exitCode = 0; + + vi.spyOn(process.stdout, 'write').mockImplementation((s: any) => { stdoutLines.push(String(s)); return true; }); + vi.spyOn(process.stderr, 'write').mockImplementation((s: any) => { stderrLines.push(String(s)); return true; }); + vi.spyOn(console, 'log').mockImplementation((...a: any[]) => { stdoutLines.push(a.join(' ')); }); + vi.spyOn(console, 'error').mockImplementation((...a: any[]) => { stderrLines.push(a.join(' ')); }); + vi.spyOn(process, 'exit').mockImplementation((code?: any) => { exitCode = code ?? 0; throw new Error(`process.exit(${code})`); }); + + const program = createCli(); + try { + await program.parseAsync(['node', 'cw', ...args]); + } catch (e: any) { + if (!e.message?.startsWith('process.exit')) throw e; + } + + vi.restoreAllMocks(); + return { + stdout: stdoutLines.join('\n'), + stderr: stderrLines.join('\n'), + exitCode, + }; +} + +describe('cw errand start', () => { + it('calls errand.create.mutate with correct args and prints output', async () => { + mockClient.errand.create.mutate.mockResolvedValueOnce({ + id: 'errand-abc123', + branch: 'cw/errand/fix-typo-errand-ab', + agentId: 'agent-xyz', + }); + const { stdout, exitCode } = await runCli(['errand', 'start', 'fix typo', '--project', 'proj-1']); + expect(mockClient.errand.create.mutate).toHaveBeenCalledWith({ + description: 'fix typo', + projectId: 'proj-1', + baseBranch: undefined, + }); + expect(stdout).toContain('Errand started'); + expect(stdout).toContain('errand-abc123'); + expect(exitCode).toBe(0); + }); + + it('exits 1 and prints length error without calling tRPC when description > 200 chars', async () => { + const longDesc = 'x'.repeat(201); + const { stderr, exitCode } = await runCli(['errand', 'start', longDesc, '--project', 'proj-1']); + expect(mockClient.errand.create.mutate).not.toHaveBeenCalled(); + expect(stderr).toContain('description must be ≤200 characters (201 given)'); + expect(exitCode).toBe(1); + }); + + it('passes --base option as baseBranch', async () => { + mockClient.errand.create.mutate.mockResolvedValueOnce({ id: 'e1', branch: 'b', agentId: 'a' }); + await runCli(['errand', 'start', 'fix thing', '--project', 'p1', '--base', 'develop']); + expect(mockClient.errand.create.mutate).toHaveBeenCalledWith( + expect.objectContaining({ baseBranch: 'develop' }) + ); + }); +}); + +describe('cw errand list', () => { + it('prints tab-separated rows for errands', async () => { + mockClient.errand.list.query.mockResolvedValueOnce([ + { id: 'errand-abc123full', description: 'fix the bug', branch: 'cw/errand/fix-bug-errand-ab', status: 'active', agentAlias: 'my-agent' }, + ]); + const { stdout } = await runCli(['errand', 'list']); + expect(stdout).toContain('errand-a'); // id.slice(0,8) + expect(stdout).toContain('fix the bug'); + expect(stdout).toContain('active'); + expect(stdout).toContain('my-agent'); + }); + + it('prints "No errands found" on empty result', async () => { + mockClient.errand.list.query.mockResolvedValueOnce([]); + const { stdout } = await runCli(['errand', 'list']); + expect(stdout).toContain('No errands found'); + }); + + it('truncates description at 60 chars with ellipsis', async () => { + const longDesc = 'a'.repeat(65); + mockClient.errand.list.query.mockResolvedValueOnce([ + { id: 'x'.repeat(16), description: longDesc, branch: 'b', status: 'active', agentAlias: null }, + ]); + const { stdout } = await runCli(['errand', 'list']); + expect(stdout).toContain('a'.repeat(57) + '...'); + }); + + it('passes --status filter to query', async () => { + mockClient.errand.list.query.mockResolvedValueOnce([]); + await runCli(['errand', 'list', '--status', 'active']); + expect(mockClient.errand.list.query).toHaveBeenCalledWith(expect.objectContaining({ status: 'active' })); + }); + + it('passes --project filter to query', async () => { + mockClient.errand.list.query.mockResolvedValueOnce([]); + await runCli(['errand', 'list', '--project', 'proj-99']); + expect(mockClient.errand.list.query).toHaveBeenCalledWith(expect.objectContaining({ projectId: 'proj-99' })); + }); + + it('shows "-" for null agentAlias', async () => { + mockClient.errand.list.query.mockResolvedValueOnce([ + { id: 'x'.repeat(16), description: 'test', branch: 'b', status: 'active', agentAlias: null }, + ]); + const { stdout } = await runCli(['errand', 'list']); + expect(stdout).toContain('-'); + }); +}); + +describe('cw errand chat', () => { + it('calls sendMessage.mutate with no stdout on success', async () => { + mockClient.errand.sendMessage.mutate.mockResolvedValueOnce({ success: true }); + const { stdout, exitCode } = await runCli(['errand', 'chat', 'e1', 'hello there']); + expect(mockClient.errand.sendMessage.mutate).toHaveBeenCalledWith({ id: 'e1', message: 'hello there' }); + expect(stdout.trim()).toBe(''); + expect(exitCode).toBe(0); + }); + + it('exits 1 and prints error when tRPC throws (agent not running)', async () => { + mockClient.errand.sendMessage.mutate.mockRejectedValueOnce(new Error('Agent is not running (status: stopped)')); + const { stderr, exitCode } = await runCli(['errand', 'chat', 'e1', 'msg']); + expect(stderr).toContain('Agent is not running'); + expect(exitCode).toBe(1); + }); +}); + +describe('cw errand diff', () => { + it('writes raw diff to stdout and exits 0', async () => { + mockClient.errand.diff.query.mockResolvedValueOnce({ diff: 'diff --git a/foo.ts b/foo.ts\n+++ change' }); + const { stdout, exitCode } = await runCli(['errand', 'diff', 'e1']); + expect(stdout).toContain('diff --git'); + expect(exitCode).toBe(0); + }); + + it('produces no output on empty diff and exits 0', async () => { + mockClient.errand.diff.query.mockResolvedValueOnce({ diff: '' }); + const { stdout, exitCode } = await runCli(['errand', 'diff', 'e1']); + expect(stdout.trim()).toBe(''); + expect(exitCode).toBe(0); + }); + + it('exits 1 with "Errand not found" on NOT_FOUND error', async () => { + mockClient.errand.diff.query.mockRejectedValueOnce(new Error('NOT_FOUND: errand not found')); + const { stderr, exitCode } = await runCli(['errand', 'diff', 'missing-id']); + expect(stderr).toContain('Errand missing-id not found'); + expect(exitCode).toBe(1); + }); +}); + +describe('cw errand complete', () => { + it('prints "Errand marked as ready for review"', async () => { + mockClient.errand.complete.mutate.mockResolvedValueOnce({}); + const { stdout, exitCode } = await runCli(['errand', 'complete', 'errand-1']); + expect(stdout).toContain('Errand errand-1 marked as ready for review'); + expect(exitCode).toBe(0); + }); +}); + +describe('cw errand merge', () => { + it('prints "Merged into " on clean merge', async () => { + mockClient.errand.get.query.mockResolvedValueOnce({ + id: 'e1', branch: 'cw/errand/fix-bug-e1', baseBranch: 'main', status: 'pending_review', + conflictFiles: [], projectPath: '/path/to/repo', + }); + mockClient.errand.merge.mutate.mockResolvedValueOnce({ status: 'merged' }); + const { stdout, exitCode } = await runCli(['errand', 'merge', 'e1']); + expect(stdout).toContain('Merged cw/errand/fix-bug-e1 into main'); + expect(exitCode).toBe(0); + }); + + it('exits 1 and prints conflicting files on conflict', async () => { + mockClient.errand.get.query.mockResolvedValueOnce({ + id: 'e1', branch: 'cw/errand/fix-bug-e1', baseBranch: 'main', status: 'pending_review', + conflictFiles: [], projectPath: '/repo', + }); + const conflictError = Object.assign(new Error('Merge conflict'), { + data: { conflictFiles: ['src/a.ts', 'src/b.ts'] }, + }); + mockClient.errand.merge.mutate.mockRejectedValueOnce(conflictError); + const { stderr, exitCode } = await runCli(['errand', 'merge', 'e1']); + expect(stderr).toContain('Merge conflict in 2 file(s)'); + expect(stderr).toContain('src/a.ts'); + expect(stderr).toContain('src/b.ts'); + expect(stderr).toContain('Run: cw errand resolve e1'); + expect(exitCode).toBe(1); + }); + + it('uses --target override instead of baseBranch', async () => { + mockClient.errand.get.query.mockResolvedValueOnce({ + id: 'e1', branch: 'cw/errand/fix-e1', baseBranch: 'main', status: 'pending_review', + conflictFiles: [], projectPath: '/repo', + }); + mockClient.errand.merge.mutate.mockResolvedValueOnce({ status: 'merged' }); + const { stdout } = await runCli(['errand', 'merge', 'e1', '--target', 'develop']); + expect(stdout).toContain('Merged cw/errand/fix-e1 into develop'); + expect(mockClient.errand.merge.mutate).toHaveBeenCalledWith({ id: 'e1', target: 'develop' }); + }); +}); + +describe('cw errand resolve', () => { + it('prints worktree path and conflicting files when status is conflict', async () => { + mockClient.errand.get.query.mockResolvedValueOnce({ + id: 'e1', status: 'conflict', conflictFiles: ['src/a.ts', 'src/b.ts'], + projectPath: '/home/user/project', branch: 'cw/errand/fix-e1', baseBranch: 'main', + }); + const { stdout, exitCode } = await runCli(['errand', 'resolve', 'e1']); + expect(stdout).toContain('/home/user/project/.cw-worktrees/e1'); + expect(stdout).toContain('src/a.ts'); + expect(stdout).toContain('src/b.ts'); + expect(stdout).toContain('cw errand merge e1'); + expect(exitCode).toBe(0); + }); + + it('exits 1 with status message when errand is not in conflict', async () => { + mockClient.errand.get.query.mockResolvedValueOnce({ + id: 'e1', status: 'pending_review', conflictFiles: [], projectPath: '/repo', + }); + const { stderr, exitCode } = await runCli(['errand', 'resolve', 'e1']); + expect(stderr).toContain('is not in conflict'); + expect(stderr).toContain('pending_review'); + expect(exitCode).toBe(1); + }); +}); + +describe('cw errand abandon', () => { + it('prints "Errand abandoned"', async () => { + mockClient.errand.abandon.mutate.mockResolvedValueOnce({}); + const { stdout, exitCode } = await runCli(['errand', 'abandon', 'errand-1']); + expect(stdout).toContain('Errand errand-1 abandoned'); + expect(exitCode).toBe(0); + }); +}); + +describe('cw errand delete', () => { + it('prints "Errand deleted"', async () => { + mockClient.errand.delete.mutate.mockResolvedValueOnce({ success: true }); + const { stdout, exitCode } = await runCli(['errand', 'delete', 'errand-1']); + expect(stdout).toContain('Errand errand-1 deleted'); + expect(exitCode).toBe(0); + }); +}); diff --git a/apps/server/cli/index.ts b/apps/server/cli/index.ts index 007035c..8fc0425 100644 --- a/apps/server/cli/index.ts +++ b/apps/server/cli/index.ts @@ -1728,6 +1728,195 @@ See the Codewalkers documentation for .cw-preview.yml format and options.`; } }); + // ── Errand commands ──────────────────────────────────────────────── + const errandCommand = program + .command('errand') + .description('Manage lightweight interactive agent sessions for small changes'); + + errandCommand + .command('start ') + .description('Start a new errand session') + .requiredOption('--project ', 'Project ID') + .option('--base ', 'Base branch to create errand from (default: main)') + .action(async (description: string, options: { project: string; base?: string }) => { + if (description.length > 200) { + console.error(`Error: description must be ≤200 characters (${description.length} given)`); + process.exit(1); + } + try { + const client = createDefaultTrpcClient(); + const errand = await client.errand.create.mutate({ + description, + projectId: options.project, + baseBranch: options.base, + }); + console.log('Errand started'); + console.log(` ID: ${errand.id}`); + console.log(` Branch: ${errand.branch}`); + console.log(` Agent: ${errand.agentId}`); + } catch (error) { + console.error('Failed to start errand:', (error as Error).message); + process.exit(1); + } + }); + + errandCommand + .command('list') + .description('List errands') + .option('--project ', 'Filter by project') + .option('--status ', 'Filter by status: active|pending_review|conflict|merged|abandoned') + .action(async (options: { project?: string; status?: string }) => { + try { + const client = createDefaultTrpcClient(); + const errands = await client.errand.list.query({ + projectId: options.project, + status: options.status as any, + }); + if (errands.length === 0) { + console.log('No errands found'); + return; + } + for (const e of errands) { + const desc = e.description.length > 60 ? e.description.slice(0, 57) + '...' : e.description; + console.log([e.id.slice(0, 8), desc, e.branch, e.status, e.agentAlias ?? '-'].join('\t')); + } + } catch (error) { + console.error('Failed to list errands:', (error as Error).message); + process.exit(1); + } + }); + + errandCommand + .command('chat ') + .description('Deliver a message to the running errand agent') + .action(async (id: string, message: string) => { + try { + const client = createDefaultTrpcClient(); + await client.errand.sendMessage.mutate({ id, message }); + // No stdout on success — agent response appears in UI log stream + } catch (error) { + console.error((error as Error).message); + process.exit(1); + } + }); + + errandCommand + .command('diff ') + .description('Print unified git diff between base branch and errand branch') + .action(async (id: string) => { + try { + const client = createDefaultTrpcClient(); + const { diff } = await client.errand.diff.query({ id }); + if (diff) process.stdout.write(diff); + // Empty diff: no output, exit 0 — not an error + } catch (error) { + const msg = (error as Error).message; + if (msg.includes('not found') || msg.includes('NOT_FOUND')) { + console.error(`Errand ${id} not found`); + } else { + console.error(msg); + } + process.exit(1); + } + }); + + errandCommand + .command('complete ') + .description('Mark errand as done and ready for review') + .action(async (id: string) => { + try { + const client = createDefaultTrpcClient(); + await client.errand.complete.mutate({ id }); + console.log(`Errand ${id} marked as ready for review`); + } catch (error) { + console.error((error as Error).message); + process.exit(1); + } + }); + + errandCommand + .command('merge ') + .description('Merge errand branch into target branch') + .option('--target ', 'Target branch (default: baseBranch stored in DB)') + .action(async (id: string, options: { target?: string }) => { + try { + const client = createDefaultTrpcClient(); + const errand = await client.errand.get.query({ id }); + await client.errand.merge.mutate({ id, target: options.target }); + const target = options.target ?? errand.baseBranch; + console.log(`Merged ${errand.branch} into ${target}`); + } catch (error) { + const err = error as any; + const conflictFiles: string[] | undefined = + err?.data?.conflictFiles ?? err?.shape?.data?.conflictFiles; + if (conflictFiles) { + console.error(`Merge conflict in ${conflictFiles.length} file(s):`); + for (const f of conflictFiles) console.error(` ${f}`); + console.error(`Run: cw errand resolve ${id}`); + } else { + console.error((error as Error).message); + } + process.exit(1); + } + }); + + errandCommand + .command('resolve ') + .description('Print worktree path and conflicting files for manual resolution') + .action(async (id: string) => { + try { + const client = createDefaultTrpcClient(); + const errand = await client.errand.get.query({ id }); + if (errand.status !== 'conflict') { + console.error(`Errand ${id} is not in conflict (status: ${errand.status})`); + process.exit(1); + } + // projectPath is added to errand.get by Task 1; cast until type is updated + const projectPath = (errand as any).projectPath as string | null | undefined; + const worktreePath = projectPath + ? `${projectPath}/.cw-worktrees/${id}` + : `.cw-worktrees/${id}`; + console.log(`Resolve conflicts in worktree: ${worktreePath}`); + console.log('Conflicting files:'); + for (const f of errand.conflictFiles ?? []) { + console.log(` ${f}`); + } + console.log('After resolving: stage and commit changes in the worktree, then run:'); + console.log(` cw errand merge ${id}`); + } catch (error) { + console.error((error as Error).message); + process.exit(1); + } + }); + + errandCommand + .command('abandon ') + .description('Stop agent, remove worktree and branch, keep DB record as abandoned') + .action(async (id: string) => { + try { + const client = createDefaultTrpcClient(); + await client.errand.abandon.mutate({ id }); + console.log(`Errand ${id} abandoned`); + } catch (error) { + console.error((error as Error).message); + process.exit(1); + } + }); + + errandCommand + .command('delete ') + .description('Stop agent, remove worktree, delete branch, and delete DB record') + .action(async (id: string) => { + try { + const client = createDefaultTrpcClient(); + await client.errand.delete.mutate({ id }); + console.log(`Errand ${id} deleted`); + } catch (error) { + console.error((error as Error).message); + process.exit(1); + } + }); + return program; } diff --git a/apps/server/container.ts b/apps/server/container.ts index 4468184..daa6151 100644 --- a/apps/server/container.ts +++ b/apps/server/container.ts @@ -22,6 +22,7 @@ import { DrizzleConversationRepository, DrizzleChatSessionRepository, DrizzleReviewCommentRepository, + DrizzleErrandRepository, } from './db/index.js'; import type { InitiativeRepository } from './db/repositories/initiative-repository.js'; import type { PhaseRepository } from './db/repositories/phase-repository.js'; @@ -36,6 +37,7 @@ import type { LogChunkRepository } from './db/repositories/log-chunk-repository. import type { ConversationRepository } from './db/repositories/conversation-repository.js'; import type { ChatSessionRepository } from './db/repositories/chat-session-repository.js'; import type { ReviewCommentRepository } from './db/repositories/review-comment-repository.js'; +import type { ErrandRepository } from './db/repositories/errand-repository.js'; import type { EventBus } from './events/index.js'; import { createEventBus } from './events/index.js'; import { ProcessManager, ProcessRegistry } from './process/index.js'; @@ -77,6 +79,7 @@ export interface Repositories { conversationRepository: ConversationRepository; chatSessionRepository: ChatSessionRepository; reviewCommentRepository: ReviewCommentRepository; + errandRepository: ErrandRepository; } /** @@ -98,6 +101,7 @@ export function createRepositories(db: DrizzleDatabase): Repositories { conversationRepository: new DrizzleConversationRepository(db), chatSessionRepository: new DrizzleChatSessionRepository(db), reviewCommentRepository: new DrizzleReviewCommentRepository(db), + errandRepository: new DrizzleErrandRepository(db), }; } diff --git a/apps/server/dispatch/manager.test.ts b/apps/server/dispatch/manager.test.ts index cb0f4e6..e0032e9 100644 --- a/apps/server/dispatch/manager.test.ts +++ b/apps/server/dispatch/manager.test.ts @@ -83,6 +83,7 @@ function createMockAgentManager( getResult: vi.fn().mockResolvedValue(null), getPendingQuestions: vi.fn().mockResolvedValue(null), resumeForConversation: vi.fn().mockResolvedValue(false), + sendUserMessage: vi.fn().mockResolvedValue(undefined), }; } diff --git a/apps/server/drizzle/0034_salty_next_avengers.sql b/apps/server/drizzle/0034_salty_next_avengers.sql new file mode 100644 index 0000000..8d67028 --- /dev/null +++ b/apps/server/drizzle/0034_salty_next_avengers.sql @@ -0,0 +1,17 @@ +CREATE TABLE `errands` ( + `id` text PRIMARY KEY NOT NULL, + `description` text NOT NULL, + `branch` text NOT NULL, + `base_branch` text DEFAULT 'main' NOT NULL, + `agent_id` text, + `project_id` text NOT NULL, + `status` text DEFAULT 'active' NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + `conflict_files` text, + FOREIGN KEY (`agent_id`) REFERENCES `agents`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `errands_project_id_idx` ON `errands` (`project_id`);--> statement-breakpoint +CREATE INDEX `errands_status_idx` ON `errands` (`status`); \ No newline at end of file diff --git a/apps/server/drizzle/meta/0034_snapshot.json b/apps/server/drizzle/meta/0034_snapshot.json new file mode 100644 index 0000000..011f44a --- /dev/null +++ b/apps/server/drizzle/meta/0034_snapshot.json @@ -0,0 +1,1988 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "443071fe-31d6-498a-9f4a-4a3ff24a46fc", + "prevId": "5fbe1151-1dfb-4b0c-a7fa-2177369543fd", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'claude'" + }, + "config_json": { + "name": "config_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_exhausted": { + "name": "is_exhausted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "exhausted_until": { + "name": "exhausted_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agent_log_chunks": { + "name": "agent_log_chunks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_number": { + "name": "session_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "agent_log_chunks_agent_id_idx": { + "name": "agent_log_chunks_agent_id_idx", + "columns": [ + "agent_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agents": { + "name": "agents", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_id": { + "name": "worktree_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'claude'" + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'idle'" + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'execute'" + }, + "pid": { + "name": "pid", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_file_path": { + "name": "output_file_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pending_questions": { + "name": "pending_questions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_dismissed_at": { + "name": "user_dismissed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "agents_name_unique": { + "name": "agents_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "agents_task_id_tasks_id_fk": { + "name": "agents_task_id_tasks_id_fk", + "tableFrom": "agents", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "agents_initiative_id_initiatives_id_fk": { + "name": "agents_initiative_id_initiatives_id_fk", + "tableFrom": "agents", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "agents_account_id_accounts_id_fk": { + "name": "agents_account_id_accounts_id_fk", + "tableFrom": "agents", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "change_set_entries": { + "name": "change_set_entries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "change_set_id": { + "name": "change_set_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous_state": { + "name": "previous_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "new_state": { + "name": "new_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "change_set_entries_change_set_id_idx": { + "name": "change_set_entries_change_set_id_idx", + "columns": [ + "change_set_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "change_set_entries_change_set_id_change_sets_id_fk": { + "name": "change_set_entries_change_set_id_change_sets_id_fk", + "tableFrom": "change_set_entries", + "tableTo": "change_sets", + "columnsFrom": [ + "change_set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "change_sets": { + "name": "change_sets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'applied'" + }, + "reverted_at": { + "name": "reverted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "change_sets_initiative_id_idx": { + "name": "change_sets_initiative_id_idx", + "columns": [ + "initiative_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "change_sets_agent_id_agents_id_fk": { + "name": "change_sets_agent_id_agents_id_fk", + "tableFrom": "change_sets", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "change_sets_initiative_id_initiatives_id_fk": { + "name": "change_sets_initiative_id_initiatives_id_fk", + "tableFrom": "change_sets", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_messages": { + "name": "chat_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "chat_session_id": { + "name": "chat_session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "change_set_id": { + "name": "change_set_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "chat_messages_session_id_idx": { + "name": "chat_messages_session_id_idx", + "columns": [ + "chat_session_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chat_messages_chat_session_id_chat_sessions_id_fk": { + "name": "chat_messages_chat_session_id_chat_sessions_id_fk", + "tableFrom": "chat_messages", + "tableTo": "chat_sessions", + "columnsFrom": [ + "chat_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_messages_change_set_id_change_sets_id_fk": { + "name": "chat_messages_change_set_id_change_sets_id_fk", + "tableFrom": "chat_messages", + "tableTo": "change_sets", + "columnsFrom": [ + "change_set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_sessions": { + "name": "chat_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "chat_sessions_target_idx": { + "name": "chat_sessions_target_idx", + "columns": [ + "target_type", + "target_id" + ], + "isUnique": false + }, + "chat_sessions_initiative_id_idx": { + "name": "chat_sessions_initiative_id_idx", + "columns": [ + "initiative_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chat_sessions_initiative_id_initiatives_id_fk": { + "name": "chat_sessions_initiative_id_initiatives_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_sessions_agent_id_agents_id_fk": { + "name": "chat_sessions_agent_id_agents_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "conversations": { + "name": "conversations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "from_agent_id": { + "name": "from_agent_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_agent_id": { + "name": "to_agent_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase_id": { + "name": "phase_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "question": { + "name": "question", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "answer": { + "name": "answer", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "conversations_to_agent_status_idx": { + "name": "conversations_to_agent_status_idx", + "columns": [ + "to_agent_id", + "status" + ], + "isUnique": false + }, + "conversations_from_agent_idx": { + "name": "conversations_from_agent_idx", + "columns": [ + "from_agent_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "conversations_from_agent_id_agents_id_fk": { + "name": "conversations_from_agent_id_agents_id_fk", + "tableFrom": "conversations", + "tableTo": "agents", + "columnsFrom": [ + "from_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_to_agent_id_agents_id_fk": { + "name": "conversations_to_agent_id_agents_id_fk", + "tableFrom": "conversations", + "tableTo": "agents", + "columnsFrom": [ + "to_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_initiative_id_initiatives_id_fk": { + "name": "conversations_initiative_id_initiatives_id_fk", + "tableFrom": "conversations", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "conversations_phase_id_phases_id_fk": { + "name": "conversations_phase_id_phases_id_fk", + "tableFrom": "conversations", + "tableTo": "phases", + "columnsFrom": [ + "phase_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "conversations_task_id_tasks_id_fk": { + "name": "conversations_task_id_tasks_id_fk", + "tableFrom": "conversations", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "errands": { + "name": "errands", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'main'" + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conflict_files": { + "name": "conflict_files", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "errands_project_id_idx": { + "name": "errands_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "errands_status_idx": { + "name": "errands_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "errands_agent_id_agents_id_fk": { + "name": "errands_agent_id_agents_id_fk", + "tableFrom": "errands", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "errands_project_id_projects_id_fk": { + "name": "errands_project_id_projects_id_fk", + "tableFrom": "errands", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "initiative_projects": { + "name": "initiative_projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "initiative_project_unique": { + "name": "initiative_project_unique", + "columns": [ + "initiative_id", + "project_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "initiative_projects_initiative_id_initiatives_id_fk": { + "name": "initiative_projects_initiative_id_initiatives_id_fk", + "tableFrom": "initiative_projects", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "initiative_projects_project_id_projects_id_fk": { + "name": "initiative_projects_project_id_projects_id_fk", + "tableFrom": "initiative_projects", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "initiatives": { + "name": "initiatives", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "execution_mode": { + "name": "execution_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'review_per_phase'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "messages": { + "name": "messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "sender_type": { + "name": "sender_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipient_type": { + "name": "recipient_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_id": { + "name": "recipient_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'info'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requires_response": { + "name": "requires_response", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "messages_sender_id_agents_id_fk": { + "name": "messages_sender_id_agents_id_fk", + "tableFrom": "messages", + "tableTo": "agents", + "columnsFrom": [ + "sender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "messages_recipient_id_agents_id_fk": { + "name": "messages_recipient_id_agents_id_fk", + "tableFrom": "messages", + "tableTo": "agents", + "columnsFrom": [ + "recipient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "messages_parent_message_id_messages_id_fk": { + "name": "messages_parent_message_id_messages_id_fk", + "tableFrom": "messages", + "tableTo": "messages", + "columnsFrom": [ + "parent_message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pages": { + "name": "pages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_page_id": { + "name": "parent_page_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "pages_initiative_id_initiatives_id_fk": { + "name": "pages_initiative_id_initiatives_id_fk", + "tableFrom": "pages", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pages_parent_page_id_pages_id_fk": { + "name": "pages_parent_page_id_pages_id_fk", + "tableFrom": "pages", + "tableTo": "pages", + "columnsFrom": [ + "parent_page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "phase_dependencies": { + "name": "phase_dependencies", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "phase_id": { + "name": "phase_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "depends_on_phase_id": { + "name": "depends_on_phase_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "phase_dependencies_phase_id_phases_id_fk": { + "name": "phase_dependencies_phase_id_phases_id_fk", + "tableFrom": "phase_dependencies", + "tableTo": "phases", + "columnsFrom": [ + "phase_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "phase_dependencies_depends_on_phase_id_phases_id_fk": { + "name": "phase_dependencies_depends_on_phase_id_phases_id_fk", + "tableFrom": "phase_dependencies", + "tableTo": "phases", + "columnsFrom": [ + "depends_on_phase_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "phases": { + "name": "phases", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "merge_base": { + "name": "merge_base", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "phases_initiative_id_initiatives_id_fk": { + "name": "phases_initiative_id_initiatives_id_fk", + "tableFrom": "phases", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'main'" + }, + "last_fetched_at": { + "name": "last_fetched_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "projects_name_unique": { + "name": "projects_name_unique", + "columns": [ + "name" + ], + "isUnique": true + }, + "projects_url_unique": { + "name": "projects_url_unique", + "columns": [ + "url" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "review_comments": { + "name": "review_comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "phase_id": { + "name": "phase_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "line_number": { + "name": "line_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "line_type": { + "name": "line_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'you'" + }, + "parent_comment_id": { + "name": "parent_comment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved": { + "name": "resolved", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "review_comments_phase_id_idx": { + "name": "review_comments_phase_id_idx", + "columns": [ + "phase_id" + ], + "isUnique": false + }, + "review_comments_parent_id_idx": { + "name": "review_comments_parent_id_idx", + "columns": [ + "parent_comment_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "review_comments_phase_id_phases_id_fk": { + "name": "review_comments_phase_id_phases_id_fk", + "tableFrom": "review_comments", + "tableTo": "phases", + "columnsFrom": [ + "phase_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_comments_parent_comment_id_review_comments_id_fk": { + "name": "review_comments_parent_comment_id_review_comments_id_fk", + "tableFrom": "review_comments", + "tableTo": "review_comments", + "columnsFrom": [ + "parent_comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "task_dependencies": { + "name": "task_dependencies", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "depends_on_task_id": { + "name": "depends_on_task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "task_dependencies_task_id_tasks_id_fk": { + "name": "task_dependencies_task_id_tasks_id_fk", + "tableFrom": "task_dependencies", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_dependencies_depends_on_task_id_tasks_id_fk": { + "name": "task_dependencies_depends_on_task_id_tasks_id_fk", + "tableFrom": "task_dependencies", + "tableTo": "tasks", + "columnsFrom": [ + "depends_on_task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "phase_id": { + "name": "phase_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_task_id": { + "name": "parent_task_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'auto'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'execute'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'medium'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "tasks_phase_id_phases_id_fk": { + "name": "tasks_phase_id_phases_id_fk", + "tableFrom": "tasks", + "tableTo": "phases", + "columnsFrom": [ + "phase_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_initiative_id_initiatives_id_fk": { + "name": "tasks_initiative_id_initiatives_id_fk", + "tableFrom": "tasks", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_parent_task_id_tasks_id_fk": { + "name": "tasks_parent_task_id_tasks_id_fk", + "tableFrom": "tasks", + "tableTo": "tasks", + "columnsFrom": [ + "parent_task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/server/test/harness.ts b/apps/server/test/harness.ts index bb5c0ba..31cd7f1 100644 --- a/apps/server/test/harness.ts +++ b/apps/server/test/harness.ts @@ -25,6 +25,7 @@ import type { MessageRepository } from '../db/repositories/message-repository.js import type { AgentRepository } from '../db/repositories/agent-repository.js'; import type { InitiativeRepository } from '../db/repositories/initiative-repository.js'; import type { PhaseRepository } from '../db/repositories/phase-repository.js'; +import type { ErrandRepository } from '../db/repositories/errand-repository.js'; import type { Initiative, Phase, Task } from '../db/schema.js'; import { createTestDatabase } from '../db/repositories/drizzle/test-helpers.js'; import { createRepositories } from '../container.js'; @@ -204,6 +205,8 @@ export interface TestHarness { initiativeRepository: InitiativeRepository; /** Phase repository */ phaseRepository: PhaseRepository; + /** Errand repository */ + errandRepository: ErrandRepository; // tRPC Caller /** tRPC caller for direct procedure calls */ @@ -409,7 +412,7 @@ export function createTestHarness(): TestHarness { // Create repositories const repos = createRepositories(db); - const { taskRepository, messageRepository, agentRepository, initiativeRepository, phaseRepository } = repos; + const { taskRepository, messageRepository, agentRepository, initiativeRepository, phaseRepository, errandRepository } = repos; // Create real managers wired to mocks const dispatchManager = new DefaultDispatchManager( @@ -447,6 +450,7 @@ export function createTestHarness(): TestHarness { coordinationManager, initiativeRepository, phaseRepository, + errandRepository, }); // Create tRPC caller @@ -470,6 +474,7 @@ export function createTestHarness(): TestHarness { agentRepository, initiativeRepository, phaseRepository, + errandRepository, // tRPC Caller caller, diff --git a/apps/server/trpc/context.ts b/apps/server/trpc/context.ts index f4889d6..3c259c3 100644 --- a/apps/server/trpc/context.ts +++ b/apps/server/trpc/context.ts @@ -19,6 +19,7 @@ import type { LogChunkRepository } from '../db/repositories/log-chunk-repository import type { ConversationRepository } from '../db/repositories/conversation-repository.js'; import type { ChatSessionRepository } from '../db/repositories/chat-session-repository.js'; import type { ReviewCommentRepository } from '../db/repositories/review-comment-repository.js'; +import type { ErrandRepository } from '../db/repositories/errand-repository.js'; import type { AccountCredentialManager } from '../agent/credentials/types.js'; import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js'; import type { CoordinationManager } from '../coordination/types.js'; @@ -80,6 +81,8 @@ export interface TRPCContext { chatSessionRepository?: ChatSessionRepository; /** Review comment repository for inline review comments on phase diffs */ reviewCommentRepository?: ReviewCommentRepository; + /** Errand repository for errand CRUD operations */ + errandRepository?: ErrandRepository; /** Project sync manager for remote fetch/sync operations */ projectSyncManager?: ProjectSyncManager; /** Absolute path to the workspace root (.cwrc directory) */ @@ -113,6 +116,7 @@ export interface CreateContextOptions { conversationRepository?: ConversationRepository; chatSessionRepository?: ChatSessionRepository; reviewCommentRepository?: ReviewCommentRepository; + errandRepository?: ErrandRepository; projectSyncManager?: ProjectSyncManager; workspaceRoot?: string; } @@ -148,6 +152,7 @@ export function createContext(options: CreateContextOptions): TRPCContext { conversationRepository: options.conversationRepository, chatSessionRepository: options.chatSessionRepository, reviewCommentRepository: options.reviewCommentRepository, + errandRepository: options.errandRepository, projectSyncManager: options.projectSyncManager, workspaceRoot: options.workspaceRoot, }; diff --git a/apps/server/trpc/routers/_helpers.ts b/apps/server/trpc/routers/_helpers.ts index 67fa3ef..928aac4 100644 --- a/apps/server/trpc/routers/_helpers.ts +++ b/apps/server/trpc/routers/_helpers.ts @@ -19,6 +19,7 @@ import type { LogChunkRepository } from '../../db/repositories/log-chunk-reposit import type { ConversationRepository } from '../../db/repositories/conversation-repository.js'; import type { ChatSessionRepository } from '../../db/repositories/chat-session-repository.js'; import type { ReviewCommentRepository } from '../../db/repositories/review-comment-repository.js'; +import type { ErrandRepository } from '../../db/repositories/errand-repository.js'; import type { DispatchManager, PhaseDispatchManager } from '../../dispatch/types.js'; import type { CoordinationManager } from '../../coordination/types.js'; import type { BranchManager } from '../../git/branch-manager.js'; @@ -225,3 +226,13 @@ export function requireProjectSyncManager(ctx: TRPCContext): ProjectSyncManager } return ctx.projectSyncManager; } + +export function requireErrandRepository(ctx: TRPCContext): ErrandRepository { + if (!ctx.errandRepository) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Errand repository not available', + }); + } + return ctx.errandRepository; +} diff --git a/apps/server/trpc/routers/errand.ts b/apps/server/trpc/routers/errand.ts new file mode 100644 index 0000000..57fb738 --- /dev/null +++ b/apps/server/trpc/routers/errand.ts @@ -0,0 +1,442 @@ +/** + * 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 { + 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 + 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; + }), + }), + }; +} diff --git a/apps/web/src/components/CreateErrandDialog.tsx b/apps/web/src/components/CreateErrandDialog.tsx new file mode 100644 index 0000000..0e76e17 --- /dev/null +++ b/apps/web/src/components/CreateErrandDialog.tsx @@ -0,0 +1,142 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { toast } from 'sonner'; +import { cn } from '@/lib/utils'; +import { trpc } from '@/lib/trpc'; + +interface CreateErrandDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function CreateErrandDialog({ open, onOpenChange }: CreateErrandDialogProps) { + const [description, setDescription] = useState(''); + const [projectId, setProjectId] = useState(''); + const [baseBranch, setBaseBranch] = useState(''); + const [error, setError] = useState(null); + + const navigate = useNavigate(); + const utils = trpc.useUtils(); + + const projectsQuery = trpc.listProjects.useQuery(); + + const createMutation = trpc.errand.create.useMutation({ + onSuccess: (data) => { + toast.success('Errand started'); + onOpenChange(false); + utils.errand.list.invalidate(); + navigate({ to: '/errands', search: { selected: data.id } }); + }, + onError: (err) => { + setError(err.message); + }, + }); + + useEffect(() => { + if (open) { + setDescription(''); + setProjectId(''); + setBaseBranch(''); + setError(null); + } + }, [open]); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + createMutation.mutate({ + description: description.trim(), + projectId, + baseBranch: baseBranch.trim() || undefined, + }); + } + + const canSubmit = + description.trim().length > 0 && + description.length <= 200 && + projectId !== '' && + !createMutation.isPending; + + return ( + + + + New Errand + + Start a small isolated change with a dedicated agent. + + +
+
+ +