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', () => {
|
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');
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
return program;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 { 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,
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
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.
|
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 |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
|
|||||||
Reference in New Issue
Block a user