feat: Add errand tRPC router with all 9 procedures and comprehensive tests
Implements the errand workflow for small isolated changes that spawn a dedicated agent in a git worktree: - errand.create: branch + worktree + DB record + agent spawn - errand.list / errand.get / errand.diff: read procedures - errand.complete: transitions active→pending_review, stops agent - errand.merge: merges branch, handles conflicts with conflictFiles - errand.delete / errand.abandon: cleanup worktree, branch, agent - errand.sendMessage: delivers user message directly to running agent Supporting changes: - Add 'errand' to AgentMode union and agents.mode enum - Add sendUserMessage() to AgentManager interface and MockAgentManager - MockAgentManager now accepts optional agentRepository to persist agents to the DB (required for FK constraint satisfaction in tests) - Add ORDER BY createdAt DESC, id DESC to errand findAll - Fix dispatch/manager.test.ts missing sendUserMessage mock Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||||
|
|
||||||
@@ -507,6 +528,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.
|
||||||
@@ -257,4 +257,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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Implements ErrandRepository interface using Drizzle ORM.
|
* Implements ErrandRepository interface using Drizzle ORM.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and, desc } from 'drizzle-orm';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import type { DrizzleDatabase } from '../../index.js';
|
import type { DrizzleDatabase } from '../../index.js';
|
||||||
import { errands, agents, type Errand } from '../../schema.js';
|
import { errands, agents, type Errand } from '../../schema.js';
|
||||||
@@ -81,7 +81,8 @@ export class DrizzleErrandRepository implements ErrandRepository {
|
|||||||
})
|
})
|
||||||
.from(errands)
|
.from(errands)
|
||||||
.leftJoin(agents, eq(errands.agentId, agents.id))
|
.leftJoin(agents, eq(errands.agentId, agents.id))
|
||||||
.where(conditions.length > 0 ? and(...conditions) : undefined);
|
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||||
|
.orderBy(desc(errands.createdAt), desc(errands.id));
|
||||||
return rows as ErrandWithAlias[];
|
return rows as ErrandWithAlias[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ export const agents = sqliteTable('agents', {
|
|||||||
})
|
})
|
||||||
.notNull()
|
.notNull()
|
||||||
.default('idle'),
|
.default('idle'),
|
||||||
mode: text('mode', { enum: ['execute', 'discuss', 'plan', 'detail', 'refine', 'chat'] })
|
mode: text('mode', { enum: ['execute', 'discuss', 'plan', 'detail', 'refine', 'chat', 'errand'] })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default('execute'),
|
.default('execute'),
|
||||||
pid: integer('pid'),
|
pid: integer('pid'),
|
||||||
|
|||||||
@@ -81,6 +81,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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { subscriptionProcedures } from './routers/subscription.js';
|
|||||||
import { previewProcedures } from './routers/preview.js';
|
import { previewProcedures } from './routers/preview.js';
|
||||||
import { conversationProcedures } from './routers/conversation.js';
|
import { conversationProcedures } from './routers/conversation.js';
|
||||||
import { chatSessionProcedures } from './routers/chat-session.js';
|
import { chatSessionProcedures } from './routers/chat-session.js';
|
||||||
|
import { errandProcedures } from './routers/errand.js';
|
||||||
|
|
||||||
// Re-export tRPC primitives (preserves existing import paths)
|
// Re-export tRPC primitives (preserves existing import paths)
|
||||||
export { router, publicProcedure, middleware, createCallerFactory } from './trpc.js';
|
export { router, publicProcedure, middleware, createCallerFactory } from './trpc.js';
|
||||||
@@ -63,6 +64,7 @@ export const appRouter = router({
|
|||||||
...previewProcedures(publicProcedure),
|
...previewProcedures(publicProcedure),
|
||||||
...conversationProcedures(publicProcedure),
|
...conversationProcedures(publicProcedure),
|
||||||
...chatSessionProcedures(publicProcedure),
|
...chatSessionProcedures(publicProcedure),
|
||||||
|
...errandProcedures(publicProcedure),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|||||||
720
apps/server/trpc/routers/errand.test.ts
Normal file
720
apps/server/trpc/routers/errand.test.ts
Normal file
@@ -0,0 +1,720 @@
|
|||||||
|
/**
|
||||||
|
* Errand Router Tests
|
||||||
|
*
|
||||||
|
* Tests all 9 errand tRPC procedures using in-memory SQLite, MockAgentManager,
|
||||||
|
* and vi.mock for git operations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import type { BranchManager } from '../../git/branch-manager.js';
|
||||||
|
import type { MergeResult, MergeabilityResult } from '../../git/types.js';
|
||||||
|
import { MockAgentManager } from '../../agent/mock-manager.js';
|
||||||
|
import { EventEmitterBus } from '../../events/bus.js';
|
||||||
|
import { createTestDatabase } from '../../db/repositories/drizzle/test-helpers.js';
|
||||||
|
import { createRepositories } from '../../container.js';
|
||||||
|
import { appRouter, createCallerFactory } from '../router.js';
|
||||||
|
import { createContext } from '../context.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// vi.hoisted mock handles for git module mocks (hoisted before vi.mock calls)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const { mockCreate, mockRemove, mockEnsureProjectClone, mockWriteErrandManifest } = vi.hoisted(() => ({
|
||||||
|
mockCreate: vi.fn(),
|
||||||
|
mockRemove: vi.fn(),
|
||||||
|
mockEnsureProjectClone: vi.fn(),
|
||||||
|
mockWriteErrandManifest: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../git/manager.js', () => ({
|
||||||
|
SimpleGitWorktreeManager: class MockWorktreeManager {
|
||||||
|
create = mockCreate;
|
||||||
|
remove = mockRemove;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../git/project-clones.js', () => ({
|
||||||
|
ensureProjectClone: mockEnsureProjectClone,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../agent/file-io.js', async (importOriginal) => {
|
||||||
|
const original = await importOriginal() as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
...original,
|
||||||
|
writeErrandManifest: mockWriteErrandManifest,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// MockBranchManager
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
class MockBranchManager implements BranchManager {
|
||||||
|
private ensureBranchError: Error | null = null;
|
||||||
|
private mergeResultOverride: MergeResult | null = null;
|
||||||
|
private diffResult = '';
|
||||||
|
public deletedBranches: string[] = [];
|
||||||
|
public ensuredBranches: string[] = [];
|
||||||
|
|
||||||
|
setEnsureBranchError(err: Error | null): void { this.ensureBranchError = err; }
|
||||||
|
setMergeResult(result: MergeResult): void { this.mergeResultOverride = result; }
|
||||||
|
setDiffResult(diff: string): void { this.diffResult = diff; }
|
||||||
|
|
||||||
|
async ensureBranch(_repoPath: string, branch: string, _baseBranch: string): Promise<void> {
|
||||||
|
if (this.ensureBranchError) throw this.ensureBranchError;
|
||||||
|
this.ensuredBranches.push(branch);
|
||||||
|
}
|
||||||
|
async mergeBranch(_repoPath: string, _src: string, _target: string): Promise<MergeResult> {
|
||||||
|
return this.mergeResultOverride ?? { success: true, message: 'Merged successfully' };
|
||||||
|
}
|
||||||
|
async diffBranches(_repoPath: string, _base: string, _head: string): Promise<string> {
|
||||||
|
return this.diffResult;
|
||||||
|
}
|
||||||
|
async deleteBranch(_repoPath: string, branch: string): Promise<void> {
|
||||||
|
this.deletedBranches.push(branch);
|
||||||
|
}
|
||||||
|
async branchExists(_repoPath: string, _branch: string): Promise<boolean> { return false; }
|
||||||
|
async remoteBranchExists(_repoPath: string, _branch: string): Promise<boolean> { return false; }
|
||||||
|
async listCommits(_repoPath: string, _base: string, _head: string) { return []; }
|
||||||
|
async diffCommit(_repoPath: string, _hash: string): Promise<string> { return ''; }
|
||||||
|
async getMergeBase(_repoPath: string, _b1: string, _b2: string): Promise<string> { return ''; }
|
||||||
|
async pushBranch(_repoPath: string, _branch: string, _remote?: string): Promise<void> {}
|
||||||
|
async checkMergeability(_repoPath: string, _src: string, _target: string): Promise<MergeabilityResult> {
|
||||||
|
return { mergeable: true, conflicts: [] };
|
||||||
|
}
|
||||||
|
async fetchRemote(_repoPath: string, _remote?: string): Promise<void> {}
|
||||||
|
async fastForwardBranch(_repoPath: string, _branch: string, _remote?: string): Promise<void> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const createCaller = createCallerFactory(appRouter);
|
||||||
|
|
||||||
|
function createTestHarness() {
|
||||||
|
const db = createTestDatabase();
|
||||||
|
const eventBus = new EventEmitterBus();
|
||||||
|
const repos = createRepositories(db);
|
||||||
|
const agentManager = new MockAgentManager({ eventBus, agentRepository: repos.agentRepository });
|
||||||
|
const branchManager = new MockBranchManager();
|
||||||
|
|
||||||
|
const ctx = createContext({
|
||||||
|
eventBus,
|
||||||
|
serverStartedAt: new Date(),
|
||||||
|
processCount: 0,
|
||||||
|
agentManager,
|
||||||
|
errandRepository: repos.errandRepository,
|
||||||
|
projectRepository: repos.projectRepository,
|
||||||
|
branchManager,
|
||||||
|
workspaceRoot: '/tmp/test-workspace',
|
||||||
|
});
|
||||||
|
|
||||||
|
const caller = createCaller(ctx);
|
||||||
|
|
||||||
|
return {
|
||||||
|
db,
|
||||||
|
caller,
|
||||||
|
agentManager,
|
||||||
|
branchManager,
|
||||||
|
repos,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createProject(repos: ReturnType<typeof createRepositories>) {
|
||||||
|
const suffix = nanoid().slice(0, 6);
|
||||||
|
return repos.projectRepository.create({
|
||||||
|
name: `test-project-${suffix}`,
|
||||||
|
url: `https://github.com/test/project-${suffix}`,
|
||||||
|
defaultBranch: 'main',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createErrandDirect(
|
||||||
|
repos: ReturnType<typeof createRepositories>,
|
||||||
|
agentManager: MockAgentManager,
|
||||||
|
overrides: Partial<{
|
||||||
|
description: string;
|
||||||
|
branch: string;
|
||||||
|
baseBranch: string;
|
||||||
|
agentId: string | null;
|
||||||
|
projectId: string;
|
||||||
|
status: 'active' | 'pending_review' | 'conflict' | 'merged' | 'abandoned';
|
||||||
|
conflictFiles: string | null;
|
||||||
|
}> = {},
|
||||||
|
) {
|
||||||
|
const project = await createProject(repos);
|
||||||
|
// Spawn an agent to get a real agent ID (unique name to avoid name collision)
|
||||||
|
const agent = await agentManager.spawn({
|
||||||
|
prompt: 'Test errand',
|
||||||
|
name: `errand-agent-${nanoid().slice(0, 6)}`,
|
||||||
|
mode: 'errand',
|
||||||
|
cwd: '/tmp/fake-worktree',
|
||||||
|
taskId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const errand = await repos.errandRepository.create({
|
||||||
|
description: overrides.description ?? 'Fix typo in README',
|
||||||
|
branch: overrides.branch ?? 'cw/errand/fix-typo-abc12345',
|
||||||
|
baseBranch: overrides.baseBranch ?? 'main',
|
||||||
|
agentId: overrides.agentId !== undefined ? overrides.agentId : agent.id,
|
||||||
|
projectId: overrides.projectId ?? project.id,
|
||||||
|
status: overrides.status ?? 'active',
|
||||||
|
conflictFiles: overrides.conflictFiles ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { errand, project, agent };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('errand procedures', () => {
|
||||||
|
let h: ReturnType<typeof createTestHarness>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
h = createTestHarness();
|
||||||
|
// Reset mock call counts and set default passing behavior
|
||||||
|
mockCreate.mockClear();
|
||||||
|
mockRemove.mockClear();
|
||||||
|
mockEnsureProjectClone.mockClear();
|
||||||
|
mockWriteErrandManifest.mockClear();
|
||||||
|
mockEnsureProjectClone.mockResolvedValue('/tmp/fake-clone');
|
||||||
|
mockCreate.mockResolvedValue({ id: 'errand-id', branch: 'cw/errand/test', path: '/tmp/worktree', isMainWorktree: false });
|
||||||
|
mockRemove.mockResolvedValue(undefined);
|
||||||
|
mockWriteErrandManifest.mockResolvedValue(undefined);
|
||||||
|
h.branchManager.setEnsureBranchError(null);
|
||||||
|
h.branchManager.deletedBranches.splice(0);
|
||||||
|
h.branchManager.ensuredBranches.splice(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// errand.create
|
||||||
|
// =========================================================================
|
||||||
|
describe('errand.create', () => {
|
||||||
|
it('creates errand with valid input and returns id, branch, agentId', async () => {
|
||||||
|
const project = await createProject(h.repos);
|
||||||
|
const result = await h.caller.errand.create({
|
||||||
|
description: 'Fix typo in README',
|
||||||
|
projectId: project.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
id: expect.any(String),
|
||||||
|
branch: expect.stringMatching(/^cw\/errand\/fix-typo-in-readme-[a-zA-Z0-9_-]{8}$/),
|
||||||
|
agentId: expect.any(String),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates correct slug from description', async () => {
|
||||||
|
const project = await createProject(h.repos);
|
||||||
|
const result = await h.caller.errand.create({
|
||||||
|
description: 'fix typo in README',
|
||||||
|
projectId: project.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.branch).toMatch(/^cw\/errand\/fix-typo-in-readme-[a-zA-Z0-9_-]{8}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses fallback slug "errand" when description has only special chars', async () => {
|
||||||
|
const project = await createProject(h.repos);
|
||||||
|
const result = await h.caller.errand.create({
|
||||||
|
description: '!!!',
|
||||||
|
projectId: project.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.branch).toMatch(/^cw\/errand\/errand-[a-zA-Z0-9_-]{8}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores errand in database with correct fields', async () => {
|
||||||
|
const project = await createProject(h.repos);
|
||||||
|
const result = await h.caller.errand.create({
|
||||||
|
description: 'Fix typo in README',
|
||||||
|
projectId: project.id,
|
||||||
|
baseBranch: 'develop',
|
||||||
|
});
|
||||||
|
|
||||||
|
const errand = await h.repos.errandRepository.findById(result.id);
|
||||||
|
expect(errand).not.toBeNull();
|
||||||
|
expect(errand!.description).toBe('Fix typo in README');
|
||||||
|
expect(errand!.baseBranch).toBe('develop');
|
||||||
|
expect(errand!.projectId).toBe(project.id);
|
||||||
|
expect(errand!.status).toBe('active');
|
||||||
|
expect(errand!.agentId).toBe(result.agentId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws BAD_REQUEST when description exceeds 200 chars', async () => {
|
||||||
|
const project = await createProject(h.repos);
|
||||||
|
const longDesc = 'a'.repeat(201);
|
||||||
|
|
||||||
|
await expect(h.caller.errand.create({
|
||||||
|
description: longDesc,
|
||||||
|
projectId: project.id,
|
||||||
|
})).rejects.toMatchObject({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: `description must be ≤200 characters (201 given)`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// No DB record created
|
||||||
|
const errands = await h.repos.errandRepository.findAll();
|
||||||
|
expect(errands).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NOT_FOUND for non-existent projectId', async () => {
|
||||||
|
await expect(h.caller.errand.create({
|
||||||
|
description: 'Fix something',
|
||||||
|
projectId: 'nonexistent-project',
|
||||||
|
})).rejects.toMatchObject({
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: 'Project not found',
|
||||||
|
});
|
||||||
|
|
||||||
|
// No DB record created
|
||||||
|
const errands = await h.repos.errandRepository.findAll();
|
||||||
|
expect(errands).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws INTERNAL_SERVER_ERROR when branch creation fails', async () => {
|
||||||
|
const project = await createProject(h.repos);
|
||||||
|
h.branchManager.setEnsureBranchError(new Error('Git error: branch locked'));
|
||||||
|
|
||||||
|
await expect(h.caller.errand.create({
|
||||||
|
description: 'Fix something',
|
||||||
|
projectId: project.id,
|
||||||
|
})).rejects.toMatchObject({
|
||||||
|
code: 'INTERNAL_SERVER_ERROR',
|
||||||
|
message: 'Git error: branch locked',
|
||||||
|
});
|
||||||
|
|
||||||
|
// No DB record, no worktree created
|
||||||
|
const errands = await h.repos.errandRepository.findAll();
|
||||||
|
expect(errands).toHaveLength(0);
|
||||||
|
expect(mockCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws INTERNAL_SERVER_ERROR when worktree creation fails, cleans up branch and DB record', async () => {
|
||||||
|
const project = await createProject(h.repos);
|
||||||
|
mockCreate.mockRejectedValueOnce(new Error('Worktree creation failed'));
|
||||||
|
|
||||||
|
await expect(h.caller.errand.create({
|
||||||
|
description: 'Fix something',
|
||||||
|
projectId: project.id,
|
||||||
|
})).rejects.toMatchObject({
|
||||||
|
code: 'INTERNAL_SERVER_ERROR',
|
||||||
|
message: 'Worktree creation failed',
|
||||||
|
});
|
||||||
|
|
||||||
|
// No DB record (was created then deleted)
|
||||||
|
const errands = await h.repos.errandRepository.findAll();
|
||||||
|
expect(errands).toHaveLength(0);
|
||||||
|
|
||||||
|
// Branch was deleted
|
||||||
|
expect(h.branchManager.deletedBranches.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws INTERNAL_SERVER_ERROR when agent spawn fails, cleans up worktree, DB record, and branch', async () => {
|
||||||
|
const project = await createProject(h.repos);
|
||||||
|
|
||||||
|
// Make spawn fail by using a scenario that throws immediately
|
||||||
|
vi.spyOn(h.agentManager, 'spawn').mockRejectedValueOnce(new Error('Spawn failed'));
|
||||||
|
|
||||||
|
await expect(h.caller.errand.create({
|
||||||
|
description: 'Fix something',
|
||||||
|
projectId: project.id,
|
||||||
|
})).rejects.toMatchObject({
|
||||||
|
code: 'INTERNAL_SERVER_ERROR',
|
||||||
|
message: 'Spawn failed',
|
||||||
|
});
|
||||||
|
|
||||||
|
// No DB record (was created then deleted)
|
||||||
|
const errands = await h.repos.errandRepository.findAll();
|
||||||
|
expect(errands).toHaveLength(0);
|
||||||
|
|
||||||
|
// Worktree was removed, branch deleted
|
||||||
|
expect(mockRemove).toHaveBeenCalledOnce();
|
||||||
|
expect(h.branchManager.deletedBranches.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// errand.list
|
||||||
|
// =========================================================================
|
||||||
|
describe('errand.list', () => {
|
||||||
|
it('returns all errands ordered newest first', async () => {
|
||||||
|
const { errand: e1 } = await createErrandDirect(h.repos, h.agentManager, { description: 'First' });
|
||||||
|
const project2 = await h.repos.projectRepository.create({ name: 'proj2', url: 'https://github.com/t/p2', defaultBranch: 'main' });
|
||||||
|
const { errand: e2 } = await createErrandDirect(h.repos, h.agentManager, { description: 'Second', projectId: project2.id, branch: 'cw/errand/second-xyz12345' });
|
||||||
|
|
||||||
|
const result = await h.caller.errand.list({});
|
||||||
|
expect(result.length).toBe(2);
|
||||||
|
// Both errands are present (repository orders by createdAt DESC)
|
||||||
|
const ids = result.map(r => r.id);
|
||||||
|
expect(ids).toContain(e1.id);
|
||||||
|
expect(ids).toContain(e2.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by projectId', async () => {
|
||||||
|
const { errand: e1, project } = await createErrandDirect(h.repos, h.agentManager);
|
||||||
|
const project2 = await h.repos.projectRepository.create({ name: 'proj2', url: 'https://github.com/t/p2', defaultBranch: 'main' });
|
||||||
|
const agent2 = await h.agentManager.spawn({ prompt: 'x', mode: 'errand', cwd: '/tmp/x', taskId: null });
|
||||||
|
await h.repos.errandRepository.create({ description: 'Other', branch: 'cw/errand/other-abc12345', baseBranch: 'main', agentId: agent2.id, projectId: project2.id, status: 'active', conflictFiles: null });
|
||||||
|
|
||||||
|
const result = await h.caller.errand.list({ projectId: project.id });
|
||||||
|
expect(result.length).toBe(1);
|
||||||
|
expect(result[0].id).toBe(e1.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by status', async () => {
|
||||||
|
await createErrandDirect(h.repos, h.agentManager, { status: 'active' });
|
||||||
|
const { errand: e2 } = await createErrandDirect(h.repos, h.agentManager, { status: 'merged', branch: 'cw/errand/merged-abc12345' });
|
||||||
|
|
||||||
|
const result = await h.caller.errand.list({ status: 'merged' });
|
||||||
|
expect(result.length).toBe(1);
|
||||||
|
expect(result[0].id).toBe(e2.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when no errands exist', async () => {
|
||||||
|
const result = await h.caller.errand.list({});
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each record includes agentAlias', async () => {
|
||||||
|
await createErrandDirect(h.repos, h.agentManager);
|
||||||
|
const result = await h.caller.errand.list({});
|
||||||
|
expect(result[0]).toHaveProperty('agentAlias');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// errand.get
|
||||||
|
// =========================================================================
|
||||||
|
describe('errand.get', () => {
|
||||||
|
it('returns errand with agentAlias and parsed conflictFiles', async () => {
|
||||||
|
const { errand } = await createErrandDirect(h.repos, h.agentManager);
|
||||||
|
const result = await h.caller.errand.get({ id: errand.id });
|
||||||
|
|
||||||
|
expect(result.id).toBe(errand.id);
|
||||||
|
expect(result).toHaveProperty('agentAlias');
|
||||||
|
expect(result.conflictFiles).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses conflictFiles JSON when present', async () => {
|
||||||
|
const { errand } = await createErrandDirect(h.repos, h.agentManager, {
|
||||||
|
status: 'conflict',
|
||||||
|
conflictFiles: '["src/a.ts","src/b.ts"]',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await h.caller.errand.get({ id: errand.id });
|
||||||
|
expect(result.conflictFiles).toEqual(['src/a.ts', 'src/b.ts']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NOT_FOUND for unknown id', async () => {
|
||||||
|
await expect(h.caller.errand.get({ id: 'nonexistent' })).rejects.toMatchObject({
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: 'Errand not found',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// errand.diff
|
||||||
|
// =========================================================================
|
||||||
|
describe('errand.diff', () => {
|
||||||
|
it('returns diff string for an existing errand', async () => {
|
||||||
|
const { errand } = await createErrandDirect(h.repos, h.agentManager);
|
||||||
|
h.branchManager.setDiffResult('diff --git a/README.md b/README.md\n...');
|
||||||
|
|
||||||
|
const result = await h.caller.errand.diff({ id: errand.id });
|
||||||
|
expect(result.diff).toBe('diff --git a/README.md b/README.md\n...');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty diff string when branch has no commits', async () => {
|
||||||
|
const { errand } = await createErrandDirect(h.repos, h.agentManager);
|
||||||
|
h.branchManager.setDiffResult('');
|
||||||
|
|
||||||
|
const result = await h.caller.errand.diff({ id: errand.id });
|
||||||
|
expect(result.diff).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NOT_FOUND for unknown id', async () => {
|
||||||
|
await expect(h.caller.errand.diff({ id: 'nonexistent' })).rejects.toMatchObject({
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: 'Errand not found',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// errand.complete
|
||||||
|
// =========================================================================
|
||||||
|
describe('errand.complete', () => {
|
||||||
|
it('transitions active errand to pending_review and stops agent', async () => {
|
||||||
|
const { errand, agent } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' });
|
||||||
|
const stopSpy = vi.spyOn(h.agentManager, 'stop');
|
||||||
|
|
||||||
|
const result = await h.caller.errand.complete({ id: errand.id });
|
||||||
|
|
||||||
|
expect(result!.status).toBe('pending_review');
|
||||||
|
expect(stopSpy).toHaveBeenCalledWith(agent.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws BAD_REQUEST when status is pending_review', async () => {
|
||||||
|
const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'pending_review' });
|
||||||
|
|
||||||
|
await expect(h.caller.errand.complete({ id: errand.id })).rejects.toMatchObject({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: "Cannot complete an errand with status 'pending_review'",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws BAD_REQUEST when status is merged', async () => {
|
||||||
|
const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'merged', agentId: null });
|
||||||
|
|
||||||
|
await expect(h.caller.errand.complete({ id: errand.id })).rejects.toMatchObject({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: "Cannot complete an errand with status 'merged'",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// errand.merge
|
||||||
|
// =========================================================================
|
||||||
|
describe('errand.merge', () => {
|
||||||
|
it('merges clean pending_review errand, removes worktree, sets status to merged', async () => {
|
||||||
|
const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'pending_review' });
|
||||||
|
h.branchManager.setMergeResult({ success: true, message: 'Merged' });
|
||||||
|
|
||||||
|
const result = await h.caller.errand.merge({ id: errand.id });
|
||||||
|
|
||||||
|
expect(result).toEqual({ status: 'merged' });
|
||||||
|
expect(mockRemove).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
const updated = await h.repos.errandRepository.findById(errand.id);
|
||||||
|
expect(updated!.status).toBe('merged');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges clean conflict errand (re-merge after resolve)', async () => {
|
||||||
|
const { errand } = await createErrandDirect(h.repos, h.agentManager, {
|
||||||
|
status: 'conflict',
|
||||||
|
conflictFiles: '["src/a.ts"]',
|
||||||
|
});
|
||||||
|
h.branchManager.setMergeResult({ success: true, message: 'Merged' });
|
||||||
|
|
||||||
|
const result = await h.caller.errand.merge({ id: errand.id });
|
||||||
|
expect(result).toEqual({ status: 'merged' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges into target branch override', async () => {
|
||||||
|
const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'pending_review' });
|
||||||
|
const mergeSpy = vi.spyOn(h.branchManager, 'mergeBranch');
|
||||||
|
|
||||||
|
await h.caller.errand.merge({ id: errand.id, target: 'develop' });
|
||||||
|
|
||||||
|
expect(mergeSpy).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
errand.branch,
|
||||||
|
'develop',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws BAD_REQUEST and stores conflictFiles on merge conflict', async () => {
|
||||||
|
const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'pending_review' });
|
||||||
|
h.branchManager.setMergeResult({
|
||||||
|
success: false,
|
||||||
|
conflicts: ['src/a.ts', 'src/b.ts'],
|
||||||
|
message: 'Conflict detected',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(h.caller.errand.merge({ id: errand.id })).rejects.toMatchObject({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'Merge conflict in 2 file(s)',
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = await h.repos.errandRepository.findById(errand.id);
|
||||||
|
expect(updated!.status).toBe('conflict');
|
||||||
|
expect(JSON.parse(updated!.conflictFiles!)).toEqual(['src/a.ts', 'src/b.ts']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws BAD_REQUEST when status is active', async () => {
|
||||||
|
const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' });
|
||||||
|
|
||||||
|
await expect(h.caller.errand.merge({ id: errand.id })).rejects.toMatchObject({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: "Cannot merge an errand with status 'active'",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws BAD_REQUEST when status is abandoned', async () => {
|
||||||
|
const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'abandoned', agentId: null });
|
||||||
|
|
||||||
|
await expect(h.caller.errand.merge({ id: errand.id })).rejects.toMatchObject({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: "Cannot merge an errand with status 'abandoned'",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// errand.delete
|
||||||
|
// =========================================================================
|
||||||
|
describe('errand.delete', () => {
|
||||||
|
it('deletes active errand: stops agent, removes worktree, deletes branch and DB record', async () => {
|
||||||
|
const { errand, agent } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' });
|
||||||
|
const stopSpy = vi.spyOn(h.agentManager, 'stop');
|
||||||
|
|
||||||
|
const result = await h.caller.errand.delete({ id: errand.id });
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true });
|
||||||
|
expect(stopSpy).toHaveBeenCalledWith(agent.id);
|
||||||
|
expect(mockRemove).toHaveBeenCalledOnce();
|
||||||
|
expect(h.branchManager.deletedBranches).toContain(errand.branch);
|
||||||
|
|
||||||
|
const deleted = await h.repos.errandRepository.findById(errand.id);
|
||||||
|
expect(deleted).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes non-active errand: skips agent stop', async () => {
|
||||||
|
const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'pending_review' });
|
||||||
|
const stopSpy = vi.spyOn(h.agentManager, 'stop');
|
||||||
|
|
||||||
|
const result = await h.caller.errand.delete({ id: errand.id });
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true });
|
||||||
|
expect(stopSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const deleted = await h.repos.errandRepository.findById(errand.id);
|
||||||
|
expect(deleted).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('succeeds when worktree already removed (no-op)', async () => {
|
||||||
|
const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' });
|
||||||
|
mockRemove.mockRejectedValueOnce(new Error('Worktree not found'));
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
const result = await h.caller.errand.delete({ id: errand.id });
|
||||||
|
expect(result).toEqual({ success: true });
|
||||||
|
|
||||||
|
const deleted = await h.repos.errandRepository.findById(errand.id);
|
||||||
|
expect(deleted).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('succeeds when branch already deleted (no-op)', async () => {
|
||||||
|
const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' });
|
||||||
|
|
||||||
|
// DeleteBranch doesn't throw (BranchManager interface says no-op if not found)
|
||||||
|
const result = await h.caller.errand.delete({ id: errand.id });
|
||||||
|
expect(result).toEqual({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NOT_FOUND for unknown id', async () => {
|
||||||
|
await expect(h.caller.errand.delete({ id: 'nonexistent' })).rejects.toMatchObject({
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: 'Errand not found',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// errand.sendMessage
|
||||||
|
// =========================================================================
|
||||||
|
describe('errand.sendMessage', () => {
|
||||||
|
it('sends message to active running errand agent', async () => {
|
||||||
|
const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' });
|
||||||
|
const sendSpy = vi.spyOn(h.agentManager, 'sendUserMessage');
|
||||||
|
|
||||||
|
const result = await h.caller.errand.sendMessage({ id: errand.id, message: 'Hello agent' });
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true });
|
||||||
|
expect(sendSpy).toHaveBeenCalledWith(errand.agentId, 'Hello agent');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT create a conversations record', async () => {
|
||||||
|
const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' });
|
||||||
|
await h.caller.errand.sendMessage({ id: errand.id, message: 'Hello agent' });
|
||||||
|
|
||||||
|
// No pending conversation records should exist for the agent
|
||||||
|
const convs = await h.repos.conversationRepository.findPendingForAgent(errand.agentId!);
|
||||||
|
expect(convs).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws BAD_REQUEST when agent is stopped', async () => {
|
||||||
|
const { errand, agent } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' });
|
||||||
|
// Stop the agent to set its status to stopped
|
||||||
|
await h.agentManager.stop(agent.id);
|
||||||
|
|
||||||
|
await expect(h.caller.errand.sendMessage({ id: errand.id, message: 'Hello' })).rejects.toMatchObject({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'Agent is not running (status: stopped)',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws BAD_REQUEST when errand is not active', async () => {
|
||||||
|
const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'pending_review' });
|
||||||
|
|
||||||
|
await expect(h.caller.errand.sendMessage({ id: errand.id, message: 'Hello' })).rejects.toMatchObject({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'Errand is not active',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// errand.abandon
|
||||||
|
// =========================================================================
|
||||||
|
describe('errand.abandon', () => {
|
||||||
|
it('abandons active errand: stops agent, removes worktree, deletes branch, sets status', async () => {
|
||||||
|
const { errand, agent } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' });
|
||||||
|
const stopSpy = vi.spyOn(h.agentManager, 'stop');
|
||||||
|
|
||||||
|
const result = await h.caller.errand.abandon({ id: errand.id });
|
||||||
|
|
||||||
|
expect(result!.status).toBe('abandoned');
|
||||||
|
expect(result!.agentId).toBe(agent.id); // agentId preserved
|
||||||
|
expect(stopSpy).toHaveBeenCalledWith(agent.id);
|
||||||
|
expect(mockRemove).toHaveBeenCalledOnce();
|
||||||
|
expect(h.branchManager.deletedBranches).toContain(errand.branch);
|
||||||
|
|
||||||
|
// DB record preserved with abandoned status
|
||||||
|
const found = await h.repos.errandRepository.findById(errand.id);
|
||||||
|
expect(found!.status).toBe('abandoned');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('abandons pending_review errand: skips agent stop', async () => {
|
||||||
|
const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'pending_review' });
|
||||||
|
const stopSpy = vi.spyOn(h.agentManager, 'stop');
|
||||||
|
|
||||||
|
const result = await h.caller.errand.abandon({ id: errand.id });
|
||||||
|
|
||||||
|
expect(result!.status).toBe('abandoned');
|
||||||
|
expect(stopSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('abandons conflict errand: skips agent stop, removes worktree, deletes branch', async () => {
|
||||||
|
const { errand } = await createErrandDirect(h.repos, h.agentManager, {
|
||||||
|
status: 'conflict',
|
||||||
|
conflictFiles: '["src/a.ts"]',
|
||||||
|
agentId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await h.caller.errand.abandon({ id: errand.id });
|
||||||
|
expect(result!.status).toBe('abandoned');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws BAD_REQUEST when status is merged', async () => {
|
||||||
|
const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'merged', agentId: null });
|
||||||
|
|
||||||
|
await expect(h.caller.errand.abandon({ id: errand.id })).rejects.toMatchObject({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: "Cannot abandon an errand with status 'merged'",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws BAD_REQUEST when status is abandoned', async () => {
|
||||||
|
const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'abandoned', agentId: null });
|
||||||
|
|
||||||
|
await expect(h.caller.errand.abandon({ id: errand.id })).rejects.toMatchObject({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: "Cannot abandon an errand with status 'abandoned'",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
430
apps/server/trpc/routers/errand.ts
Normal file
430
apps/server/trpc/routers/errand.ts
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
/**
|
||||||
|
* 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 { SimpleGitWorktreeManager } from '../../git/manager.js';
|
||||||
|
import { ensureProjectClone } 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({
|
||||||
|
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' });
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...errand,
|
||||||
|
conflictFiles: errand.conflictFiles ? (JSON.parse(errand.conflictFiles) as string[]) : null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 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' });
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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', conflictFiles: null });
|
||||||
|
return { status: 'merged' };
|
||||||
|
} else {
|
||||||
|
// Conflict — persist conflict files and throw
|
||||||
|
const conflictFilesList = result.conflicts ?? [];
|
||||||
|
await repo.update(input.id, {
|
||||||
|
status: 'conflict',
|
||||||
|
conflictFiles: JSON.stringify(conflictFilesList),
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -269,3 +269,25 @@ Persistent chat loop for iterative phase/task refinement via agent.
|
|||||||
`sendChatMessage` finds or creates an active session, stores the user message, then either resumes the existing agent (if `waiting_for_input`) or spawns a fresh one with full chat history + initiative context. Agent runs in `'chat'` mode and signals `"questions"` after applying changes, staying alive for the next message.
|
`sendChatMessage` finds or creates an active session, stores the user message, then either resumes the existing agent (if `waiting_for_input`) or spawns a fresh one with full chat history + initiative context. Agent runs in `'chat'` mode and signals `"questions"` after applying changes, staying alive for the next message.
|
||||||
|
|
||||||
Context dependency: `requireChatSessionRepository(ctx)`, `requireAgentManager(ctx)`, `requireInitiativeRepository(ctx)`, `requireTaskRepository(ctx)`.
|
Context dependency: `requireChatSessionRepository(ctx)`, `requireAgentManager(ctx)`, `requireInitiativeRepository(ctx)`, `requireTaskRepository(ctx)`.
|
||||||
|
|
||||||
|
## Errand Procedures
|
||||||
|
|
||||||
|
Small isolated changes that spawn a dedicated agent in a git worktree. Errands are scoped to a project and use a branch named `cw/errand/<slug>-<8-char-id>`.
|
||||||
|
|
||||||
|
| Procedure | Type | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| `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.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 |
|
||||||
|
| `errand.delete` | mutation | Delete errand and clean up worktree/branch: `{id}` → `{success: true}` |
|
||||||
|
| `errand.sendMessage` | mutation | Send message to running errand agent: `{id, message}` → `{success: true}` |
|
||||||
|
| `errand.abandon` | mutation | Abandon errand (stop agent, clean up, set status): `{id}` → Errand |
|
||||||
|
|
||||||
|
**Errand statuses**: `active` → `pending_review` (via complete) → `merged` (via merge) or `conflict` (merge failed) → retry merge. `abandoned` is terminal. Only `pending_review` and `conflict` errands can be merged.
|
||||||
|
|
||||||
|
**Merge conflict flow**: On conflict, `errand.merge` updates status to `conflict` and stores `conflictFiles` (JSON string[]). After manual resolution, call `errand.merge` again.
|
||||||
|
|
||||||
|
Context dependencies: `requireErrandRepository(ctx)`, `requireProjectRepository(ctx)`, `requireAgentManager(ctx)`, `requireBranchManager(ctx)`, `ctx.workspaceRoot` (for `ensureProjectClone`). `SimpleGitWorktreeManager` is created on-the-fly per project clone path.
|
||||||
|
|||||||
Reference in New Issue
Block a user