Merge branch 'cw/small-change-flow' into cw-merge-1772827396087

This commit is contained in:
Lukas May
2026-03-06 21:03:16 +01:00
18 changed files with 3828 additions and 5 deletions

View File

@@ -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', () => { describe('getResult', () => {
it('returns null when agent has no result', async () => { it('returns null when agent has no result', async () => {
const result = await manager.getResult('agent-123'); const result = await manager.getResult('agent-123');

View File

@@ -26,6 +26,7 @@ import type {
AgentDeletedEvent, AgentDeletedEvent,
AgentWaitingEvent, AgentWaitingEvent,
} from '../events/index.js'; } from '../events/index.js';
import type { AgentRepository } from '../db/repositories/agent-repository.js';
/** /**
* Scenario configuration for mock agent behavior. * Scenario configuration for mock agent behavior.
@@ -83,10 +84,12 @@ export class MockAgentManager implements AgentManager {
private scenarioOverrides: Map<string, MockAgentScenario> = new Map(); private scenarioOverrides: Map<string, MockAgentScenario> = new Map();
private defaultScenario: MockAgentScenario; private defaultScenario: MockAgentScenario;
private eventBus?: EventBus; 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.eventBus = options?.eventBus;
this.defaultScenario = options?.defaultScenario ?? DEFAULT_SCENARIO; 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). * Completion happens async via setTimeout (even if delay=0).
*/ */
async spawn(options: SpawnAgentOptions): Promise<AgentInfo> { async spawn(options: SpawnAgentOptions): Promise<AgentInfo> {
const { taskId, prompt } = options; const { taskId } = options;
const name = options.name ?? `agent-${taskId?.slice(0, 6) ?? 'noTask'}`; const name = options.name ?? `agent-${taskId?.slice(0, 6) ?? 'noTask'}`;
// Check name uniqueness // Check name uniqueness
@@ -121,11 +124,29 @@ export class MockAgentManager implements AgentManager {
} }
} }
const agentId = randomUUID();
const sessionId = randomUUID(); const sessionId = randomUUID();
const worktreeId = randomUUID(); const worktreeId = randomUUID();
const now = new Date(); 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) // Determine scenario (override takes precedence — use original name or generated)
const scenario = this.scenarioOverrides.get(name) ?? this.defaultScenario; const scenario = this.scenarioOverrides.get(name) ?? this.defaultScenario;
@@ -509,6 +530,18 @@ export class MockAgentManager implements AgentManager {
return true; 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<void> {
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. * Clear all agents and pending timers.
* Useful for test cleanup. * Useful for test cleanup.

View File

@@ -15,7 +15,7 @@ export type AgentStatus = 'idle' | 'running' | 'waiting_for_input' | 'stopped' |
* - plan: Plan initiative into phases * - plan: Plan initiative into phases
* - detail: Detail phase into individual tasks * - 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. * Context data written as input files in agent workdir before spawn.
@@ -263,4 +263,14 @@ export interface AgentManager {
question: string, question: string,
fromAgentId: string, fromAgentId: string,
): Promise<boolean>; ): Promise<boolean>;
/**
* 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<void>;
} }

View File

@@ -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 <id> 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 <id> 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 <branch> into <baseBranch>" 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 <id> 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 <id> 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);
});
});

View File

@@ -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>')
.description('Start a new errand session')
.requiredOption('--project <id>', 'Project ID')
.option('--base <branch>', '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 <id>', 'Filter by project')
.option('--status <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 <id> <message>')
.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 <id>')
.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 <id>')
.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 <id>')
.description('Merge errand branch into target branch')
.option('--target <branch>', '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 <id>')
.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 <id>')
.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 <id>')
.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; return program;
} }

View File

@@ -22,6 +22,7 @@ import {
DrizzleConversationRepository, DrizzleConversationRepository,
DrizzleChatSessionRepository, DrizzleChatSessionRepository,
DrizzleReviewCommentRepository, DrizzleReviewCommentRepository,
DrizzleErrandRepository,
} from './db/index.js'; } from './db/index.js';
import type { InitiativeRepository } from './db/repositories/initiative-repository.js'; import type { InitiativeRepository } from './db/repositories/initiative-repository.js';
import type { PhaseRepository } from './db/repositories/phase-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 { ConversationRepository } from './db/repositories/conversation-repository.js';
import type { ChatSessionRepository } from './db/repositories/chat-session-repository.js'; import type { ChatSessionRepository } from './db/repositories/chat-session-repository.js';
import type { ReviewCommentRepository } from './db/repositories/review-comment-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 type { EventBus } from './events/index.js';
import { createEventBus } from './events/index.js'; import { createEventBus } from './events/index.js';
import { ProcessManager, ProcessRegistry } from './process/index.js'; import { ProcessManager, ProcessRegistry } from './process/index.js';
@@ -77,6 +79,7 @@ export interface Repositories {
conversationRepository: ConversationRepository; conversationRepository: ConversationRepository;
chatSessionRepository: ChatSessionRepository; chatSessionRepository: ChatSessionRepository;
reviewCommentRepository: ReviewCommentRepository; reviewCommentRepository: ReviewCommentRepository;
errandRepository: ErrandRepository;
} }
/** /**
@@ -98,6 +101,7 @@ export function createRepositories(db: DrizzleDatabase): Repositories {
conversationRepository: new DrizzleConversationRepository(db), conversationRepository: new DrizzleConversationRepository(db),
chatSessionRepository: new DrizzleChatSessionRepository(db), chatSessionRepository: new DrizzleChatSessionRepository(db),
reviewCommentRepository: new DrizzleReviewCommentRepository(db), reviewCommentRepository: new DrizzleReviewCommentRepository(db),
errandRepository: new DrizzleErrandRepository(db),
}; };
} }

View File

@@ -83,6 +83,7 @@ function createMockAgentManager(
getResult: vi.fn().mockResolvedValue(null), getResult: vi.fn().mockResolvedValue(null),
getPendingQuestions: vi.fn().mockResolvedValue(null), getPendingQuestions: vi.fn().mockResolvedValue(null),
resumeForConversation: vi.fn().mockResolvedValue(false), resumeForConversation: vi.fn().mockResolvedValue(false),
sendUserMessage: vi.fn().mockResolvedValue(undefined),
}; };
} }

View File

@@ -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`);

File diff suppressed because it is too large Load Diff

View File

@@ -25,6 +25,7 @@ import type { MessageRepository } from '../db/repositories/message-repository.js
import type { AgentRepository } from '../db/repositories/agent-repository.js'; import type { AgentRepository } from '../db/repositories/agent-repository.js';
import type { InitiativeRepository } from '../db/repositories/initiative-repository.js'; import type { InitiativeRepository } from '../db/repositories/initiative-repository.js';
import type { PhaseRepository } from '../db/repositories/phase-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 type { Initiative, Phase, Task } from '../db/schema.js';
import { createTestDatabase } from '../db/repositories/drizzle/test-helpers.js'; import { createTestDatabase } from '../db/repositories/drizzle/test-helpers.js';
import { createRepositories } from '../container.js'; import { createRepositories } from '../container.js';
@@ -204,6 +205,8 @@ export interface TestHarness {
initiativeRepository: InitiativeRepository; initiativeRepository: InitiativeRepository;
/** Phase repository */ /** Phase repository */
phaseRepository: PhaseRepository; phaseRepository: PhaseRepository;
/** Errand repository */
errandRepository: ErrandRepository;
// tRPC Caller // tRPC Caller
/** tRPC caller for direct procedure calls */ /** tRPC caller for direct procedure calls */
@@ -409,7 +412,7 @@ export function createTestHarness(): TestHarness {
// Create repositories // Create repositories
const repos = createRepositories(db); 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 // Create real managers wired to mocks
const dispatchManager = new DefaultDispatchManager( const dispatchManager = new DefaultDispatchManager(
@@ -447,6 +450,7 @@ export function createTestHarness(): TestHarness {
coordinationManager, coordinationManager,
initiativeRepository, initiativeRepository,
phaseRepository, phaseRepository,
errandRepository,
}); });
// Create tRPC caller // Create tRPC caller
@@ -470,6 +474,7 @@ export function createTestHarness(): TestHarness {
agentRepository, agentRepository,
initiativeRepository, initiativeRepository,
phaseRepository, phaseRepository,
errandRepository,
// tRPC Caller // tRPC Caller
caller, caller,

View File

@@ -19,6 +19,7 @@ import type { LogChunkRepository } from '../db/repositories/log-chunk-repository
import type { ConversationRepository } from '../db/repositories/conversation-repository.js'; import type { ConversationRepository } from '../db/repositories/conversation-repository.js';
import type { ChatSessionRepository } from '../db/repositories/chat-session-repository.js'; import type { ChatSessionRepository } from '../db/repositories/chat-session-repository.js';
import type { ReviewCommentRepository } from '../db/repositories/review-comment-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 { AccountCredentialManager } from '../agent/credentials/types.js';
import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js'; import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js';
import type { CoordinationManager } from '../coordination/types.js'; import type { CoordinationManager } from '../coordination/types.js';
@@ -80,6 +81,8 @@ export interface TRPCContext {
chatSessionRepository?: ChatSessionRepository; chatSessionRepository?: ChatSessionRepository;
/** Review comment repository for inline review comments on phase diffs */ /** Review comment repository for inline review comments on phase diffs */
reviewCommentRepository?: ReviewCommentRepository; reviewCommentRepository?: ReviewCommentRepository;
/** Errand repository for errand CRUD operations */
errandRepository?: ErrandRepository;
/** Project sync manager for remote fetch/sync operations */ /** Project sync manager for remote fetch/sync operations */
projectSyncManager?: ProjectSyncManager; projectSyncManager?: ProjectSyncManager;
/** Absolute path to the workspace root (.cwrc directory) */ /** Absolute path to the workspace root (.cwrc directory) */
@@ -113,6 +116,7 @@ export interface CreateContextOptions {
conversationRepository?: ConversationRepository; conversationRepository?: ConversationRepository;
chatSessionRepository?: ChatSessionRepository; chatSessionRepository?: ChatSessionRepository;
reviewCommentRepository?: ReviewCommentRepository; reviewCommentRepository?: ReviewCommentRepository;
errandRepository?: ErrandRepository;
projectSyncManager?: ProjectSyncManager; projectSyncManager?: ProjectSyncManager;
workspaceRoot?: string; workspaceRoot?: string;
} }
@@ -148,6 +152,7 @@ export function createContext(options: CreateContextOptions): TRPCContext {
conversationRepository: options.conversationRepository, conversationRepository: options.conversationRepository,
chatSessionRepository: options.chatSessionRepository, chatSessionRepository: options.chatSessionRepository,
reviewCommentRepository: options.reviewCommentRepository, reviewCommentRepository: options.reviewCommentRepository,
errandRepository: options.errandRepository,
projectSyncManager: options.projectSyncManager, projectSyncManager: options.projectSyncManager,
workspaceRoot: options.workspaceRoot, workspaceRoot: options.workspaceRoot,
}; };

View File

@@ -19,6 +19,7 @@ import type { LogChunkRepository } from '../../db/repositories/log-chunk-reposit
import type { ConversationRepository } from '../../db/repositories/conversation-repository.js'; import type { ConversationRepository } from '../../db/repositories/conversation-repository.js';
import type { ChatSessionRepository } from '../../db/repositories/chat-session-repository.js'; import type { ChatSessionRepository } from '../../db/repositories/chat-session-repository.js';
import type { ReviewCommentRepository } from '../../db/repositories/review-comment-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 { DispatchManager, PhaseDispatchManager } from '../../dispatch/types.js';
import type { CoordinationManager } from '../../coordination/types.js'; import type { CoordinationManager } from '../../coordination/types.js';
import type { BranchManager } from '../../git/branch-manager.js'; import type { BranchManager } from '../../git/branch-manager.js';
@@ -225,3 +226,13 @@ export function requireProjectSyncManager(ctx: TRPCContext): ProjectSyncManager
} }
return ctx.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;
}

