From 269a2d2616ad87f47c3dde386746c70748845992 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Thu, 5 Mar 2026 21:39:29 +0100 Subject: [PATCH 01/84] feat: Extend AgentInfo with exitCode + add getAgentInputFiles/getAgentPrompt tRPC procedures Adds exitCode to AgentInfo type and propagates it through all toAgentInfo() implementations. Enhances getAgent to also return taskName and initiativeName from their respective repositories. Adds two new filesystem-reading tRPC procedures for the Agent Details tab: getAgentInputFiles (reads .cw/input/ files with binary detection, 500 KB cap, sorted) and getAgentPrompt (reads .cw/agent-logs//PROMPT.md with 1 MB cap and structured ENOENT handling). Co-Authored-By: Claude Sonnet 4.6 --- .../agent/lifecycle/cleanup-strategy.ts | 1 + apps/server/agent/lifecycle/controller.ts | 1 + apps/server/agent/manager.ts | 2 + apps/server/agent/mock-manager.ts | 1 + apps/server/agent/types.ts | 2 + apps/server/dispatch/manager.test.ts | 2 + apps/server/trpc/routers/agent.test.ts | 288 ++++++++++++++++++ apps/server/trpc/routers/agent.ts | 123 +++++++- docs/server-api.md | 4 +- 9 files changed, 421 insertions(+), 3 deletions(-) create mode 100644 apps/server/trpc/routers/agent.test.ts diff --git a/apps/server/agent/lifecycle/cleanup-strategy.ts b/apps/server/agent/lifecycle/cleanup-strategy.ts index 4391124..a2ca671 100644 --- a/apps/server/agent/lifecycle/cleanup-strategy.ts +++ b/apps/server/agent/lifecycle/cleanup-strategy.ts @@ -18,6 +18,7 @@ export interface AgentInfo { status: string; initiativeId?: string | null; worktreeId: string; + exitCode?: number | null; } export interface CleanupStrategy { diff --git a/apps/server/agent/lifecycle/controller.ts b/apps/server/agent/lifecycle/controller.ts index 833634d..7c7e8aa 100644 --- a/apps/server/agent/lifecycle/controller.ts +++ b/apps/server/agent/lifecycle/controller.ts @@ -353,6 +353,7 @@ export class AgentLifecycleController { status: agent.status, initiativeId: agent.initiativeId, worktreeId: agent.worktreeId, + exitCode: agent.exitCode ?? null, }; } } \ No newline at end of file diff --git a/apps/server/agent/manager.ts b/apps/server/agent/manager.ts index 3bde16a..1b25798 100644 --- a/apps/server/agent/manager.ts +++ b/apps/server/agent/manager.ts @@ -1085,6 +1085,7 @@ export class MultiProviderAgentManager implements AgentManager { createdAt: Date; updatedAt: Date; userDismissedAt?: Date | null; + exitCode?: number | null; }): AgentInfo { return { id: agent.id, @@ -1100,6 +1101,7 @@ export class MultiProviderAgentManager implements AgentManager { createdAt: agent.createdAt, updatedAt: agent.updatedAt, userDismissedAt: agent.userDismissedAt, + exitCode: agent.exitCode ?? null, }; } } diff --git a/apps/server/agent/mock-manager.ts b/apps/server/agent/mock-manager.ts index 63eac8d..d8cb009 100644 --- a/apps/server/agent/mock-manager.ts +++ b/apps/server/agent/mock-manager.ts @@ -142,6 +142,7 @@ export class MockAgentManager implements AgentManager { accountId: null, createdAt: now, updatedAt: now, + exitCode: null, }; const record: MockAgentRecord = { diff --git a/apps/server/agent/types.ts b/apps/server/agent/types.ts index 94737d9..1f5e029 100644 --- a/apps/server/agent/types.ts +++ b/apps/server/agent/types.ts @@ -93,6 +93,8 @@ export interface AgentInfo { updatedAt: Date; /** When the user dismissed this agent (null if not dismissed) */ userDismissedAt?: Date | null; + /** Process exit code — null while running or if not yet exited */ + exitCode: number | null; } /** diff --git a/apps/server/dispatch/manager.test.ts b/apps/server/dispatch/manager.test.ts index 10a412e..c6558ad 100644 --- a/apps/server/dispatch/manager.test.ts +++ b/apps/server/dispatch/manager.test.ts @@ -70,6 +70,7 @@ function createMockAgentManager( accountId: null, createdAt: new Date(), updatedAt: new Date(), + exitCode: null, }; mockAgents.push(newAgent); return newAgent; @@ -101,6 +102,7 @@ function createIdleAgent(id: string, name: string): AgentInfo { accountId: null, createdAt: new Date(), updatedAt: new Date(), + exitCode: null, }; } diff --git a/apps/server/trpc/routers/agent.test.ts b/apps/server/trpc/routers/agent.test.ts new file mode 100644 index 0000000..49de47e --- /dev/null +++ b/apps/server/trpc/routers/agent.test.ts @@ -0,0 +1,288 @@ +/** + * Agent Router Tests + * + * Tests for getAgent (exitCode, taskName, initiativeName), + * getAgentInputFiles, and getAgentPrompt procedures. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; +import { appRouter, createCallerFactory } from '../index.js'; +import type { TRPCContext } from '../context.js'; +import type { EventBus } from '../../events/types.js'; + +const createCaller = createCallerFactory(appRouter); + +function createMockEventBus(): EventBus { + return { + emit: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + }; +} + +function createTestContext(overrides: Partial = {}): TRPCContext { + return { + eventBus: createMockEventBus(), + serverStartedAt: new Date('2026-01-30T12:00:00Z'), + processCount: 0, + ...overrides, + }; +} + +/** Minimal AgentInfo fixture matching the full interface */ +function makeAgentInfo(overrides: Record = {}) { + return { + id: 'agent-1', + name: 'test-agent', + taskId: null, + initiativeId: null, + sessionId: null, + worktreeId: 'test-agent', + status: 'stopped' as const, + mode: 'execute' as const, + provider: 'claude', + accountId: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + userDismissedAt: null, + exitCode: null, + ...overrides, + }; +} + +describe('getAgent', () => { + it('returns exitCode: 1 when agent has exitCode 1', async () => { + const mockManager = { + get: vi.fn().mockResolvedValue(makeAgentInfo({ exitCode: 1 })), + }; + + const ctx = createTestContext({ agentManager: mockManager as any }); + const caller = createCaller(ctx); + const result = await caller.getAgent({ id: 'agent-1' }); + + expect(result.exitCode).toBe(1); + }); + + it('returns exitCode: null when agent has no exitCode', async () => { + const mockManager = { + get: vi.fn().mockResolvedValue(makeAgentInfo({ exitCode: null })), + }; + + const ctx = createTestContext({ agentManager: mockManager as any }); + const caller = createCaller(ctx); + const result = await caller.getAgent({ id: 'agent-1' }); + + expect(result.exitCode).toBeNull(); + }); + + it('returns taskName and initiativeName from repositories', async () => { + const mockManager = { + get: vi.fn().mockResolvedValue(makeAgentInfo({ taskId: 'task-1', initiativeId: 'init-1' })), + }; + const mockTaskRepo = { + findById: vi.fn().mockResolvedValue({ id: 'task-1', name: 'My Task' }), + }; + const mockInitiativeRepo = { + findById: vi.fn().mockResolvedValue({ id: 'init-1', name: 'My Initiative' }), + }; + + const ctx = createTestContext({ + agentManager: mockManager as any, + taskRepository: mockTaskRepo as any, + initiativeRepository: mockInitiativeRepo as any, + }); + const caller = createCaller(ctx); + const result = await caller.getAgent({ id: 'agent-1' }); + + expect(result.taskName).toBe('My Task'); + expect(result.initiativeName).toBe('My Initiative'); + }); + + it('returns taskName: null and initiativeName: null when agent has no taskId or initiativeId', async () => { + const mockManager = { + get: vi.fn().mockResolvedValue(makeAgentInfo({ taskId: null, initiativeId: null })), + }; + + const ctx = createTestContext({ agentManager: mockManager as any }); + const caller = createCaller(ctx); + const result = await caller.getAgent({ id: 'agent-1' }); + + expect(result.taskName).toBeNull(); + expect(result.initiativeName).toBeNull(); + }); +}); + +describe('getAgentInputFiles', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-test-')); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + function makeAgentManagerWithWorktree(worktreeId = 'test-worktree', agentName = 'test-agent') { + return { + get: vi.fn().mockResolvedValue(makeAgentInfo({ worktreeId, name: agentName })), + }; + } + + it('returns worktree_missing when worktree dir does not exist', async () => { + const nonExistentRoot = path.join(tmpDir, 'no-such-dir'); + const mockManager = makeAgentManagerWithWorktree('test-worktree'); + + const ctx = createTestContext({ + agentManager: mockManager as any, + workspaceRoot: nonExistentRoot, + }); + const caller = createCaller(ctx); + const result = await caller.getAgentInputFiles({ id: 'agent-1' }); + + expect(result).toEqual({ files: [], reason: 'worktree_missing' }); + }); + + it('returns input_dir_missing when worktree exists but .cw/input does not', async () => { + const worktreeId = 'test-worktree'; + const worktreeRoot = path.join(tmpDir, 'agent-workdirs', worktreeId); + await fs.mkdir(worktreeRoot, { recursive: true }); + + const mockManager = makeAgentManagerWithWorktree(worktreeId); + const ctx = createTestContext({ + agentManager: mockManager as any, + workspaceRoot: tmpDir, + }); + const caller = createCaller(ctx); + const result = await caller.getAgentInputFiles({ id: 'agent-1' }); + + expect(result).toEqual({ files: [], reason: 'input_dir_missing' }); + }); + + it('returns sorted file list with correct name, content, sizeBytes', async () => { + const worktreeId = 'test-worktree'; + const inputDir = path.join(tmpDir, 'agent-workdirs', worktreeId, '.cw', 'input'); + await fs.mkdir(inputDir, { recursive: true }); + await fs.mkdir(path.join(inputDir, 'pages'), { recursive: true }); + + const manifestContent = '{"files": ["a"]}'; + const fooContent = '# Foo\nHello world'; + await fs.writeFile(path.join(inputDir, 'manifest.json'), manifestContent); + await fs.writeFile(path.join(inputDir, 'pages', 'foo.md'), fooContent); + + const mockManager = makeAgentManagerWithWorktree(worktreeId); + const ctx = createTestContext({ + agentManager: mockManager as any, + workspaceRoot: tmpDir, + }); + const caller = createCaller(ctx); + const result = await caller.getAgentInputFiles({ id: 'agent-1' }); + + expect(result.reason).toBeUndefined(); + expect(result.files).toHaveLength(2); + // Sorted alphabetically: manifest.json before pages/foo.md + expect(result.files[0].name).toBe('manifest.json'); + expect(result.files[0].content).toBe(manifestContent); + expect(result.files[0].sizeBytes).toBe(Buffer.byteLength(manifestContent)); + expect(result.files[1].name).toBe(path.join('pages', 'foo.md')); + expect(result.files[1].content).toBe(fooContent); + expect(result.files[1].sizeBytes).toBe(Buffer.byteLength(fooContent)); + }); + + it('skips binary files (containing null byte)', async () => { + const worktreeId = 'test-worktree'; + const inputDir = path.join(tmpDir, 'agent-workdirs', worktreeId, '.cw', 'input'); + await fs.mkdir(inputDir, { recursive: true }); + + // Binary file with null byte + const binaryData = Buffer.from([0x89, 0x50, 0x00, 0x4e, 0x47]); + await fs.writeFile(path.join(inputDir, 'image.png'), binaryData); + // Text file should still be returned + await fs.writeFile(path.join(inputDir, 'text.txt'), 'hello'); + + const mockManager = makeAgentManagerWithWorktree(worktreeId); + const ctx = createTestContext({ + agentManager: mockManager as any, + workspaceRoot: tmpDir, + }); + const caller = createCaller(ctx); + const result = await caller.getAgentInputFiles({ id: 'agent-1' }); + + expect(result.files).toHaveLength(1); + expect(result.files[0].name).toBe('text.txt'); + }); + + it('truncates files larger than 500 KB and preserves original sizeBytes', async () => { + const worktreeId = 'test-worktree'; + const inputDir = path.join(tmpDir, 'agent-workdirs', worktreeId, '.cw', 'input'); + await fs.mkdir(inputDir, { recursive: true }); + + const MAX_SIZE = 500 * 1024; + const largeContent = Buffer.alloc(MAX_SIZE + 100 * 1024, 'a'); // 600 KB + await fs.writeFile(path.join(inputDir, 'big.txt'), largeContent); + + const mockManager = makeAgentManagerWithWorktree(worktreeId); + const ctx = createTestContext({ + agentManager: mockManager as any, + workspaceRoot: tmpDir, + }); + const caller = createCaller(ctx); + const result = await caller.getAgentInputFiles({ id: 'agent-1' }); + + expect(result.files).toHaveLength(1); + expect(result.files[0].sizeBytes).toBe(largeContent.length); + expect(result.files[0].content).toContain('[truncated — file exceeds 500 KB]'); + }); +}); + +describe('getAgentPrompt', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-prompt-test-')); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('returns prompt_not_written when PROMPT.md does not exist', async () => { + const mockManager = { + get: vi.fn().mockResolvedValue(makeAgentInfo({ name: 'test-agent' })), + }; + + const ctx = createTestContext({ + agentManager: mockManager as any, + workspaceRoot: tmpDir, + }); + const caller = createCaller(ctx); + const result = await caller.getAgentPrompt({ id: 'agent-1' }); + + expect(result).toEqual({ content: null, reason: 'prompt_not_written' }); + }); + + it('returns prompt content when PROMPT.md exists', async () => { + const agentName = 'test-agent'; + const promptDir = path.join(tmpDir, '.cw', 'agent-logs', agentName); + await fs.mkdir(promptDir, { recursive: true }); + const promptContent = '# System\nHello'; + await fs.writeFile(path.join(promptDir, 'PROMPT.md'), promptContent); + + const mockManager = { + get: vi.fn().mockResolvedValue(makeAgentInfo({ name: agentName })), + }; + + const ctx = createTestContext({ + agentManager: mockManager as any, + workspaceRoot: tmpDir, + }); + const caller = createCaller(ctx); + const result = await caller.getAgentPrompt({ id: 'agent-1' }); + + expect(result).toEqual({ content: promptContent }); + }); +}); diff --git a/apps/server/trpc/routers/agent.ts b/apps/server/trpc/routers/agent.ts index 74fdc50..53b42fc 100644 --- a/apps/server/trpc/routers/agent.ts +++ b/apps/server/trpc/routers/agent.ts @@ -5,11 +5,13 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { tracked, type TrackedEnvelope } from '@trpc/server'; +import path from 'path'; +import fs from 'fs/promises'; import type { ProcedureBuilder } from '../trpc.js'; import type { TRPCContext } from '../context.js'; import type { AgentInfo, AgentResult, PendingQuestions } from '../../agent/types.js'; import type { AgentOutputEvent } from '../../events/types.js'; -import { requireAgentManager, requireLogChunkRepository } from './_helpers.js'; +import { requireAgentManager, requireLogChunkRepository, requireTaskRepository, requireInitiativeRepository } from './_helpers.js'; export const spawnAgentInputSchema = z.object({ name: z.string().min(1).optional(), @@ -120,7 +122,23 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) { getAgent: publicProcedure .input(agentIdentifierSchema) .query(async ({ ctx, input }) => { - return resolveAgent(ctx, input); + const agent = await resolveAgent(ctx, input); + + let taskName: string | null = null; + let initiativeName: string | null = null; + + if (agent.taskId) { + const taskRepo = requireTaskRepository(ctx); + const task = await taskRepo.findById(agent.taskId); + taskName = task?.name ?? null; + } + if (agent.initiativeId) { + const initiativeRepo = requireInitiativeRepository(ctx); + const initiative = await initiativeRepo.findById(agent.initiativeId); + initiativeName = initiative?.name ?? null; + } + + return { ...agent, taskName, initiativeName }; }), getAgentByName: publicProcedure @@ -246,5 +264,106 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) { cleanup(); } }), + + getAgentInputFiles: publicProcedure + .input(z.object({ id: z.string().min(1) })) + .output(z.object({ + files: z.array(z.object({ + name: z.string(), + content: z.string(), + sizeBytes: z.number(), + })), + reason: z.enum(['worktree_missing', 'input_dir_missing']).optional(), + })) + .query(async ({ ctx, input }) => { + const agent = await resolveAgent(ctx, { id: input.id }); + + const worktreeRoot = path.join(ctx.workspaceRoot!, 'agent-workdirs', agent.worktreeId); + const inputDir = path.join(worktreeRoot, '.cw', 'input'); + + // Check worktree root exists + try { + await fs.stat(worktreeRoot); + } catch { + return { files: [], reason: 'worktree_missing' as const }; + } + + // Check input dir exists + try { + await fs.stat(inputDir); + } catch { + return { files: [], reason: 'input_dir_missing' as const }; + } + + // Walk inputDir recursively + const entries = await fs.readdir(inputDir, { recursive: true, withFileTypes: true }); + const MAX_SIZE = 500 * 1024; + const results: Array<{ name: string; content: string; sizeBytes: number }> = []; + + for (const entry of entries) { + if (!entry.isFile()) continue; + // entry.parentPath is available in Node 20+ + const dir = (entry as any).parentPath ?? (entry as any).path; + const fullPath = path.join(dir, entry.name); + const relativeName = path.relative(inputDir, fullPath); + + try { + // Binary detection: read first 512 bytes + const fd = await fs.open(fullPath, 'r'); + const headerBuf = Buffer.alloc(512); + const { bytesRead } = await fd.read(headerBuf, 0, 512, 0); + await fd.close(); + if (headerBuf.slice(0, bytesRead).includes(0)) continue; // skip binary + + const raw = await fs.readFile(fullPath); + const sizeBytes = raw.length; + let content: string; + if (sizeBytes > MAX_SIZE) { + content = raw.slice(0, MAX_SIZE).toString('utf-8') + '\n\n[truncated — file exceeds 500 KB]'; + } else { + content = raw.toString('utf-8'); + } + results.push({ name: relativeName, content, sizeBytes }); + } catch { + continue; // skip unreadable files + } + } + + results.sort((a, b) => a.name.localeCompare(b.name)); + return { files: results }; + }), + + getAgentPrompt: publicProcedure + .input(z.object({ id: z.string().min(1) })) + .output(z.object({ + content: z.string().nullable(), + reason: z.enum(['prompt_not_written']).optional(), + })) + .query(async ({ ctx, input }) => { + const agent = await resolveAgent(ctx, { id: input.id }); + + const promptPath = path.join(ctx.workspaceRoot!, '.cw', 'agent-logs', agent.name, 'PROMPT.md'); + + let raw: string; + try { + raw = await fs.readFile(promptPath, 'utf-8'); + } catch (err: any) { + if (err?.code === 'ENOENT') { + return { content: null, reason: 'prompt_not_written' as const }; + } + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `Failed to read prompt file: ${String(err)}`, + }); + } + + const MAX_BYTES = 1024 * 1024; // 1 MB + if (Buffer.byteLength(raw, 'utf-8') > MAX_BYTES) { + const buf = Buffer.from(raw, 'utf-8'); + raw = buf.slice(0, MAX_BYTES).toString('utf-8') + '\n\n[truncated — prompt exceeds 1 MB]'; + } + + return { content: raw }; + }), }; } diff --git a/docs/server-api.md b/docs/server-api.md index 90095db..dc22e20 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -59,10 +59,12 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE | dismissAgent | mutation | Dismiss agent (set userDismissedAt) | | resumeAgent | mutation | Resume with answers | | listAgents | query | All agents | -| getAgent | query | Single agent by name or ID | +| getAgent | query | Single agent by name or ID; also returns `taskName`, `initiativeName`, `exitCode` | | getAgentResult | query | Execution result | | getAgentQuestions | query | Pending questions | | getAgentOutput | query | Full output from DB log chunks | +| getAgentInputFiles | query | Files written to agent's `.cw/input/` dir (text only, sorted, 500 KB cap) | +| getAgentPrompt | query | Content of `.cw/agent-logs//PROMPT.md` (1 MB cap) | | getActiveRefineAgent | query | Active refine agent for initiative | | listWaitingAgents | query | Agents waiting for input | | onAgentOutput | subscription | Live raw JSONL output stream via EventBus | From 9c4131c814d756c7cffc6a9dd7c227de8d94611f Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 10:58:02 +0100 Subject: [PATCH 02/84] feat: add addAccountByToken tRPC mutation with upsert logic and tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new mutation that accepts an email + raw OAuth token and upserts the account — creating it if it doesn't exist, updating credentials if it does. Covers all four scenarios with unit tests (new, existing, empty-email, empty-token validation). --- apps/server/trpc/router.test.ts | 69 +++++++++++++++++++++++++++++ apps/server/trpc/routers/account.ts | 24 ++++++++++ docs/server-api.md | 1 + 3 files changed, 94 insertions(+) diff --git a/apps/server/trpc/router.test.ts b/apps/server/trpc/router.test.ts index c585ce5..eb64e50 100644 --- a/apps/server/trpc/router.test.ts +++ b/apps/server/trpc/router.test.ts @@ -14,6 +14,7 @@ import { } from './index.js'; import type { TRPCContext } from './context.js'; import type { EventBus } from '../events/types.js'; +import type { AccountRepository } from '../db/repositories/account-repository.js'; // Create caller factory for the app router const createCaller = createCallerFactory(appRouter); @@ -161,6 +162,74 @@ describe('tRPC Router', () => { }); }); + describe('addAccountByToken procedure', () => { + let mockRepo: AccountRepository; + let caller: ReturnType; + + beforeEach(() => { + vi.resetAllMocks(); + mockRepo = { + findByEmail: vi.fn(), + updateAccountAuth: vi.fn(), + create: vi.fn(), + } as unknown as AccountRepository; + const ctx = createTestContext({ accountRepository: mockRepo }); + caller = createCaller(ctx); + }); + + it('creates a new account and returns { upserted: false, account }', async () => { + const stubAccount = { id: 'acc-1', email: 'new@example.com', provider: 'claude' }; + vi.mocked(mockRepo.findByEmail).mockResolvedValue(null); + vi.mocked(mockRepo.create).mockResolvedValue(stubAccount as any); + + const result = await caller.addAccountByToken({ email: 'new@example.com', token: 'tok-abc' }); + + expect(result).toEqual({ upserted: false, account: stubAccount }); + expect(mockRepo.create).toHaveBeenCalledWith({ + email: 'new@example.com', + provider: 'claude', + configJson: '{"hasCompletedOnboarding":true}', + credentials: '{"claudeAiOauth":{"accessToken":"tok-abc"}}', + }); + expect(mockRepo.updateAccountAuth).not.toHaveBeenCalled(); + }); + + it('updates an existing account and returns { upserted: true, account }', async () => { + const existingAccount = { id: 'acc-existing', email: 'existing@example.com', provider: 'claude' }; + const updatedAccount = { ...existingAccount, configJson: '{"hasCompletedOnboarding":true}' }; + vi.mocked(mockRepo.findByEmail).mockResolvedValue(existingAccount as any); + vi.mocked(mockRepo.updateAccountAuth).mockResolvedValue(updatedAccount as any); + + const result = await caller.addAccountByToken({ email: 'existing@example.com', token: 'tok-xyz' }); + + expect(result).toEqual({ upserted: true, account: updatedAccount }); + expect(mockRepo.updateAccountAuth).toHaveBeenCalledWith( + 'acc-existing', + '{"hasCompletedOnboarding":true}', + '{"claudeAiOauth":{"accessToken":"tok-xyz"}}', + ); + expect(mockRepo.create).not.toHaveBeenCalled(); + }); + + it('throws BAD_REQUEST when email is empty', async () => { + await expect( + caller.addAccountByToken({ email: '', provider: 'claude', token: 'tok' }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + expect(mockRepo.findByEmail).not.toHaveBeenCalled(); + expect(mockRepo.create).not.toHaveBeenCalled(); + expect(mockRepo.updateAccountAuth).not.toHaveBeenCalled(); + }); + + it('throws BAD_REQUEST when token is empty', async () => { + await expect( + caller.addAccountByToken({ email: 'user@example.com', provider: 'claude', token: '' }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + expect(mockRepo.findByEmail).not.toHaveBeenCalled(); + expect(mockRepo.create).not.toHaveBeenCalled(); + expect(mockRepo.updateAccountAuth).not.toHaveBeenCalled(); + }); + }); + describe('Zod schema validation', () => { it('healthResponseSchema should reject invalid status', () => { const invalid = { diff --git a/apps/server/trpc/routers/account.ts b/apps/server/trpc/routers/account.ts index 99c638d..b40a4db 100644 --- a/apps/server/trpc/routers/account.ts +++ b/apps/server/trpc/routers/account.ts @@ -72,5 +72,29 @@ export function accountProcedures(publicProcedure: ProcedureBuilder) { .query(() => { return listProviderNames(); }), + + addAccountByToken: publicProcedure + .input(z.object({ + email: z.string().min(1), + provider: z.string().default('claude'), + token: z.string().min(1), + })) + .mutation(async ({ ctx, input }) => { + const repo = requireAccountRepository(ctx); + const credentials = JSON.stringify({ claudeAiOauth: { accessToken: input.token } }); + const configJson = JSON.stringify({ hasCompletedOnboarding: true }); + const existing = await repo.findByEmail(input.email); + if (existing) { + const account = await repo.updateAccountAuth(existing.id, configJson, credentials); + return { upserted: true, account }; + } + const account = await repo.create({ + email: input.email, + provider: input.provider, + configJson, + credentials, + }); + return { upserted: false, account }; + }), }; } diff --git a/docs/server-api.md b/docs/server-api.md index 2b69321..0f7341d 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -190,6 +190,7 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE | updateAccountAuth | mutation | Update credentials | | markAccountExhausted | mutation | Set exhaustion timer | | listProviderNames | query | Available provider names | +| addAccountByToken | mutation | Upsert account from OAuth token; returns `{ upserted, account }` | ### Proposals | Procedure | Type | Description | From f4dbaae0e3a1c77ace8b9976587118b8f1de6e01 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 12:12:32 +0100 Subject: [PATCH 03/84] fix: Guard worktree creation against branch === baseBranch Throws if branch and baseBranch are identical, preventing git branch -f from force-resetting shared branches (like the initiative branch) when accidentally passed as both. --- apps/server/git/manager.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/server/git/manager.ts b/apps/server/git/manager.ts index f7d3c1b..a28dd2a 100644 --- a/apps/server/git/manager.ts +++ b/apps/server/git/manager.ts @@ -61,10 +61,18 @@ export class SimpleGitWorktreeManager implements WorktreeManager { const worktreePath = path.join(this.worktreesDir, id); log.info({ id, branch, baseBranch }, 'creating worktree'); + // Safety: never force-reset a branch to its own base — this would nuke + // shared branches like the initiative branch if passed as both branch and baseBranch. + if (branch === baseBranch) { + throw new Error(`Worktree branch and baseBranch are the same (${branch}). Use a unique branch name.`); + } + // Create worktree — reuse existing branch or create new one const branchExists = await this.branchExists(branch); if (branchExists) { - // Branch exists from a previous run — reset it to baseBranch and check it out + // Branch exists from a previous run — reset it to baseBranch and check it out. + // Only safe because branch !== baseBranch (checked above), so we're resetting + // an agent-scoped branch, not a shared branch like main or the initiative branch. await this.git.raw(['branch', '-f', branch, baseBranch]); await this.git.raw(['worktree', 'add', worktreePath, branch]); } else { From 9f5715558e34f7d97964fd206a39e7d5a846d351 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 12:14:16 +0100 Subject: [PATCH 04/84] fix: Auto-dismiss conflict panel and re-check mergeability on completion Instead of showing a manual "Re-check Mergeability" button after the conflict agent finishes, auto-dismiss the agent and trigger mergeability re-check immediately when the state transitions to completed. --- .../review/ConflictResolutionPanel.tsx | 36 +++++++++---------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/apps/web/src/components/review/ConflictResolutionPanel.tsx b/apps/web/src/components/review/ConflictResolutionPanel.tsx index f4d99fd..cc55c06 100644 --- a/apps/web/src/components/review/ConflictResolutionPanel.tsx +++ b/apps/web/src/components/review/ConflictResolutionPanel.tsx @@ -1,5 +1,5 @@ import { Loader2, AlertCircle, GitMerge, CheckCircle2, ChevronDown, ChevronRight, Terminal } from 'lucide-react'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Button } from '@/components/ui/button'; import { QuestionForm } from '@/components/QuestionForm'; import { useConflictAgent } from '@/hooks/useConflictAgent'; @@ -13,6 +13,17 @@ interface ConflictResolutionPanelProps { export function ConflictResolutionPanel({ initiativeId, conflicts, onResolved }: ConflictResolutionPanelProps) { const { state, agent, questions, spawn, resume, stop, dismiss } = useConflictAgent(initiativeId); const [showManual, setShowManual] = useState(false); + const prevStateRef = useRef(state); + + // Auto-dismiss and re-check mergeability when conflict agent completes + useEffect(() => { + const prev = prevStateRef.current; + prevStateRef.current = state; + if (prev !== 'completed' && state === 'completed') { + dismiss(); + onResolved(); + } + }, [state, dismiss, onResolved]); if (state === 'none') { return ( @@ -117,26 +128,13 @@ git commit --no-edit`} } if (state === 'completed') { + // Auto-dismiss effect above handles this — show brief success message during transition return (
-
-
- - Conflicts resolved -
-
- -
+
+ + Conflicts resolved — re-checking mergeability... +
); From 17f92040c732c12d690466fe8bb311209e93d44c Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 12:14:37 +0100 Subject: [PATCH 05/84] fix: Ensure agents write signal.json to the correct directory Two additional fixes to prevent agents from writing .cw/output/ in the wrong location: 1. Always create .cw/output/ at the agent workdir root during spawn, even when no inputContext is provided. This gives the agent a visible anchor directory so it doesn't create one inside a project subdir. 2. Add absolute output path instruction to the workspace layout prompt for multi-project agents, explicitly telling them to write .cw/output/ relative to the workdir root, not their current cd location. --- apps/server/agent/manager.ts | 4 ++++ apps/server/agent/prompts/workspace.ts | 2 ++ 2 files changed, 6 insertions(+) diff --git a/apps/server/agent/manager.ts b/apps/server/agent/manager.ts index d567fcc..ac36b83 100644 --- a/apps/server/agent/manager.ts +++ b/apps/server/agent/manager.ts @@ -297,6 +297,10 @@ export class MultiProviderAgentManager implements AgentManager { if (options.inputContext) { await writeInputFiles({ agentWorkdir: agentCwd, ...options.inputContext, agentId, agentName: alias }); log.debug({ alias }, 'input files written'); + } else { + // Always create .cw/output/ at the agent workdir root so the agent + // writes signal.json here rather than in a project subdirectory. + await mkdir(join(agentCwd, '.cw', 'output'), { recursive: true }); } // 4. Build spawn command diff --git a/apps/server/agent/prompts/workspace.ts b/apps/server/agent/prompts/workspace.ts index 846850a..f01c6d3 100644 --- a/apps/server/agent/prompts/workspace.ts +++ b/apps/server/agent/prompts/workspace.ts @@ -36,5 +36,7 @@ This is an isolated git worktree. Other agents may be working in parallel on sep The following project directories contain the source code (git worktrees): ${lines.join('\n')} + +**IMPORTANT**: All \`.cw/output/\` paths (signal.json, progress.md, etc.) are relative to this working directory (\`${agentCwd}\`), NOT to any project subdirectory. Always write to \`${join(agentCwd, '.cw/output/')}\` regardless of your current \`cd\` location. `; } From a69527b7d61c65f3dac8ded7d9cd6b31e40c1b6c Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 12:18:31 +0100 Subject: [PATCH 06/84] fix: Remove upward box-shadow on ReviewHeader that covers tab bar The sticky ReviewHeader had shadow-[0_-50px_0_0_hsl(var(--background))] which painted a 50px background-color rectangle upward, overlapping the tab navigation bar (only ~12px away). The header's bg-card is already opaque, making the shadow unnecessary for scroll coverage. --- apps/web/src/components/review/ReviewHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/review/ReviewHeader.tsx b/apps/web/src/components/review/ReviewHeader.tsx index a8225eb..44e55fc 100644 --- a/apps/web/src/components/review/ReviewHeader.tsx +++ b/apps/web/src/components/review/ReviewHeader.tsx @@ -97,7 +97,7 @@ export function ReviewHeader({ const total = totalCount ?? 0; return ( -
+
{/* Phase selector row */} {phases.length > 1 && (
From eac03862e392afe1b109fdb80d96ddca0a72d24c Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 12:19:59 +0100 Subject: [PATCH 07/84] fix: Prevent lost task completions after server restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs causing empty phase diffs when server restarts during agent execution: 1. Startup ordering race: reconcileAfterRestart() emitted agent:stopped before orchestrator registered listeners — events lost. Moved reconciliation to after orchestrator.start(). 2. Stuck in_progress tasks: recoverDispatchQueues() only re-queued pending tasks. Added recovery for in_progress tasks whose agents are dead (not running/waiting_for_input). 3. Branch force-reset destroys work: git branch -f wiped commits when a second agent was dispatched for the same task. Now checks if the branch has commits beyond baseBranch before resetting. Also adds: - agent:crashed handler with auto-retry (MAX_TASK_RETRIES=3) - retryCount column on tasks table + migration - retryCount reset on manual retryBlockedTask() --- apps/server/container.ts | 11 +++-- apps/server/db/schema.ts | 1 + apps/server/dispatch/manager.ts | 4 +- .../drizzle/0034_add_task_retry_count.sql | 1 + apps/server/drizzle/meta/_journal.json | 7 +++ apps/server/execution/orchestrator.ts | 48 ++++++++++++++++++- apps/server/git/manager.ts | 19 ++++++-- docs/database.md | 1 + docs/dispatch-events.md | 15 +++++- 9 files changed, 94 insertions(+), 13 deletions(-) create mode 100644 apps/server/drizzle/0034_add_task_retry_count.sql diff --git a/apps/server/container.ts b/apps/server/container.ts index 5e6aefd..4468184 100644 --- a/apps/server/container.ts +++ b/apps/server/container.ts @@ -187,10 +187,6 @@ export async function createContainer(options?: ContainerOptions): Promise> = new Map(); @@ -44,6 +48,7 @@ export class ExecutionOrchestrator { private conflictResolutionService: ConflictResolutionService, private eventBus: EventBus, private workspaceRoot: string, + private agentRepository?: AgentRepository, ) {} /** @@ -66,6 +71,13 @@ export class ExecutionOrchestrator { }); }); + // Auto-retry crashed agent tasks (up to MAX_TASK_RETRIES) + this.eventBus.on('agent:crashed', (event) => { + this.handleAgentCrashed(event).catch((err) => { + log.error({ err: err instanceof Error ? err.message : String(err) }, 'error handling agent:crashed'); + }); + }); + // Recover in-memory dispatch queues from DB state (survives server restarts) this.recoverDispatchQueues().catch((err) => { log.error({ err: err instanceof Error ? err.message : String(err) }, 'dispatch queue recovery failed'); @@ -111,6 +123,27 @@ export class ExecutionOrchestrator { this.scheduleDispatch(); } + private async handleAgentCrashed(event: AgentCrashedEvent): Promise { + const { taskId, agentId, error } = event.payload; + if (!taskId) return; + + const task = await this.taskRepository.findById(taskId); + if (!task || task.status !== 'in_progress') return; + + const retryCount = (task.retryCount ?? 0) + 1; + if (retryCount > MAX_TASK_RETRIES) { + log.warn({ taskId, agentId, retryCount, error }, 'task exceeded max retries, leaving in_progress'); + return; + } + + // Reset task for re-dispatch with incremented retry count + await this.taskRepository.update(taskId, { status: 'pending', retryCount }); + await this.dispatchManager.queue(taskId); + log.info({ taskId, agentId, retryCount, error }, 'crashed task re-queued for retry'); + + this.scheduleDispatch(); + } + private async runDispatchCycle(): Promise { this.dispatchRunning = true; try { @@ -560,7 +593,7 @@ export class ExecutionOrchestrator { } } - // Re-queue pending tasks for in_progress phases into the task dispatch queue + // Re-queue pending tasks and recover stuck in_progress tasks for in_progress phases if (phase.status === 'in_progress') { const tasks = await this.taskRepository.findByPhaseId(phase.id); for (const task of tasks) { @@ -571,6 +604,17 @@ export class ExecutionOrchestrator { } catch { // Already queued or task issue } + } else if (task.status === 'in_progress' && this.agentRepository) { + // Check if the assigned agent is still alive + const agent = await this.agentRepository.findByTaskId(task.id); + const isAlive = agent && (agent.status === 'running' || agent.status === 'waiting_for_input'); + if (!isAlive) { + // Agent is dead — reset task for re-dispatch + await this.taskRepository.update(task.id, { status: 'pending' }); + await this.dispatchManager.queue(task.id); + tasksRecovered++; + log.info({ taskId: task.id, agentId: agent?.id }, 'recovered stuck in_progress task (dead agent)'); + } } } } diff --git a/apps/server/git/manager.ts b/apps/server/git/manager.ts index a28dd2a..95539af 100644 --- a/apps/server/git/manager.ts +++ b/apps/server/git/manager.ts @@ -70,10 +70,21 @@ export class SimpleGitWorktreeManager implements WorktreeManager { // Create worktree — reuse existing branch or create new one const branchExists = await this.branchExists(branch); if (branchExists) { - // Branch exists from a previous run — reset it to baseBranch and check it out. - // Only safe because branch !== baseBranch (checked above), so we're resetting - // an agent-scoped branch, not a shared branch like main or the initiative branch. - await this.git.raw(['branch', '-f', branch, baseBranch]); + // Branch exists from a previous run. Check if it has commits beyond baseBranch + // before resetting — a previous agent may have done real work on this branch. + try { + const aheadCount = await this.git.raw(['rev-list', '--count', `${baseBranch}..${branch}`]); + if (parseInt(aheadCount.trim(), 10) > 0) { + log.warn({ branch, baseBranch, aheadBy: aheadCount.trim() }, 'branch has commits beyond base, preserving'); + } else { + await this.git.raw(['branch', '-f', branch, baseBranch]); + } + } catch { + // If rev-list fails (e.g. baseBranch doesn't exist yet), fall back to reset + await this.git.raw(['branch', '-f', branch, baseBranch]); + } + // Prune stale worktree references before adding new one + await this.git.raw(['worktree', 'prune']); await this.git.raw(['worktree', 'add', worktreePath, branch]); } else { // git worktree add -b diff --git a/docs/database.md b/docs/database.md index 6afb841..dd12a0b 100644 --- a/docs/database.md +++ b/docs/database.md @@ -51,6 +51,7 @@ All adapters use nanoid() for IDs, auto-manage timestamps, and use Drizzle's `.r | status | text enum | 'pending' \| 'in_progress' \| 'completed' \| 'blocked' | | order | integer | default 0 | | summary | text nullable | Agent result summary — propagated to dependent tasks as context | +| retryCount | integer NOT NULL | default 0, incremented on agent crash auto-retry, reset on manual retry | | createdAt, updatedAt | integer/timestamp | | ### task_dependencies diff --git a/docs/dispatch-events.md b/docs/dispatch-events.md index 5356b0e..d9e336d 100644 --- a/docs/dispatch-events.md +++ b/docs/dispatch-events.md @@ -112,9 +112,22 @@ InitiativeChangesRequestedEvent { initiativeId, phaseId, taskId } | Event | Action | |-------|--------| | `phase:queued` | Dispatch ready phases → dispatch their tasks to idle agents | -| `agent:stopped` | Re-dispatch queued tasks (freed agent slot) | +| `agent:stopped` | Auto-complete task (unless user_requested), re-dispatch queued tasks (freed agent slot) | +| `agent:crashed` | Auto-retry crashed task up to `MAX_TASK_RETRIES` (3). Increments `retryCount`, resets status to `pending`, re-queues. Exceeding retries leaves task `in_progress` for manual intervention. | | `task:completed` | Merge task branch (if branch exists), check phase completion, dispatch next queued task | +### Crash Recovery + +When an agent crashes (`agent:crashed` event), the orchestrator automatically retries the task: +1. Finds the task associated with the crashed agent +2. Checks `task.retryCount` against `MAX_TASK_RETRIES` (3) +3. If under limit: increments `retryCount`, resets task to `pending`, re-queues for dispatch +4. If over limit: logs warning, task stays `in_progress` for manual intervention + +On server restart, `recoverDispatchQueues()` also recovers stuck `in_progress` tasks whose agents are dead (status is not `running` or `waiting_for_input`). These are reset to `pending` and re-queued. + +Manual retry via `retryBlockedTask()` resets `retryCount` to 0, giving the task a fresh set of automatic retries. + ### Coalesced Scheduling Multiple rapid events (e.g. several `phase:queued` from `queueAllPhases`) are coalesced into a single async dispatch cycle via `scheduleDispatch()`. The cycle loops `dispatchNextPhase()` + `dispatchNext()` until both queues are drained, then re-runs if new events arrived during execution. From 00e426ac00823f2c1cc77a1a6f59a51e0a5c2d07 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 12:31:35 +0100 Subject: [PATCH 08/84] fix: Roll back merge when push fails in initiative approval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When merge_and_push failed at the push step, the local defaultBranch ref was left pointing at the merge commit. This made the three-dot diff (defaultBranch...initiativeBranch) return empty because main already contained all changes — causing the review tab to show "no changes." Now mergeBranch returns the previous ref, and approveInitiative restores it on push failure. Also repaired the corrupted clone state. --- apps/server/execution/orchestrator.test.ts | 62 +++++++++++++++++++- apps/server/execution/orchestrator.ts | 13 +++- apps/server/git/branch-manager.ts | 6 ++ apps/server/git/simple-git-branch-manager.ts | 11 +++- apps/server/git/types.ts | 2 + 5 files changed, 91 insertions(+), 3 deletions(-) diff --git a/apps/server/execution/orchestrator.test.ts b/apps/server/execution/orchestrator.test.ts index fb52e13..6cf293d 100644 --- a/apps/server/execution/orchestrator.test.ts +++ b/apps/server/execution/orchestrator.test.ts @@ -6,7 +6,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ExecutionOrchestrator } from './orchestrator.js'; +import { ensureProjectClone } from '../git/project-clones.js'; import type { BranchManager } from '../git/branch-manager.js'; + +vi.mock('../git/project-clones.js', () => ({ + ensureProjectClone: vi.fn().mockResolvedValue('/tmp/test-workspace/clones/test'), +})); import type { PhaseRepository } from '../db/repositories/phase-repository.js'; import type { TaskRepository } from '../db/repositories/task-repository.js'; import type { InitiativeRepository } from '../db/repositories/initiative-repository.js'; @@ -39,7 +44,7 @@ function createMockEventBus(): EventBus & { handlers: Map; e function createMocks() { const branchManager: BranchManager = { ensureBranch: vi.fn(), - mergeBranch: vi.fn().mockResolvedValue({ success: true, message: 'merged' }), + mergeBranch: vi.fn().mockResolvedValue({ success: true, message: 'merged', previousRef: 'abc000' }), diffBranches: vi.fn().mockResolvedValue(''), deleteBranch: vi.fn(), branchExists: vi.fn().mockResolvedValue(true), @@ -51,6 +56,7 @@ function createMocks() { checkMergeability: vi.fn().mockResolvedValue({ mergeable: true }), fetchRemote: vi.fn(), fastForwardBranch: vi.fn(), + updateRef: vi.fn(), }; const phaseRepository = { @@ -306,4 +312,58 @@ describe('ExecutionOrchestrator', () => { expect(mocks.phaseDispatchManager.completePhase).not.toHaveBeenCalled(); }); }); + + describe('approveInitiative', () => { + function setupApproveTest(mocks: ReturnType) { + const initiative = { id: 'init-1', branch: 'cw/test', status: 'pending_review' }; + const project = { id: 'proj-1', name: 'test', url: 'https://example.com', defaultBranch: 'main' }; + vi.mocked(mocks.initiativeRepository.findById).mockResolvedValue(initiative as any); + vi.mocked(mocks.projectRepository.findProjectsByInitiativeId).mockResolvedValue([project] as any); + vi.mocked(mocks.branchManager.branchExists).mockResolvedValue(true); + vi.mocked(mocks.branchManager.mergeBranch).mockResolvedValue({ success: true, message: 'ok', previousRef: 'abc000' }); + return { initiative, project }; + } + + it('should roll back merge when push fails', async () => { + setupApproveTest(mocks); + vi.mocked(mocks.branchManager.pushBranch).mockRejectedValue(new Error('non-fast-forward')); + + const orchestrator = createOrchestrator(mocks); + + await expect(orchestrator.approveInitiative('init-1', 'merge_and_push')).rejects.toThrow('non-fast-forward'); + + // Should have rolled back the merge by restoring the previous ref + expect(mocks.branchManager.updateRef).toHaveBeenCalledWith( + expect.any(String), + 'main', + 'abc000', + ); + + // Should NOT have marked initiative as completed + expect(mocks.initiativeRepository.update).not.toHaveBeenCalled(); + }); + + it('should complete initiative when push succeeds', async () => { + setupApproveTest(mocks); + + const orchestrator = createOrchestrator(mocks); + + await orchestrator.approveInitiative('init-1', 'merge_and_push'); + + expect(mocks.branchManager.updateRef).not.toHaveBeenCalled(); + expect(mocks.initiativeRepository.update).toHaveBeenCalledWith('init-1', { status: 'completed' }); + }); + + it('should not attempt rollback for push_branch strategy', async () => { + setupApproveTest(mocks); + vi.mocked(mocks.branchManager.pushBranch).mockRejectedValue(new Error('auth failed')); + + const orchestrator = createOrchestrator(mocks); + + await expect(orchestrator.approveInitiative('init-1', 'push_branch')).rejects.toThrow('auth failed'); + + // No merge happened, so no rollback needed + expect(mocks.branchManager.updateRef).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/server/execution/orchestrator.ts b/apps/server/execution/orchestrator.ts index 9dba50c..5b8c521 100644 --- a/apps/server/execution/orchestrator.ts +++ b/apps/server/execution/orchestrator.ts @@ -695,7 +695,18 @@ export class ExecutionOrchestrator { if (!result.success) { throw new Error(`Failed to merge ${initiative.branch} into ${project.defaultBranch} for project ${project.name}: ${result.message}`); } - await this.branchManager.pushBranch(clonePath, project.defaultBranch); + try { + await this.branchManager.pushBranch(clonePath, project.defaultBranch); + } catch (pushErr) { + // Roll back the merge so the diff doesn't disappear from the review tab. + // Without rollback, defaultBranch includes the initiative changes and the + // three-dot diff (defaultBranch...initiativeBranch) becomes empty. + if (result.previousRef) { + log.warn({ project: project.name, previousRef: result.previousRef }, 'push failed — rolling back merge'); + await this.branchManager.updateRef(clonePath, project.defaultBranch, result.previousRef); + } + throw pushErr; + } log.info({ initiativeId, project: project.name }, 'initiative branch merged into default and pushed'); } else { await this.branchManager.pushBranch(clonePath, initiative.branch); diff --git a/apps/server/git/branch-manager.ts b/apps/server/git/branch-manager.ts index ceb399c..9ba6d85 100644 --- a/apps/server/git/branch-manager.ts +++ b/apps/server/git/branch-manager.ts @@ -88,4 +88,10 @@ export interface BranchManager { * (i.e. the branches have diverged). */ fastForwardBranch(repoPath: string, branch: string, remote?: string): Promise; + + /** + * Force-update a branch ref to point at a specific commit. + * Used to roll back a merge when a subsequent push fails. + */ + updateRef(repoPath: string, branch: string, commitHash: string): Promise; } diff --git a/apps/server/git/simple-git-branch-manager.ts b/apps/server/git/simple-git-branch-manager.ts index e686a6f..b8147d0 100644 --- a/apps/server/git/simple-git-branch-manager.ts +++ b/apps/server/git/simple-git-branch-manager.ts @@ -39,6 +39,9 @@ export class SimpleGitBranchManager implements BranchManager { const tempBranch = `cw-merge-${Date.now()}`; try { + // Capture the target branch ref before merge so callers can roll back on push failure + const previousRef = (await repoGit.raw(['rev-parse', targetBranch])).trim(); + // Create worktree with a temp branch starting at targetBranch's commit await repoGit.raw(['worktree', 'add', '-b', tempBranch, tmpPath, targetBranch]); @@ -53,7 +56,7 @@ export class SimpleGitBranchManager implements BranchManager { await repoGit.raw(['update-ref', `refs/heads/${targetBranch}`, mergeCommit]); log.info({ repoPath, sourceBranch, targetBranch }, 'merge completed cleanly'); - return { success: true, message: `Merged ${sourceBranch} into ${targetBranch}` }; + return { success: true, message: `Merged ${sourceBranch} into ${targetBranch}`, previousRef }; } catch (mergeErr) { // Check for merge conflicts const status = await wtGit.status(); @@ -208,4 +211,10 @@ export class SimpleGitBranchManager implements BranchManager { await git.raw(['merge', '--ff-only', remoteBranch, branch]); log.info({ repoPath, branch, remoteBranch }, 'fast-forwarded branch'); } + + async updateRef(repoPath: string, branch: string, commitHash: string): Promise { + const git = simpleGit(repoPath); + await git.raw(['update-ref', `refs/heads/${branch}`, commitHash]); + log.info({ repoPath, branch, commitHash: commitHash.slice(0, 7) }, 'branch ref updated'); + } } diff --git a/apps/server/git/types.ts b/apps/server/git/types.ts index 8471b75..51a35b7 100644 --- a/apps/server/git/types.ts +++ b/apps/server/git/types.ts @@ -56,6 +56,8 @@ export interface MergeResult { conflicts?: string[]; /** Human-readable message describing the result */ message: string; + /** The target branch's commit hash before the merge (for rollback on push failure) */ + previousRef?: string; } // ============================================================================= From 6a76e17cef0a37ff6ff3556760653d4091c4376e Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 12:31:41 +0100 Subject: [PATCH 09/84] feat: Add errands table, errand agent mode, and push rollback on merge failure - Add `errands` table to schema with status enum and relations to agents/projects - Add `errand` mode to agents.mode enum - Add push rollback in orchestrator: if push fails after merge, reset to previousRef to preserve the review diff - Extend MergeResult with previousRef for rollback support; update branch-manager and simple-git-branch-manager - Add orchestrator tests for push rollback behaviour Co-Authored-By: Claude Sonnet 4.6 --- apps/server/db/schema.ts | 34 +++++++++++++++++++++++++- apps/server/drizzle/meta/_journal.json | 7 ++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/apps/server/db/schema.ts b/apps/server/db/schema.ts index 3889111..bbdfc36 100644 --- a/apps/server/db/schema.ts +++ b/apps/server/db/schema.ts @@ -262,7 +262,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'), @@ -629,3 +629,35 @@ export const reviewComments = sqliteTable('review_comments', { export type ReviewComment = InferSelectModel; export type NewReviewComment = InferInsertModel; + +// ============================================================================ +// ERRANDS +// ============================================================================ + +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').references(() => projects.id, { onDelete: 'cascade' }), + status: text('status', { + enum: ['active', 'pending_review', 'conflict', 'merged', 'abandoned'], + }).notNull().default('active'), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), +}); + +export const errandsRelations = relations(errands, ({ one }) => ({ + agent: one(agents, { + fields: [errands.agentId], + references: [agents.id], + }), + project: one(projects, { + fields: [errands.projectId], + references: [projects.id], + }), +})); + +export type Errand = InferSelectModel; +export type NewErrand = InferInsertModel; diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index 6e91f90..cf73e84 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -246,6 +246,13 @@ "when": 1772496000000, "tag": "0034_add_task_retry_count", "breakpoints": true + }, + { + "idx": 35, + "version": "6", + "when": 1772796561474, + "tag": "0035_faulty_human_fly", + "breakpoints": true } ] } \ No newline at end of file From 5d1292c7adb7336a630df0d333537cae904db52c Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 12:34:21 +0100 Subject: [PATCH 10/84] fix: Use update-ref for fast-forward to avoid dirty working tree failures fastForwardBranch used git merge --ff-only which fails when the clone has uncommitted files. This caused the ff to be silently skipped, the merge to proceed on stale main, and the push to fail (non-fast-forward). Switched to update-ref which only moves the branch pointer without touching the working tree. --- apps/server/git/simple-git-branch-manager.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/server/git/simple-git-branch-manager.ts b/apps/server/git/simple-git-branch-manager.ts index b8147d0..5b46640 100644 --- a/apps/server/git/simple-git-branch-manager.ts +++ b/apps/server/git/simple-git-branch-manager.ts @@ -208,7 +208,18 @@ export class SimpleGitBranchManager implements BranchManager { async fastForwardBranch(repoPath: string, branch: string, remote = 'origin'): Promise { const git = simpleGit(repoPath); const remoteBranch = `${remote}/${branch}`; - await git.raw(['merge', '--ff-only', remoteBranch, branch]); + + // Verify it's a genuine fast-forward (branch is ancestor of remote) + try { + await git.raw(['merge-base', '--is-ancestor', branch, remoteBranch]); + } catch { + throw new Error(`Cannot fast-forward ${branch}: it has diverged from ${remoteBranch}`); + } + + // Use update-ref instead of git merge so dirty working trees don't block it. + // The clone may have uncommitted agent work; we only need to advance the ref. + const targetCommit = (await git.raw(['rev-parse', remoteBranch])).trim(); + await git.raw(['update-ref', `refs/heads/${branch}`, targetCommit]); log.info({ repoPath, branch, remoteBranch }, 'fast-forwarded branch'); } From 940b0f8ed291eda4ed1e95ae84b72c61a08d3e37 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 12:35:06 +0100 Subject: [PATCH 11/84] =?UTF-8?q?feat:=20Add=20errands=20persistence=20lay?= =?UTF-8?q?er=20=E2=80=94=20repository=20port,=20Drizzle=20adapter,=20migr?= =?UTF-8?q?ation,=20and=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add errand-repository.ts port with ErrandRepository, ErrandWithAlias, ErrandStatus types - Add DrizzleErrandRepository adapter with create, findById (left-joins agents for alias), findAll (optional projectId/status filters, desc by createdAt), update, delete - Wire exports into repositories/index.ts and repositories/drizzle/index.ts - Add migration 0035_faulty_human_fly.sql (CREATE TABLE errands) and drizzle snapshot - Add 13 tests covering CRUD, filtering, ordering, agentAlias join, cascade/set-null FK behaviour - Update docs/database.md to document the errands table and ErrandRepository Co-Authored-By: Claude Sonnet 4.6 --- .../db/repositories/drizzle/errand.test.ts | 336 +++ apps/server/db/repositories/drizzle/errand.ts | 89 + apps/server/db/repositories/drizzle/index.ts | 1 + .../db/repositories/errand-repository.ts | 15 + apps/server/db/repositories/index.ts | 8 + apps/server/drizzle/0035_faulty_human_fly.sql | 13 + apps/server/drizzle/meta/0035_snapshot.json | 1974 +++++++++++++++++ docs/database.md | 24 +- 8 files changed, 2456 insertions(+), 4 deletions(-) create mode 100644 apps/server/db/repositories/drizzle/errand.test.ts 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/0035_faulty_human_fly.sql create mode 100644 apps/server/drizzle/meta/0035_snapshot.json diff --git a/apps/server/db/repositories/drizzle/errand.test.ts b/apps/server/db/repositories/drizzle/errand.test.ts new file mode 100644 index 0000000..749b2ad --- /dev/null +++ b/apps/server/db/repositories/drizzle/errand.test.ts @@ -0,0 +1,336 @@ +/** + * DrizzleErrandRepository Tests + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { DrizzleErrandRepository } from './errand.js'; +import { createTestDatabase } from './test-helpers.js'; +import type { DrizzleDatabase } from '../../index.js'; +import { projects, agents, errands } from '../../schema.js'; +import { nanoid } from 'nanoid'; +import { eq } from 'drizzle-orm'; + +describe('DrizzleErrandRepository', () => { + let db: DrizzleDatabase; + let repo: DrizzleErrandRepository; + + beforeEach(() => { + db = createTestDatabase(); + repo = new DrizzleErrandRepository(db); + }); + + // Helper: create a project record + async function createProject(name = 'Test Project', suffix = '') { + const id = nanoid(); + const now = new Date(); + const [project] = await db.insert(projects).values({ + id, + name: name + suffix + id, + url: `https://github.com/test/${id}`, + defaultBranch: 'main', + createdAt: now, + updatedAt: now, + }).returning(); + return project; + } + + // Helper: create an agent record + async function createAgent(name?: string) { + const id = nanoid(); + const now = new Date(); + const agentName = name ?? `agent-${id}`; + const [agent] = await db.insert(agents).values({ + id, + name: agentName, + worktreeId: `agent-workdirs/${agentName}`, + provider: 'claude', + status: 'idle', + mode: 'execute', + createdAt: now, + updatedAt: now, + }).returning(); + return agent; + } + + // Helper: create an errand + async function createErrand(overrides: Partial<{ + id: string; + description: string; + branch: string; + baseBranch: string; + agentId: string | null; + projectId: string | null; + status: 'active' | 'pending_review' | 'conflict' | 'merged' | 'abandoned'; + createdAt: Date; + }> = {}) { + const project = await createProject(); + const id = overrides.id ?? nanoid(); + return repo.create({ + id, + description: overrides.description ?? 'Test errand', + branch: overrides.branch ?? 'feature/test', + baseBranch: overrides.baseBranch ?? 'main', + agentId: overrides.agentId !== undefined ? overrides.agentId : null, + projectId: overrides.projectId !== undefined ? overrides.projectId : project.id, + status: overrides.status ?? 'active', + }); + } + + describe('create + findById', () => { + it('should create errand and find by id with all fields', async () => { + const project = await createProject(); + const id = nanoid(); + + await repo.create({ + id, + description: 'Fix the bug', + branch: 'fix/bug-123', + baseBranch: 'main', + agentId: null, + projectId: project.id, + status: 'active', + }); + + const found = await repo.findById(id); + expect(found).toBeDefined(); + expect(found!.id).toBe(id); + expect(found!.description).toBe('Fix the bug'); + expect(found!.branch).toBe('fix/bug-123'); + expect(found!.baseBranch).toBe('main'); + expect(found!.status).toBe('active'); + expect(found!.projectId).toBe(project.id); + expect(found!.agentId).toBeNull(); + expect(found!.agentAlias).toBeNull(); + }); + }); + + describe('findAll', () => { + it('should return all errands ordered by createdAt desc', async () => { + const project = await createProject(); + const t1 = new Date('2024-01-01T00:00:00Z'); + const t2 = new Date('2024-01-02T00:00:00Z'); + const t3 = new Date('2024-01-03T00:00:00Z'); + + const id1 = nanoid(); + const id2 = nanoid(); + const id3 = nanoid(); + + await db.insert(errands).values([ + { id: id1, description: 'Errand 1', branch: 'b1', baseBranch: 'main', agentId: null, projectId: project.id, status: 'active', createdAt: t1, updatedAt: t1 }, + { id: id2, description: 'Errand 2', branch: 'b2', baseBranch: 'main', agentId: null, projectId: project.id, status: 'active', createdAt: t2, updatedAt: t2 }, + { id: id3, description: 'Errand 3', branch: 'b3', baseBranch: 'main', agentId: null, projectId: project.id, status: 'active', createdAt: t3, updatedAt: t3 }, + ]); + + const result = await repo.findAll(); + expect(result.length).toBeGreaterThanOrEqual(3); + // Find our three in the results + const ids = result.map((e) => e.id); + expect(ids.indexOf(id3)).toBeLessThan(ids.indexOf(id2)); + expect(ids.indexOf(id2)).toBeLessThan(ids.indexOf(id1)); + }); + + it('should filter by projectId', async () => { + const projectA = await createProject('A'); + const projectB = await createProject('B'); + const now = new Date(); + + const idA1 = nanoid(); + const idA2 = nanoid(); + const idB1 = nanoid(); + + await db.insert(errands).values([ + { id: idA1, description: 'A1', branch: 'b-a1', baseBranch: 'main', agentId: null, projectId: projectA.id, status: 'active', createdAt: now, updatedAt: now }, + { id: idA2, description: 'A2', branch: 'b-a2', baseBranch: 'main', agentId: null, projectId: projectA.id, status: 'active', createdAt: now, updatedAt: now }, + { id: idB1, description: 'B1', branch: 'b-b1', baseBranch: 'main', agentId: null, projectId: projectB.id, status: 'active', createdAt: now, updatedAt: now }, + ]); + + const result = await repo.findAll({ projectId: projectA.id }); + expect(result).toHaveLength(2); + expect(result.map((e) => e.id).sort()).toEqual([idA1, idA2].sort()); + }); + + it('should filter by status', async () => { + const project = await createProject(); + const now = new Date(); + + const id1 = nanoid(); + const id2 = nanoid(); + const id3 = nanoid(); + + await db.insert(errands).values([ + { id: id1, description: 'E1', branch: 'b1', baseBranch: 'main', agentId: null, projectId: project.id, status: 'active', createdAt: now, updatedAt: now }, + { id: id2, description: 'E2', branch: 'b2', baseBranch: 'main', agentId: null, projectId: project.id, status: 'pending_review', createdAt: now, updatedAt: now }, + { id: id3, description: 'E3', branch: 'b3', baseBranch: 'main', agentId: null, projectId: project.id, status: 'merged', createdAt: now, updatedAt: now }, + ]); + + const result = await repo.findAll({ status: 'pending_review' }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(id2); + }); + + it('should filter by both projectId and status', async () => { + const projectA = await createProject('PA'); + const projectB = await createProject('PB'); + const now = new Date(); + + const idMatch = nanoid(); + const idOtherStatus = nanoid(); + const idOtherProject = nanoid(); + const idNeither = nanoid(); + + await db.insert(errands).values([ + { id: idMatch, description: 'Match', branch: 'b1', baseBranch: 'main', agentId: null, projectId: projectA.id, status: 'pending_review', createdAt: now, updatedAt: now }, + { id: idOtherStatus, description: 'Wrong status', branch: 'b2', baseBranch: 'main', agentId: null, projectId: projectA.id, status: 'active', createdAt: now, updatedAt: now }, + { id: idOtherProject, description: 'Wrong project', branch: 'b3', baseBranch: 'main', agentId: null, projectId: projectB.id, status: 'pending_review', createdAt: now, updatedAt: now }, + { id: idNeither, description: 'Neither', branch: 'b4', baseBranch: 'main', agentId: null, projectId: projectB.id, status: 'active', createdAt: now, updatedAt: now }, + ]); + + const result = await repo.findAll({ projectId: projectA.id, status: 'pending_review' }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(idMatch); + }); + }); + + describe('findById', () => { + it('should return agentAlias when agentId is set', async () => { + const agent = await createAgent('known-agent'); + const project = await createProject(); + const id = nanoid(); + const now = new Date(); + + await db.insert(errands).values({ + id, + description: 'With agent', + branch: 'feature/x', + baseBranch: 'main', + agentId: agent.id, + projectId: project.id, + status: 'active', + createdAt: now, + updatedAt: now, + }); + + const found = await repo.findById(id); + expect(found).toBeDefined(); + expect(found!.agentAlias).toBe(agent.name); + }); + + it('should return agentAlias as null when agentId is null', async () => { + const project = await createProject(); + const id = nanoid(); + const now = new Date(); + + await db.insert(errands).values({ + id, + description: 'No agent', + branch: 'feature/y', + baseBranch: 'main', + agentId: null, + projectId: project.id, + status: 'active', + createdAt: now, + updatedAt: now, + }); + + const found = await repo.findById(id); + expect(found).toBeDefined(); + expect(found!.agentAlias).toBeNull(); + }); + + it('should return undefined for unknown id', async () => { + const found = await repo.findById('nonexistent'); + expect(found).toBeUndefined(); + }); + }); + + describe('update', () => { + it('should update status and advance updatedAt', async () => { + const project = await createProject(); + const id = nanoid(); + const past = new Date('2024-01-01T00:00:00Z'); + + await db.insert(errands).values({ + id, + description: 'Errand', + branch: 'feature/update', + baseBranch: 'main', + agentId: null, + projectId: project.id, + status: 'active', + createdAt: past, + updatedAt: past, + }); + + const updated = await repo.update(id, { status: 'pending_review' }); + expect(updated.status).toBe('pending_review'); + expect(updated.updatedAt.getTime()).toBeGreaterThan(past.getTime()); + }); + + it('should throw on unknown id', async () => { + await expect( + repo.update('nonexistent', { status: 'merged' }) + ).rejects.toThrow('Errand not found'); + }); + }); + + describe('delete', () => { + it('should delete errand and findById returns undefined', async () => { + const errand = await createErrand(); + await repo.delete(errand.id); + const found = await repo.findById(errand.id); + expect(found).toBeUndefined(); + }); + }); + + describe('cascade and set null', () => { + it('should cascade delete errands when project is deleted', async () => { + const project = await createProject(); + const id = nanoid(); + const now = new Date(); + + await db.insert(errands).values({ + id, + description: 'Cascade test', + branch: 'feature/cascade', + baseBranch: 'main', + agentId: null, + projectId: project.id, + status: 'active', + createdAt: now, + updatedAt: now, + }); + + // Delete project — should cascade delete errands + await db.delete(projects).where(eq(projects.id, project.id)); + + const found = await repo.findById(id); + expect(found).toBeUndefined(); + }); + + it('should set agentId to null when agent is deleted', async () => { + const agent = await createAgent(); + const project = await createProject(); + const id = nanoid(); + const now = new Date(); + + await db.insert(errands).values({ + id, + description: 'Agent null test', + branch: 'feature/agent-null', + baseBranch: 'main', + agentId: agent.id, + projectId: project.id, + status: 'active', + createdAt: now, + updatedAt: now, + }); + + // Delete agent — should set null + await db.delete(agents).where(eq(agents.id, agent.id)); + + const [errand] = await db.select().from(errands).where(eq(errands.id, id)); + expect(errand).toBeDefined(); + expect(errand.agentId).toBeNull(); + }); + }); +}); diff --git a/apps/server/db/repositories/drizzle/errand.ts b/apps/server/db/repositories/drizzle/errand.ts new file mode 100644 index 0000000..0774e4b --- /dev/null +++ b/apps/server/db/repositories/drizzle/errand.ts @@ -0,0 +1,89 @@ +/** + * Drizzle Errand Repository Adapter + * + * Implements ErrandRepository interface using Drizzle ORM. + */ + +import { eq, desc, and } from 'drizzle-orm'; +import type { DrizzleDatabase } from '../../index.js'; +import { errands, agents } from '../../schema.js'; +import type { + ErrandRepository, + ErrandWithAlias, + ErrandStatus, + CreateErrandData, + UpdateErrandData, +} from '../errand-repository.js'; +import type { Errand } from '../../schema.js'; + +export class DrizzleErrandRepository implements ErrandRepository { + constructor(private db: DrizzleDatabase) {} + + async create(data: CreateErrandData): Promise { + const now = new Date(); + const [created] = await this.db + .insert(errands) + .values({ ...data, createdAt: now, updatedAt: now }) + .returning(); + return created; + } + + async findById(id: string): Promise { + const result = 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, + 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); + return result[0] ?? undefined; + } + + async findAll(opts?: { projectId?: string; status?: ErrandStatus }): Promise { + const conditions = []; + if (opts?.projectId) conditions.push(eq(errands.projectId, opts.projectId)); + if (opts?.status) conditions.push(eq(errands.status, opts.status)); + + return this.db + .select({ + id: errands.id, + description: errands.description, + branch: errands.branch, + baseBranch: errands.baseBranch, + agentId: errands.agentId, + projectId: errands.projectId, + status: errands.status, + 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) + .orderBy(desc(errands.createdAt)); + } + + async update(id: string, data: UpdateErrandData): Promise { + const [updated] = await this.db + .update(errands) + .set({ ...data, updatedAt: new Date() }) + .where(eq(errands.id, id)) + .returning(); + if (!updated) throw new Error(`Errand not found: ${id}`); + return updated; + } + + 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..9502e34 --- /dev/null +++ b/apps/server/db/repositories/errand-repository.ts @@ -0,0 +1,15 @@ +import type { Errand, NewErrand } from '../schema.js'; + +export type ErrandStatus = 'active' | 'pending_review' | 'conflict' | 'merged' | 'abandoned'; +export type ErrandWithAlias = Errand & { agentAlias: string | null }; + +export type CreateErrandData = Omit; +export type UpdateErrandData = Partial>; + +export interface ErrandRepository { + create(data: CreateErrandData): Promise; + findById(id: string): Promise; + findAll(opts?: { projectId?: string; status?: ErrandStatus }): 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..c1407df 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, + ErrandWithAlias, + ErrandStatus, + CreateErrandData, + UpdateErrandData, +} from './errand-repository.js'; diff --git a/apps/server/drizzle/0035_faulty_human_fly.sql b/apps/server/drizzle/0035_faulty_human_fly.sql new file mode 100644 index 0000000..5afe9b5 --- /dev/null +++ b/apps/server/drizzle/0035_faulty_human_fly.sql @@ -0,0 +1,13 @@ +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, + `status` text DEFAULT 'active' NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + 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 +); \ No newline at end of file diff --git a/apps/server/drizzle/meta/0035_snapshot.json b/apps/server/drizzle/meta/0035_snapshot.json new file mode 100644 index 0000000..d735a97 --- /dev/null +++ b/apps/server/drizzle/meta/0035_snapshot.json @@ -0,0 +1,1974 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "c84e499f-7df8-4091-b2a5-6b12847898bd", + "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": 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": {}, + "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 + }, + "retry_count": { + "name": "retry_count", + "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": { + "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/docs/database.md b/docs/database.md index dd12a0b..0cc848f 100644 --- a/docs/database.md +++ b/docs/database.md @@ -5,8 +5,8 @@ ## Architecture - **Schema**: `apps/server/db/schema.ts` — all tables, columns, relations -- **Ports** (interfaces): `apps/server/db/repositories/*.ts` — 13 repository interfaces -- **Adapters** (implementations): `apps/server/db/repositories/drizzle/*.ts` — 13 Drizzle adapters +- **Ports** (interfaces): `apps/server/db/repositories/*.ts` — 14 repository interfaces +- **Adapters** (implementations): `apps/server/db/repositories/drizzle/*.ts` — 14 Drizzle adapters - **Barrel exports**: `apps/server/db/index.ts` re-exports everything All adapters use nanoid() for IDs, auto-manage timestamps, and use Drizzle's `.returning()` for atomic reads after writes. @@ -196,6 +196,21 @@ Messages within a chat session. Index: `(chatSessionId)`. +### errands + +Tracks errand work items linked to a project branch, optionally assigned to an agent. + +| Column | Type | Notes | +|--------|------|-------| +| id | text PK | caller-supplied | +| description | text NOT NULL | human-readable description | +| branch | text NOT NULL | working branch name | +| baseBranch | text NOT NULL | default 'main' | +| agentId | text FK → agents (set null) | assigned agent; null if unassigned | +| projectId | text FK → projects (cascade) | owning project | +| status | text enum | active, pending_review, conflict, merged, abandoned; default 'active' | +| createdAt, updatedAt | integer/timestamp | | + ### review_comments Inline review comments on phase diffs, persisted across page reloads. @@ -216,7 +231,7 @@ Index: `(phaseId)`. ## Repository Interfaces -13 repositories, each with standard CRUD plus domain-specific methods: +14 repositories, each with standard CRUD plus domain-specific methods: | Repository | Key Methods | |-----------|-------------| @@ -233,6 +248,7 @@ Index: `(phaseId)`. | ConversationRepository | create, findById, findPendingForAgent, answer | | ChatSessionRepository | createSession, findActiveSession, findActiveSessionByAgentId, updateSession, createMessage, findMessagesBySessionId | | ReviewCommentRepository | create, findByPhaseId, resolve, unresolve, delete | +| ErrandRepository | create, findById, findAll (filter by projectId/status), update, delete | ## Migrations @@ -244,4 +260,4 @@ Key rules: - See [database-migrations.md](database-migrations.md) for full workflow - Snapshots stale after 0008; migrations 0008+ are hand-written -Current migrations: 0000 through 0030 (31 total). +Current migrations: 0000 through 0035 (36 total). From a86a373d42f966f32ab45813a4b458456df10cd7 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 12:37:21 +0100 Subject: [PATCH 12/84] fix: Handle push to checked-out branch in local non-bare repos git refuses to push to a branch that is currently checked out in a non-bare repository. When the clone's origin is the user's local repo, this blocks merge_and_push entirely. On "branch is currently checked out" error, temporarily set receive.denyCurrentBranch=updateInstead on the remote and retry. This uses git's built-in mechanism to update the working tree safely (rejects if dirty). --- apps/server/git/simple-git-branch-manager.ts | 23 ++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/apps/server/git/simple-git-branch-manager.ts b/apps/server/git/simple-git-branch-manager.ts index 5b46640..47b690e 100644 --- a/apps/server/git/simple-git-branch-manager.ts +++ b/apps/server/git/simple-git-branch-manager.ts @@ -6,7 +6,7 @@ * on project clones without requiring a worktree. */ -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { simpleGit } from 'simple-git'; @@ -164,7 +164,26 @@ export class SimpleGitBranchManager implements BranchManager { async pushBranch(repoPath: string, branch: string, remote = 'origin'): Promise { const git = simpleGit(repoPath); - await git.push(remote, branch); + try { + await git.push(remote, branch); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (!msg.includes('branch is currently checked out')) throw err; + + // Local non-bare repo with the branch checked out — temporarily allow it. + // receive.denyCurrentBranch=updateInstead updates the remote's working tree + // and index to match, or rejects if the working tree is dirty. + const remoteUrl = (await git.remote(['get-url', remote]))?.trim(); + if (!remoteUrl) throw err; + const remotePath = resolve(repoPath, remoteUrl); + const remoteGit = simpleGit(remotePath); + await remoteGit.addConfig('receive.denyCurrentBranch', 'updateInstead'); + try { + await git.push(remote, branch); + } finally { + await remoteGit.raw(['config', '--unset', 'receive.denyCurrentBranch']); + } + } log.info({ repoPath, branch, remote }, 'branch pushed to remote'); } From 243f24a39789614e78ab12fe470d1a55bc3aea1e Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 12:46:39 +0100 Subject: [PATCH 13/84] fix: Eliminate content page flickering from layout shifts and double invalidation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reserve fixed height for "Saving..." indicator instead of conditionally rendering it, preventing layout shift on every auto-save cycle - Remove getPage from updatePage mutation cache invalidation — useAutoSave already handles optimistic updates, and SSE events cover external changes. This eliminates double-invalidation (mutation + SSE) refetch storms. - Memoize TiptapEditor extensions array to avoid recreating extensions and pageLinkDeletionDetector on every render - Memoize useLiveUpdates rules array in initiative detail page --- apps/web/src/components/editor/ContentTab.tsx | 8 +-- .../src/components/editor/TiptapEditor.tsx | 56 +++++++++---------- apps/web/src/lib/invalidation.ts | 6 +- apps/web/src/routes/initiatives/$id.tsx | 9 ++- 4 files changed, 43 insertions(+), 36 deletions(-) diff --git a/apps/web/src/components/editor/ContentTab.tsx b/apps/web/src/components/editor/ContentTab.tsx index dcb8ff5..71de63b 100644 --- a/apps/web/src/components/editor/ContentTab.tsx +++ b/apps/web/src/components/editor/ContentTab.tsx @@ -253,13 +253,13 @@ export function ContentTab({ initiativeId, initiativeName }: ContentTabProps) { {resolvedActivePageId && ( <> - {(isSaving || updateInitiativeMutation.isPending) && ( -
+
+ {(isSaving || updateInitiativeMutation.isPending) && ( Saving... -
- )} + )} +
{activePageQuery.isSuccess && ( { - if (node.type.name === 'heading') { - return `Heading ${node.attrs.level}`; - } - return "Type '/' for commands..."; - }, - }), - Link.configure({ - openOnClick: false, - }), - SlashCommands, - BlockSelectionExtension, - ]; - - const extensions = enablePageLinks - ? [...baseExtensions, PageLinkExtension, pageLinkDeletionDetector] - : baseExtensions; + const extensions = useMemo(() => { + const detector = createPageLinkDeletionDetector(onPageLinkDeletedRef); + const base = [ + StarterKit, + Table.configure({ resizable: true, cellMinWidth: 50 }), + TableRow, + TableCell, + TableHeader, + Placeholder.configure({ + includeChildren: true, + placeholder: ({ node }) => { + if (node.type.name === 'heading') { + return `Heading ${node.attrs.level}`; + } + return "Type '/' for commands..."; + }, + }), + Link.configure({ + openOnClick: false, + }), + SlashCommands, + BlockSelectionExtension, + ]; + return enablePageLinks + ? [...base, PageLinkExtension, detector] + : base; + }, [enablePageLinks]); const editor = useEditor( { diff --git a/apps/web/src/lib/invalidation.ts b/apps/web/src/lib/invalidation.ts index 3cd6e1f..eb5b517 100644 --- a/apps/web/src/lib/invalidation.ts +++ b/apps/web/src/lib/invalidation.ts @@ -71,7 +71,11 @@ const INVALIDATION_MAP: Partial> = { revertChangeSet: ["listPhases", "listPhaseTasks", "listInitiativeTasks", "listPages", "getPage", "listChangeSets", "getRootPage", "getChangeSet"], // --- Pages --- - updatePage: ["listPages", "getPage", "getRootPage"], + // NOTE: getPage omitted — useAutoSave handles optimistic updates for the + // active page, and SSE `page:updated` events cover external changes. + // Including getPage here caused double-invalidation (mutation + SSE) and + // refetch storms that flickered the editor. + updatePage: ["listPages", "getRootPage"], createPage: ["listPages", "getRootPage"], deletePage: ["listPages", "getRootPage"], diff --git a/apps/web/src/routes/initiatives/$id.tsx b/apps/web/src/routes/initiatives/$id.tsx index 678100c..f56dbed 100644 --- a/apps/web/src/routes/initiatives/$id.tsx +++ b/apps/web/src/routes/initiatives/$id.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { motion } from "motion/react"; import { AlertCircle } from "lucide-react"; @@ -11,6 +12,7 @@ import { ExecutionTab } from "@/components/ExecutionTab"; import { ReviewTab } from "@/components/review"; import { PipelineTab } from "@/components/pipeline"; import { useLiveUpdates } from "@/hooks"; +import type { LiveUpdateRule } from "@/hooks/useLiveUpdates"; type Tab = "content" | "plan" | "execution" | "review"; const TABS: Tab[] = ["content", "plan", "execution", "review"]; @@ -27,15 +29,16 @@ function InitiativeDetailPage() { const { tab: activeTab } = Route.useSearch(); const navigate = useNavigate(); - // Single SSE stream for all live updates - useLiveUpdates([ + // Single SSE stream for all live updates — memoized to avoid re-subscribe on render + const liveUpdateRules = useMemo(() => [ { prefix: 'task:', invalidate: ['listPhases', 'listTasks', 'listInitiativeTasks', 'getPhaseDependencies', 'listPhaseTaskDependencies'] }, { prefix: 'phase:', invalidate: ['listPhases', 'listTasks', 'listInitiativePhaseDependencies', 'getPhaseDependencies'] }, { prefix: 'agent:', invalidate: ['listAgents', 'getActiveRefineAgent'] }, { prefix: 'page:', invalidate: ['listPages', 'getPage', 'getRootPage'] }, { prefix: 'changeset:', invalidate: ['getChangeSet', 'listChangeSets'] }, { prefix: 'preview:', invalidate: ['listPreviews', 'getPreviewStatus'] }, - ]); + ], []); + useLiveUpdates(liveUpdateRules); // tRPC queries const initiativeQuery = trpc.getInitiative.useQuery({ id }); From b6218584eef9d76d40fc4d8735db8150d2e49150 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 13:06:33 +0100 Subject: [PATCH 14/84] feat: Show project pills on initiative cards in list view Add projects to the listInitiatives tRPC response and render them as outline badge pills between the initiative name and activity row. --- apps/server/trpc/routers/initiative.ts | 17 ++++++++++++++++- apps/web/src/components/InitiativeCard.tsx | 13 +++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/apps/server/trpc/routers/initiative.ts b/apps/server/trpc/routers/initiative.ts index 6b48b77..e28048b 100644 --- a/apps/server/trpc/routers/initiative.ts +++ b/apps/server/trpc/routers/initiative.ts @@ -140,16 +140,31 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) { ) .map(a => ({ initiativeId: a.initiativeId ?? '', mode: a.mode ?? '', status: a.status })); + // Batch-fetch projects for all initiatives + const projectRepo = ctx.projectRepository; + const projectsByInitiativeId = new Map>(); + if (projectRepo) { + await Promise.all(initiatives.map(async (init) => { + const projects = await projectRepo.findProjectsByInitiativeId(init.id); + projectsByInitiativeId.set(init.id, projects.map(p => ({ id: p.id, name: p.name }))); + })); + } + + const addProjects = (init: typeof initiatives[0]) => ({ + projects: projectsByInitiativeId.get(init.id) ?? [], + }); + if (ctx.phaseRepository) { const phaseRepo = ctx.phaseRepository; return Promise.all(initiatives.map(async (init) => { const phases = await phaseRepo.findByInitiativeId(init.id); - return { ...init, activity: deriveInitiativeActivity(init, phases, activeArchitectAgents) }; + return { ...init, ...addProjects(init), activity: deriveInitiativeActivity(init, phases, activeArchitectAgents) }; })); } return initiatives.map(init => ({ ...init, + ...addProjects(init), activity: deriveInitiativeActivity(init, [], activeArchitectAgents), })); }), diff --git a/apps/web/src/components/InitiativeCard.tsx b/apps/web/src/components/InitiativeCard.tsx index a4f7e18..602892d 100644 --- a/apps/web/src/components/InitiativeCard.tsx +++ b/apps/web/src/components/InitiativeCard.tsx @@ -1,6 +1,7 @@ import { MoreHorizontal } from "lucide-react"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { DropdownMenu, DropdownMenuContent, @@ -20,6 +21,7 @@ export interface SerializedInitiative { branch: string | null; createdAt: string; updatedAt: string; + projects?: Array<{ id: string; name: string }>; activity: { state: string; activePhase?: { id: string; name: string }; @@ -113,6 +115,17 @@ export function InitiativeCard({ initiative, onClick }: InitiativeCardProps) {
+ {/* Project pills */} + {initiative.projects && initiative.projects.length > 0 && ( +
+ {initiative.projects.map((p) => ( + + {p.name} + + ))} +
+ )} + {/* Row 2: Activity dot + label + active phase + progress */}
Date: Fri, 6 Mar 2026 13:07:25 +0100 Subject: [PATCH 15/84] fix: Move project pills inline after initiative name --- apps/web/src/components/InitiativeCard.tsx | 29 ++++++++++------------ 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/InitiativeCard.tsx b/apps/web/src/components/InitiativeCard.tsx index 602892d..6ab41ee 100644 --- a/apps/web/src/components/InitiativeCard.tsx +++ b/apps/web/src/components/InitiativeCard.tsx @@ -89,11 +89,19 @@ export function InitiativeCard({ initiative, onClick }: InitiativeCardProps) { className="p-4" onClick={onClick} > - {/* Row 1: Name + overflow menu */} -
- - {initiative.name} - + {/* Row 1: Name + project pills + overflow menu */} +
+
+ + {initiative.name} + + {initiative.projects && initiative.projects.length > 0 && + initiative.projects.map((p) => ( + + {p.name} + + ))} +
e.stopPropagation()}> @@ -115,17 +123,6 @@ export function InitiativeCard({ initiative, onClick }: InitiativeCardProps) {
- {/* Project pills */} - {initiative.projects && initiative.projects.length > 0 && ( -
- {initiative.projects.map((p) => ( - - {p.name} - - ))} -
- )} - {/* Row 2: Activity dot + label + active phase + progress */}
Date: Fri, 6 Mar 2026 13:10:46 +0100 Subject: [PATCH 16/84] feat: Add Agent Logs tab to task slide-over MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add getTaskAgent tRPC procedure to find the most recent agent for a task. TaskSlideOver now has Details/Agent Logs tabs — logs tab renders AgentOutputViewer when an agent exists, or an empty state otherwise. --- apps/server/trpc/routers/agent.ts | 11 + .../components/execution/TaskSlideOver.tsx | 216 ++++++++++++------ 2 files changed, 156 insertions(+), 71 deletions(-) diff --git a/apps/server/trpc/routers/agent.ts b/apps/server/trpc/routers/agent.ts index a0c3660..b547b80 100644 --- a/apps/server/trpc/routers/agent.ts +++ b/apps/server/trpc/routers/agent.ts @@ -184,6 +184,17 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) { return candidates[0] ?? null; }), + getTaskAgent: publicProcedure + .input(z.object({ taskId: z.string().min(1) })) + .query(async ({ ctx, input }): Promise => { + const agentManager = requireAgentManager(ctx); + const all = await agentManager.list(); + const matches = all + .filter(a => a.taskId === input.taskId) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + return matches[0] ?? null; + }), + getActiveConflictAgent: publicProcedure .input(z.object({ initiativeId: z.string().min(1) })) .query(async ({ ctx, input }): Promise => { diff --git a/apps/web/src/components/execution/TaskSlideOver.tsx b/apps/web/src/components/execution/TaskSlideOver.tsx index ff9a4d3..0885782 100644 --- a/apps/web/src/components/execution/TaskSlideOver.tsx +++ b/apps/web/src/components/execution/TaskSlideOver.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useMemo } from "react"; +import { useCallback, useEffect, useRef, useMemo, useState } from "react"; import { motion, AnimatePresence } from "motion/react"; import { X, Trash2, MessageCircle, RotateCw } from "lucide-react"; import type { ChatTarget } from "@/components/chat/ChatSlideOver"; @@ -7,12 +7,15 @@ import { Button } from "@/components/ui/button"; import { StatusBadge } from "@/components/StatusBadge"; import { StatusDot } from "@/components/StatusDot"; import { TiptapEditor } from "@/components/editor/TiptapEditor"; +import { AgentOutputViewer } from "@/components/AgentOutputViewer"; import { getCategoryConfig } from "@/lib/category"; import { markdownToTiptapJson } from "@/lib/markdown-to-tiptap"; import { useExecutionContext } from "./ExecutionContext"; import { trpc } from "@/lib/trpc"; import { cn } from "@/lib/utils"; +type SlideOverTab = "details" | "logs"; + interface TaskSlideOverProps { onOpenChat?: (target: ChatTarget) => void; } @@ -24,8 +27,15 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) { const deleteTaskMutation = trpc.deleteTask.useMutation(); const updateTaskMutation = trpc.updateTask.useMutation(); + const [tab, setTab] = useState("details"); + const close = useCallback(() => setSelectedTaskId(null), [setSelectedTaskId]); + // Reset tab when task changes + useEffect(() => { + setTab("details"); + }, [selectedEntry?.task?.id]); + // Escape key closes useEffect(() => { if (!selectedEntry) return; @@ -152,80 +162,107 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) {
+ {/* Tab bar */} +
+ {(["details", "logs"] as const).map((t) => ( + + ))} +
+ {/* Content */} -
- {/* Metadata grid */} -
- - - - - - - - - - - {task.type} - - - - {selectedEntry.agentName ?? "Unassigned"} - - -
+
+ {tab === "details" ? ( +
+ {/* Metadata grid */} +
+ + + + + + + + + + + {task.type} + + + + {selectedEntry.agentName ?? "Unassigned"} + + +
- {/* Description — editable tiptap */} -
- -
+ {/* Description — editable tiptap */} +
+ +
- {/* Dependencies */} -
- {dependencies.length === 0 ? ( -

None

- ) : ( -
    - {dependencies.map((dep) => ( -
  • - - - {dep.name} - -
  • - ))} -
- )} -
+ {/* Dependencies */} +
+ {dependencies.length === 0 ? ( +

None

+ ) : ( +
    + {dependencies.map((dep) => ( +
  • + + + {dep.name} + +
  • + ))} +
+ )} +
- {/* Blocks */} -
- {dependents.length === 0 ? ( -

None

- ) : ( -
    - {dependents.map((dep) => ( -
  • - - - {dep.name} - -
  • - ))} -
- )} -
+ {/* Blocks */} +
+ {dependents.length === 0 ? ( +

None

+ ) : ( +
    + {dependents.map((dep) => ( +
  • + + + {dep.name} + +
  • + ))} +
+ )} +
+
+ ) : ( + + )}
{/* Footer */} @@ -293,6 +330,43 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) { ); } +// --------------------------------------------------------------------------- +// Agent Logs Tab +// --------------------------------------------------------------------------- + +function AgentLogsTab({ taskId }: { taskId: string }) { + const { data: agent, isLoading } = trpc.getTaskAgent.useQuery( + { taskId }, + { refetchOnWindowFocus: false }, + ); + + if (isLoading) { + return ( +
+ Loading... +
+ ); + } + + if (!agent) { + return ( +
+ No agent has been assigned to this task yet. +
+ ); + } + + return ( +
+ +
+ ); +} + // --------------------------------------------------------------------------- // Small helpers // --------------------------------------------------------------------------- From 3e2a57044735a052509265f873865f6f9c1d5837 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 13:12:25 +0100 Subject: [PATCH 17/84] feat: Emit account_switched event on account exhaustion in lifecycle controller Passes EventBus through LifecycleFactory and AgentLifecycleController so that when an account is marked exhausted, an agent:account_switched event is emitted with the previous and new account IDs. Co-Authored-By: Claude Sonnet 4.6 --- apps/server/agent/lifecycle/controller.ts | 27 ++++++++++++++++++++--- apps/server/agent/lifecycle/factory.ts | 8 +++++-- apps/server/agent/manager.ts | 3 +++ 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/apps/server/agent/lifecycle/controller.ts b/apps/server/agent/lifecycle/controller.ts index 833634d..537542b 100644 --- a/apps/server/agent/lifecycle/controller.ts +++ b/apps/server/agent/lifecycle/controller.ts @@ -21,6 +21,7 @@ import type { RetryPolicy, AgentError } from './retry-policy.js'; import { AgentExhaustedError, AgentFailureError } from './retry-policy.js'; import type { AgentErrorAnalyzer } from './error-analyzer.js'; import type { CleanupStrategy, AgentInfo } from './cleanup-strategy.js'; +import type { EventBus, AgentAccountSwitchedEvent } from '../../events/types.js'; const log = createModuleLogger('lifecycle-controller'); @@ -48,6 +49,7 @@ export class AgentLifecycleController { private cleanupStrategy: CleanupStrategy, private accountRepository?: AccountRepository, private debug: boolean = false, + private eventBus?: EventBus, ) {} /** @@ -304,7 +306,7 @@ export class AgentLifecycleController { } /** - * Handle account exhaustion by marking account as exhausted. + * Handle account exhaustion by marking account as exhausted and emitting account_switched event. */ private async handleAccountExhaustion(agentId: string): Promise { if (!this.accountRepository) { @@ -319,15 +321,34 @@ export class AgentLifecycleController { return; } + const previousAccountId = agent.accountId; + // Mark account as exhausted for 1 hour const exhaustedUntil = new Date(Date.now() + 60 * 60 * 1000); - await this.accountRepository.markExhausted(agent.accountId, exhaustedUntil); + await this.accountRepository.markExhausted(previousAccountId, exhaustedUntil); log.info({ agentId, - accountId: agent.accountId, + accountId: previousAccountId, exhaustedUntil }, 'marked account as exhausted due to usage limits'); + + // Find the next available account and emit account_switched event + const newAccount = await this.accountRepository.findNextAvailable(agent.provider ?? 'claude'); + if (newAccount && this.eventBus) { + const event: AgentAccountSwitchedEvent = { + type: 'agent:account_switched', + timestamp: new Date(), + payload: { + agentId, + name: agent.name, + previousAccountId, + newAccountId: newAccount.id, + reason: 'account_exhausted', + }, + }; + this.eventBus.emit(event); + } } catch (error) { log.warn({ agentId, diff --git a/apps/server/agent/lifecycle/factory.ts b/apps/server/agent/lifecycle/factory.ts index 4bff87b..51c502a 100644 --- a/apps/server/agent/lifecycle/factory.ts +++ b/apps/server/agent/lifecycle/factory.ts @@ -14,6 +14,7 @@ import type { AgentRepository } from '../../db/repositories/agent-repository.js' import type { AccountRepository } from '../../db/repositories/account-repository.js'; import type { ProcessManager } from '../process-manager.js'; import type { CleanupManager } from '../cleanup-manager.js'; +import type { EventBus } from '../../events/types.js'; export interface LifecycleFactoryOptions { repository: AgentRepository; @@ -21,6 +22,7 @@ export interface LifecycleFactoryOptions { cleanupManager: CleanupManager; accountRepository?: AccountRepository; debug?: boolean; + eventBus?: EventBus; } /** @@ -32,7 +34,8 @@ export function createLifecycleController(options: LifecycleFactoryOptions): Age processManager, cleanupManager, accountRepository, - debug = false + debug = false, + eventBus, } = options; // Create core components @@ -51,7 +54,8 @@ export function createLifecycleController(options: LifecycleFactoryOptions): Age cleanupManager, cleanupStrategy, accountRepository, - debug + debug, + eventBus, ); return lifecycleController; diff --git a/apps/server/agent/manager.ts b/apps/server/agent/manager.ts index ac36b83..ce367d7 100644 --- a/apps/server/agent/manager.ts +++ b/apps/server/agent/manager.ts @@ -98,6 +98,7 @@ export class MultiProviderAgentManager implements AgentManager { cleanupManager: this.cleanupManager, accountRepository, debug, + eventBus, }); // Listen for process crashed events to handle agents specially @@ -607,6 +608,7 @@ export class MultiProviderAgentManager implements AgentManager { this.activeAgents.set(agentId, activeEntry); if (this.eventBus) { + // verified: payload matches AgentResumedEvent shape (agentId, name, taskId, sessionId) const event: AgentResumedEvent = { type: 'agent:resumed', timestamp: new Date(), @@ -796,6 +798,7 @@ export class MultiProviderAgentManager implements AgentManager { log.info({ agentId, pid }, 'resume detached subprocess started'); if (this.eventBus) { + // verified: payload matches AgentResumedEvent shape (agentId, name, taskId, sessionId) const event: AgentResumedEvent = { type: 'agent:resumed', timestamp: new Date(), From b2f4004191b063a908182bfd154c6ee6062a3c3f Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 13:13:01 +0100 Subject: [PATCH 18/84] feat: Persist agent prompt in DB so getAgentPrompt survives log cleanup The `getAgentPrompt` tRPC procedure previously read exclusively from `.cw/agent-logs//PROMPT.md`. Once the cleanup-manager removes that directory, the prompt is gone forever. Adds a `prompt` text column to the `agents` table and writes the fully assembled prompt (including workspace layout, inter-agent comms, and preview sections) to the DB in the same `repository.update()` call that saves pid/outputFilePath after spawn. `getAgentPrompt` now reads from DB first (`agent.prompt`) and falls back to the filesystem only for agents spawned before this change. Addresses review comment [MMcmVlEK16bBfkJuXvG6h]. Co-Authored-By: Claude Sonnet 4.6 --- apps/server/agent/manager.ts | 4 +- apps/server/agent/mock-manager.ts | 1 + apps/server/agent/types.ts | 2 + .../db/repositories/agent-repository.ts | 1 + apps/server/db/schema.ts | 1 + apps/server/dispatch/manager.test.ts | 2 + apps/server/drizzle/0031_icy_silvermane.sql | 1 + apps/server/drizzle/meta/0031_snapshot.json | 1159 +++++++++++++++++ apps/server/drizzle/meta/_journal.json | 7 + .../integration/crash-race-condition.test.ts | 4 +- apps/server/trpc/routers/agent.test.ts | 41 +- apps/server/trpc/routers/agent.ts | 24 +- docs/database.md | 1 + docs/server-api.md | 2 +- 14 files changed, 1239 insertions(+), 11 deletions(-) create mode 100644 apps/server/drizzle/0031_icy_silvermane.sql create mode 100644 apps/server/drizzle/meta/0031_snapshot.json diff --git a/apps/server/agent/manager.ts b/apps/server/agent/manager.ts index 1b25798..1fb3655 100644 --- a/apps/server/agent/manager.ts +++ b/apps/server/agent/manager.ts @@ -328,7 +328,7 @@ export class MultiProviderAgentManager implements AgentManager { this.createLogChunkCallback(agentId, alias, 1), ); - await this.repository.update(agentId, { pid, outputFilePath }); + await this.repository.update(agentId, { pid, outputFilePath, prompt }); // Write spawn diagnostic file for post-execution verification const diagnostic = { @@ -1086,6 +1086,7 @@ export class MultiProviderAgentManager implements AgentManager { updatedAt: Date; userDismissedAt?: Date | null; exitCode?: number | null; + prompt?: string | null; }): AgentInfo { return { id: agent.id, @@ -1102,6 +1103,7 @@ export class MultiProviderAgentManager implements AgentManager { updatedAt: agent.updatedAt, userDismissedAt: agent.userDismissedAt, exitCode: agent.exitCode ?? null, + prompt: agent.prompt ?? null, }; } } diff --git a/apps/server/agent/mock-manager.ts b/apps/server/agent/mock-manager.ts index d8cb009..68d49be 100644 --- a/apps/server/agent/mock-manager.ts +++ b/apps/server/agent/mock-manager.ts @@ -143,6 +143,7 @@ export class MockAgentManager implements AgentManager { createdAt: now, updatedAt: now, exitCode: null, + prompt: null, }; const record: MockAgentRecord = { diff --git a/apps/server/agent/types.ts b/apps/server/agent/types.ts index 1f5e029..dcd44c8 100644 --- a/apps/server/agent/types.ts +++ b/apps/server/agent/types.ts @@ -95,6 +95,8 @@ export interface AgentInfo { userDismissedAt?: Date | null; /** Process exit code — null while running or if not yet exited */ exitCode: number | null; + /** Full assembled prompt passed to the agent process — null for agents spawned before DB persistence */ + prompt: string | null; } /** diff --git a/apps/server/db/repositories/agent-repository.ts b/apps/server/db/repositories/agent-repository.ts index c54ca40..f4f4994 100644 --- a/apps/server/db/repositories/agent-repository.ts +++ b/apps/server/db/repositories/agent-repository.ts @@ -45,6 +45,7 @@ export interface UpdateAgentData { accountId?: string | null; pid?: number | null; exitCode?: number | null; + prompt?: string | null; outputFilePath?: string | null; result?: string | null; pendingQuestions?: string | null; diff --git a/apps/server/db/schema.ts b/apps/server/db/schema.ts index 1e371db..3fdb362 100644 --- a/apps/server/db/schema.ts +++ b/apps/server/db/schema.ts @@ -265,6 +265,7 @@ export const agents = sqliteTable('agents', { .default('execute'), pid: integer('pid'), exitCode: integer('exit_code'), // Process exit code for debugging crashes + prompt: text('prompt'), // Full assembled prompt passed to the agent process (persisted for durability after log cleanup) outputFilePath: text('output_file_path'), result: text('result'), pendingQuestions: text('pending_questions'), diff --git a/apps/server/dispatch/manager.test.ts b/apps/server/dispatch/manager.test.ts index c6558ad..cb0f4e6 100644 --- a/apps/server/dispatch/manager.test.ts +++ b/apps/server/dispatch/manager.test.ts @@ -71,6 +71,7 @@ function createMockAgentManager( createdAt: new Date(), updatedAt: new Date(), exitCode: null, + prompt: null, }; mockAgents.push(newAgent); return newAgent; @@ -103,6 +104,7 @@ function createIdleAgent(id: string, name: string): AgentInfo { createdAt: new Date(), updatedAt: new Date(), exitCode: null, + prompt: null, }; } diff --git a/apps/server/drizzle/0031_icy_silvermane.sql b/apps/server/drizzle/0031_icy_silvermane.sql new file mode 100644 index 0000000..43dbeed --- /dev/null +++ b/apps/server/drizzle/0031_icy_silvermane.sql @@ -0,0 +1 @@ +ALTER TABLE `agents` ADD `prompt` text; diff --git a/apps/server/drizzle/meta/0031_snapshot.json b/apps/server/drizzle/meta/0031_snapshot.json new file mode 100644 index 0000000..f60484b --- /dev/null +++ b/apps/server/drizzle/meta/0031_snapshot.json @@ -0,0 +1,1159 @@ +{ + "id": "f85b9df3-dead-4c46-90ac-cf36bcaa6eb4", + "prevId": "c0b6d7d3-c9da-440a-9fb8-9dd88df5672a", + "version": "6", + "dialect": "sqlite", + "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_dir": { + "name": "config_dir", + "type": "text", + "primaryKey": false, + "notNull": true, + "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": {} + }, + "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 + }, + "prompt": { + "name": "prompt", + "type": "text", + "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 + } + }, + "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", + "columnsFrom": [ + "task_id" + ], + "tableTo": "tasks", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "agents_initiative_id_initiatives_id_fk": { + "name": "agents_initiative_id_initiatives_id_fk", + "tableFrom": "agents", + "columnsFrom": [ + "initiative_id" + ], + "tableTo": "initiatives", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "agents_account_id_accounts_id_fk": { + "name": "agents_account_id_accounts_id_fk", + "tableFrom": "agents", + "columnsFrom": [ + "account_id" + ], + "tableTo": "accounts", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "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", + "columnsFrom": [ + "initiative_id" + ], + "tableTo": "initiatives", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "initiative_projects_project_id_projects_id_fk": { + "name": "initiative_projects_project_id_projects_id_fk", + "tableFrom": "initiative_projects", + "columnsFrom": [ + "project_id" + ], + "tableTo": "projects", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "merge_requires_approval": { + "name": "merge_requires_approval", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "merge_target": { + "name": "merge_target", + "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": {}, + "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", + "columnsFrom": [ + "sender_id" + ], + "tableTo": "agents", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "messages_recipient_id_agents_id_fk": { + "name": "messages_recipient_id_agents_id_fk", + "tableFrom": "messages", + "columnsFrom": [ + "recipient_id" + ], + "tableTo": "agents", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "messages_parent_message_id_messages_id_fk": { + "name": "messages_parent_message_id_messages_id_fk", + "tableFrom": "messages", + "columnsFrom": [ + "parent_message_id" + ], + "tableTo": "messages", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "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", + "columnsFrom": [ + "initiative_id" + ], + "tableTo": "initiatives", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "pages_parent_page_id_pages_id_fk": { + "name": "pages_parent_page_id_pages_id_fk", + "tableFrom": "pages", + "columnsFrom": [ + "parent_page_id" + ], + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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", + "columnsFrom": [ + "phase_id" + ], + "tableTo": "phases", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "phase_dependencies_depends_on_phase_id_phases_id_fk": { + "name": "phase_dependencies_depends_on_phase_id_phases_id_fk", + "tableFrom": "phase_dependencies", + "columnsFrom": [ + "depends_on_phase_id" + ], + "tableTo": "phases", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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 + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "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": {}, + "foreignKeys": { + "phases_initiative_id_initiatives_id_fk": { + "name": "phases_initiative_id_initiatives_id_fk", + "tableFrom": "phases", + "columnsFrom": [ + "initiative_id" + ], + "tableTo": "initiatives", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plans": { + "name": "plans", + "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 + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "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": {}, + "foreignKeys": { + "plans_phase_id_phases_id_fk": { + "name": "plans_phase_id_phases_id_fk", + "tableFrom": "plans", + "columnsFrom": [ + "phase_id" + ], + "tableTo": "phases", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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 + }, + "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": {} + }, + "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", + "columnsFrom": [ + "task_id" + ], + "tableTo": "tasks", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "task_dependencies_depends_on_task_id_tasks_id_fk": { + "name": "task_dependencies_depends_on_task_id_tasks_id_fk", + "tableFrom": "task_dependencies", + "columnsFrom": [ + "depends_on_task_id" + ], + "tableTo": "tasks", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "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 + }, + "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'" + }, + "requires_approval": { + "name": "requires_approval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "order": { + "name": "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": { + "tasks_plan_id_plans_id_fk": { + "name": "tasks_plan_id_plans_id_fk", + "tableFrom": "tasks", + "columnsFrom": [ + "plan_id" + ], + "tableTo": "plans", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "tasks_phase_id_phases_id_fk": { + "name": "tasks_phase_id_phases_id_fk", + "tableFrom": "tasks", + "columnsFrom": [ + "phase_id" + ], + "tableTo": "phases", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "tasks_initiative_id_initiatives_id_fk": { + "name": "tasks_initiative_id_initiatives_id_fk", + "tableFrom": "tasks", + "columnsFrom": [ + "initiative_id" + ], + "tableTo": "initiatives", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + }, + "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 ac6687d..e4b74fa 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -218,6 +218,13 @@ "when": 1772150400000, "tag": "0030_remove_task_approval", "breakpoints": true + }, + { + "idx": 31, + "version": "6", + "when": 1772798869413, + "tag": "0031_icy_silvermane", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/server/test/integration/crash-race-condition.test.ts b/apps/server/test/integration/crash-race-condition.test.ts index f7ce25f..4af02a1 100644 --- a/apps/server/test/integration/crash-race-condition.test.ts +++ b/apps/server/test/integration/crash-race-condition.test.ts @@ -32,6 +32,7 @@ interface TestAgent { initiativeId: string | null; userDismissedAt: Date | null; exitCode: number | null; + prompt: string | null; } describe('Crash marking race condition', () => { @@ -72,7 +73,8 @@ describe('Crash marking race condition', () => { pendingQuestions: null, initiativeId: 'init-1', userDismissedAt: null, - exitCode: null + exitCode: null, + prompt: null, }; // Mock repository that tracks all update calls diff --git a/apps/server/trpc/routers/agent.test.ts b/apps/server/trpc/routers/agent.test.ts index 49de47e..21bcc6d 100644 --- a/apps/server/trpc/routers/agent.test.ts +++ b/apps/server/trpc/routers/agent.test.ts @@ -50,6 +50,7 @@ function makeAgentInfo(overrides: Record = {}) { updatedAt: new Date('2026-01-01T00:00:00Z'), userDismissedAt: null, exitCode: null, + prompt: null, ...overrides, }; } @@ -273,7 +274,7 @@ describe('getAgentPrompt', () => { await fs.writeFile(path.join(promptDir, 'PROMPT.md'), promptContent); const mockManager = { - get: vi.fn().mockResolvedValue(makeAgentInfo({ name: agentName })), + get: vi.fn().mockResolvedValue(makeAgentInfo({ name: agentName, prompt: null })), }; const ctx = createTestContext({ @@ -285,4 +286,42 @@ describe('getAgentPrompt', () => { expect(result).toEqual({ content: promptContent }); }); + + it('returns prompt from DB when agent.prompt is set (no file needed)', async () => { + const dbPromptContent = '# DB Prompt\nThis is persisted in the database'; + const mockManager = { + get: vi.fn().mockResolvedValue(makeAgentInfo({ name: 'test-agent', prompt: dbPromptContent })), + }; + + // workspaceRoot has no PROMPT.md — but DB value takes precedence + const ctx = createTestContext({ + agentManager: mockManager as any, + workspaceRoot: tmpDir, + }); + const caller = createCaller(ctx); + const result = await caller.getAgentPrompt({ id: 'agent-1' }); + + expect(result).toEqual({ content: dbPromptContent }); + }); + + it('falls back to PROMPT.md when agent.prompt is null in DB', async () => { + const agentName = 'test-agent'; + const promptDir = path.join(tmpDir, '.cw', 'agent-logs', agentName); + await fs.mkdir(promptDir, { recursive: true }); + const fileContent = '# File Prompt\nThis is from the file (legacy)'; + await fs.writeFile(path.join(promptDir, 'PROMPT.md'), fileContent); + + const mockManager = { + get: vi.fn().mockResolvedValue(makeAgentInfo({ name: agentName, prompt: null })), + }; + + const ctx = createTestContext({ + agentManager: mockManager as any, + workspaceRoot: tmpDir, + }); + const caller = createCaller(ctx); + const result = await caller.getAgentPrompt({ id: 'agent-1' }); + + expect(result).toEqual({ content: fileContent }); + }); }); diff --git a/apps/server/trpc/routers/agent.ts b/apps/server/trpc/routers/agent.ts index 53b42fc..8c2f2fe 100644 --- a/apps/server/trpc/routers/agent.ts +++ b/apps/server/trpc/routers/agent.ts @@ -342,6 +342,22 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) { .query(async ({ ctx, input }) => { const agent = await resolveAgent(ctx, { id: input.id }); + const MAX_BYTES = 1024 * 1024; // 1 MB + + function truncateIfNeeded(text: string): string { + if (Buffer.byteLength(text, 'utf-8') > MAX_BYTES) { + const buf = Buffer.from(text, 'utf-8'); + return buf.slice(0, MAX_BYTES).toString('utf-8') + '\n\n[truncated — prompt exceeds 1 MB]'; + } + return text; + } + + // Prefer DB-persisted prompt (durable even after log file cleanup) + if (agent.prompt !== null) { + return { content: truncateIfNeeded(agent.prompt) }; + } + + // Fall back to filesystem for agents spawned before DB persistence was added const promptPath = path.join(ctx.workspaceRoot!, '.cw', 'agent-logs', agent.name, 'PROMPT.md'); let raw: string; @@ -357,13 +373,7 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) { }); } - const MAX_BYTES = 1024 * 1024; // 1 MB - if (Buffer.byteLength(raw, 'utf-8') > MAX_BYTES) { - const buf = Buffer.from(raw, 'utf-8'); - raw = buf.slice(0, MAX_BYTES).toString('utf-8') + '\n\n[truncated — prompt exceeds 1 MB]'; - } - - return { content: raw }; + return { content: truncateIfNeeded(raw) }; }), }; } diff --git a/docs/database.md b/docs/database.md index a32cac5..1c683b7 100644 --- a/docs/database.md +++ b/docs/database.md @@ -70,6 +70,7 @@ All adapters use nanoid() for IDs, auto-manage timestamps, and use Drizzle's `.r | mode | text enum | 'execute' \| 'discuss' \| 'plan' \| 'detail' \| 'refine' | | pid | integer nullable | OS process ID | | exitCode | integer nullable | | +| prompt | text nullable | Full assembled prompt passed to agent at spawn; persisted for durability after log cleanup | | outputFilePath | text nullable | | | result | text nullable | JSON | | pendingQuestions | text nullable | JSON | diff --git a/docs/server-api.md b/docs/server-api.md index dc22e20..929c748 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -64,7 +64,7 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE | getAgentQuestions | query | Pending questions | | getAgentOutput | query | Full output from DB log chunks | | getAgentInputFiles | query | Files written to agent's `.cw/input/` dir (text only, sorted, 500 KB cap) | -| getAgentPrompt | query | Content of `.cw/agent-logs//PROMPT.md` (1 MB cap) | +| getAgentPrompt | query | Assembled prompt — reads from DB (`agents.prompt`) first; falls back to `.cw/agent-logs//PROMPT.md` for pre-persistence agents (1 MB cap) | | getActiveRefineAgent | query | Active refine agent for initiative | | listWaitingAgents | query | Agents waiting for input | | onAgentOutput | subscription | Live raw JSONL output stream via EventBus | From 4ee71d45f42a465ebd42ded0eab4175ab1c0a9a1 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 13:17:32 +0100 Subject: [PATCH 19/84] test: Add regression tests for agent:account_switched emission in lifecycle controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds controller.test.ts with three test cases asserting that handleAccountExhaustion correctly emits agent:account_switched with previousAccountId, newAccountId, and reason fields — and that the emit is skipped when no new account is available or the agent has no accountId. Co-Authored-By: Claude Sonnet 4.6 --- .../server/agent/lifecycle/controller.test.ts | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 apps/server/agent/lifecycle/controller.test.ts diff --git a/apps/server/agent/lifecycle/controller.test.ts b/apps/server/agent/lifecycle/controller.test.ts new file mode 100644 index 0000000..de7462b --- /dev/null +++ b/apps/server/agent/lifecycle/controller.test.ts @@ -0,0 +1,154 @@ +/** + * 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, + 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(); + }); + }); +}); From d52317ac5de860b82879195751977c1277cccc36 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 13:18:42 +0100 Subject: [PATCH 20/84] feat: Add timestamps to agent logs and fix horizontal scroll - getAgentOutput now returns timestamped chunks ({ content, createdAt }[]) instead of a flat string, preserving DB chunk timestamps - parseAgentOutput accepts TimestampedChunk[] and propagates timestamps to each ParsedMessage - AgentOutputViewer displays HH:MM:SS timestamps on text, tool_call, system, and session_end messages - Live subscription chunks get client-side Date.now() timestamps - Fix horizontal scroll: overflow-x-hidden + break-words on content areas - AgentLogsTab polls getTaskAgent every 5s until an agent is found, then stops polling for live subscription to take over - Fix slide-over layout: details tab scrolls independently, logs tab fills remaining flex space for proper AgentOutputViewer sizing --- apps/server/trpc/routers/agent.ts | 7 +- apps/web/src/components/AgentOutputViewer.tsx | 62 ++++-- .../components/execution/TaskSlideOver.tsx | 6 +- apps/web/src/lib/parse-agent-output.ts | 200 ++++++++++-------- docs/agent.md | 7 +- docs/server-api.md | 3 +- 6 files changed, 170 insertions(+), 115 deletions(-) diff --git a/apps/server/trpc/routers/agent.ts b/apps/server/trpc/routers/agent.ts index b547b80..d2f1461 100644 --- a/apps/server/trpc/routers/agent.ts +++ b/apps/server/trpc/routers/agent.ts @@ -218,12 +218,15 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) { getAgentOutput: publicProcedure .input(agentIdentifierSchema) - .query(async ({ ctx, input }): Promise => { + .query(async ({ ctx, input }) => { const agent = await resolveAgent(ctx, input); const logChunkRepo = requireLogChunkRepository(ctx); const chunks = await logChunkRepo.findByAgentId(agent.id); - return chunks.map(c => c.content).join(''); + return chunks.map(c => ({ + content: c.content, + createdAt: c.createdAt.toISOString(), + })); }), onAgentOutput: publicProcedure diff --git a/apps/web/src/components/AgentOutputViewer.tsx b/apps/web/src/components/AgentOutputViewer.tsx index 3eaaeb3..48663ed 100644 --- a/apps/web/src/components/AgentOutputViewer.tsx +++ b/apps/web/src/components/AgentOutputViewer.tsx @@ -6,6 +6,7 @@ import { trpc } from "@/lib/trpc"; import { useSubscriptionWithErrorHandling } from "@/hooks"; import { type ParsedMessage, + type TimestampedChunk, getMessageStyling, parseAgentOutput, } from "@/lib/parse-agent-output"; @@ -21,8 +22,8 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO const [messages, setMessages] = useState([]); const [follow, setFollow] = useState(true); const containerRef = useRef(null); - // Accumulate raw JSONL: initial query data + live subscription chunks - const rawBufferRef = useRef(''); + // Accumulate timestamped chunks: initial query data + live subscription chunks + const chunksRef = useRef([]); // Load initial/historical output const outputQuery = trpc.getAgentOutput.useQuery( @@ -40,8 +41,8 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO // TrackedEnvelope shape: { id, data: { agentId, data: string } } const raw = event?.data?.data ?? event?.data; const chunk = typeof raw === 'string' ? raw : JSON.stringify(raw); - rawBufferRef.current += chunk; - setMessages(parseAgentOutput(rawBufferRef.current)); + chunksRef.current = [...chunksRef.current, { content: chunk, createdAt: new Date().toISOString() }]; + setMessages(parseAgentOutput(chunksRef.current)); }, onError: (error) => { console.error('Agent output subscription error:', error); @@ -54,14 +55,14 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO // Set initial output when query loads useEffect(() => { if (outputQuery.data) { - rawBufferRef.current = outputQuery.data; + chunksRef.current = outputQuery.data; setMessages(parseAgentOutput(outputQuery.data)); } }, [outputQuery.data]); // Reset output when agent changes useEffect(() => { - rawBufferRef.current = ''; + chunksRef.current = []; setMessages([]); setFollow(true); }, [agentId]); @@ -160,57 +161,64 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
{isLoading ? (
Loading output...
) : !hasOutput ? (
No output yet...
) : ( -
+
{messages.map((message, index) => (
{message.type === 'system' && (
System {message.content} +
)} {message.type === 'text' && ( -
- {message.content} -
+ <> + +
+ {message.content} +
+ )} {message.type === 'tool_call' && ( -
- - {message.meta?.toolName} - -
+
+
+ + {message.meta?.toolName} + + +
+
{message.content}
)} {message.type === 'tool_result' && ( -
+
Result -
+
{message.content}
)} {message.type === 'error' && ( -
+
Error -
+
{message.content}
@@ -228,6 +236,7 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO {message.meta?.duration && ( {(message.meta.duration / 1000).toFixed(1)}s )} +
)} @@ -239,3 +248,16 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
); } + +function formatTime(date: Date): string { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); +} + +function Timestamp({ date }: { date?: Date }) { + if (!date) return null; + return ( + + {formatTime(date)} + + ); +} diff --git a/apps/web/src/components/execution/TaskSlideOver.tsx b/apps/web/src/components/execution/TaskSlideOver.tsx index 0885782..1df8f13 100644 --- a/apps/web/src/components/execution/TaskSlideOver.tsx +++ b/apps/web/src/components/execution/TaskSlideOver.tsx @@ -184,7 +184,7 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) {
{/* Content */} -
+
{tab === "details" ? (
{/* Metadata grid */} @@ -337,7 +337,7 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) { function AgentLogsTab({ taskId }: { taskId: string }) { const { data: agent, isLoading } = trpc.getTaskAgent.useQuery( { taskId }, - { refetchOnWindowFocus: false }, + { refetchOnWindowFocus: false, refetchInterval: (query) => query.state.data ? false : 5000 }, ); if (isLoading) { @@ -357,7 +357,7 @@ function AgentLogsTab({ taskId }: { taskId: string }) { } return ( -
+
({ content: c.content, timestamp: new Date(c.createdAt) })); + const parsedMessages: ParsedMessage[] = []; - for (const line of lines) { - try { - const event = JSON.parse(line); + for (const chunk of chunks) { + const lines = chunk.content.split("\n").filter(Boolean); + for (const line of lines) { + try { + const event = JSON.parse(line); - // System initialization - if (event.type === "system" && event.session_id) { - parsedMessages.push({ - type: "system", - content: `Session started: ${event.session_id}`, - }); - } - - // Assistant messages with text and tool calls - else if ( - event.type === "assistant" && - Array.isArray(event.message?.content) - ) { - for (const block of event.message.content) { - if (block.type === "text" && block.text) { - parsedMessages.push({ - type: "text", - content: block.text, - }); - } else if (block.type === "tool_use") { - parsedMessages.push({ - type: "tool_call", - content: formatToolCall(block), - meta: { toolName: block.name }, - }); - } + // System initialization + if (event.type === "system" && event.session_id) { + parsedMessages.push({ + type: "system", + content: `Session started: ${event.session_id}`, + timestamp: chunk.timestamp, + }); } - } - // User messages with tool results - else if ( - event.type === "user" && - Array.isArray(event.message?.content) - ) { - for (const block of event.message.content) { - if (block.type === "tool_result") { - const rawContent = block.content; - const output = - typeof rawContent === "string" - ? rawContent - : Array.isArray(rawContent) - ? rawContent - .map((c: any) => c.text ?? JSON.stringify(c)) - .join("\n") - : (event.tool_use_result?.stdout || ""); - const stderr = event.tool_use_result?.stderr; - - if (stderr) { + // Assistant messages with text and tool calls + else if ( + event.type === "assistant" && + Array.isArray(event.message?.content) + ) { + for (const block of event.message.content) { + if (block.type === "text" && block.text) { parsedMessages.push({ - type: "error", - content: stderr, - meta: { isError: true }, + type: "text", + content: block.text, + timestamp: chunk.timestamp, }); - } else if (output) { - const displayOutput = - output.length > 1000 - ? output.substring(0, 1000) + "\n... (truncated)" - : output; + } else if (block.type === "tool_use") { parsedMessages.push({ - type: "tool_result", - content: displayOutput, + type: "tool_call", + content: formatToolCall(block), + timestamp: chunk.timestamp, + meta: { toolName: block.name }, }); } } } - } - // Legacy streaming format - else if (event.type === "stream_event" && event.event?.delta?.text) { + // User messages with tool results + else if ( + event.type === "user" && + Array.isArray(event.message?.content) + ) { + for (const block of event.message.content) { + if (block.type === "tool_result") { + const rawContent = block.content; + const output = + typeof rawContent === "string" + ? rawContent + : Array.isArray(rawContent) + ? rawContent + .map((c: any) => c.text ?? JSON.stringify(c)) + .join("\n") + : (event.tool_use_result?.stdout || ""); + const stderr = event.tool_use_result?.stderr; + + if (stderr) { + parsedMessages.push({ + type: "error", + content: stderr, + timestamp: chunk.timestamp, + meta: { isError: true }, + }); + } else if (output) { + const displayOutput = + output.length > 1000 + ? output.substring(0, 1000) + "\n... (truncated)" + : output; + parsedMessages.push({ + type: "tool_result", + content: displayOutput, + timestamp: chunk.timestamp, + }); + } + } + } + } + + // Legacy streaming format + else if (event.type === "stream_event" && event.event?.delta?.text) { + parsedMessages.push({ + type: "text", + content: event.event.delta.text, + timestamp: chunk.timestamp, + }); + } + + // Session completion + else if (event.type === "result") { + parsedMessages.push({ + type: "session_end", + content: event.is_error ? "Session failed" : "Session completed", + timestamp: chunk.timestamp, + meta: { + isError: event.is_error, + cost: event.total_cost_usd, + duration: event.duration_ms, + }, + }); + } + } catch { + // Not JSON, display as-is parsedMessages.push({ - type: "text", - content: event.event.delta.text, + type: "error", + content: line, + timestamp: chunk.timestamp, + meta: { isError: true }, }); } - - // Session completion - else if (event.type === "result") { - parsedMessages.push({ - type: "session_end", - content: event.is_error ? "Session failed" : "Session completed", - meta: { - isError: event.is_error, - cost: event.total_cost_usd, - duration: event.duration_ms, - }, - }); - } - } catch { - // Not JSON, display as-is - parsedMessages.push({ - type: "error", - content: line, - meta: { isError: true }, - }); } } return parsedMessages; diff --git a/docs/agent.md b/docs/agent.md index 7083585..bd692b9 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -153,9 +153,10 @@ Agent output is persisted to `agent_log_chunks` table and drives all live stream - DB insert → `agent:output` event emission (single source of truth for UI) - No FK to agents — survives agent deletion - Session tracking: spawn=1, resume=previousMax+1 -- Read path (`getAgentOutput` tRPC): concatenates all DB chunks (no file fallback) -- Live path (`onAgentOutput` subscription): listens for `agent:output` events -- Frontend: initial query loads from DB, subscription accumulates raw JSONL, both parsed via `parseAgentOutput()` +- Read path (`getAgentOutput` tRPC): returns timestamped chunks `{ content, createdAt }[]` from DB +- Live path (`onAgentOutput` subscription): listens for `agent:output` events (client stamps with `Date.now()`) +- Frontend: initial query loads timestamped chunks, subscription accumulates live chunks, both parsed via `parseAgentOutput()` which accepts `TimestampedChunk[]` +- Timestamps displayed inline (HH:MM:SS) on text, tool_call, system, and session_end messages ## Inter-Agent Communication diff --git a/docs/server-api.md b/docs/server-api.md index ec11000..5921317 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -62,7 +62,8 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE | getAgent | query | Single agent by name or ID | | getAgentResult | query | Execution result | | getAgentQuestions | query | Pending questions | -| getAgentOutput | query | Full output from DB log chunks | +| getAgentOutput | query | Timestamped log chunks from DB (`{ content, createdAt }[]`) | +| getTaskAgent | query | Most recent agent assigned to a task (by taskId) | | getActiveRefineAgent | query | Active refine agent for initiative | | getActiveConflictAgent | query | Active conflict resolution agent for initiative (name starts with `conflict-`) | | listWaitingAgents | query | Agents waiting for input | From 89282d33b35679afd59b19b03bdf2e3e63efbb59 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 13:21:55 +0100 Subject: [PATCH 21/84] feat: Add AddAccountDialog component for account management UI Two-tab dialog (setup token + credentials JSON) with full validation, error handling, provider select with fallback, and state reset on open. Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/components/AddAccountDialog.tsx | 289 +++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 apps/web/src/components/AddAccountDialog.tsx diff --git a/apps/web/src/components/AddAccountDialog.tsx b/apps/web/src/components/AddAccountDialog.tsx new file mode 100644 index 0000000..b9928f4 --- /dev/null +++ b/apps/web/src/components/AddAccountDialog.tsx @@ -0,0 +1,289 @@ +import { useState, useEffect } from 'react' +import { Loader2 } from 'lucide-react' +import { toast } from 'sonner' +import { trpc } from '@/lib/trpc' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } 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 { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select' + +interface AddAccountDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +export function AddAccountDialog({ open, onOpenChange }: AddAccountDialogProps) { + const [tab, setTab] = useState<'token' | 'credentials'>('token') + + // Tab A — token + const [email, setEmail] = useState('') + const [token, setToken] = useState('') + const [provider, setProvider] = useState('claude') + + // Tab B — credentials JSON + const [credEmail, setCredEmail] = useState('') + const [credProvider, setCredProvider] = useState('claude') + const [configJsonText, setConfigJsonText] = useState('') + const [credentialsText, setCredentialsText] = useState('') + + // Validation errors + const [emailError, setEmailError] = useState('') + const [tokenError, setTokenError] = useState('') + const [credEmailError, setCredEmailError] = useState('') + const [configJsonError, setConfigJsonError] = useState('') + const [credentialsError, setCredentialsError] = useState('') + const [serverError, setServerError] = useState('') + + const utils = trpc.useUtils() + const providersQuery = trpc.listProviderNames.useQuery() + + const addByToken = trpc.addAccountByToken.useMutation({ + onSuccess: (data) => { + const msg = data.upserted + ? `Account updated — credentials refreshed for ${email}.` + : `Account added: ${email}.` + toast.success(msg) + void utils.systemHealthCheck.invalidate() + onOpenChange(false) + }, + onError: (err) => { + setServerError(err.message) + }, + }) + + const addAccount = trpc.addAccount.useMutation({ + onSuccess: () => { + toast.success(`Account added: ${credEmail}.`) + void utils.systemHealthCheck.invalidate() + onOpenChange(false) + }, + onError: (err) => { + if (err.message.toLowerCase().includes('already exists')) { + setCredEmailError( + "An account with this email already exists. Use 'Update credentials' on the existing account instead." + ) + } else { + setServerError(err.message) + } + }, + }) + + useEffect(() => { + if (open) { + setTab('token') + setEmail(''); setToken(''); setProvider('claude') + setCredEmail(''); setCredProvider('claude') + setConfigJsonText(''); setCredentialsText('') + setEmailError(''); setTokenError('') + setCredEmailError(''); setConfigJsonError(''); setCredentialsError('') + setServerError('') + } + }, [open]) + + function handleSubmit() { + setEmailError(''); setTokenError(''); setCredEmailError('') + setConfigJsonError(''); setCredentialsError(''); setServerError('') + + if (tab === 'token') { + let hasError = false + if (email.trim() === '') { + setEmailError('Required') + hasError = true + } else if (!EMAIL_REGEX.test(email)) { + setEmailError('Enter a valid email address') + hasError = true + } + if (token.trim() === '') { + setTokenError('Required') + hasError = true + } + if (hasError) return + addByToken.mutate({ email, token, provider }) + } else { + let hasError = false + if (credEmail.trim() === '') { + setCredEmailError('Required') + hasError = true + } else if (!EMAIL_REGEX.test(credEmail)) { + setCredEmailError('Enter a valid email address') + hasError = true + } + if (configJsonText.trim() !== '') { + try { + JSON.parse(configJsonText) + } catch { + setConfigJsonError('Invalid JSON') + hasError = true + } + } + if (credentialsText.trim() !== '') { + try { + JSON.parse(credentialsText) + } catch { + setCredentialsError('Invalid JSON') + hasError = true + } + } + if (hasError) return + addAccount.mutate({ + email: credEmail, + provider: credProvider, + configJson: configJsonText.trim() || undefined, + credentials: credentialsText.trim() || undefined, + }) + } + } + + const isPending = tab === 'token' ? addByToken.isPending : addAccount.isPending + + function renderProviderSelect(value: string, onChange: (v: string) => void) { + if (providersQuery.isError) { + return onChange(e.target.value)} /> + } + return ( + + ) + } + + return ( + + + + Add Account + + +
+ + +
+ + {tab === 'token' ? ( +
+
+ + setEmail(e.target.value)} + placeholder="user@example.com" + /> + {emailError &&

{emailError}

} +
+
+ + setToken(e.target.value)} + /> + {tokenError &&

{tokenError}

} + {serverError &&

{serverError}

} +
+
+ + {renderProviderSelect(provider, setProvider)} +
+
+ ) : ( +
+
+ + setCredEmail(e.target.value)} + placeholder="user@example.com" + /> + {credEmailError &&

{credEmailError}

} +
+
+ + {renderProviderSelect(credProvider, setCredProvider)} +
+
+ +