Merge branch 'cw/agent-details' into cw-merge-1772802959182
This commit is contained in:
@@ -18,6 +18,7 @@ export interface AgentInfo {
|
|||||||
status: string;
|
status: string;
|
||||||
initiativeId?: string | null;
|
initiativeId?: string | null;
|
||||||
worktreeId: string;
|
worktreeId: string;
|
||||||
|
exitCode?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CleanupStrategy {
|
export interface CleanupStrategy {
|
||||||
|
|||||||
@@ -374,6 +374,7 @@ export class AgentLifecycleController {
|
|||||||
status: agent.status,
|
status: agent.status,
|
||||||
initiativeId: agent.initiativeId,
|
initiativeId: agent.initiativeId,
|
||||||
worktreeId: agent.worktreeId,
|
worktreeId: agent.worktreeId,
|
||||||
|
exitCode: agent.exitCode ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -347,7 +347,7 @@ export class MultiProviderAgentManager implements AgentManager {
|
|||||||
this.createLogChunkCallback(agentId, alias, 1),
|
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
|
// Register agent and start polling BEFORE non-critical I/O so that a
|
||||||
// diagnostic-write failure can never orphan a running process.
|
// diagnostic-write failure can never orphan a running process.
|
||||||
@@ -1182,6 +1182,8 @@ export class MultiProviderAgentManager implements AgentManager {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
userDismissedAt?: Date | null;
|
userDismissedAt?: Date | null;
|
||||||
|
exitCode?: number | null;
|
||||||
|
prompt?: string | null;
|
||||||
}): AgentInfo {
|
}): AgentInfo {
|
||||||
return {
|
return {
|
||||||
id: agent.id,
|
id: agent.id,
|
||||||
@@ -1197,6 +1199,8 @@ export class MultiProviderAgentManager implements AgentManager {
|
|||||||
createdAt: agent.createdAt,
|
createdAt: agent.createdAt,
|
||||||
updatedAt: agent.updatedAt,
|
updatedAt: agent.updatedAt,
|
||||||
userDismissedAt: agent.userDismissedAt,
|
userDismissedAt: agent.userDismissedAt,
|
||||||
|
exitCode: agent.exitCode ?? null,
|
||||||
|
prompt: agent.prompt ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,6 +142,8 @@ export class MockAgentManager implements AgentManager {
|
|||||||
accountId: null,
|
accountId: null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
exitCode: null,
|
||||||
|
prompt: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const record: MockAgentRecord = {
|
const record: MockAgentRecord = {
|
||||||
|
|||||||
@@ -95,6 +95,10 @@ export interface AgentInfo {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
/** When the user dismissed this agent (null if not dismissed) */
|
/** When the user dismissed this agent (null if not dismissed) */
|
||||||
userDismissedAt?: Date | null;
|
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;
|
accountId?: string | null;
|
||||||
pid?: number | null;
|
pid?: number | null;
|
||||||
exitCode?: number | null;
|
exitCode?: number | null;
|
||||||
|
prompt?: string | null;
|
||||||
outputFilePath?: string | null;
|
outputFilePath?: string | null;
|
||||||
result?: string | null;
|
result?: string | null;
|
||||||
pendingQuestions?: string | null;
|
pendingQuestions?: string | null;
|
||||||
|
|||||||
@@ -267,6 +267,7 @@ export const agents = sqliteTable('agents', {
|
|||||||
.default('execute'),
|
.default('execute'),
|
||||||
pid: integer('pid'),
|
pid: integer('pid'),
|
||||||
exitCode: integer('exit_code'), // Process exit code for debugging crashes
|
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'),
|
outputFilePath: text('output_file_path'),
|
||||||
result: text('result'),
|
result: text('result'),
|
||||||
pendingQuestions: text('pending_questions'),
|
pendingQuestions: text('pending_questions'),
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ function createMockAgentManager(
|
|||||||
accountId: null,
|
accountId: null,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
|
exitCode: null,
|
||||||
|
prompt: null,
|
||||||
};
|
};
|
||||||
mockAgents.push(newAgent);
|
mockAgents.push(newAgent);
|
||||||
return newAgent;
|
return newAgent;
|
||||||
@@ -101,6 +103,8 @@ function createIdleAgent(id: string, name: string): AgentInfo {
|
|||||||
accountId: null,
|
accountId: null,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: 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,
|
"when": 1772796561474,
|
||||||
"tag": "0035_faulty_human_fly",
|
"tag": "0035_faulty_human_fly",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 36,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1772798869413,
|
||||||
|
"tag": "0036_icy_silvermane",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ interface TestAgent {
|
|||||||
initiativeId: string | null;
|
initiativeId: string | null;
|
||||||
userDismissedAt: Date | null;
|
userDismissedAt: Date | null;
|
||||||
exitCode: number | null;
|
exitCode: number | null;
|
||||||
|
prompt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Crash marking race condition', () => {
|
describe('Crash marking race condition', () => {
|
||||||
@@ -72,7 +73,8 @@ describe('Crash marking race condition', () => {
|
|||||||
pendingQuestions: null,
|
pendingQuestions: null,
|
||||||
initiativeId: 'init-1',
|
initiativeId: 'init-1',
|
||||||
userDismissedAt: null,
|
userDismissedAt: null,
|
||||||
exitCode: null
|
exitCode: null,
|
||||||
|
prompt: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mock repository that tracks all update calls
|
// 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 { TRPCError } from '@trpc/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { tracked, type TrackedEnvelope } from '@trpc/server';
|
import { tracked, type TrackedEnvelope } from '@trpc/server';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs/promises';
|
||||||
import type { ProcedureBuilder } from '../trpc.js';
|
import type { ProcedureBuilder } from '../trpc.js';
|
||||||
import type { TRPCContext } from '../context.js';
|
import type { TRPCContext } from '../context.js';
|
||||||
import type { AgentInfo, AgentResult, PendingQuestions } from '../../agent/types.js';
|
import type { AgentInfo, AgentResult, PendingQuestions } from '../../agent/types.js';
|
||||||
import type { AgentOutputEvent } from '../../events/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({
|
export const spawnAgentInputSchema = z.object({
|
||||||
name: z.string().min(1).optional(),
|
name: z.string().min(1).optional(),
|
||||||
@@ -120,7 +122,23 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
getAgent: publicProcedure
|
getAgent: publicProcedure
|
||||||
.input(agentIdentifierSchema)
|
.input(agentIdentifierSchema)
|
||||||
.query(async ({ ctx, input }) => {
|
.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
|
getAgentByName: publicProcedure
|
||||||
@@ -281,5 +299,116 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
cleanup();
|
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) };
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
230
apps/web/src/components/AgentDetailsPanel.tsx
Normal file
230
apps/web/src/components/AgentDetailsPanel.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="h-full overflow-y-auto p-4 space-y-6">
|
||||||
|
<section>
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">Metadata</h3>
|
||||||
|
<MetadataSection agentId={agentId} />
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">Input Files</h3>
|
||||||
|
<InputFilesSection agentId={agentId} />
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">Effective Prompt</h3>
|
||||||
|
<EffectivePromptSection agentId={agentId} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetadataSection({ agentId }: { agentId: string }) {
|
||||||
|
const query = trpc.getAgent.useQuery({ id: agentId });
|
||||||
|
|
||||||
|
if (query.isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} variant="line" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.isError) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-destructive">{query.error.message}</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => void query.refetch()}>Retry</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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: (
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<StatusDot status={agent.status} size="sm" />
|
||||||
|
{agent.status}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Mode',
|
||||||
|
value: modeLabel(agent.mode),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Provider',
|
||||||
|
value: agent.provider,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Initiative',
|
||||||
|
value: agent.initiativeId ? (
|
||||||
|
<Link
|
||||||
|
to="/initiatives/$initiativeId"
|
||||||
|
params={{ initiativeId: agent.initiativeId }}
|
||||||
|
className="underline underline-offset-2"
|
||||||
|
>
|
||||||
|
{(agent as { initiativeName?: string | null }).initiativeName ?? agent.initiativeId}
|
||||||
|
</Link>
|
||||||
|
) : '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: (
|
||||||
|
<span className={agent.exitCode === 1 ? 'text-destructive' : ''}>
|
||||||
|
{agent.exitCode ?? '—'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{rows.map(({ label, value }) => (
|
||||||
|
<div key={label} className="flex items-center gap-4 py-1.5 border-b border-border/30 last:border-0">
|
||||||
|
<span className="w-28 shrink-0 text-xs text-muted-foreground">{label}</span>
|
||||||
|
<span className="text-sm">{value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputFilesSection({ agentId }: { agentId: string }) {
|
||||||
|
const query = trpc.getAgentInputFiles.useQuery({ id: agentId });
|
||||||
|
const [selectedFile, setSelectedFile] = useState<string | null>(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 (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton variant="line" />
|
||||||
|
<Skeleton variant="line" />
|
||||||
|
<Skeleton variant="line" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.isError) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-destructive">{query.error.message}</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => void query.refetch()}>Retry</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = query.data;
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
if (data.reason === 'worktree_missing') {
|
||||||
|
return <p className="text-sm text-muted-foreground">Worktree no longer exists — input files unavailable</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.reason === 'input_dir_missing') {
|
||||||
|
return <p className="text-sm text-muted-foreground">Input directory not found — this agent may not have received input files</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { files } = data;
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
return <p className="text-sm text-muted-foreground">No input files found</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col md:flex-row gap-2 min-h-0">
|
||||||
|
{/* File list */}
|
||||||
|
<div className="md:w-48 shrink-0 overflow-y-auto space-y-0.5">
|
||||||
|
{files.map(file => (
|
||||||
|
<button
|
||||||
|
key={file.name}
|
||||||
|
onClick={() => setSelectedFile(file.name)}
|
||||||
|
className={cn(
|
||||||
|
"w-full text-left px-2 py-1 text-xs rounded truncate",
|
||||||
|
selectedFile === file.name
|
||||||
|
? "bg-muted font-medium"
|
||||||
|
: "hover:bg-muted/50 text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{file.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Content pane */}
|
||||||
|
<pre className="flex-1 text-xs font-mono overflow-auto bg-terminal rounded p-3 min-h-0">
|
||||||
|
{files.find(f => f.name === selectedFile)?.content ?? ''}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EffectivePromptSection({ agentId }: { agentId: string }) {
|
||||||
|
const query = trpc.getAgentPrompt.useQuery({ id: agentId });
|
||||||
|
|
||||||
|
if (query.isLoading) {
|
||||||
|
return <Skeleton variant="rect" className="h-32 w-full" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.isError) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-destructive">{query.error.message}</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => void query.refetch()}>Retry</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = query.data;
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
if (data.reason === 'prompt_not_written') {
|
||||||
|
return <p className="text-sm text-muted-foreground">Prompt file not available — agent may have been spawned before this feature was added</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.content) {
|
||||||
|
return (
|
||||||
|
<pre className="text-xs font-mono overflow-y-auto max-h-[400px] bg-terminal rounded p-3 whitespace-pre-wrap">
|
||||||
|
{data.content}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { createFileRoute, useNavigate, useSearch } from "@tanstack/react-router";
|
import { createFileRoute, useNavigate, useSearch } from "@tanstack/react-router";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { AlertCircle, RefreshCw, Terminal, Users } from "lucide-react";
|
import { AlertCircle, RefreshCw, Terminal, Users } from "lucide-react";
|
||||||
@@ -9,8 +9,9 @@ import { Skeleton } from "@/components/Skeleton";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { trpc } from "@/lib/trpc";
|
import { trpc } from "@/lib/trpc";
|
||||||
import { AgentOutputViewer } from "@/components/AgentOutputViewer";
|
import { AgentOutputViewer } from "@/components/AgentOutputViewer";
|
||||||
|
import { AgentDetailsPanel } from "@/components/AgentDetailsPanel";
|
||||||
import { AgentActions } from "@/components/AgentActions";
|
import { AgentActions } from "@/components/AgentActions";
|
||||||
import { formatRelativeTime } from "@/lib/utils";
|
import { formatRelativeTime, cn } from "@/lib/utils";
|
||||||
import { modeLabel } from "@/lib/labels";
|
import { modeLabel } from "@/lib/labels";
|
||||||
import { StatusDot } from "@/components/StatusDot";
|
import { StatusDot } from "@/components/StatusDot";
|
||||||
import { useLiveUpdates } from "@/hooks";
|
import { useLiveUpdates } from "@/hooks";
|
||||||
@@ -29,7 +30,12 @@ export const Route = createFileRoute("/agents")({
|
|||||||
|
|
||||||
function AgentsPage() {
|
function AgentsPage() {
|
||||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||||
|
const [activeTab, setActiveTab] = useState<'output' | 'details'>('output');
|
||||||
const { filter } = useSearch({ from: "/agents" });
|
const { filter } = useSearch({ from: "/agents" });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveTab('output');
|
||||||
|
}, [selectedAgentId]);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Live updates
|
// Live updates
|
||||||
@@ -308,15 +314,49 @@ function AgentsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Output Viewer */}
|
{/* Right: Output/Details Viewer */}
|
||||||
<div className="min-h-0 overflow-hidden">
|
<div className="min-h-0 overflow-hidden">
|
||||||
{selectedAgent ? (
|
{selectedAgent ? (
|
||||||
<AgentOutputViewer
|
<div className="flex flex-col min-h-0 h-full">
|
||||||
agentId={selectedAgent.id}
|
{/* Tab bar */}
|
||||||
agentName={selectedAgent.name}
|
<div className="flex shrink-0 border-b border-terminal-border">
|
||||||
status={selectedAgent.status}
|
<button
|
||||||
onStop={handleStop}
|
className={cn(
|
||||||
/>
|
"px-4 py-2 text-sm font-medium",
|
||||||
|
activeTab === 'output'
|
||||||
|
? "border-b-2 border-primary text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
onClick={() => setActiveTab('output')}
|
||||||
|
>
|
||||||
|
Output
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-2 text-sm font-medium",
|
||||||
|
activeTab === 'details'
|
||||||
|
? "border-b-2 border-primary text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
onClick={() => setActiveTab('details')}
|
||||||
|
>
|
||||||
|
Details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Panel content */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
|
{activeTab === 'output' ? (
|
||||||
|
<AgentOutputViewer
|
||||||
|
agentId={selectedAgent.id}
|
||||||
|
agentName={selectedAgent.name}
|
||||||
|
status={selectedAgent.status}
|
||||||
|
onStop={handleStop}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<AgentDetailsPanel agentId={selectedAgent.id} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-3 rounded-lg border border-dashed">
|
<div className="flex h-full flex-col items-center justify-center gap-3 rounded-lg border border-dashed">
|
||||||
<Terminal className="h-10 w-10 text-muted-foreground/30" />
|
<Terminal className="h-10 w-10 text-muted-foreground/30" />
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ All adapters use nanoid() for IDs, auto-manage timestamps, and use Drizzle's `.r
|
|||||||
| mode | text enum | 'execute' \| 'discuss' \| 'plan' \| 'detail' \| 'refine' |
|
| mode | text enum | 'execute' \| 'discuss' \| 'plan' \| 'detail' \| 'refine' |
|
||||||
| pid | integer nullable | OS process ID |
|
| pid | integer nullable | OS process ID |
|
||||||
| exitCode | integer nullable | |
|
| exitCode | integer nullable | |
|
||||||
|
| prompt | text nullable | Full assembled prompt passed to agent at spawn; persisted for durability after log cleanup |
|
||||||
| outputFilePath | text nullable | |
|
| outputFilePath | text nullable | |
|
||||||
| result | text nullable | JSON |
|
| result | text nullable | JSON |
|
||||||
| pendingQuestions | text nullable | JSON |
|
| pendingQuestions | text nullable | JSON |
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ Use `mapEntityStatus(rawStatus)` from `StatusDot.tsx` to convert raw entity stat
|
|||||||
|-------|-----------|---------|
|
|-------|-----------|---------|
|
||||||
| `/` | `routes/index.tsx` | Dashboard / initiative list |
|
| `/` | `routes/index.tsx` | Dashboard / initiative list |
|
||||||
| `/initiatives/$id` | `routes/initiatives/$initiativeId.tsx` | Initiative detail (tabbed) |
|
| `/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 |
|
| `/settings` | `routes/settings/index.tsx` | Settings page |
|
||||||
|
|
||||||
## Initiative Detail Tabs
|
## 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
|
2. **Execution Tab** — Pipeline visualization, phase management, task dispatch
|
||||||
3. **Review Tab** — Pending proposals from agents
|
3. **Review Tab** — Pending proposals from agents
|
||||||
|
|
||||||
## Component Inventory (73 components)
|
## Component Inventory (74 components)
|
||||||
|
|
||||||
### Core Components (`src/components/`)
|
### Core Components (`src/components/`)
|
||||||
| Component | Purpose |
|
| 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 |
|
| `StatusBadge` | Colored badge using status tokens |
|
||||||
| `TaskRow` | Task list item with status, priority, category |
|
| `TaskRow` | Task list item with status, priority, category |
|
||||||
| `QuestionForm` | Agent question form with options |
|
| `QuestionForm` | Agent question form with options |
|
||||||
|
| `AgentDetailsPanel` | Details tab for agent right-panel: metadata, input files, effective prompt |
|
||||||
| `InboxDetailPanel` | Agent message detail + response form |
|
| `InboxDetailPanel` | Agent message detail + response form |
|
||||||
| `ProjectPicker` | Checkbox list for project selection |
|
| `ProjectPicker` | Checkbox list for project selection |
|
||||||
| `RegisterProjectDialog` | Dialog to register new git project |
|
| `RegisterProjectDialog` | Dialog to register new git project |
|
||||||
|
|||||||
@@ -59,11 +59,13 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
|
|||||||
| dismissAgent | mutation | Dismiss agent (set userDismissedAt) |
|
| dismissAgent | mutation | Dismiss agent (set userDismissedAt) |
|
||||||
| resumeAgent | mutation | Resume with answers |
|
| resumeAgent | mutation | Resume with answers |
|
||||||
| listAgents | query | All agents |
|
| 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 |
|
| getAgentResult | query | Execution result |
|
||||||
| getAgentQuestions | query | Pending questions |
|
| getAgentQuestions | query | Pending questions |
|
||||||
| getAgentOutput | query | Timestamped log chunks from DB (`{ content, createdAt }[]`) |
|
| getAgentOutput | query | Timestamped log chunks from DB (`{ content, createdAt }[]`) |
|
||||||
| getTaskAgent | query | Most recent agent assigned to a task (by taskId) |
|
| getTaskAgent | query | Most recent agent assigned to a task (by taskId) |
|
||||||
|
| getAgentInputFiles | query | Files written to agent's `.cw/input/` dir (text only, sorted, 500 KB cap) |
|
||||||
|
| getAgentPrompt | query | Assembled prompt — reads from DB (`agents.prompt`) first; falls back to `.cw/agent-logs/<name>/PROMPT.md` for pre-persistence agents (1 MB cap) |
|
||||||
| getActiveRefineAgent | query | Active refine agent for initiative |
|
| getActiveRefineAgent | query | Active refine agent for initiative |
|
||||||
| getActiveConflictAgent | query | Active conflict resolution agent for initiative (name starts with `conflict-`) |
|
| getActiveConflictAgent | query | Active conflict resolution agent for initiative (name starts with `conflict-`) |
|
||||||
| listWaitingAgents | query | Agents waiting for input |
|
| listWaitingAgents | query | Agents waiting for input |
|
||||||
|
|||||||
Reference in New Issue
Block a user