From 269a2d2616ad87f47c3dde386746c70748845992 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Thu, 5 Mar 2026 21:39:29 +0100 Subject: [PATCH 1/3] 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 b2f4004191b063a908182bfd154c6ee6062a3c3f Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 13:13:01 +0100 Subject: [PATCH 2/3] 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 7088c511a9a5f3e49256fd815b495dd8cbce4884 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 13:21:59 +0100 Subject: [PATCH 3/3] feat: Add Details tab to agent right-panel with metadata, input files, and prompt sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an Output/Details tab bar to the agents page right-panel. The Details tab renders AgentDetailsPanel, which surfaces agent metadata (status, mode, provider, initiative link, task name, exit code), input files with a file-picker UI, and the effective prompt text — all streamed via the new getAgent/getAgentInputFiles/getAgentPrompt tRPC procedures. Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/components/AgentDetailsPanel.tsx | 230 ++++++++++++++++++ apps/web/src/routes/agents.tsx | 58 ++++- docs/frontend.md | 4 +- 3 files changed, 282 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/components/AgentDetailsPanel.tsx diff --git a/apps/web/src/components/AgentDetailsPanel.tsx b/apps/web/src/components/AgentDetailsPanel.tsx new file mode 100644 index 0000000..6086f3d --- /dev/null +++ b/apps/web/src/components/AgentDetailsPanel.tsx @@ -0,0 +1,230 @@ +import { useState, useEffect } from "react"; +import { Link } from "@tanstack/react-router"; +import { trpc } from "@/lib/trpc"; +import { cn } from "@/lib/utils"; +import { Skeleton } from "@/components/Skeleton"; +import { Button } from "@/components/ui/button"; +import { StatusDot } from "@/components/StatusDot"; +import { formatRelativeTime } from "@/lib/utils"; +import { modeLabel } from "@/lib/labels"; + +export function AgentDetailsPanel({ agentId }: { agentId: string }) { + return ( +
+
+

Metadata

+ +
+
+

Input Files

+ +
+
+

Effective Prompt

+ +
+
+ ); +} + +function MetadataSection({ agentId }: { agentId: string }) { + const query = trpc.getAgent.useQuery({ id: agentId }); + + if (query.isLoading) { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ); + } + + if (query.isError) { + return ( +
+

{query.error.message}

+ +
+ ); + } + + const agent = query.data; + if (!agent) return null; + + const showExitCode = !['idle', 'running', 'waiting_for_input'].includes(agent.status); + + const rows: Array<{ label: string; value: React.ReactNode }> = [ + { + label: 'Status', + value: ( + + + {agent.status} + + ), + }, + { + label: 'Mode', + value: modeLabel(agent.mode), + }, + { + label: 'Provider', + value: agent.provider, + }, + { + label: 'Initiative', + value: agent.initiativeId ? ( + + {(agent as { initiativeName?: string | null }).initiativeName ?? agent.initiativeId} + + ) : '—', + }, + { + label: 'Task', + value: (agent as { taskName?: string | null }).taskName ?? (agent.taskId ? agent.taskId : '—'), + }, + { + label: 'Created', + value: formatRelativeTime(String(agent.createdAt)), + }, + ]; + + if (showExitCode) { + rows.push({ + label: 'Exit Code', + value: ( + + {agent.exitCode ?? '—'} + + ), + }); + } + + return ( +
+ {rows.map(({ label, value }) => ( +
+ {label} + {value} +
+ ))} +
+ ); +} + +function InputFilesSection({ agentId }: { agentId: string }) { + const query = trpc.getAgentInputFiles.useQuery({ id: agentId }); + const [selectedFile, setSelectedFile] = useState(null); + + useEffect(() => { + setSelectedFile(null); + }, [agentId]); + + useEffect(() => { + if (!query.data?.files) return; + if (selectedFile !== null) return; + const manifest = query.data.files.find(f => f.name === 'manifest.json'); + setSelectedFile(manifest?.name ?? query.data.files[0]?.name ?? null); + }, [query.data?.files]); + + if (query.isLoading) { + return ( +
+ + + +
+ ); + } + + if (query.isError) { + return ( +
+

{query.error.message}

+ +
+ ); + } + + const data = query.data; + if (!data) return null; + + if (data.reason === 'worktree_missing') { + return

Worktree no longer exists — input files unavailable

; + } + + if (data.reason === 'input_dir_missing') { + return

Input directory not found — this agent may not have received input files

; + } + + const { files } = data; + + if (files.length === 0) { + return

No input files found

; + } + + return ( +
+ {/* File list */} +
+ {files.map(file => ( + + ))} +
+ {/* Content pane */} +
+        {files.find(f => f.name === selectedFile)?.content ?? ''}
+      
+
+ ); +} + +function EffectivePromptSection({ agentId }: { agentId: string }) { + const query = trpc.getAgentPrompt.useQuery({ id: agentId }); + + if (query.isLoading) { + return ; + } + + if (query.isError) { + return ( +
+

{query.error.message}

+ +
+ ); + } + + const data = query.data; + if (!data) return null; + + if (data.reason === 'prompt_not_written') { + return

Prompt file not available — agent may have been spawned before this feature was added

; + } + + if (data.content) { + return ( +
+        {data.content}
+      
+ ); + } + + return null; +} diff --git a/apps/web/src/routes/agents.tsx b/apps/web/src/routes/agents.tsx index 95ff6ce..d03f7a5 100644 --- a/apps/web/src/routes/agents.tsx +++ b/apps/web/src/routes/agents.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { createFileRoute, useNavigate, useSearch } from "@tanstack/react-router"; import { motion } from "motion/react"; import { AlertCircle, RefreshCw, Terminal, Users } from "lucide-react"; @@ -9,8 +9,9 @@ import { Skeleton } from "@/components/Skeleton"; import { toast } from "sonner"; import { trpc } from "@/lib/trpc"; import { AgentOutputViewer } from "@/components/AgentOutputViewer"; +import { AgentDetailsPanel } from "@/components/AgentDetailsPanel"; import { AgentActions } from "@/components/AgentActions"; -import { formatRelativeTime } from "@/lib/utils"; +import { formatRelativeTime, cn } from "@/lib/utils"; import { modeLabel } from "@/lib/labels"; import { StatusDot } from "@/components/StatusDot"; import { useLiveUpdates } from "@/hooks"; @@ -29,7 +30,12 @@ export const Route = createFileRoute("/agents")({ function AgentsPage() { const [selectedAgentId, setSelectedAgentId] = useState(null); + const [activeTab, setActiveTab] = useState<'output' | 'details'>('output'); const { filter } = useSearch({ from: "/agents" }); + + useEffect(() => { + setActiveTab('output'); + }, [selectedAgentId]); const navigate = useNavigate(); // Live updates @@ -308,15 +314,49 @@ function AgentsPage() { )} - {/* Right: Output Viewer */} + {/* Right: Output/Details Viewer */}
{selectedAgent ? ( - +
+ {/* Tab bar */} +
+ + +
+ {/* Panel content */} +
+ {activeTab === 'output' ? ( + + ) : ( + + )} +
+
) : (
diff --git a/docs/frontend.md b/docs/frontend.md index 523bdbf..f538920 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -44,6 +44,7 @@ Use `mapEntityStatus(rawStatus)` from `StatusDot.tsx` to convert raw entity stat |-------|-----------|---------| | `/` | `routes/index.tsx` | Dashboard / initiative list | | `/initiatives/$id` | `routes/initiatives/$initiativeId.tsx` | Initiative detail (tabbed) | +| `/agents` | `routes/agents.tsx` | Agent list with Output / Details tab panel | | `/settings` | `routes/settings/index.tsx` | Settings page | ## Initiative Detail Tabs @@ -54,7 +55,7 @@ The initiative detail page has three tabs managed via local state (not URL param 2. **Execution Tab** — Pipeline visualization, phase management, task dispatch 3. **Review Tab** — Pending proposals from agents -## Component Inventory (73 components) +## Component Inventory (74 components) ### Core Components (`src/components/`) | Component | Purpose | @@ -66,6 +67,7 @@ The initiative detail page has three tabs managed via local state (not URL param | `StatusBadge` | Colored badge using status tokens | | `TaskRow` | Task list item with status, priority, category | | `QuestionForm` | Agent question form with options | +| `AgentDetailsPanel` | Details tab for agent right-panel: metadata, input files, effective prompt | | `InboxDetailPanel` | Agent message detail + response form | | `ProjectPicker` | Checkbox list for project selection | | `RegisterProjectDialog` | Dialog to register new git project |