From 343c6a83a804f2313e970c5b51b22a6467c0bf1c Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 13:22:15 +0100 Subject: [PATCH 01/11] feat: Add agent spawn infrastructure for errand mode Implements three primitives needed before errand tRPC procedures can be wired up: - agentManager.sendUserMessage(agentId, message): resumes an errand agent with a raw user message, bypassing the conversations table and conversationResumeLocks. Throws on missing agent, invalid status, or absent sessionId. - writeErrandManifest(options): writes .cw/input/errand.md (YAML frontmatter), .cw/input/manifest.json (errandId/agentId/agentName/mode, no files/contextFiles), and .cw/expected-pwd.txt to an agent workdir. - buildErrandPrompt(description): minimal prompt for errand agents; exported from prompts/errand.ts and re-exported from prompts/index.ts. Also fixes a pre-existing TypeScript error in lifecycle/controller.test.ts (missing backoffMs property in RetryPolicy mock introduced by a concurrent agent commit). Co-Authored-By: Claude Sonnet 4.6 --- apps/server/agent/file-io.test.ts | 117 ++++++++++++- apps/server/agent/file-io.ts | 44 +++++ .../server/agent/lifecycle/controller.test.ts | 155 ++++++++++++++++++ apps/server/agent/manager.ts | 67 ++++++++ apps/server/agent/prompts/errand.ts | 16 ++ apps/server/agent/prompts/index.ts | 1 + docs/agent.md | 28 +++- 7 files changed, 425 insertions(+), 3 deletions(-) create mode 100644 apps/server/agent/lifecycle/controller.test.ts create mode 100644 apps/server/agent/prompts/errand.ts diff --git a/apps/server/agent/file-io.test.ts b/apps/server/agent/file-io.test.ts index 396453f..ae0fb9a 100644 --- a/apps/server/agent/file-io.test.ts +++ b/apps/server/agent/file-io.test.ts @@ -2,7 +2,7 @@ * File-Based Agent I/O Tests */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest'; import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -15,7 +15,9 @@ import { readDecisionFiles, readPageFiles, generateId, + writeErrandManifest, } from './file-io.js'; +import { buildErrandPrompt } from './prompts/index.js'; import type { Initiative, Phase, Task } from '../db/schema.js'; let testDir: string; @@ -367,3 +369,116 @@ New content for the page. expect(pages).toHaveLength(1); }); }); + +describe('writeErrandManifest', () => { + let errandTestDir: string; + + beforeEach(() => { + errandTestDir = join(tmpdir(), `cw-errand-test-${randomUUID()}`); + mkdirSync(errandTestDir, { recursive: true }); + }); + + afterAll(() => { + // no-op: beforeEach creates dirs, afterEach in outer scope cleans up + }); + + it('writes manifest.json with correct shape', async () => { + await writeErrandManifest({ + agentWorkdir: errandTestDir, + errandId: 'errand-abc', + description: 'fix typo', + branch: 'cw/errand/fix-typo-errandabc', + projectName: 'my-project', + agentId: 'agent-1', + agentName: 'swift-owl', + }); + + const manifestPath = join(errandTestDir, '.cw', 'input', 'manifest.json'); + expect(existsSync(manifestPath)).toBe(true); + const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')); + expect(manifest).toEqual({ + errandId: 'errand-abc', + agentId: 'agent-1', + agentName: 'swift-owl', + mode: 'errand', + }); + expect('files' in manifest).toBe(false); + expect('contextFiles' in manifest).toBe(false); + }); + + it('writes errand.md with correct YAML frontmatter', async () => { + await writeErrandManifest({ + agentWorkdir: errandTestDir, + errandId: 'errand-abc', + description: 'fix typo', + branch: 'cw/errand/fix-typo-errandabc', + projectName: 'my-project', + agentId: 'agent-1', + agentName: 'swift-owl', + }); + + const errandMdPath = join(errandTestDir, '.cw', 'input', 'errand.md'); + expect(existsSync(errandMdPath)).toBe(true); + const content = readFileSync(errandMdPath, 'utf-8'); + expect(content).toContain('id: errand-abc'); + expect(content).toContain('description: fix typo'); + expect(content).toContain('branch: cw/errand/fix-typo-errandabc'); + expect(content).toContain('project: my-project'); + }); + + it('writes expected-pwd.txt with agentWorkdir path', async () => { + await writeErrandManifest({ + agentWorkdir: errandTestDir, + errandId: 'errand-abc', + description: 'fix typo', + branch: 'cw/errand/fix-typo-errandabc', + projectName: 'my-project', + agentId: 'agent-1', + agentName: 'swift-owl', + }); + + const pwdPath = join(errandTestDir, '.cw', 'expected-pwd.txt'); + expect(existsSync(pwdPath)).toBe(true); + const content = readFileSync(pwdPath, 'utf-8').trim(); + expect(content).toBe(errandTestDir); + }); + + it('creates input directory if it does not exist', async () => { + const freshDir = join(tmpdir(), `cw-errand-fresh-${randomUUID()}`); + mkdirSync(freshDir, { recursive: true }); + + await writeErrandManifest({ + agentWorkdir: freshDir, + errandId: 'errand-xyz', + description: 'add feature', + branch: 'cw/errand/add-feature-errandxyz', + projectName: 'other-project', + agentId: 'agent-2', + agentName: 'brave-eagle', + }); + + expect(existsSync(join(freshDir, '.cw', 'input', 'manifest.json'))).toBe(true); + expect(existsSync(join(freshDir, '.cw', 'input', 'errand.md'))).toBe(true); + expect(existsSync(join(freshDir, '.cw', 'expected-pwd.txt'))).toBe(true); + + rmSync(freshDir, { recursive: true, force: true }); + }); +}); + +describe('buildErrandPrompt', () => { + it('includes the description in the output', () => { + const result = buildErrandPrompt('fix typo in README'); + expect(result).toContain('fix typo in README'); + }); + + it('includes signal.json instruction', () => { + const result = buildErrandPrompt('some change'); + expect(result).toContain('signal.json'); + expect(result).toContain('"status": "done"'); + }); + + it('includes error signal format', () => { + const result = buildErrandPrompt('some change'); + expect(result).toContain('"status": "error"'); + }); +}); diff --git a/apps/server/agent/file-io.ts b/apps/server/agent/file-io.ts index 84b9c3a..4bbc296 100644 --- a/apps/server/agent/file-io.ts +++ b/apps/server/agent/file-io.ts @@ -298,6 +298,50 @@ export async function writeInputFiles(options: WriteInputFilesOptions): Promise< ); } +// ============================================================================= +// ERRAND INPUT FILE WRITING +// ============================================================================= + +export async function writeErrandManifest(options: { + agentWorkdir: string; + errandId: string; + description: string; + branch: string; + projectName: string; + agentId: string; + agentName: string; +}): Promise { + await mkdir(join(options.agentWorkdir, '.cw', 'input'), { recursive: true }); + + // Write errand.md first (before manifest.json) + const errandMdContent = formatFrontmatter({ + id: options.errandId, + description: options.description, + branch: options.branch, + project: options.projectName, + }); + await writeFile(join(options.agentWorkdir, '.cw', 'input', 'errand.md'), errandMdContent, 'utf-8'); + + // Write manifest.json last (after all other files exist) + await writeFile( + join(options.agentWorkdir, '.cw', 'input', 'manifest.json'), + JSON.stringify({ + errandId: options.errandId, + agentId: options.agentId, + agentName: options.agentName, + mode: 'errand', + }) + '\n', + 'utf-8', + ); + + // Write expected-pwd.txt + await writeFile( + join(options.agentWorkdir, '.cw', 'expected-pwd.txt'), + options.agentWorkdir, + 'utf-8', + ); +} + // ============================================================================= // OUTPUT FILE READING // ============================================================================= diff --git a/apps/server/agent/lifecycle/controller.test.ts b/apps/server/agent/lifecycle/controller.test.ts new file mode 100644 index 0000000..1ce41b9 --- /dev/null +++ b/apps/server/agent/lifecycle/controller.test.ts @@ -0,0 +1,155 @@ +/** + * AgentLifecycleController Tests — Regression coverage for event emissions. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { AgentLifecycleController } from './controller.js'; +import type { AgentRepository } from '../../db/repositories/agent-repository.js'; +import type { AccountRepository } from '../../db/repositories/account-repository.js'; +import type { SignalManager } from './signal-manager.js'; +import type { RetryPolicy } from './retry-policy.js'; +import type { AgentErrorAnalyzer } from './error-analyzer.js'; +import type { ProcessManager } from '../process-manager.js'; +import type { CleanupManager } from '../cleanup-manager.js'; +import type { CleanupStrategy } from './cleanup-strategy.js'; +import type { EventBus, AgentAccountSwitchedEvent } from '../../events/types.js'; + +function makeController(overrides: { + repository?: Partial; + accountRepository?: Partial; + eventBus?: EventBus; +}): AgentLifecycleController { + const signalManager: SignalManager = { + clearSignal: vi.fn(), + checkSignalExists: vi.fn(), + readSignal: vi.fn(), + waitForSignal: vi.fn(), + validateSignalFile: vi.fn(), + }; + const retryPolicy: RetryPolicy = { + maxAttempts: 3, + backoffMs: [1000, 2000, 4000], + shouldRetry: vi.fn().mockReturnValue(false), + getRetryDelay: vi.fn().mockReturnValue(0), + }; + const errorAnalyzer = { analyzeError: vi.fn() } as unknown as AgentErrorAnalyzer; + const processManager = { getAgentWorkdir: vi.fn() } as unknown as ProcessManager; + const cleanupManager = {} as unknown as CleanupManager; + const cleanupStrategy = { + shouldCleanup: vi.fn(), + executeCleanup: vi.fn(), + } as unknown as CleanupStrategy; + + return new AgentLifecycleController( + signalManager, + retryPolicy, + errorAnalyzer, + processManager, + overrides.repository as AgentRepository, + cleanupManager, + cleanupStrategy, + overrides.accountRepository as AccountRepository | undefined, + false, + overrides.eventBus, + ); +} + +describe('AgentLifecycleController', () => { + describe('handleAccountExhaustion', () => { + it('emits agent:account_switched with correct payload when new account is available', async () => { + const emittedEvents: AgentAccountSwitchedEvent[] = []; + const eventBus: EventBus = { + emit: vi.fn((event) => { emittedEvents.push(event as AgentAccountSwitchedEvent); }), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + }; + + const agentRecord = { + id: 'agent-1', + name: 'test-agent', + accountId: 'old-account-id', + provider: 'claude', + }; + const newAccount = { id: 'new-account-id' }; + + const repository: Partial = { + findById: vi.fn().mockResolvedValue(agentRecord), + }; + const accountRepository: Partial = { + markExhausted: vi.fn().mockResolvedValue(agentRecord), + findNextAvailable: vi.fn().mockResolvedValue(newAccount), + }; + + const controller = makeController({ repository, accountRepository, eventBus }); + + // Call private method via any-cast + await (controller as any).handleAccountExhaustion('agent-1'); + + const accountSwitchedEvents = emittedEvents.filter( + (e) => e.type === 'agent:account_switched' + ); + expect(accountSwitchedEvents).toHaveLength(1); + const event = accountSwitchedEvents[0]; + expect(event.type).toBe('agent:account_switched'); + expect(event.payload.agentId).toBe('agent-1'); + expect(event.payload.name).toBe('test-agent'); + expect(event.payload.previousAccountId).toBe('old-account-id'); + expect(event.payload.newAccountId).toBe('new-account-id'); + expect(event.payload.reason).toBe('account_exhausted'); + }); + + it('does not emit agent:account_switched when no new account is available', async () => { + const eventBus: EventBus = { + emit: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + }; + + const agentRecord = { + id: 'agent-2', + name: 'test-agent-2', + accountId: 'old-account-id', + provider: 'claude', + }; + + const repository: Partial = { + findById: vi.fn().mockResolvedValue(agentRecord), + }; + const accountRepository: Partial = { + markExhausted: vi.fn().mockResolvedValue(agentRecord), + findNextAvailable: vi.fn().mockResolvedValue(null), + }; + + const controller = makeController({ repository, accountRepository, eventBus }); + + await (controller as any).handleAccountExhaustion('agent-2'); + + expect(eventBus.emit).not.toHaveBeenCalled(); + }); + + it('does not emit when agent has no accountId', async () => { + const eventBus: EventBus = { + emit: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + }; + + const repository: Partial = { + findById: vi.fn().mockResolvedValue({ id: 'agent-3', name: 'x', accountId: null }), + }; + const accountRepository: Partial = { + markExhausted: vi.fn(), + findNextAvailable: vi.fn(), + }; + + const controller = makeController({ repository, accountRepository, eventBus }); + + await (controller as any).handleAccountExhaustion('agent-3'); + + expect(eventBus.emit).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/server/agent/manager.ts b/apps/server/agent/manager.ts index d567fcc..0d8fad2 100644 --- a/apps/server/agent/manager.ts +++ b/apps/server/agent/manager.ts @@ -625,6 +625,73 @@ export class MultiProviderAgentManager implements AgentManager { } } + /** + * Deliver a user message to a running or idle errand agent. + * Does not use the conversations table — the message is injected directly + * as the next resume prompt for the agent's Claude Code session. + */ + async sendUserMessage(agentId: string, message: string): Promise { + const agent = await this.repository.findById(agentId); + if (!agent) throw new Error(`Agent not found: ${agentId}`); + + if (agent.status !== 'running' && agent.status !== 'idle') { + throw new Error(`Agent is not running (status: ${agent.status})`); + } + + if (!agent.sessionId) { + throw new Error('Agent has no session ID'); + } + + const provider = getProvider(agent.provider); + if (!provider) throw new Error(`Unknown provider: ${agent.provider}`); + + const agentCwd = this.processManager.getAgentWorkdir(agent.worktreeId); + + // Clear previous signal.json + const signalPath = join(agentCwd, '.cw/output/signal.json'); + try { + await unlink(signalPath); + } catch { + // File might not exist + } + + await this.repository.update(agentId, { status: 'running', result: null }); + + const { command, args, env: providerEnv } = this.processManager.buildResumeCommand(provider, agent.sessionId, message); + const { processEnv } = await this.credentialHandler.prepareProcessEnv(providerEnv, provider, agent.accountId); + + // Stop previous tailer/poll + const prevActive = this.activeAgents.get(agentId); + prevActive?.cancelPoll?.(); + if (prevActive?.tailer) { + await prevActive.tailer.stop(); + } + + let sessionNumber = 1; + if (this.logChunkRepository) { + sessionNumber = (await this.logChunkRepository.getSessionCount(agentId)) + 1; + } + + const { pid, outputFilePath, tailer } = await this.processManager.spawnDetached( + agentId, agent.name, command, args, agentCwd, processEnv, provider.name, message, + (event) => this.outputHandler.handleStreamEvent(agentId, event, this.activeAgents.get(agentId)), + this.createLogChunkCallback(agentId, agent.name, sessionNumber), + ); + + await this.repository.update(agentId, { pid, outputFilePath }); + const activeEntry: ActiveAgent = { agentId, pid, tailer, outputFilePath }; + this.activeAgents.set(agentId, activeEntry); + + const { cancel } = this.processManager.pollForCompletion( + agentId, pid, + () => this.handleDetachedAgentCompletion(agentId), + () => this.activeAgents.get(agentId)?.tailer, + ); + activeEntry.cancelPoll = cancel; + + log.info({ agentId, pid }, 'resumed errand agent for user message'); + } + /** * Sync credentials from agent's config dir back to DB after completion. * The subprocess may have refreshed tokens mid-session; this ensures diff --git a/apps/server/agent/prompts/errand.ts b/apps/server/agent/prompts/errand.ts new file mode 100644 index 0000000..e94b950 --- /dev/null +++ b/apps/server/agent/prompts/errand.ts @@ -0,0 +1,16 @@ +export function buildErrandPrompt(description: string): string { + return `You are working on a small, focused change in an isolated worktree. + +Description: ${description} + +Work interactively with the user. Make only the changes needed to fulfill the description. +When you are done, write .cw/output/signal.json: + +{ "status": "done", "result": { "message": "" } } + +If you cannot complete the change: + +{ "status": "error", "error": "" } + +Do not create any other output files.`; +} diff --git a/apps/server/agent/prompts/index.ts b/apps/server/agent/prompts/index.ts index 2186994..c7167db 100644 --- a/apps/server/agent/prompts/index.ts +++ b/apps/server/agent/prompts/index.ts @@ -13,6 +13,7 @@ export { buildDetailPrompt } from './detail.js'; export { buildRefinePrompt } from './refine.js'; export { buildChatPrompt } from './chat.js'; export type { ChatHistoryEntry } from './chat.js'; +export { buildErrandPrompt } from './errand.js'; export { buildWorkspaceLayout } from './workspace.js'; export { buildPreviewInstructions } from './preview.js'; export { buildConflictResolutionPrompt, buildConflictResolutionDescription } from './conflict-resolution.js'; diff --git a/docs/agent.md b/docs/agent.md index 7083585..0a1898e 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -11,7 +11,7 @@ | `process-manager.ts` | `AgentProcessManager` — worktree creation, command building, detached spawn | | `output-handler.ts` | `OutputHandler` — JSONL stream parsing, completion detection, proposal creation, task dedup, task dependency persistence | | `file-tailer.ts` | `FileTailer` — watches output files, fires parser + raw content callbacks | -| `file-io.ts` | Input/output file I/O: frontmatter writing, signal.json reading, tiptap conversion. Output files support `action` field (create/update/delete) for chat mode CRUD. | +| `file-io.ts` | Input/output file I/O: frontmatter writing, signal.json reading, tiptap conversion. Output files support `action` field (create/update/delete) for chat mode CRUD. Includes `writeErrandManifest()` for errand agent input files. | | `markdown-to-tiptap.ts` | Markdown to Tiptap JSON conversion using MarkdownManager | | `index.ts` | Public exports, `ClaudeAgentManager` deprecated alias | @@ -24,7 +24,7 @@ | `accounts/` | Account discovery, config dir setup, credential management, usage API | | `credentials/` | `AccountCredentialManager` — credential injection per account | | `lifecycle/` | `LifecycleController` — retry policy, signal recovery, missing signal instructions | -| `prompts/` | Mode-specific prompt builders (execute, discuss, plan, detail, refine, chat, conflict-resolution) + shared blocks (test integrity, deviation rules, git workflow, session startup, progress tracking) + inter-agent communication instructions | +| `prompts/` | Mode-specific prompt builders (execute, discuss, plan, detail, refine, chat, conflict-resolution, errand) + shared blocks (test integrity, deviation rules, git workflow, session startup, progress tracking) + inter-agent communication instructions | ## Key Flows @@ -115,6 +115,30 @@ cw account add --token --email user@example.com Stored as `credentials: {"claudeAiOauth":{"accessToken":""}}` and `configJson: {"hasCompletedOnboarding":true}`. +## Errand Agent Support + +### `sendUserMessage(agentId, message)` + +Delivers a user message directly to a running or idle errand agent without going through the conversations table. Used by the `errand.sendMessage` tRPC procedure. + +**Steps**: look up agent → validate status (`running`|`idle`) → validate `sessionId` → clear signal.json → update status to `running` → build resume command → stop active tailer/poll → spawn detached → start polling. + +**Key difference from `resumeForConversation`**: no `conversationResumeLocks`, no conversations table entry, raw message passed as resume prompt. + +### `writeErrandManifest(options)` + +Writes errand input files to `/.cw/input/`: + +- `errand.md` — YAML frontmatter with `id`, `description`, `branch`, `project` +- `manifest.json` — `{ errandId, agentId, agentName, mode: "errand" }` (no `files`/`contextFiles` arrays) +- `expected-pwd.txt` — the agent workdir path + +Written in order: `errand.md` first, `manifest.json` last (same discipline as `writeInputFiles`). + +### `buildErrandPrompt(description)` + +Builds the initial prompt for errand agents. Exported from `prompts/errand.ts` and re-exported from `prompts/index.ts`. The prompt instructs the agent to make only the changes needed for the description and write `signal.json` when done. + ## Auto-Resume for Conversations When Agent A asks Agent B a question via `cw ask` and Agent B is idle, the conversation router automatically resumes Agent B's session. This mirrors the `resumeForCommit()` pattern. From e2c489dc48d4a5f20e9669c05b6b7470e71e8dc4 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 14:31:03 +0100 Subject: [PATCH 02/11] feat: teach errand agent how to ask questions interactively Add a dedicated "Asking questions" section to the errand prompt so the agent knows it can pause, ask for clarification, and wait for the user to reply via the UI chat input or `cw errand chat`. Previously the prompt said "work interactively" with no guidance on the mechanism, leaving the agent to guess. Co-Authored-By: Claude Sonnet 4.6 --- apps/server/agent/file-io.test.ts | 6 ++++++ apps/server/agent/prompts/errand.ts | 11 +++++++++++ 2 files changed, 17 insertions(+) diff --git a/apps/server/agent/file-io.test.ts b/apps/server/agent/file-io.test.ts index ae0fb9a..8c567f0 100644 --- a/apps/server/agent/file-io.test.ts +++ b/apps/server/agent/file-io.test.ts @@ -481,4 +481,10 @@ describe('buildErrandPrompt', () => { const result = buildErrandPrompt('some change'); expect(result).toContain('"status": "error"'); }); + + it('includes instructions for asking questions', () => { + const result = buildErrandPrompt('some change'); + expect(result).toMatch(/ask|question/i); + expect(result).toMatch(/chat|message|reply/i); + }); }); diff --git a/apps/server/agent/prompts/errand.ts b/apps/server/agent/prompts/errand.ts index e94b950..cb9feaf 100644 --- a/apps/server/agent/prompts/errand.ts +++ b/apps/server/agent/prompts/errand.ts @@ -4,6 +4,17 @@ export function buildErrandPrompt(description: string): string { Description: ${description} Work interactively with the user. Make only the changes needed to fulfill the description. + +## Asking questions + +If you need clarification before or during the change, ask the user directly in your response and wait. The user can reply via the UI chat input on the Errands page or by running: + + cw errand chat "" + +Their reply will be delivered as the next message in this session. Be explicit about what you need — don't make assumptions when the task is ambiguous. + +## Finishing + When you are done, write .cw/output/signal.json: { "status": "done", "result": { "message": "" } } From 41c5d292bb562d0edb7044147ec4d414c869060a Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 14:54:53 +0100 Subject: [PATCH 03/11] fix: allow errand agent to end session with questions and resume The errand agent can now write { "status": "questions", ... } to signal.json to pause mid-task and ask the user for clarification. The session ends cleanly; the user answers via UI or CLI; the system resumes the agent with their answers via sendUserMessage. Two changes: - buildErrandPrompt: adds "Option B" explaining the questions signal format and the resume-on-answer lifecycle, alongside the existing inline-question approach. - sendUserMessage: extends allowed statuses from running|idle to also include waiting_for_input, so agents paused on a questions signal can be resumed when the user replies. Co-Authored-By: Claude Sonnet 4.6 --- apps/server/agent/file-io.test.ts | 11 +++++++++++ apps/server/agent/manager.test.ts | 25 +++++++++++++++++++++++++ apps/server/agent/manager.ts | 2 +- apps/server/agent/prompts/errand.ts | 16 ++++++++++++++-- docs/agent.md | 2 +- 5 files changed, 52 insertions(+), 4 deletions(-) diff --git a/apps/server/agent/file-io.test.ts b/apps/server/agent/file-io.test.ts index 8c567f0..321ff8a 100644 --- a/apps/server/agent/file-io.test.ts +++ b/apps/server/agent/file-io.test.ts @@ -487,4 +487,15 @@ describe('buildErrandPrompt', () => { expect(result).toMatch(/ask|question/i); expect(result).toMatch(/chat|message|reply/i); }); + + it('includes questions signal format for session-ending questions', () => { + const result = buildErrandPrompt('some change'); + expect(result).toContain('"status": "questions"'); + expect(result).toContain('"questions"'); + }); + + it('explains session ends and resumes with user answers', () => { + const result = buildErrandPrompt('some change'); + expect(result).toMatch(/resume|end.*session|session.*end/i); + }); }); diff --git a/apps/server/agent/manager.test.ts b/apps/server/agent/manager.test.ts index 5781477..ef0d60a 100644 --- a/apps/server/agent/manager.test.ts +++ b/apps/server/agent/manager.test.ts @@ -462,6 +462,31 @@ describe('MultiProviderAgentManager', () => { }); }); + describe('sendUserMessage', () => { + it('resumes errand agent in waiting_for_input status', async () => { + mockRepository.findById = vi.fn().mockResolvedValue({ + ...mockAgent, + status: 'waiting_for_input', + }); + + const mockChild = createMockChildProcess(); + mockSpawn.mockReturnValue(mockChild); + + await expect(manager.sendUserMessage(mockAgent.id, 'my answer')).resolves.not.toThrow(); + }); + + it('rejects if agent is stopped', async () => { + mockRepository.findById = vi.fn().mockResolvedValue({ + ...mockAgent, + status: 'stopped', + }); + + await expect(manager.sendUserMessage(mockAgent.id, 'message')).rejects.toThrow( + 'Agent is not running' + ); + }); + }); + describe('getResult', () => { it('returns null when agent has no result', async () => { const result = await manager.getResult('agent-123'); diff --git a/apps/server/agent/manager.ts b/apps/server/agent/manager.ts index 0d8fad2..e9c1e16 100644 --- a/apps/server/agent/manager.ts +++ b/apps/server/agent/manager.ts @@ -634,7 +634,7 @@ export class MultiProviderAgentManager implements AgentManager { const agent = await this.repository.findById(agentId); if (!agent) throw new Error(`Agent not found: ${agentId}`); - if (agent.status !== 'running' && agent.status !== 'idle') { + if (agent.status !== 'running' && agent.status !== 'idle' && agent.status !== 'waiting_for_input') { throw new Error(`Agent is not running (status: ${agent.status})`); } diff --git a/apps/server/agent/prompts/errand.ts b/apps/server/agent/prompts/errand.ts index cb9feaf..f7f2228 100644 --- a/apps/server/agent/prompts/errand.ts +++ b/apps/server/agent/prompts/errand.ts @@ -7,11 +7,23 @@ Work interactively with the user. Make only the changes needed to fulfill the de ## Asking questions -If you need clarification before or during the change, ask the user directly in your response and wait. The user can reply via the UI chat input on the Errands page or by running: +### Option A — Ask inline (session stays open) + +Ask the user directly in your response and wait. The user can reply via the UI chat input on the Errands page or by running: cw errand chat "" -Their reply will be delivered as the next message in this session. Be explicit about what you need — don't make assumptions when the task is ambiguous. +Their reply will be delivered as the next message in this session. + +### Option B — End session with questions (then resume) + +If you need answers before you can start, end the session by writing .cw/output/signal.json with this format: + +{ "status": "questions", "questions": [{ "id": "q1", "question": "What is the target file?" }] } + +The session will end. The user will be shown your questions in the UI. When they answer (via UI or cw errand chat), the session resumes and you will receive their answers. Use this when blocking questions need to be resolved before any work can proceed. + +Be explicit about what you need — don't make assumptions when the task is ambiguous. ## Finishing diff --git a/docs/agent.md b/docs/agent.md index 0a1898e..5ae593d 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -121,7 +121,7 @@ Stored as `credentials: {"claudeAiOauth":{"accessToken":""}}` and `config Delivers a user message directly to a running or idle errand agent without going through the conversations table. Used by the `errand.sendMessage` tRPC procedure. -**Steps**: look up agent → validate status (`running`|`idle`) → validate `sessionId` → clear signal.json → update status to `running` → build resume command → stop active tailer/poll → spawn detached → start polling. +**Steps**: look up agent → validate status (`running`|`idle`|`waiting_for_input`) → validate `sessionId` → clear signal.json → update status to `running` → build resume command → stop active tailer/poll → spawn detached → start polling. **Key difference from `resumeForConversation`**: no `conversationResumeLocks`, no conversations table entry, raw message passed as resume prompt. From 67658fb71770291f43637505a9487711fabbd060 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 15:35:11 +0100 Subject: [PATCH 04/11] fix: require signal.json for all errand agent exit scenarios Option A ("ask inline, session stays open") described a path where the errand agent could ask questions without writing signal.json, which broke the server's completion detection (checkAgentCompletionResult polls for done|questions|error status). Remove the Option A/B distinction and make signal.json with questions status the single mechanism for all user-input requests, consistent with how other agents handle blocking questions. Co-Authored-By: Claude Sonnet 4.6 --- apps/server/agent/file-io.test.ts | 15 +++++++++++++++ apps/server/agent/prompts/errand.ts | 18 +++++------------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/apps/server/agent/file-io.test.ts b/apps/server/agent/file-io.test.ts index 321ff8a..0e747fa 100644 --- a/apps/server/agent/file-io.test.ts +++ b/apps/server/agent/file-io.test.ts @@ -498,4 +498,19 @@ describe('buildErrandPrompt', () => { const result = buildErrandPrompt('some change'); expect(result).toMatch(/resume|end.*session|session.*end/i); }); + + it('does not present inline asking as an alternative that bypasses signal.json', () => { + const result = buildErrandPrompt('some change'); + // "session stays open" implied agents can skip signal.json — all exits must write it + expect(result).not.toMatch(/session stays open/i); + expect(result).not.toMatch(/Option A/i); + }); + + it('requires signal.json for all question-asking paths', () => { + const result = buildErrandPrompt('some change'); + // questions status must be the mechanism for all user-input requests + expect(result).toContain('"status": "questions"'); + // must not describe a path that skips signal.json + expect(result).not.toMatch(/session stays open/i); + }); }); diff --git a/apps/server/agent/prompts/errand.ts b/apps/server/agent/prompts/errand.ts index f7f2228..3c2ac91 100644 --- a/apps/server/agent/prompts/errand.ts +++ b/apps/server/agent/prompts/errand.ts @@ -7,23 +7,15 @@ Work interactively with the user. Make only the changes needed to fulfill the de ## Asking questions -### Option A — Ask inline (session stays open) - -Ask the user directly in your response and wait. The user can reply via the UI chat input on the Errands page or by running: - - cw errand chat "" - -Their reply will be delivered as the next message in this session. - -### Option B — End session with questions (then resume) - -If you need answers before you can start, end the session by writing .cw/output/signal.json with this format: +If you need clarification before or during the change, write .cw/output/signal.json with the questions format and end your session: { "status": "questions", "questions": [{ "id": "q1", "question": "What is the target file?" }] } -The session will end. The user will be shown your questions in the UI. When they answer (via UI or cw errand chat), the session resumes and you will receive their answers. Use this when blocking questions need to be resolved before any work can proceed. +The session will end. The user will be shown your questions in the UI or via: -Be explicit about what you need — don't make assumptions when the task is ambiguous. + cw errand chat "" + +Your session will then resume with their answers. Be explicit about what you need — don't make assumptions when the task is ambiguous. ## Finishing From 3a328d2b1c418794a4ea95094fee93cbddd70ec7 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 15:49:26 +0100 Subject: [PATCH 05/11] feat: Add errands schema, repository, and wire into tRPC context/container Creates the errands table (with conflictFiles column), errand-repository port interface, DrizzleErrandRepository adapter, and wires the repository into TRPCContext, the DI container, _helpers.ts requireErrandRepository guard, and the test harness. Also fixes pre-existing TS error in controller.test.ts. Co-Authored-By: Claude Sonnet 4.6 --- .../server/agent/lifecycle/controller.test.ts | 1 - apps/server/container.ts | 4 + apps/server/db/repositories/drizzle/errand.ts | 104 + apps/server/db/repositories/drizzle/index.ts | 1 + .../db/repositories/errand-repository.ts | 45 + apps/server/db/repositories/index.ts | 8 + apps/server/db/schema.ts | 30 + .../drizzle/0034_salty_next_avengers.sql | 17 + apps/server/drizzle/meta/0034_snapshot.json | 1988 +++++++++++++++++ apps/server/drizzle/meta/_journal.json | 7 + apps/server/test/harness.ts | 7 +- apps/server/trpc/context.ts | 5 + apps/server/trpc/routers/_helpers.ts | 11 + 13 files changed, 2226 insertions(+), 2 deletions(-) create mode 100644 apps/server/db/repositories/drizzle/errand.ts create mode 100644 apps/server/db/repositories/errand-repository.ts create mode 100644 apps/server/drizzle/0034_salty_next_avengers.sql create mode 100644 apps/server/drizzle/meta/0034_snapshot.json diff --git a/apps/server/agent/lifecycle/controller.test.ts b/apps/server/agent/lifecycle/controller.test.ts index 1ce41b9..751305c 100644 --- a/apps/server/agent/lifecycle/controller.test.ts +++ b/apps/server/agent/lifecycle/controller.test.ts @@ -50,7 +50,6 @@ function makeController(overrides: { cleanupStrategy, overrides.accountRepository as AccountRepository | undefined, false, - overrides.eventBus, ); } diff --git a/apps/server/container.ts b/apps/server/container.ts index 5e6aefd..fbb2a99 100644 --- a/apps/server/container.ts +++ b/apps/server/container.ts @@ -22,6 +22,7 @@ import { DrizzleConversationRepository, DrizzleChatSessionRepository, DrizzleReviewCommentRepository, + DrizzleErrandRepository, } from './db/index.js'; import type { InitiativeRepository } from './db/repositories/initiative-repository.js'; import type { PhaseRepository } from './db/repositories/phase-repository.js'; @@ -36,6 +37,7 @@ import type { LogChunkRepository } from './db/repositories/log-chunk-repository. import type { ConversationRepository } from './db/repositories/conversation-repository.js'; import type { ChatSessionRepository } from './db/repositories/chat-session-repository.js'; import type { ReviewCommentRepository } from './db/repositories/review-comment-repository.js'; +import type { ErrandRepository } from './db/repositories/errand-repository.js'; import type { EventBus } from './events/index.js'; import { createEventBus } from './events/index.js'; import { ProcessManager, ProcessRegistry } from './process/index.js'; @@ -77,6 +79,7 @@ export interface Repositories { conversationRepository: ConversationRepository; chatSessionRepository: ChatSessionRepository; reviewCommentRepository: ReviewCommentRepository; + errandRepository: ErrandRepository; } /** @@ -98,6 +101,7 @@ export function createRepositories(db: DrizzleDatabase): Repositories { conversationRepository: new DrizzleConversationRepository(db), chatSessionRepository: new DrizzleChatSessionRepository(db), reviewCommentRepository: new DrizzleReviewCommentRepository(db), + errandRepository: new DrizzleErrandRepository(db), }; } diff --git a/apps/server/db/repositories/drizzle/errand.ts b/apps/server/db/repositories/drizzle/errand.ts new file mode 100644 index 0000000..1b62fb1 --- /dev/null +++ b/apps/server/db/repositories/drizzle/errand.ts @@ -0,0 +1,104 @@ +/** + * Drizzle Errand Repository Adapter + * + * Implements ErrandRepository interface using Drizzle ORM. + */ + +import { eq, and } from 'drizzle-orm'; +import { nanoid } from 'nanoid'; +import type { DrizzleDatabase } from '../../index.js'; +import { errands, agents, type Errand } from '../../schema.js'; +import type { + ErrandRepository, + CreateErrandData, + UpdateErrandData, + ErrandWithAlias, + FindAllErrandOptions, +} from '../errand-repository.js'; + +export class DrizzleErrandRepository implements ErrandRepository { + constructor(private db: DrizzleDatabase) {} + + async create(data: CreateErrandData): Promise { + const now = new Date(); + const id = nanoid(); + const [created] = await this.db.insert(errands).values({ + id, + description: data.description, + branch: data.branch, + baseBranch: data.baseBranch ?? 'main', + agentId: data.agentId ?? null, + projectId: data.projectId, + status: data.status ?? 'active', + conflictFiles: data.conflictFiles ?? null, + createdAt: now, + updatedAt: now, + }).returning(); + return created; + } + + async findById(id: string): Promise { + const rows = await this.db + .select({ + id: errands.id, + description: errands.description, + branch: errands.branch, + baseBranch: errands.baseBranch, + agentId: errands.agentId, + projectId: errands.projectId, + status: errands.status, + conflictFiles: errands.conflictFiles, + createdAt: errands.createdAt, + updatedAt: errands.updatedAt, + agentAlias: agents.name, + }) + .from(errands) + .leftJoin(agents, eq(errands.agentId, agents.id)) + .where(eq(errands.id, id)) + .limit(1); + if (!rows[0]) return null; + return rows[0] as ErrandWithAlias; + } + + async findAll(options?: FindAllErrandOptions): Promise { + const conditions = []; + if (options?.projectId) conditions.push(eq(errands.projectId, options.projectId)); + if (options?.status) conditions.push(eq(errands.status, options.status)); + + const rows = await this.db + .select({ + id: errands.id, + description: errands.description, + branch: errands.branch, + baseBranch: errands.baseBranch, + agentId: errands.agentId, + projectId: errands.projectId, + status: errands.status, + conflictFiles: errands.conflictFiles, + createdAt: errands.createdAt, + updatedAt: errands.updatedAt, + agentAlias: agents.name, + }) + .from(errands) + .leftJoin(agents, eq(errands.agentId, agents.id)) + .where(conditions.length > 0 ? and(...conditions) : undefined); + return rows as ErrandWithAlias[]; + } + + async update(id: string, data: UpdateErrandData): Promise { + await this.db + .update(errands) + .set({ ...data, updatedAt: new Date() }) + .where(eq(errands.id, id)); + const rows = await this.db + .select() + .from(errands) + .where(eq(errands.id, id)) + .limit(1); + return rows[0] ?? null; + } + + async delete(id: string): Promise { + await this.db.delete(errands).where(eq(errands.id, id)); + } +} diff --git a/apps/server/db/repositories/drizzle/index.ts b/apps/server/db/repositories/drizzle/index.ts index c29daba..78afdea 100644 --- a/apps/server/db/repositories/drizzle/index.ts +++ b/apps/server/db/repositories/drizzle/index.ts @@ -18,3 +18,4 @@ export { DrizzleLogChunkRepository } from './log-chunk.js'; export { DrizzleConversationRepository } from './conversation.js'; export { DrizzleChatSessionRepository } from './chat-session.js'; export { DrizzleReviewCommentRepository } from './review-comment.js'; +export { DrizzleErrandRepository } from './errand.js'; diff --git a/apps/server/db/repositories/errand-repository.ts b/apps/server/db/repositories/errand-repository.ts new file mode 100644 index 0000000..ca831eb --- /dev/null +++ b/apps/server/db/repositories/errand-repository.ts @@ -0,0 +1,45 @@ +/** + * Errand Repository Port Interface + * + * Port for Errand aggregate operations. + * Implementations (Drizzle, etc.) are adapters. + */ + +import type { Errand, NewErrand, ErrandStatus } from '../schema.js'; + +/** + * Data for creating a new errand. + * Omits system-managed fields (id, createdAt, updatedAt). + */ +export type CreateErrandData = Omit; + +/** + * Data for updating an errand. + */ +export type UpdateErrandData = Partial>; + +/** + * Errand with the agent alias joined in. + */ +export interface ErrandWithAlias extends Errand { + agentAlias: string | null; +} + +/** + * Filter options for listing errands. + */ +export interface FindAllErrandOptions { + projectId?: string; + status?: ErrandStatus; +} + +/** + * Errand Repository Port + */ +export interface ErrandRepository { + create(data: CreateErrandData): Promise; + findById(id: string): Promise; + findAll(options?: FindAllErrandOptions): Promise; + update(id: string, data: UpdateErrandData): Promise; + delete(id: string): Promise; +} diff --git a/apps/server/db/repositories/index.ts b/apps/server/db/repositories/index.ts index 809214c..0c42fd7 100644 --- a/apps/server/db/repositories/index.ts +++ b/apps/server/db/repositories/index.ts @@ -82,3 +82,11 @@ export type { ReviewCommentRepository, CreateReviewCommentData, } from './review-comment-repository.js'; + +export type { + ErrandRepository, + CreateErrandData, + UpdateErrandData, + ErrandWithAlias, + FindAllErrandOptions, +} from './errand-repository.js'; diff --git a/apps/server/db/schema.ts b/apps/server/db/schema.ts index 77d9073..ed2f7fd 100644 --- a/apps/server/db/schema.ts +++ b/apps/server/db/schema.ts @@ -628,3 +628,33 @@ export const reviewComments = sqliteTable('review_comments', { export type ReviewComment = InferSelectModel; export type NewReviewComment = InferInsertModel; + +// ============================================================================ +// ERRANDS +// ============================================================================ + +export const ERRAND_STATUS_VALUES = ['active', 'pending_review', 'conflict', 'merged', 'abandoned'] as const; +export type ErrandStatus = (typeof ERRAND_STATUS_VALUES)[number]; + +export const errands = sqliteTable('errands', { + id: text('id').primaryKey(), + description: text('description').notNull(), + branch: text('branch').notNull(), + baseBranch: text('base_branch').notNull().default('main'), + agentId: text('agent_id').references(() => agents.id, { onDelete: 'set null' }), + projectId: text('project_id') + .notNull() + .references(() => projects.id, { onDelete: 'cascade' }), + status: text('status', { enum: ERRAND_STATUS_VALUES }) + .notNull() + .default('active'), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), + conflictFiles: text('conflict_files'), // JSON-encoded string[] | null; set on merge conflict +}, (table) => [ + index('errands_project_id_idx').on(table.projectId), + index('errands_status_idx').on(table.status), +]); + +export type Errand = InferSelectModel; +export type NewErrand = InferInsertModel; diff --git a/apps/server/drizzle/0034_salty_next_avengers.sql b/apps/server/drizzle/0034_salty_next_avengers.sql new file mode 100644 index 0000000..8d67028 --- /dev/null +++ b/apps/server/drizzle/0034_salty_next_avengers.sql @@ -0,0 +1,17 @@ +CREATE TABLE `errands` ( + `id` text PRIMARY KEY NOT NULL, + `description` text NOT NULL, + `branch` text NOT NULL, + `base_branch` text DEFAULT 'main' NOT NULL, + `agent_id` text, + `project_id` text NOT NULL, + `status` text DEFAULT 'active' NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + `conflict_files` text, + FOREIGN KEY (`agent_id`) REFERENCES `agents`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `errands_project_id_idx` ON `errands` (`project_id`);--> statement-breakpoint +CREATE INDEX `errands_status_idx` ON `errands` (`status`); \ No newline at end of file diff --git a/apps/server/drizzle/meta/0034_snapshot.json b/apps/server/drizzle/meta/0034_snapshot.json new file mode 100644 index 0000000..011f44a --- /dev/null +++ b/apps/server/drizzle/meta/0034_snapshot.json @@ -0,0 +1,1988 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "443071fe-31d6-498a-9f4a-4a3ff24a46fc", + "prevId": "5fbe1151-1dfb-4b0c-a7fa-2177369543fd", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'claude'" + }, + "config_json": { + "name": "config_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_exhausted": { + "name": "is_exhausted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "exhausted_until": { + "name": "exhausted_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agent_log_chunks": { + "name": "agent_log_chunks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_number": { + "name": "session_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "agent_log_chunks_agent_id_idx": { + "name": "agent_log_chunks_agent_id_idx", + "columns": [ + "agent_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agents": { + "name": "agents", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_id": { + "name": "worktree_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'claude'" + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'idle'" + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'execute'" + }, + "pid": { + "name": "pid", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_file_path": { + "name": "output_file_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pending_questions": { + "name": "pending_questions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_dismissed_at": { + "name": "user_dismissed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "agents_name_unique": { + "name": "agents_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "agents_task_id_tasks_id_fk": { + "name": "agents_task_id_tasks_id_fk", + "tableFrom": "agents", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "agents_initiative_id_initiatives_id_fk": { + "name": "agents_initiative_id_initiatives_id_fk", + "tableFrom": "agents", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "agents_account_id_accounts_id_fk": { + "name": "agents_account_id_accounts_id_fk", + "tableFrom": "agents", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "change_set_entries": { + "name": "change_set_entries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "change_set_id": { + "name": "change_set_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous_state": { + "name": "previous_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "new_state": { + "name": "new_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "change_set_entries_change_set_id_idx": { + "name": "change_set_entries_change_set_id_idx", + "columns": [ + "change_set_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "change_set_entries_change_set_id_change_sets_id_fk": { + "name": "change_set_entries_change_set_id_change_sets_id_fk", + "tableFrom": "change_set_entries", + "tableTo": "change_sets", + "columnsFrom": [ + "change_set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "change_sets": { + "name": "change_sets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'applied'" + }, + "reverted_at": { + "name": "reverted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "change_sets_initiative_id_idx": { + "name": "change_sets_initiative_id_idx", + "columns": [ + "initiative_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "change_sets_agent_id_agents_id_fk": { + "name": "change_sets_agent_id_agents_id_fk", + "tableFrom": "change_sets", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "change_sets_initiative_id_initiatives_id_fk": { + "name": "change_sets_initiative_id_initiatives_id_fk", + "tableFrom": "change_sets", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_messages": { + "name": "chat_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "chat_session_id": { + "name": "chat_session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "change_set_id": { + "name": "change_set_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "chat_messages_session_id_idx": { + "name": "chat_messages_session_id_idx", + "columns": [ + "chat_session_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chat_messages_chat_session_id_chat_sessions_id_fk": { + "name": "chat_messages_chat_session_id_chat_sessions_id_fk", + "tableFrom": "chat_messages", + "tableTo": "chat_sessions", + "columnsFrom": [ + "chat_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_messages_change_set_id_change_sets_id_fk": { + "name": "chat_messages_change_set_id_change_sets_id_fk", + "tableFrom": "chat_messages", + "tableTo": "change_sets", + "columnsFrom": [ + "change_set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_sessions": { + "name": "chat_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "chat_sessions_target_idx": { + "name": "chat_sessions_target_idx", + "columns": [ + "target_type", + "target_id" + ], + "isUnique": false + }, + "chat_sessions_initiative_id_idx": { + "name": "chat_sessions_initiative_id_idx", + "columns": [ + "initiative_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chat_sessions_initiative_id_initiatives_id_fk": { + "name": "chat_sessions_initiative_id_initiatives_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_sessions_agent_id_agents_id_fk": { + "name": "chat_sessions_agent_id_agents_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "conversations": { + "name": "conversations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "from_agent_id": { + "name": "from_agent_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_agent_id": { + "name": "to_agent_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase_id": { + "name": "phase_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "question": { + "name": "question", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "answer": { + "name": "answer", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "conversations_to_agent_status_idx": { + "name": "conversations_to_agent_status_idx", + "columns": [ + "to_agent_id", + "status" + ], + "isUnique": false + }, + "conversations_from_agent_idx": { + "name": "conversations_from_agent_idx", + "columns": [ + "from_agent_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "conversations_from_agent_id_agents_id_fk": { + "name": "conversations_from_agent_id_agents_id_fk", + "tableFrom": "conversations", + "tableTo": "agents", + "columnsFrom": [ + "from_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_to_agent_id_agents_id_fk": { + "name": "conversations_to_agent_id_agents_id_fk", + "tableFrom": "conversations", + "tableTo": "agents", + "columnsFrom": [ + "to_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_initiative_id_initiatives_id_fk": { + "name": "conversations_initiative_id_initiatives_id_fk", + "tableFrom": "conversations", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "conversations_phase_id_phases_id_fk": { + "name": "conversations_phase_id_phases_id_fk", + "tableFrom": "conversations", + "tableTo": "phases", + "columnsFrom": [ + "phase_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "conversations_task_id_tasks_id_fk": { + "name": "conversations_task_id_tasks_id_fk", + "tableFrom": "conversations", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "errands": { + "name": "errands", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'main'" + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conflict_files": { + "name": "conflict_files", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "errands_project_id_idx": { + "name": "errands_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "errands_status_idx": { + "name": "errands_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "errands_agent_id_agents_id_fk": { + "name": "errands_agent_id_agents_id_fk", + "tableFrom": "errands", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "errands_project_id_projects_id_fk": { + "name": "errands_project_id_projects_id_fk", + "tableFrom": "errands", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "initiative_projects": { + "name": "initiative_projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "initiative_project_unique": { + "name": "initiative_project_unique", + "columns": [ + "initiative_id", + "project_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "initiative_projects_initiative_id_initiatives_id_fk": { + "name": "initiative_projects_initiative_id_initiatives_id_fk", + "tableFrom": "initiative_projects", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "initiative_projects_project_id_projects_id_fk": { + "name": "initiative_projects_project_id_projects_id_fk", + "tableFrom": "initiative_projects", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "initiatives": { + "name": "initiatives", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "execution_mode": { + "name": "execution_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'review_per_phase'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "messages": { + "name": "messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "sender_type": { + "name": "sender_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipient_type": { + "name": "recipient_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_id": { + "name": "recipient_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'info'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requires_response": { + "name": "requires_response", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "messages_sender_id_agents_id_fk": { + "name": "messages_sender_id_agents_id_fk", + "tableFrom": "messages", + "tableTo": "agents", + "columnsFrom": [ + "sender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "messages_recipient_id_agents_id_fk": { + "name": "messages_recipient_id_agents_id_fk", + "tableFrom": "messages", + "tableTo": "agents", + "columnsFrom": [ + "recipient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "messages_parent_message_id_messages_id_fk": { + "name": "messages_parent_message_id_messages_id_fk", + "tableFrom": "messages", + "tableTo": "messages", + "columnsFrom": [ + "parent_message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pages": { + "name": "pages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_page_id": { + "name": "parent_page_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "pages_initiative_id_initiatives_id_fk": { + "name": "pages_initiative_id_initiatives_id_fk", + "tableFrom": "pages", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pages_parent_page_id_pages_id_fk": { + "name": "pages_parent_page_id_pages_id_fk", + "tableFrom": "pages", + "tableTo": "pages", + "columnsFrom": [ + "parent_page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "phase_dependencies": { + "name": "phase_dependencies", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "phase_id": { + "name": "phase_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "depends_on_phase_id": { + "name": "depends_on_phase_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "phase_dependencies_phase_id_phases_id_fk": { + "name": "phase_dependencies_phase_id_phases_id_fk", + "tableFrom": "phase_dependencies", + "tableTo": "phases", + "columnsFrom": [ + "phase_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "phase_dependencies_depends_on_phase_id_phases_id_fk": { + "name": "phase_dependencies_depends_on_phase_id_phases_id_fk", + "tableFrom": "phase_dependencies", + "tableTo": "phases", + "columnsFrom": [ + "depends_on_phase_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "phases": { + "name": "phases", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "merge_base": { + "name": "merge_base", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "phases_initiative_id_initiatives_id_fk": { + "name": "phases_initiative_id_initiatives_id_fk", + "tableFrom": "phases", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'main'" + }, + "last_fetched_at": { + "name": "last_fetched_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "projects_name_unique": { + "name": "projects_name_unique", + "columns": [ + "name" + ], + "isUnique": true + }, + "projects_url_unique": { + "name": "projects_url_unique", + "columns": [ + "url" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "review_comments": { + "name": "review_comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "phase_id": { + "name": "phase_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "line_number": { + "name": "line_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "line_type": { + "name": "line_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'you'" + }, + "parent_comment_id": { + "name": "parent_comment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved": { + "name": "resolved", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "review_comments_phase_id_idx": { + "name": "review_comments_phase_id_idx", + "columns": [ + "phase_id" + ], + "isUnique": false + }, + "review_comments_parent_id_idx": { + "name": "review_comments_parent_id_idx", + "columns": [ + "parent_comment_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "review_comments_phase_id_phases_id_fk": { + "name": "review_comments_phase_id_phases_id_fk", + "tableFrom": "review_comments", + "tableTo": "phases", + "columnsFrom": [ + "phase_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_comments_parent_comment_id_review_comments_id_fk": { + "name": "review_comments_parent_comment_id_review_comments_id_fk", + "tableFrom": "review_comments", + "tableTo": "review_comments", + "columnsFrom": [ + "parent_comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "task_dependencies": { + "name": "task_dependencies", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "depends_on_task_id": { + "name": "depends_on_task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "task_dependencies_task_id_tasks_id_fk": { + "name": "task_dependencies_task_id_tasks_id_fk", + "tableFrom": "task_dependencies", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_dependencies_depends_on_task_id_tasks_id_fk": { + "name": "task_dependencies_depends_on_task_id_tasks_id_fk", + "tableFrom": "task_dependencies", + "tableTo": "tasks", + "columnsFrom": [ + "depends_on_task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "phase_id": { + "name": "phase_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "initiative_id": { + "name": "initiative_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_task_id": { + "name": "parent_task_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'auto'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'execute'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'medium'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "tasks_phase_id_phases_id_fk": { + "name": "tasks_phase_id_phases_id_fk", + "tableFrom": "tasks", + "tableTo": "phases", + "columnsFrom": [ + "phase_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_initiative_id_initiatives_id_fk": { + "name": "tasks_initiative_id_initiatives_id_fk", + "tableFrom": "tasks", + "tableTo": "initiatives", + "columnsFrom": [ + "initiative_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_parent_task_id_tasks_id_fk": { + "name": "tasks_parent_task_id_tasks_id_fk", + "tableFrom": "tasks", + "tableTo": "tasks", + "columnsFrom": [ + "parent_task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index 2c92726..91e8afc 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -239,6 +239,13 @@ "when": 1772409600000, "tag": "0033_drop_approval_columns", "breakpoints": true + }, + { + "idx": 34, + "version": "6", + "when": 1772808163349, + "tag": "0034_salty_next_avengers", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/server/test/harness.ts b/apps/server/test/harness.ts index bb5c0ba..31cd7f1 100644 --- a/apps/server/test/harness.ts +++ b/apps/server/test/harness.ts @@ -25,6 +25,7 @@ import type { MessageRepository } from '../db/repositories/message-repository.js import type { AgentRepository } from '../db/repositories/agent-repository.js'; import type { InitiativeRepository } from '../db/repositories/initiative-repository.js'; import type { PhaseRepository } from '../db/repositories/phase-repository.js'; +import type { ErrandRepository } from '../db/repositories/errand-repository.js'; import type { Initiative, Phase, Task } from '../db/schema.js'; import { createTestDatabase } from '../db/repositories/drizzle/test-helpers.js'; import { createRepositories } from '../container.js'; @@ -204,6 +205,8 @@ export interface TestHarness { initiativeRepository: InitiativeRepository; /** Phase repository */ phaseRepository: PhaseRepository; + /** Errand repository */ + errandRepository: ErrandRepository; // tRPC Caller /** tRPC caller for direct procedure calls */ @@ -409,7 +412,7 @@ export function createTestHarness(): TestHarness { // Create repositories const repos = createRepositories(db); - const { taskRepository, messageRepository, agentRepository, initiativeRepository, phaseRepository } = repos; + const { taskRepository, messageRepository, agentRepository, initiativeRepository, phaseRepository, errandRepository } = repos; // Create real managers wired to mocks const dispatchManager = new DefaultDispatchManager( @@ -447,6 +450,7 @@ export function createTestHarness(): TestHarness { coordinationManager, initiativeRepository, phaseRepository, + errandRepository, }); // Create tRPC caller @@ -470,6 +474,7 @@ export function createTestHarness(): TestHarness { agentRepository, initiativeRepository, phaseRepository, + errandRepository, // tRPC Caller caller, diff --git a/apps/server/trpc/context.ts b/apps/server/trpc/context.ts index f4889d6..3c259c3 100644 --- a/apps/server/trpc/context.ts +++ b/apps/server/trpc/context.ts @@ -19,6 +19,7 @@ import type { LogChunkRepository } from '../db/repositories/log-chunk-repository import type { ConversationRepository } from '../db/repositories/conversation-repository.js'; import type { ChatSessionRepository } from '../db/repositories/chat-session-repository.js'; import type { ReviewCommentRepository } from '../db/repositories/review-comment-repository.js'; +import type { ErrandRepository } from '../db/repositories/errand-repository.js'; import type { AccountCredentialManager } from '../agent/credentials/types.js'; import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js'; import type { CoordinationManager } from '../coordination/types.js'; @@ -80,6 +81,8 @@ export interface TRPCContext { chatSessionRepository?: ChatSessionRepository; /** Review comment repository for inline review comments on phase diffs */ reviewCommentRepository?: ReviewCommentRepository; + /** Errand repository for errand CRUD operations */ + errandRepository?: ErrandRepository; /** Project sync manager for remote fetch/sync operations */ projectSyncManager?: ProjectSyncManager; /** Absolute path to the workspace root (.cwrc directory) */ @@ -113,6 +116,7 @@ export interface CreateContextOptions { conversationRepository?: ConversationRepository; chatSessionRepository?: ChatSessionRepository; reviewCommentRepository?: ReviewCommentRepository; + errandRepository?: ErrandRepository; projectSyncManager?: ProjectSyncManager; workspaceRoot?: string; } @@ -148,6 +152,7 @@ export function createContext(options: CreateContextOptions): TRPCContext { conversationRepository: options.conversationRepository, chatSessionRepository: options.chatSessionRepository, reviewCommentRepository: options.reviewCommentRepository, + errandRepository: options.errandRepository, projectSyncManager: options.projectSyncManager, workspaceRoot: options.workspaceRoot, }; diff --git a/apps/server/trpc/routers/_helpers.ts b/apps/server/trpc/routers/_helpers.ts index 67fa3ef..928aac4 100644 --- a/apps/server/trpc/routers/_helpers.ts +++ b/apps/server/trpc/routers/_helpers.ts @@ -19,6 +19,7 @@ import type { LogChunkRepository } from '../../db/repositories/log-chunk-reposit import type { ConversationRepository } from '../../db/repositories/conversation-repository.js'; import type { ChatSessionRepository } from '../../db/repositories/chat-session-repository.js'; import type { ReviewCommentRepository } from '../../db/repositories/review-comment-repository.js'; +import type { ErrandRepository } from '../../db/repositories/errand-repository.js'; import type { DispatchManager, PhaseDispatchManager } from '../../dispatch/types.js'; import type { CoordinationManager } from '../../coordination/types.js'; import type { BranchManager } from '../../git/branch-manager.js'; @@ -225,3 +226,13 @@ export function requireProjectSyncManager(ctx: TRPCContext): ProjectSyncManager } return ctx.projectSyncManager; } + +export function requireErrandRepository(ctx: TRPCContext): ErrandRepository { + if (!ctx.errandRepository) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Errand repository not available', + }); + } + return ctx.errandRepository; +} From 377e8de5e9106c0b0131c7d503528801a0abec7a Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 16:21:01 +0100 Subject: [PATCH 06/11] feat: Add errand tRPC router with all 9 procedures and comprehensive tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/server/agent/mock-manager.ts | 39 +- apps/server/agent/types.ts | 12 +- apps/server/db/repositories/drizzle/errand.ts | 5 +- apps/server/db/schema.ts | 2 +- apps/server/dispatch/manager.test.ts | 1 + apps/server/trpc/router.ts | 2 + apps/server/trpc/routers/errand.test.ts | 720 ++++++++++++++++++ apps/server/trpc/routers/errand.ts | 430 +++++++++++ docs/server-api.md | 22 + 9 files changed, 1226 insertions(+), 7 deletions(-) create mode 100644 apps/server/trpc/routers/errand.test.ts create mode 100644 apps/server/trpc/routers/errand.ts diff --git a/apps/server/agent/mock-manager.ts b/apps/server/agent/mock-manager.ts index 63eac8d..7ca2361 100644 --- a/apps/server/agent/mock-manager.ts +++ b/apps/server/agent/mock-manager.ts @@ -26,6 +26,7 @@ import type { AgentDeletedEvent, AgentWaitingEvent, } from '../events/index.js'; +import type { AgentRepository } from '../db/repositories/agent-repository.js'; /** * Scenario configuration for mock agent behavior. @@ -83,10 +84,12 @@ export class MockAgentManager implements AgentManager { private scenarioOverrides: Map = new Map(); private defaultScenario: MockAgentScenario; private eventBus?: EventBus; + private agentRepository?: AgentRepository; - constructor(options?: { eventBus?: EventBus; defaultScenario?: MockAgentScenario }) { + constructor(options?: { eventBus?: EventBus; defaultScenario?: MockAgentScenario; agentRepository?: AgentRepository }) { this.eventBus = options?.eventBus; this.defaultScenario = options?.defaultScenario ?? DEFAULT_SCENARIO; + this.agentRepository = options?.agentRepository; } /** @@ -111,7 +114,7 @@ export class MockAgentManager implements AgentManager { * Completion happens async via setTimeout (even if delay=0). */ async spawn(options: SpawnAgentOptions): Promise { - const { taskId, prompt } = options; + const { taskId } = options; const name = options.name ?? `agent-${taskId?.slice(0, 6) ?? 'noTask'}`; // Check name uniqueness @@ -121,11 +124,29 @@ export class MockAgentManager implements AgentManager { } } - const agentId = randomUUID(); const sessionId = randomUUID(); const worktreeId = randomUUID(); const now = new Date(); + // Persist to agentRepository when provided (required for FK constraints in tests) + let agentId: string; + if (this.agentRepository) { + const dbAgent = await this.agentRepository.create({ + name, + worktreeId, + taskId: taskId ?? null, + initiativeId: options.initiativeId ?? null, + sessionId, + status: 'running', + mode: options.mode ?? 'execute', + provider: options.provider ?? 'claude', + accountId: null, + }); + agentId = dbAgent.id; + } else { + agentId = randomUUID(); + } + // Determine scenario (override takes precedence — use original name or generated) const scenario = this.scenarioOverrides.get(name) ?? this.defaultScenario; @@ -507,6 +528,18 @@ export class MockAgentManager implements AgentManager { return true; } + /** + * Deliver a user message to a running errand agent. + * Mock implementation: no-op (simulates message delivery without actual process interaction). + */ + async sendUserMessage(agentId: string, _message: string): Promise { + const record = this.agents.get(agentId); + if (!record) { + throw new Error(`Agent '${agentId}' not found`); + } + // Mock: succeed silently — message delivery is a no-op in tests + } + /** * Clear all agents and pending timers. * Useful for test cleanup. diff --git a/apps/server/agent/types.ts b/apps/server/agent/types.ts index 94737d9..e46bcea 100644 --- a/apps/server/agent/types.ts +++ b/apps/server/agent/types.ts @@ -15,7 +15,7 @@ export type AgentStatus = 'idle' | 'running' | 'waiting_for_input' | 'stopped' | * - plan: Plan initiative into phases * - detail: Detail phase into individual tasks */ -export type AgentMode = 'execute' | 'discuss' | 'plan' | 'detail' | 'refine' | 'chat'; +export type AgentMode = 'execute' | 'discuss' | 'plan' | 'detail' | 'refine' | 'chat' | 'errand'; /** * Context data written as input files in agent workdir before spawn. @@ -257,4 +257,14 @@ export interface AgentManager { question: string, fromAgentId: string, ): Promise; + + /** + * 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; } diff --git a/apps/server/db/repositories/drizzle/errand.ts b/apps/server/db/repositories/drizzle/errand.ts index 1b62fb1..a5999e2 100644 --- a/apps/server/db/repositories/drizzle/errand.ts +++ b/apps/server/db/repositories/drizzle/errand.ts @@ -4,7 +4,7 @@ * Implements ErrandRepository interface using Drizzle ORM. */ -import { eq, and } from 'drizzle-orm'; +import { eq, and, desc } from 'drizzle-orm'; import { nanoid } from 'nanoid'; import type { DrizzleDatabase } from '../../index.js'; import { errands, agents, type Errand } from '../../schema.js'; @@ -81,7 +81,8 @@ export class DrizzleErrandRepository implements ErrandRepository { }) .from(errands) .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[]; } diff --git a/apps/server/db/schema.ts b/apps/server/db/schema.ts index ed2f7fd..cba5967 100644 --- a/apps/server/db/schema.ts +++ b/apps/server/db/schema.ts @@ -261,7 +261,7 @@ export const agents = sqliteTable('agents', { }) .notNull() .default('idle'), - mode: text('mode', { enum: ['execute', 'discuss', 'plan', 'detail', 'refine', 'chat'] }) + mode: text('mode', { enum: ['execute', 'discuss', 'plan', 'detail', 'refine', 'chat', 'errand'] }) .notNull() .default('execute'), pid: integer('pid'), diff --git a/apps/server/dispatch/manager.test.ts b/apps/server/dispatch/manager.test.ts index 10a412e..477c2ce 100644 --- a/apps/server/dispatch/manager.test.ts +++ b/apps/server/dispatch/manager.test.ts @@ -81,6 +81,7 @@ function createMockAgentManager( getResult: vi.fn().mockResolvedValue(null), getPendingQuestions: vi.fn().mockResolvedValue(null), resumeForConversation: vi.fn().mockResolvedValue(false), + sendUserMessage: vi.fn().mockResolvedValue(undefined), }; } diff --git a/apps/server/trpc/router.ts b/apps/server/trpc/router.ts index d1c43fc..085a808 100644 --- a/apps/server/trpc/router.ts +++ b/apps/server/trpc/router.ts @@ -24,6 +24,7 @@ import { subscriptionProcedures } from './routers/subscription.js'; import { previewProcedures } from './routers/preview.js'; import { conversationProcedures } from './routers/conversation.js'; import { chatSessionProcedures } from './routers/chat-session.js'; +import { errandProcedures } from './routers/errand.js'; // Re-export tRPC primitives (preserves existing import paths) export { router, publicProcedure, middleware, createCallerFactory } from './trpc.js'; @@ -63,6 +64,7 @@ export const appRouter = router({ ...previewProcedures(publicProcedure), ...conversationProcedures(publicProcedure), ...chatSessionProcedures(publicProcedure), + ...errandProcedures(publicProcedure), }); export type AppRouter = typeof appRouter; diff --git a/apps/server/trpc/routers/errand.test.ts b/apps/server/trpc/routers/errand.test.ts new file mode 100644 index 0000000..c21e0b8 --- /dev/null +++ b/apps/server/trpc/routers/errand.test.ts @@ -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; + 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 { + if (this.ensureBranchError) throw this.ensureBranchError; + this.ensuredBranches.push(branch); + } + async mergeBranch(_repoPath: string, _src: string, _target: string): Promise { + return this.mergeResultOverride ?? { success: true, message: 'Merged successfully' }; + } + async diffBranches(_repoPath: string, _base: string, _head: string): Promise { + return this.diffResult; + } + async deleteBranch(_repoPath: string, branch: string): Promise { + this.deletedBranches.push(branch); + } + async branchExists(_repoPath: string, _branch: string): Promise { return false; } + async remoteBranchExists(_repoPath: string, _branch: string): Promise { return false; } + async listCommits(_repoPath: string, _base: string, _head: string) { return []; } + async diffCommit(_repoPath: string, _hash: string): Promise { return ''; } + async getMergeBase(_repoPath: string, _b1: string, _b2: string): Promise { return ''; } + async pushBranch(_repoPath: string, _branch: string, _remote?: string): Promise {} + async checkMergeability(_repoPath: string, _src: string, _target: string): Promise { + return { mergeable: true, conflicts: [] }; + } + async fetchRemote(_repoPath: string, _remote?: string): Promise {} + async fastForwardBranch(_repoPath: string, _branch: string, _remote?: string): Promise {} +} + +// --------------------------------------------------------------------------- +// 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) { + 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, + 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; + + 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'", + }); + }); + }); +}); diff --git a/apps/server/trpc/routers/errand.ts b/apps/server/trpc/routers/errand.ts new file mode 100644 index 0000000..4ef6a32 --- /dev/null +++ b/apps/server/trpc/routers/errand.ts @@ -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 { + 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; + }), + }), + }; +} diff --git a/docs/server-api.md b/docs/server-api.md index ec11000..dc7def9 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -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. 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/-<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. From 09e4e3d4f04d42ed400a72e71e504dbe4d0d4c0d Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 16:23:49 +0100 Subject: [PATCH 07/11] feat: add ErrandDetailPanel slide-over component Implements the errand detail panel with three context-aware views: - Active: live agent output via AgentOutputViewer, chat input, Mark Done/Abandon - Pending review/conflict: diff block, conflict notice with file list, Merge/Delete/Abandon - Merged/abandoned: read-only diff, info line with relative date, Delete only Follows TaskSlideOver.tsx patterns: Framer Motion slide-in, backdrop, Escape key close. Shift+click skips window.confirm on all destructive actions. Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/components/ErrandDetailPanel.tsx | 379 ++++++++++++++++++ 1 file changed, 379 insertions(+) create mode 100644 apps/web/src/components/ErrandDetailPanel.tsx diff --git a/apps/web/src/components/ErrandDetailPanel.tsx b/apps/web/src/components/ErrandDetailPanel.tsx new file mode 100644 index 0000000..04182a8 --- /dev/null +++ b/apps/web/src/components/ErrandDetailPanel.tsx @@ -0,0 +1,379 @@ +import { useState, useEffect } from 'react'; +import { motion, AnimatePresence } from 'motion/react'; +import { X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { StatusBadge } from '@/components/StatusBadge'; +import { AgentOutputViewer } from '@/components/AgentOutputViewer'; +import { trpc } from '@/lib/trpc'; +import { formatRelativeTime } from '@/lib/utils'; +import { toast } from 'sonner'; + +interface ErrandDetailPanelProps { + errandId: string; + onClose: () => void; +} + +export function ErrandDetailPanel({ errandId, onClose }: ErrandDetailPanelProps) { + const [message, setMessage] = useState(''); + + const errandQuery = trpc.errand.get.useQuery({ id: errandId }); + const errand = errandQuery.data; + + const diffQuery = trpc.errand.diff.useQuery( + { id: errandId }, + { enabled: errand?.status !== 'active' }, + ); + + const utils = trpc.useUtils(); + + const completeMutation = trpc.errand.complete.useMutation({ + onSuccess: () => { + utils.errand.list.invalidate(); + errandQuery.refetch(); + }, + }); + + const mergeMutation = trpc.errand.merge.useMutation({ + onSuccess: () => { + utils.errand.list.invalidate(); + toast.success(`Merged into ${errand?.baseBranch ?? 'base'}`); + onClose(); + }, + onError: () => { + errandQuery.refetch(); + }, + }); + + const deleteMutation = trpc.errand.delete.useMutation({ + onSuccess: () => { + utils.errand.list.invalidate(); + onClose(); + }, + }); + + const abandonMutation = trpc.errand.abandon.useMutation({ + onSuccess: () => { + utils.errand.list.invalidate(); + errandQuery.refetch(); + }, + }); + + const sendMutation = trpc.errand.sendMessage.useMutation({ + onSuccess: () => { + utils.errand.list.invalidate(); + setMessage(''); + }, + }); + + // Escape key closes + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') onClose(); + } + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [onClose]); + + const chatDisabled = errand?.status !== 'active' || sendMutation.isPending; + + return ( + + <> + {/* Backdrop */} + + + {/* Panel */} + + {/* Loading state */} + {errandQuery.isLoading && ( + <> +
+ Loading… + +
+
+