View File

@@ -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<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;
}),
}),
};
}

View File

@@ -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<string | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>New Errand</DialogTitle>
<DialogDescription>
Start a small isolated change with a dedicated agent.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="errand-description">Description</Label>
<Textarea
id="errand-description"
placeholder="Describe the small change to make…"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
autoFocus
/>
<p
className={cn(
'text-xs text-right',
description.length >= 190 ? 'text-destructive' : 'text-muted-foreground',
)}
>
{description.length} / 200
</p>
</div>
<div className="space-y-2">
<Label htmlFor="errand-project">Project</Label>
<select
id="errand-project"
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<option value="">Select a project</option>
{(projectsQuery.data ?? []).map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
<div className="space-y-2">
<Label htmlFor="errand-base">
Base Branch{' '}
<span className="text-muted-foreground font-normal">(optional defaults to main)</span>
</Label>
<Input
id="errand-base"
placeholder="main"
value={baseBranch}
onChange={(e) => setBaseBranch(e.target.value)}
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit}>
{createMutation.isPending ? 'Starting…' : 'New Errand'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,379 @@
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { StatusBadge } from '@/components/StatusBadge';
import { AgentOutputViewer } from '@/components/AgentOutputViewer';
import { trpc } from '@/lib/trpc';
import { formatRelativeTime } from '@/lib/utils';
import { toast } from 'sonner';
interface ErrandDetailPanelProps {
errandId: string;
onClose: () => void;
}
export function ErrandDetailPanel({ errandId, onClose }: ErrandDetailPanelProps) {
const [message, setMessage] = useState('');
const errandQuery = trpc.errand.get.useQuery({ id: errandId });
const errand = errandQuery.data;
const diffQuery = trpc.errand.diff.useQuery(
{ id: errandId },
{ enabled: errand?.status !== 'active' },
);
const utils = trpc.useUtils();
const completeMutation = trpc.errand.complete.useMutation({
onSuccess: () => {
utils.errand.list.invalidate();
errandQuery.refetch();
},
});
const mergeMutation = trpc.errand.merge.useMutation({
onSuccess: () => {
utils.errand.list.invalidate();
toast.success(`Merged into ${errand?.baseBranch ?? 'base'}`);
onClose();
},
onError: () => {
errandQuery.refetch();
},
});
const deleteMutation = trpc.errand.delete.useMutation({
onSuccess: () => {
utils.errand.list.invalidate();
onClose();
},
});
const abandonMutation = trpc.errand.abandon.useMutation({
onSuccess: () => {
utils.errand.list.invalidate();
errandQuery.refetch();
},
});
const sendMutation = trpc.errand.sendMessage.useMutation({
onSuccess: () => {
utils.errand.list.invalidate();
setMessage('');
},
});
// Escape key closes
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [onClose]);
const chatDisabled = errand?.status !== 'active' || sendMutation.isPending;
return (
<AnimatePresence>
<>
{/* Backdrop */}
<motion.div
className="fixed inset-0 z-40 bg-background/60 backdrop-blur-[2px]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={onClose}
/>
{/* Panel */}
<motion.div
className="fixed inset-y-0 right-0 z-50 flex w-full max-w-2xl flex-col border-l border-border bg-background shadow-xl"
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ duration: 0.25, ease: [0, 0, 0.2, 1] }}
>
{/* Loading state */}
{errandQuery.isLoading && (
<>
<div className="flex items-center justify-between border-b border-border px-5 py-4">
<span className="text-sm text-muted-foreground">Loading</span>
<button
onClick={onClose}
className="shrink-0 rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex-1 flex items-center justify-center">
<p className="text-sm text-muted-foreground">Loading errand</p>
</div>
</>
)}
{/* Error state */}
{errandQuery.error && (
<>
<div className="flex items-center justify-between border-b border-border px-5 py-4">
<span className="text-sm text-muted-foreground">Error</span>
<button
onClick={onClose}
className="shrink-0 rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex-1 flex flex-col items-center justify-center gap-3">
<p className="text-sm text-muted-foreground">Failed to load errand.</p>
<Button size="sm" variant="outline" onClick={() => errandQuery.refetch()}>
Retry
</Button>
</div>
</>
)}
{/* Loaded state */}
{errand && (
<>
{/* Header */}
<div className="flex items-start gap-3 border-b border-border px-5 py-4">
<div className="min-w-0 flex-1">
<h3 className="text-base font-semibold leading-snug truncate">
{errand.description}
</h3>
<p className="mt-0.5 text-xs text-muted-foreground font-mono">
{errand.branch}
</p>
</div>
<StatusBadge status={errand.status} />
{errand.agentAlias && (
<span className="text-xs text-muted-foreground shrink-0">
{errand.agentAlias}
</span>
)}
<button
onClick={onClose}
className="shrink-0 rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
{/* View: Active */}
{errand.status === 'active' && (
<>
<div className="flex-1 overflow-y-auto">
{errand.agentId && (
<AgentOutputViewer
agentId={errand.agentId}
agentName={errand.agentAlias ?? undefined}
status={undefined}
/>
)}
</div>
{/* Chat input */}
<div className="border-t border-border px-5 py-3">
<form
onSubmit={(e) => {
e.preventDefault();
if (!message.trim()) return;
sendMutation.mutate({ id: errandId, message });
}}
>
<div className="flex gap-2">
<Input
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Send a message to the agent…"
disabled={chatDisabled}
title={
chatDisabled && errand.status !== 'active'
? 'Agent is not running'
: undefined
}
/>
<Button
type="submit"
size="sm"
disabled={chatDisabled || !message.trim()}
>
Send
</Button>
</div>
</form>
</div>
{/* Footer */}
<div className="flex items-center gap-2 border-t border-border px-5 py-3">
<Button
variant="outline"
size="sm"
disabled={completeMutation.isPending}
onClick={() => completeMutation.mutate({ id: errandId })}
>
Mark Done
</Button>
<Button
variant="destructive"
size="sm"
onClick={(e) => {
if (
e.shiftKey ||
window.confirm(
'Abandon this errand? The record will be kept for reference but the branch and worktree will be removed.',
)
) {
abandonMutation.mutate({ id: errandId });
}
}}
>
Abandon
</Button>
</div>
</>
)}
{/* View: Pending Review / Conflict */}
{(errand.status === 'pending_review' || errand.status === 'conflict') && (
<>
<div className="flex flex-col flex-1 overflow-hidden">
{/* Conflict notice */}
{errand.status === 'conflict' &&
(errand.conflictFiles?.length ?? 0) > 0 && (
<div className="mx-5 mt-4 rounded-md border border-destructive bg-destructive/10 px-4 py-3 text-sm text-destructive">
Merge conflict in {errand.conflictFiles!.length} file(s):{' '}
{errand.conflictFiles!.join(', ')} resolve manually in the
worktree then re-merge.
</div>
)}
{/* Diff block */}
<div className="flex-1 overflow-y-auto px-5 py-4">
{diffQuery.isLoading ? (
<p className="text-sm text-muted-foreground">Loading diff</p>
) : diffQuery.data?.diff ? (
<pre className="overflow-x-auto rounded border border-border bg-muted/50 p-4 text-xs font-mono whitespace-pre">
{diffQuery.data.diff}
</pre>
) : (
<p className="text-sm text-muted-foreground">
No changes branch has no commits.
</p>
)}
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between border-t border-border px-5 py-3">
<div className="flex gap-2">
<Button
variant="destructive"
size="sm"
onClick={(e) => {
if (
e.shiftKey ||
window.confirm(
'Delete this errand? Branch and worktree will be removed and the record deleted.',
)
) {
deleteMutation.mutate({ id: errandId });
}
}}
>
Delete
</Button>
<Button
variant="outline"
size="sm"
onClick={(e) => {
if (
e.shiftKey ||
window.confirm(
'Abandon this errand? The record will be kept for reference but the branch and worktree will be removed.',
)
) {
abandonMutation.mutate({ id: errandId });
}
}}
>
Abandon
</Button>
</div>
<Button
size="sm"
disabled={mergeMutation.isPending}
onClick={(e) => {
const target = errand.baseBranch;
if (
e.shiftKey ||
window.confirm(`Merge this errand into ${target}?`)
) {
mergeMutation.mutate({ id: errandId });
}
}}
>
{mergeMutation.isPending ? 'Merging…' : 'Merge'}
</Button>
</div>
</>
)}
{/* View: Merged / Abandoned */}
{(errand.status === 'merged' || errand.status === 'abandoned') && (
<>
<div className="flex flex-col flex-1 overflow-hidden">
{/* Info line */}
<div className="px-5 pt-4 text-sm text-muted-foreground">
{errand.status === 'merged'
? `Merged into ${errand.baseBranch} · ${formatRelativeTime(errand.updatedAt.toISOString())}`
: `Abandoned · ${formatRelativeTime(errand.updatedAt.toISOString())}`}
</div>
{/* Read-only diff */}
<div className="flex-1 overflow-y-auto px-5 py-4">
{diffQuery.data?.diff ? (
<pre className="overflow-x-auto rounded border border-border bg-muted/50 p-4 text-xs font-mono whitespace-pre">
{diffQuery.data.diff}
</pre>
) : (
<p className="text-sm text-muted-foreground">
No changes branch has no commits.
</p>
)}
</div>
</div>
{/* Footer */}
<div className="flex items-center border-t border-border px-5 py-3">
<Button
variant="destructive"
size="sm"
onClick={(e) => {
if (
e.shiftKey ||
window.confirm(
'Delete this errand? Branch and worktree will be removed and the record deleted.',
)
) {
deleteMutation.mutate({ id: errandId });
}
}}
>
Delete
</Button>
</div>
</>
)}
</>
)}
</motion.div>
</>
</AnimatePresence>
);
}

