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 |