Merge branch 'cw/small-change-flow' into cw-merge-1772827396087
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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<string, MockAgentScenario> = 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<AgentInfo> {
|
||||
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<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.
|
||||
* Useful for test cleanup.
|
||||
|
||||
@@ -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<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>;
|
||||
}
|
||||
|
||||
266
apps/server/cli/errand.test.ts
Normal file
266
apps/server/cli/errand.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
17
apps/server/drizzle/0034_salty_next_avengers.sql
Normal file
17
apps/server/drizzle/0034_salty_next_avengers.sql
Normal 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`);
|
||||
1988
apps/server/drizzle/meta/0034_snapshot.json
Normal file
1988
apps/server/drizzle/meta/0034_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
442
apps/server/trpc/routers/errand.ts
Normal file
442
apps/server/trpc/routers/errand.ts
Normal 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';
|
||||
|
||||
// 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;
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
142
apps/web/src/components/CreateErrandDialog.tsx
Normal file
142
apps/web/src/components/CreateErrandDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
379
apps/web/src/components/ErrandDetailPanel.tsx
Normal file
379
apps/web/src/components/ErrandDetailPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
apps/web/src/routes/errands/index.tsx
Normal file
130
apps/web/src/routes/errands/index.tsx
Normal 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 "<description>" --project <id>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
163
apps/web/src/routes/hq.test.tsx
Normal file
163
apps/web/src/routes/hq.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -116,6 +116,19 @@ Uses **Commander.js** for command parsing.
|
||||
|
||||
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`)
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
|
||||
Reference in New Issue
Block a user