View File

@@ -0,0 +1,130 @@
import { useState } from 'react';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { Plus } from 'lucide-react';
import { motion } from 'motion/react';
import { Button } from '@/components/ui/button';
import { StatusBadge } from '@/components/StatusBadge';
import { CreateErrandDialog } from '@/components/CreateErrandDialog';
import { ErrandDetailPanel } from '@/components/ErrandDetailPanel';
import { trpc } from '@/lib/trpc';
import { formatRelativeTime } from '@/lib/utils';
export const Route = createFileRoute('/errands/')({
component: ErrandsPage,
validateSearch: (search: Record<string, unknown>) => ({
selected: typeof search.selected === 'string' ? search.selected : undefined,
}),
});
function ErrandsPage() {
const { selected } = Route.useSearch();
const navigate = useNavigate();
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [selectedErrandId, setSelectedErrandId] = useState<string | null>(selected ?? null);
const errandsQuery = trpc.errand.list.useQuery();
const errands = errandsQuery.data ?? [];
function selectErrand(id: string | null) {
setSelectedErrandId(id);
navigate({
to: '/errands',
search: id ? { selected: id } : {},
replace: true,
});
}
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: [0, 0, 0.2, 1] }}
className="mx-auto max-w-6xl space-y-6"
>
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="font-display text-2xl font-semibold">Errands</h1>
<Button size="sm" onClick={() => setCreateDialogOpen(true)}>
<Plus className="mr-1 h-4 w-4" />
New Errand
</Button>
</div>
{/* List / Empty state */}
{errands.length === 0 ? (
<p className="text-sm text-muted-foreground">
No errands yet. Click{' '}
<button
className="text-primary hover:underline"
onClick={() => setCreateDialogOpen(true)}
>
New Errand
</button>{' '}
or run:{' '}
<code className="rounded bg-muted px-1 py-0.5 text-xs font-mono">
cw errand start "&lt;description&gt;" --project &lt;id&gt;
</code>
</p>
) : (
<div className="overflow-hidden rounded-lg border border-border">
<table className="w-full text-sm">
<thead className="border-b border-border bg-muted/50">
<tr>
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">ID</th>
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">Description</th>
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">Branch</th>
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">Status</th>
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">Agent</th>
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">Created</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{errands.map((e) => (
<tr
key={e.id}
className="cursor-pointer hover:bg-muted/40 transition-colors"
onClick={() => selectErrand(e.id)}
>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">
{e.id.slice(0, 8)}
</td>
<td className="px-4 py-3 max-w-[280px] truncate">
{e.description.length > 60
? e.description.slice(0, 57) + '…'
: e.description}
</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground truncate max-w-[180px]">
{e.branch}
</td>
<td className="px-4 py-3">
<StatusBadge status={e.status} />
</td>
<td className="px-4 py-3 text-muted-foreground">
{e.agentAlias ?? '—'}
</td>
<td className="px-4 py-3 text-muted-foreground whitespace-nowrap">
{formatRelativeTime(e.createdAt.toISOString())}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Create dialog */}
<CreateErrandDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
/>
{/* Detail slide-over */}
{selectedErrandId && (
<ErrandDetailPanel
errandId={selectedErrandId}
onClose={() => selectErrand(null)}
/>
)}
</motion.div>
);
}