Loading errand…

+
+ + )} + + {/* Error state */} + {errandQuery.error && ( + <> +
+ Error + +
+
+

Failed to load errand.

+ +
+ + )} + + {/* Loaded state */} + {errand && ( + <> + {/* Header */} +
+
+

+ {errand.description} +

+

+ {errand.branch} +

+
+ + {errand.agentAlias && ( + + {errand.agentAlias} + + )} + +
+ + {/* View: Active */} + {errand.status === 'active' && ( + <> +
+ {errand.agentId && ( + + )} +
+ + {/* Chat input */} +
+
{ + e.preventDefault(); + if (!message.trim()) return; + sendMutation.mutate({ id: errandId, message }); + }} + > +
+ setMessage(e.target.value)} + placeholder="Send a message to the agent…" + disabled={chatDisabled} + title={ + chatDisabled && errand.status !== 'active' + ? 'Agent is not running' + : undefined + } + /> + +
+
+
+ + {/* Footer */} +
+ + +
+ + )} + + {/* View: Pending Review / Conflict */} + {(errand.status === 'pending_review' || errand.status === 'conflict') && ( + <> +
+ {/* Conflict notice */} + {errand.status === 'conflict' && + (errand.conflictFiles?.length ?? 0) > 0 && ( +
+ Merge conflict in {errand.conflictFiles!.length} file(s):{' '} + {errand.conflictFiles!.join(', ')} — resolve manually in the + worktree then re-merge. +
+ )} + + {/* Diff block */} +
+ {diffQuery.isLoading ? ( +

Loading diff…

+ ) : diffQuery.data?.diff ? ( +
+                          {diffQuery.data.diff}
+                        
+ ) : ( +

+ No changes — branch has no commits. +

+ )} +
+
+ + {/* Footer */} +
+
+ + +
+ +
+ + )} + + {/* View: Merged / Abandoned */} + {(errand.status === 'merged' || errand.status === 'abandoned') && ( + <> +
+ {/* Info line */} +
+ {errand.status === 'merged' + ? `Merged into ${errand.baseBranch} · ${formatRelativeTime(errand.updatedAt.toISOString())}` + : `Abandoned · ${formatRelativeTime(errand.updatedAt.toISOString())}`} +
+ + {/* Read-only diff */} +
+ {diffQuery.data?.diff ? ( +
+                          {diffQuery.data.diff}
+                        
+ ) : ( +

+ No changes — branch has no commits. +

+ )} +
+
+ + {/* Footer */} +
+ +
+ + )} + + )} +
+ +
+ ); +} From e86a743c0b01f5f2d3e93ad576966bd9a7b87fac Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 16:26:15 +0100 Subject: [PATCH 08/11] feat: Add all 9 cw errand CLI subcommands with tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires errand command group into CLI with start, list, chat, diff, complete, merge, resolve, abandon, and delete subcommands. All commands call tRPC procedures via createDefaultTrpcClient(). The start command validates description length client-side (≤200 chars) before making any network calls. Co-Authored-By: Claude Sonnet 4.6 --- apps/server/cli/errand.test.ts | 266 +++++++++++++++++++++++++++++++++ apps/server/cli/index.ts | 189 +++++++++++++++++++++++ docs/cli-config.md | 13 ++ 3 files changed, 468 insertions(+) create mode 100644 apps/server/cli/errand.test.ts diff --git a/apps/server/cli/errand.test.ts b/apps/server/cli/errand.test.ts new file mode 100644 index 0000000..0b2f5bb --- /dev/null +++ b/apps/server/cli/errand.test.ts @@ -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 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 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 into " 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 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 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); + }); +}); diff --git a/apps/server/cli/index.ts b/apps/server/cli/index.ts index 007035c..8fc0425 100644 --- a/apps/server/cli/index.ts +++ b/apps/server/cli/index.ts @@ -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('Start a new errand session') + .requiredOption('--project ', 'Project ID') + .option('--base ', '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 ', 'Filter by project') + .option('--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 ') + .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 ') + .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 ') + .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 ') + .description('Merge errand branch into target branch') + .option('--target ', '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 ') + .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 ') + .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 ') + .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; } diff --git a/docs/cli-config.md b/docs/cli-config.md index 7def20e..a249d64 100644 --- a/docs/cli-config.md +++ b/docs/cli-config.md @@ -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 --project [--base ]` | Start a new errand session (description ≤200 chars) | +| `list [--project ] [--status ]` | List errands; status: active\|pending_review\|conflict\|merged\|abandoned | +| `chat ` | Deliver a message to the running errand agent | +| `diff ` | Print unified git diff between base branch and errand branch | +| `complete ` | Mark errand as done and ready for review | +| `merge [--target ]` | Merge errand branch into target branch | +| `resolve ` | Print worktree path and conflicting files for manual resolution | +| `abandon ` | Stop agent, remove worktree and branch, keep DB record as abandoned | +| `delete ` | Stop agent, remove worktree, delete branch, and delete DB record | + ### Accounts (`cw account`) | Command | Description | |---------|-------------| From 14041d007fc8c3b06866d044eef6e995927679a7 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 16:27:44 +0100 Subject: [PATCH 09/11] feat: add Errands nav item, /errands route, and CreateErrandDialog - AppLayout: add Errands nav entry with pending_review badge count - /errands route: list table with ID, description, branch, status, agent, created columns; empty state with CLI hint; slide-over integration - CreateErrandDialog: description (max 200 chars with counter), project select, optional base branch; no optimistic UI due to agent spawn latency - ErrandDetailPanel: checkout from completed dependency commit (4j3ZfR_ZX_4rw7j9uj6DV) TypeScript compiles clean. Route uses TanStack Router file-based routing; routeTree.gen.ts auto-regenerated on build. Co-Authored-By: Claude Sonnet 4.6 --- .../web/src/components/CreateErrandDialog.tsx | 142 +++++++ apps/web/src/components/ErrandDetailPanel.tsx | 379 ++++++++++++++++++ apps/web/src/layouts/AppLayout.tsx | 14 +- apps/web/src/routes/errands/index.tsx | 130 ++++++ 4 files changed, 660 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/components/CreateErrandDialog.tsx create mode 100644 apps/web/src/components/ErrandDetailPanel.tsx create mode 100644 apps/web/src/routes/errands/index.tsx diff --git a/apps/web/src/components/CreateErrandDialog.tsx b/apps/web/src/components/CreateErrandDialog.tsx new file mode 100644 index 0000000..0e76e17 --- /dev/null +++ b/apps/web/src/components/CreateErrandDialog.tsx @@ -0,0 +1,142 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { toast } from 'sonner'; +import { cn } from '@/lib/utils'; +import { trpc } from '@/lib/trpc'; + +interface CreateErrandDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function CreateErrandDialog({ open, onOpenChange }: CreateErrandDialogProps) { + const [description, setDescription] = useState(''); + const [projectId, setProjectId] = useState(''); + const [baseBranch, setBaseBranch] = useState(''); + const [error, setError] = useState(null); + + const navigate = useNavigate(); + const utils = trpc.useUtils(); + + const projectsQuery = trpc.listProjects.useQuery(); + + const createMutation = trpc.errand.create.useMutation({ + onSuccess: (data) => { + toast.success('Errand started'); + onOpenChange(false); + utils.errand.list.invalidate(); + navigate({ to: '/errands', search: { selected: data.id } }); + }, + onError: (err) => { + setError(err.message); + }, + }); + + useEffect(() => { + if (open) { + setDescription(''); + setProjectId(''); + setBaseBranch(''); + setError(null); + } + }, [open]); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + createMutation.mutate({ + description: description.trim(), + projectId, + baseBranch: baseBranch.trim() || undefined, + }); + } + + const canSubmit = + description.trim().length > 0 && + description.length <= 200 && + projectId !== '' && + !createMutation.isPending; + + return ( + + + + New Errand + + Start a small isolated change with a dedicated agent. + + +
+
+ +