Merge branch 'cw/agent-details' into cw-merge-1772802959182
This commit is contained in:
@@ -18,6 +18,7 @@ export interface AgentInfo {
|
||||
status: string;
|
||||
initiativeId?: string | null;
|
||||
worktreeId: string;
|
||||
exitCode?: number | null;
|
||||
}
|
||||
|
||||
export interface CleanupStrategy {
|
||||
|
||||
@@ -374,6 +374,7 @@ export class AgentLifecycleController {
|
||||
status: agent.status,
|
||||
initiativeId: agent.initiativeId,
|
||||
worktreeId: agent.worktreeId,
|
||||
exitCode: agent.exitCode ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -347,7 +347,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 });
|
||||
|
||||
// Register agent and start polling BEFORE non-critical I/O so that a
|
||||
// diagnostic-write failure can never orphan a running process.
|
||||
@@ -1182,6 +1182,8 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
userDismissedAt?: Date | null;
|
||||
exitCode?: number | null;
|
||||
prompt?: string | null;
|
||||
}): AgentInfo {
|
||||
return {
|
||||
id: agent.id,
|
||||
@@ -1197,6 +1199,8 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
createdAt: agent.createdAt,
|
||||
updatedAt: agent.updatedAt,
|
||||
userDismissedAt: agent.userDismissedAt,
|
||||
exitCode: agent.exitCode ?? null,
|
||||
prompt: agent.prompt ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +142,8 @@ export class MockAgentManager implements AgentManager {
|
||||
accountId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
exitCode: null,
|
||||
prompt: null,
|
||||
};
|
||||
|
||||
const record: MockAgentRecord = {
|
||||
|
||||
@@ -95,6 +95,10 @@ 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;
|
||||
/** Full assembled prompt passed to the agent process — null for agents spawned before DB persistence */
|
||||
prompt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -267,6 +267,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'),
|
||||
|
||||
@@ -70,6 +70,8 @@ function createMockAgentManager(
|
||||
accountId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
exitCode: null,
|
||||
prompt: null,
|
||||
};
|
||||
mockAgents.push(newAgent);
|
||||
return newAgent;
|
||||
@@ -101,6 +103,8 @@ function createIdleAgent(id: string, name: string): AgentInfo {
|
||||
accountId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
exitCode: null,
|
||||
prompt: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
1
apps/server/drizzle/0036_icy_silvermane.sql
Normal file
1
apps/server/drizzle/0036_icy_silvermane.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `agents` ADD `prompt` text;
|
||||
1159
apps/server/drizzle/meta/0036_snapshot.json
Normal file
1159
apps/server/drizzle/meta/0036_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -253,6 +253,13 @@
|
||||
"when": 1772796561474,
|
||||
"tag": "0035_faulty_human_fly",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 36,
|
||||
"version": "6",
|
||||
"when": 1772798869413,
|
||||
"tag": "0036_icy_silvermane",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
327
apps/server/trpc/routers/agent.test.ts
Normal file
327
apps/server/trpc/routers/agent.test.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* 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> = {}): 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<string, unknown> = {}) {
|
||||
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,
|
||||
prompt: 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, 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: 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 });
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
@@ -281,5 +299,116 @@ 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 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;
|
||||
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)}`,
|
||||
});
|
||||
}
|
||||
|
||||
return { content: truncateIfNeeded(raw) };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user