View File

@@ -0,0 +1,163 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { vi, describe, it, expect, beforeEach } from 'vitest'
const mockUseQuery = vi.hoisted(() => vi.fn())
vi.mock('@/lib/trpc', () => ({
trpc: {
getHeadquartersDashboard: { useQuery: mockUseQuery },
},
}))
vi.mock('@/hooks', () => ({
useLiveUpdates: vi.fn(),
LiveUpdateRule: undefined,
}))
vi.mock('@/components/hq/HQWaitingForInputSection', () => ({
HQWaitingForInputSection: ({ items }: any) => <div data-testid="waiting">{items.length}</div>,
}))
vi.mock('@/components/hq/HQNeedsReviewSection', () => ({
HQNeedsReviewSection: ({ initiatives, phases }: any) => (
<div data-testid="needs-review">{initiatives.length},{phases.length}</div>
),
}))
vi.mock('@/components/hq/HQNeedsApprovalSection', () => ({
HQNeedsApprovalSection: ({ items }: any) => <div data-testid="needs-approval">{items.length}</div>,
}))
vi.mock('@/components/hq/HQResolvingConflictsSection', () => ({
HQResolvingConflictsSection: ({ items }: any) => <div data-testid="resolving-conflicts">{items.length}</div>,
}))
vi.mock('@/components/hq/HQBlockedSection', () => ({
HQBlockedSection: ({ items }: any) => <div data-testid="blocked">{items.length}</div>,
}))
vi.mock('@/components/hq/HQEmptyState', () => ({
HQEmptyState: () => <div data-testid="empty-state">All clear</div>,
}))
// Import after mocks are set up
import { HeadquartersPage } from './hq'
const emptyData = {
waitingForInput: [],
pendingReviewInitiatives: [],
pendingReviewPhases: [],
planningInitiatives: [],
resolvingConflicts: [],
blockedPhases: [],
}
describe('HeadquartersPage', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders skeleton loading state', () => {
mockUseQuery.mockReturnValue({ isLoading: true, isError: false, data: undefined })
render(<HeadquartersPage />)
// Should show heading
expect(screen.getByText('Headquarters')).toBeInTheDocument()
// Should show skeleton elements (there are 3)
const skeletons = document.querySelectorAll('[class*="skeleton"], [class*="h-16"]')
expect(skeletons.length).toBeGreaterThan(0)
// No section components
expect(screen.queryByTestId('waiting')).not.toBeInTheDocument()
expect(screen.queryByTestId('needs-review')).not.toBeInTheDocument()
expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument()
expect(screen.queryByTestId('blocked')).not.toBeInTheDocument()
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
})
it('renders error state with retry button', () => {
const mockRefetch = vi.fn()
mockUseQuery.mockReturnValue({ isLoading: false, isError: true, data: undefined, refetch: mockRefetch })
render(<HeadquartersPage />)
expect(screen.getByText('Failed to load headquarters data.')).toBeInTheDocument()
const retryButton = screen.getByRole('button', { name: /retry/i })
expect(retryButton).toBeInTheDocument()
fireEvent.click(retryButton)
expect(mockRefetch).toHaveBeenCalledOnce()
})
it('renders empty state when all arrays are empty', () => {
mockUseQuery.mockReturnValue({ isLoading: false, isError: false, data: emptyData })
render(<HeadquartersPage />)
expect(screen.getByTestId('empty-state')).toBeInTheDocument()
expect(screen.queryByTestId('waiting')).not.toBeInTheDocument()
expect(screen.queryByTestId('needs-review')).not.toBeInTheDocument()
expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument()
expect(screen.queryByTestId('blocked')).not.toBeInTheDocument()
})
it('renders WaitingForInput section when items exist', () => {
mockUseQuery.mockReturnValue({
isLoading: false,
isError: false,
data: { ...emptyData, waitingForInput: [{ id: '1' }] },
})
render(<HeadquartersPage />)
expect(screen.getByTestId('waiting')).toBeInTheDocument()
expect(screen.queryByTestId('needs-review')).not.toBeInTheDocument()
expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument()
expect(screen.queryByTestId('blocked')).not.toBeInTheDocument()
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
})
it('renders all sections when all arrays have items', () => {
mockUseQuery.mockReturnValue({
isLoading: false,
isError: false,
data: {
waitingForInput: [{ id: '1' }],
pendingReviewInitiatives: [{ id: '2' }],
pendingReviewPhases: [{ id: '3' }],
planningInitiatives: [{ id: '4' }],
resolvingConflicts: [{ id: '5' }],
blockedPhases: [{ id: '6' }],
},
})
render(<HeadquartersPage />)
expect(screen.getByTestId('waiting')).toBeInTheDocument()
expect(screen.getByTestId('needs-review')).toBeInTheDocument()
expect(screen.getByTestId('needs-approval')).toBeInTheDocument()
expect(screen.getByTestId('resolving-conflicts')).toBeInTheDocument()
expect(screen.getByTestId('blocked')).toBeInTheDocument()
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
})
it('renders NeedsReview section when only pendingReviewInitiatives has items', () => {
mockUseQuery.mockReturnValue({
isLoading: false,
isError: false,
data: { ...emptyData, pendingReviewInitiatives: [{ id: '1' }] },
})
render(<HeadquartersPage />)
expect(screen.getByTestId('needs-review')).toBeInTheDocument()
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
})
it('renders NeedsReview section when only pendingReviewPhases has items', () => {
mockUseQuery.mockReturnValue({
isLoading: false,
isError: false,
data: { ...emptyData, pendingReviewPhases: [{ id: '1' }] },
})
render(<HeadquartersPage />)
expect(screen.getByTestId('needs-review')).toBeInTheDocument()
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
})
})

View File

@@ -116,6 +116,19 @@ Uses **Commander.js** for command parsing.
All three commands output JSON for programmatic agent consumption. All three commands output JSON for programmatic agent consumption.
### Errand Sessions (`cw errand`)
| Command | Description |
|---------|-------------|
| `start <description> --project <id> [--base <branch>]` | Start a new errand session (description ≤200 chars) |
| `list [--project <id>] [--status <status>]` | List errands; status: active\|pending_review\|conflict\|merged\|abandoned |
| `chat <id> <message>` | Deliver a message to the running errand agent |
| `diff <id>` | Print unified git diff between base branch and errand branch |
| `complete <id>` | Mark errand as done and ready for review |
| `merge <id> [--target <branch>]` | Merge errand branch into target branch |
| `resolve <id>` | Print worktree path and conflicting files for manual resolution |
| `abandon <id>` | Stop agent, remove worktree and branch, keep DB record as abandoned |
| `delete <id>` | Stop agent, remove worktree, delete branch, and delete DB record |
### Accounts (`cw account`) ### Accounts (`cw account`)
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|