Merge branch 'cw/small-change-flow-phase-cli-errand-commands' into cw-merge-1772810965256
This commit is contained in:
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;
|
||||
}
|
||||
|
||||
|
||||
161
apps/server/db/repositories/drizzle/errand.test.ts
Normal file
161
apps/server/db/repositories/drizzle/errand.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* DrizzleErrandRepository Tests
|
||||
*
|
||||
* Tests for the Errand repository adapter.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { DrizzleErrandRepository } from './errand.js';
|
||||
import { DrizzleProjectRepository } from './project.js';
|
||||
import { createTestDatabase } from './test-helpers.js';
|
||||
import type { DrizzleDatabase } from '../../index.js';
|
||||
import type { Project } from '../../schema.js';
|
||||
|
||||
describe('DrizzleErrandRepository', () => {
|
||||
let db: DrizzleDatabase;
|
||||
let repo: DrizzleErrandRepository;
|
||||
let projectRepo: DrizzleProjectRepository;
|
||||
|
||||
const createProject = async (): Promise<Project> => {
|
||||
const suffix = Math.random().toString(36).slice(2, 8);
|
||||
return projectRepo.create({
|
||||
name: `test-project-${suffix}`,
|
||||
url: `https://github.com/test/repo-${suffix}`,
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDatabase();
|
||||
repo = new DrizzleErrandRepository(db);
|
||||
projectRepo = new DrizzleProjectRepository(db);
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creates an errand with generated id and timestamps', async () => {
|
||||
const project = await createProject();
|
||||
const errand = await repo.create({
|
||||
description: 'fix typo',
|
||||
branch: 'cw/errand/fix-typo-abc12345',
|
||||
baseBranch: 'main',
|
||||
agentId: null,
|
||||
projectId: project.id,
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
expect(errand.id).toBeDefined();
|
||||
expect(errand.id.length).toBeGreaterThan(0);
|
||||
expect(errand.description).toBe('fix typo');
|
||||
expect(errand.branch).toBe('cw/errand/fix-typo-abc12345');
|
||||
expect(errand.baseBranch).toBe('main');
|
||||
expect(errand.agentId).toBeNull();
|
||||
expect(errand.projectId).toBe(project.id);
|
||||
expect(errand.status).toBe('active');
|
||||
expect(errand.conflictFiles).toBeNull();
|
||||
expect(errand.createdAt).toBeInstanceOf(Date);
|
||||
expect(errand.updatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('returns null for non-existent errand', async () => {
|
||||
const result = await repo.findById('does-not-exist');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns errand with agentAlias null when no agent', async () => {
|
||||
const project = await createProject();
|
||||
const created = await repo.create({
|
||||
description: 'test',
|
||||
branch: 'cw/errand/test-xyz',
|
||||
baseBranch: 'main',
|
||||
agentId: null,
|
||||
projectId: project.id,
|
||||
status: 'active',
|
||||
});
|
||||
const found = await repo.findById(created.id);
|
||||
expect(found).not.toBeNull();
|
||||
expect(found!.agentAlias).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('returns empty array when no errands', async () => {
|
||||
const results = await repo.findAll();
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('filters by projectId', async () => {
|
||||
const projectA = await createProject();
|
||||
const projectB = await createProject();
|
||||
await repo.create({ description: 'a', branch: 'cw/errand/a', baseBranch: 'main', agentId: null, projectId: projectA.id, status: 'active' });
|
||||
await repo.create({ description: 'b', branch: 'cw/errand/b', baseBranch: 'main', agentId: null, projectId: projectB.id, status: 'active' });
|
||||
|
||||
const results = await repo.findAll({ projectId: projectA.id });
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].description).toBe('a');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates errand status', async () => {
|
||||
const project = await createProject();
|
||||
const created = await repo.create({
|
||||
description: 'upd test',
|
||||
branch: 'cw/errand/upd',
|
||||
baseBranch: 'main',
|
||||
agentId: null,
|
||||
projectId: project.id,
|
||||
status: 'active',
|
||||
});
|
||||
const updated = await repo.update(created.id, { status: 'pending_review' });
|
||||
expect(updated!.status).toBe('pending_review');
|
||||
});
|
||||
});
|
||||
|
||||
describe('conflictFiles column', () => {
|
||||
it('stores and retrieves conflictFiles via update + findById', async () => {
|
||||
const project = await createProject();
|
||||
const created = await repo.create({
|
||||
description: 'x',
|
||||
branch: 'cw/errand/x',
|
||||
baseBranch: 'main',
|
||||
agentId: null,
|
||||
projectId: project.id,
|
||||
status: 'active',
|
||||
});
|
||||
await repo.update(created.id, { status: 'conflict', conflictFiles: '["src/a.ts","src/b.ts"]' });
|
||||
const found = await repo.findById(created.id);
|
||||
expect(found!.conflictFiles).toBe('["src/a.ts","src/b.ts"]');
|
||||
expect(found!.status).toBe('conflict');
|
||||
});
|
||||
|
||||
it('returns null conflictFiles for non-conflict errands', async () => {
|
||||
const project = await createProject();
|
||||
const created = await repo.create({
|
||||
description: 'y',
|
||||
branch: 'cw/errand/y',
|
||||
baseBranch: 'main',
|
||||
agentId: null,
|
||||
projectId: project.id,
|
||||
status: 'active',
|
||||
});
|
||||
const found = await repo.findById(created.id);
|
||||
expect(found!.conflictFiles).toBeNull();
|
||||
});
|
||||
|
||||
it('findAll includes conflictFiles in results', async () => {
|
||||
const project = await createProject();
|
||||
const created = await repo.create({
|
||||
description: 'z',
|
||||
branch: 'cw/errand/z',
|
||||
baseBranch: 'main',
|
||||
agentId: null,
|
||||
projectId: project.id,
|
||||
status: 'active',
|
||||
});
|
||||
await repo.update(created.id, { conflictFiles: '["x.ts"]' });
|
||||
const all = await repo.findAll({ projectId: project.id });
|
||||
expect(all[0].conflictFiles).toBe('["x.ts"]');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,7 @@ vi.mock('../../git/manager.js', () => ({
|
||||
|
||||
vi.mock('../../git/project-clones.js', () => ({
|
||||
ensureProjectClone: mockEnsureProjectClone,
|
||||
getProjectCloneDir: vi.fn().mockReturnValue('repos/test-project-abc123'),
|
||||
}));
|
||||
|
||||
vi.mock('../../agent/file-io.js', async (importOriginal) => {
|
||||
@@ -393,7 +394,7 @@ describe('errand procedures', () => {
|
||||
|
||||
expect(result.id).toBe(errand.id);
|
||||
expect(result).toHaveProperty('agentAlias');
|
||||
expect(result.conflictFiles).toBeNull();
|
||||
expect(result.conflictFiles).toEqual([]);
|
||||
});
|
||||
|
||||
it('parses conflictFiles JSON when present', async () => {
|
||||
|
||||
@@ -18,8 +18,9 @@ import {
|
||||
} 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 } from '../../git/project-clones.js';
|
||||
import { ensureProjectClone, getProjectCloneDir } from '../../git/project-clones.js';
|
||||
import type { TRPCContext } from '../context.js';
|
||||
|
||||
// ErrandStatus values for input validation
|
||||
@@ -200,10 +201,27 @@ export function errandProcedures(publicProcedure: ProcedureBuilder) {
|
||||
if (!errand) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Errand not found' });
|
||||
}
|
||||
return {
|
||||
...errand,
|
||||
conflictFiles: errand.conflictFiles ? (JSON.parse(errand.conflictFiles) as string[]) : null,
|
||||
};
|
||||
|
||||
// Parse conflictFiles; return [] on null or malformed JSON
|
||||
let conflictFiles: string[] = [];
|
||||
if (errand.conflictFiles) {
|
||||
try {
|
||||
conflictFiles = JSON.parse(errand.conflictFiles) as string[];
|
||||
} catch {
|
||||
conflictFiles = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 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, conflictFiles, projectPath };
|
||||
}),
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -116,6 +116,19 @@ Uses **Commander.js** for command parsing.
|
||||
|
||||
All three commands output JSON for programmatic agent consumption.
|
||||
|
||||
### Errand Sessions (`cw errand`)
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `start <description> --project <id> [--base <branch>]` | Start a new errand session (description ≤200 chars) |
|
||||
| `list [--project <id>] [--status <status>]` | List errands; status: active\|pending_review\|conflict\|merged\|abandoned |
|
||||
| `chat <id> <message>` | Deliver a message to the running errand agent |
|
||||
| `diff <id>` | Print unified git diff between base branch and errand branch |
|
||||
| `complete <id>` | Mark errand as done and ready for review |
|
||||
| `merge <id> [--target <branch>]` | Merge errand branch into target branch |
|
||||
| `resolve <id>` | Print worktree path and conflicting files for manual resolution |
|
||||
| `abandon <id>` | Stop agent, remove worktree and branch, keep DB record as abandoned |
|
||||
| `delete <id>` | Stop agent, remove worktree, delete branch, and delete DB record |
|
||||
|
||||
### Accounts (`cw account`)
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
|
||||
@@ -278,7 +278,7 @@ Small isolated changes that spawn a dedicated agent in a git worktree. Errands a
|
||||
|-----------|------|-------------|
|
||||
| `errand.create` | mutation | Create errand: `{description, projectId, baseBranch?}` → `{id, branch, agentId}`. Creates branch, worktree, DB record, spawns agent. |
|
||||
| `errand.list` | query | List errands: `{projectId?, status?}` → ErrandWithAlias[] (ordered newest-first) |
|
||||
| `errand.get` | query | Get errand by ID: `{id}` → ErrandWithAlias with parsed `conflictFiles` |
|
||||
| `errand.get` | query | Get errand by ID: `{id}` → ErrandWithAlias with parsed `conflictFiles: string[]` (never null) and `projectPath: string \| null` (computed from workspaceRoot) |
|
||||
| `errand.diff` | query | Get branch diff: `{id}` → `{diff: string}` |
|
||||
| `errand.complete` | mutation | Mark active errand ready for review (stops agent): `{id}` → Errand |
|
||||
| `errand.merge` | mutation | Merge errand branch: `{id, target?}` → `{status: 'merged'}` or throws conflict |
|
||||
|
||||
Reference in New Issue
Block a user