Merge branch 'main' into cw/continuous-code-quality-conflict-1772832123778
# Conflicts: # apps/server/drizzle/meta/0037_snapshot.json # apps/server/drizzle/meta/_journal.json
This commit is contained in:
@@ -369,6 +369,7 @@ export class MultiProviderAgentManager implements AgentManager {
|
|||||||
agentId, pid,
|
agentId, pid,
|
||||||
() => this.handleDetachedAgentCompletion(agentId),
|
() => this.handleDetachedAgentCompletion(agentId),
|
||||||
() => this.activeAgents.get(agentId)?.tailer,
|
() => this.activeAgents.get(agentId)?.tailer,
|
||||||
|
this.createEarlyCompletionChecker(agentId),
|
||||||
);
|
);
|
||||||
activeEntry.cancelPoll = cancel;
|
activeEntry.cancelPoll = cancel;
|
||||||
|
|
||||||
@@ -406,6 +407,20 @@ export class MultiProviderAgentManager implements AgentManager {
|
|||||||
return this.toAgentInfo(agent);
|
return this.toAgentInfo(agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a callback that checks if an agent has a valid signal.json,
|
||||||
|
* used by pollForCompletion to detect hung processes.
|
||||||
|
*/
|
||||||
|
private createEarlyCompletionChecker(agentId: string): () => Promise<boolean> {
|
||||||
|
return async () => {
|
||||||
|
const agent = await this.repository.findById(agentId);
|
||||||
|
if (!agent?.worktreeId) return false;
|
||||||
|
const agentWorkdir = this.processManager.getAgentWorkdir(agent.worktreeId);
|
||||||
|
const signal = await this.outputHandler.readSignalCompletion(agentWorkdir);
|
||||||
|
return signal !== null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle completion of a detached agent.
|
* Handle completion of a detached agent.
|
||||||
*/
|
*/
|
||||||
@@ -525,6 +540,7 @@ export class MultiProviderAgentManager implements AgentManager {
|
|||||||
agentId, pid,
|
agentId, pid,
|
||||||
() => this.handleDetachedAgentCompletion(agentId),
|
() => this.handleDetachedAgentCompletion(agentId),
|
||||||
() => this.activeAgents.get(agentId)?.tailer,
|
() => this.activeAgents.get(agentId)?.tailer,
|
||||||
|
this.createEarlyCompletionChecker(agentId),
|
||||||
);
|
);
|
||||||
commitActiveEntry.cancelPoll = commitCancel;
|
commitActiveEntry.cancelPoll = commitCancel;
|
||||||
|
|
||||||
@@ -633,6 +649,7 @@ export class MultiProviderAgentManager implements AgentManager {
|
|||||||
agentId, pid,
|
agentId, pid,
|
||||||
() => this.handleDetachedAgentCompletion(agentId),
|
() => this.handleDetachedAgentCompletion(agentId),
|
||||||
() => this.activeAgents.get(agentId)?.tailer,
|
() => this.activeAgents.get(agentId)?.tailer,
|
||||||
|
this.createEarlyCompletionChecker(agentId),
|
||||||
);
|
);
|
||||||
activeEntry.cancelPoll = cancel;
|
activeEntry.cancelPoll = cancel;
|
||||||
|
|
||||||
@@ -704,6 +721,7 @@ export class MultiProviderAgentManager implements AgentManager {
|
|||||||
agentId, pid,
|
agentId, pid,
|
||||||
() => this.handleDetachedAgentCompletion(agentId),
|
() => this.handleDetachedAgentCompletion(agentId),
|
||||||
() => this.activeAgents.get(agentId)?.tailer,
|
() => this.activeAgents.get(agentId)?.tailer,
|
||||||
|
this.createEarlyCompletionChecker(agentId),
|
||||||
);
|
);
|
||||||
activeEntry.cancelPoll = cancel;
|
activeEntry.cancelPoll = cancel;
|
||||||
|
|
||||||
@@ -890,6 +908,7 @@ export class MultiProviderAgentManager implements AgentManager {
|
|||||||
agentId, pid,
|
agentId, pid,
|
||||||
() => this.handleDetachedAgentCompletion(agentId),
|
() => this.handleDetachedAgentCompletion(agentId),
|
||||||
() => this.activeAgents.get(agentId)?.tailer,
|
() => this.activeAgents.get(agentId)?.tailer,
|
||||||
|
this.createEarlyCompletionChecker(agentId),
|
||||||
);
|
);
|
||||||
resumeActiveEntry.cancelPoll = resumeCancel;
|
resumeActiveEntry.cancelPoll = resumeCancel;
|
||||||
}
|
}
|
||||||
@@ -1013,6 +1032,7 @@ export class MultiProviderAgentManager implements AgentManager {
|
|||||||
agentId, pid,
|
agentId, pid,
|
||||||
() => this.handleDetachedAgentCompletion(agentId),
|
() => this.handleDetachedAgentCompletion(agentId),
|
||||||
() => this.activeAgents.get(agentId)?.tailer,
|
() => this.activeAgents.get(agentId)?.tailer,
|
||||||
|
this.createEarlyCompletionChecker(agentId),
|
||||||
);
|
);
|
||||||
const active = this.activeAgents.get(agentId);
|
const active = this.activeAgents.get(agentId);
|
||||||
if (active) active.cancelPoll = cancel;
|
if (active) active.cancelPoll = cancel;
|
||||||
|
|||||||
@@ -1133,7 +1133,7 @@ export class OutputHandler {
|
|||||||
* Uses SignalManager for atomic read-and-validate when available.
|
* Uses SignalManager for atomic read-and-validate when available.
|
||||||
* Returns the raw JSON string on success, null if missing/invalid.
|
* Returns the raw JSON string on success, null if missing/invalid.
|
||||||
*/
|
*/
|
||||||
private async readSignalCompletion(agentWorkdir: string): Promise<string | null> {
|
async readSignalCompletion(agentWorkdir: string): Promise<string | null> {
|
||||||
// Prefer SignalManager (unified implementation with proper validation)
|
// Prefer SignalManager (unified implementation with proper validation)
|
||||||
if (this.signalManager) {
|
if (this.signalManager) {
|
||||||
const signal = await this.signalManager.readSignal(agentWorkdir);
|
const signal = await this.signalManager.readSignal(agentWorkdir);
|
||||||
|
|||||||
@@ -328,27 +328,63 @@ export class ProcessManager {
|
|||||||
* When the process exits, calls onComplete callback.
|
* When the process exits, calls onComplete callback.
|
||||||
* Returns a cancel handle to stop polling (e.g. on agent cleanup or re-resume).
|
* Returns a cancel handle to stop polling (e.g. on agent cleanup or re-resume).
|
||||||
*
|
*
|
||||||
|
* Optionally checks signal.json after a grace period to detect hung processes
|
||||||
|
* that completed work but failed to exit. If a valid signal is found while the
|
||||||
|
* process is still alive, SIGTERM is sent and normal completion proceeds.
|
||||||
|
*
|
||||||
* @param onComplete - Called when the process is no longer alive
|
* @param onComplete - Called when the process is no longer alive
|
||||||
* @param getTailer - Function to get the current tailer for final flush
|
* @param getTailer - Function to get the current tailer for final flush
|
||||||
|
* @param checkEarlyCompletion - Optional callback that returns true if signal.json indicates completion
|
||||||
*/
|
*/
|
||||||
pollForCompletion(
|
pollForCompletion(
|
||||||
agentId: string,
|
agentId: string,
|
||||||
pid: number,
|
pid: number,
|
||||||
onComplete: () => Promise<void>,
|
onComplete: () => Promise<void>,
|
||||||
getTailer: () => FileTailer | undefined,
|
getTailer: () => FileTailer | undefined,
|
||||||
|
checkEarlyCompletion?: () => Promise<boolean>,
|
||||||
): { cancel: () => void } {
|
): { cancel: () => void } {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
const startTime = Date.now();
|
||||||
|
const GRACE_PERIOD_MS = 60_000;
|
||||||
|
const SIGNAL_CHECK_INTERVAL_MS = 30_000;
|
||||||
|
let lastSignalCheck = 0;
|
||||||
|
|
||||||
|
const finalize = async () => {
|
||||||
|
const tailer = getTailer();
|
||||||
|
if (tailer) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
await tailer.stop();
|
||||||
|
}
|
||||||
|
if (!cancelled) await onComplete();
|
||||||
|
};
|
||||||
|
|
||||||
const check = async () => {
|
const check = async () => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (!isPidAlive(pid)) {
|
if (!isPidAlive(pid)) {
|
||||||
const tailer = getTailer();
|
await finalize();
|
||||||
if (tailer) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
||||||
await tailer.stop();
|
|
||||||
}
|
|
||||||
if (!cancelled) await onComplete();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Defensive signal check: after grace period, periodically check signal.json
|
||||||
|
if (checkEarlyCompletion) {
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
if (elapsed >= GRACE_PERIOD_MS && Date.now() - lastSignalCheck >= SIGNAL_CHECK_INTERVAL_MS) {
|
||||||
|
lastSignalCheck = Date.now();
|
||||||
|
try {
|
||||||
|
const hasSignal = await checkEarlyCompletion();
|
||||||
|
if (hasSignal) {
|
||||||
|
log.warn({ agentId, pid, elapsedMs: elapsed }, 'signal.json found but process still alive — sending SIGTERM');
|
||||||
|
try { process.kill(pid, 'SIGTERM'); } catch { /* already dead */ }
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
await finalize();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.debug({ agentId, err: err instanceof Error ? err.message : String(err) }, 'early completion check failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!cancelled) setTimeout(check, 1000);
|
if (!cancelled) setTimeout(check, 1000);
|
||||||
};
|
};
|
||||||
check();
|
check();
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { createDefaultTrpcClient } from './trpc-client.js';
|
|||||||
import { createContainer } from '../container.js';
|
import { createContainer } from '../container.js';
|
||||||
import { findWorkspaceRoot, writeCwrc, defaultCwConfig } from '../config/index.js';
|
import { findWorkspaceRoot, writeCwrc, defaultCwConfig } from '../config/index.js';
|
||||||
import { createModuleLogger } from '../logger/index.js';
|
import { createModuleLogger } from '../logger/index.js';
|
||||||
|
import { backfillMetricsFromPath } from '../scripts/backfill-metrics.js';
|
||||||
|
import { getDbPath } from '../db/index.js';
|
||||||
|
|
||||||
/** Environment variable for custom port */
|
/** Environment variable for custom port */
|
||||||
const CW_PORT_ENV = 'CW_PORT';
|
const CW_PORT_ENV = 'CW_PORT';
|
||||||
@@ -134,6 +136,22 @@ export function createCli(serverHandler?: (port?: number) => Promise<void>): Com
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Backfill metrics command (standalone — no server, no tRPC)
|
||||||
|
program
|
||||||
|
.command('backfill-metrics')
|
||||||
|
.description('Populate agent_metrics table from existing agent_log_chunks (run once after upgrading)')
|
||||||
|
.option('--db <path>', 'Path to the SQLite database file (defaults to configured DB path)')
|
||||||
|
.action(async (options: { db?: string }) => {
|
||||||
|
const dbPath = options.db ?? getDbPath();
|
||||||
|
console.log(`Backfilling metrics from ${dbPath}...`);
|
||||||
|
try {
|
||||||
|
await backfillMetricsFromPath(dbPath);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Backfill failed:', (error as Error).message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Agent command group
|
// Agent command group
|
||||||
const agentCommand = program
|
const agentCommand = program
|
||||||
.command('agent')
|
.command('agent')
|
||||||
|
|||||||
48
apps/server/db/repositories/drizzle/agent-metrics.test.ts
Normal file
48
apps/server/db/repositories/drizzle/agent-metrics.test.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { createTestDatabase } from './test-helpers.js';
|
||||||
|
import { agentMetrics } from '../../schema.js';
|
||||||
|
|
||||||
|
describe('agentMetrics table', () => {
|
||||||
|
it('select from empty agentMetrics returns []', async () => {
|
||||||
|
const db = createTestDatabase();
|
||||||
|
const rows = await db.select().from(agentMetrics);
|
||||||
|
expect(rows).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('insert and select a metrics row round-trips correctly', async () => {
|
||||||
|
const db = createTestDatabase();
|
||||||
|
await db.insert(agentMetrics).values({
|
||||||
|
agentId: 'agent-abc',
|
||||||
|
questionsCount: 3,
|
||||||
|
subagentsCount: 1,
|
||||||
|
compactionsCount: 0,
|
||||||
|
updatedAt: new Date('2024-01-01T00:00:00Z'),
|
||||||
|
});
|
||||||
|
const rows = await db.select().from(agentMetrics);
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].agentId).toBe('agent-abc');
|
||||||
|
expect(rows[0].questionsCount).toBe(3);
|
||||||
|
expect(rows[0].subagentsCount).toBe(1);
|
||||||
|
expect(rows[0].compactionsCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('agentId is primary key — duplicate insert throws', async () => {
|
||||||
|
const db = createTestDatabase();
|
||||||
|
await db.insert(agentMetrics).values({
|
||||||
|
agentId: 'agent-dup',
|
||||||
|
questionsCount: 0,
|
||||||
|
subagentsCount: 0,
|
||||||
|
compactionsCount: 0,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
db.insert(agentMetrics).values({
|
||||||
|
agentId: 'agent-dup',
|
||||||
|
questionsCount: 1,
|
||||||
|
subagentsCount: 0,
|
||||||
|
compactionsCount: 0,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
129
apps/server/db/repositories/drizzle/log-chunk.test.ts
Normal file
129
apps/server/db/repositories/drizzle/log-chunk.test.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { DrizzleLogChunkRepository } from './log-chunk.js';
|
||||||
|
import { createTestDatabase } from './test-helpers.js';
|
||||||
|
import type { DrizzleDatabase } from '../../index.js';
|
||||||
|
|
||||||
|
describe('DrizzleLogChunkRepository', () => {
|
||||||
|
let db: DrizzleDatabase;
|
||||||
|
let repo: DrizzleLogChunkRepository;
|
||||||
|
const testAgentId = 'agent-test-001';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = createTestDatabase();
|
||||||
|
repo = new DrizzleLogChunkRepository(db);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AskUserQuestion chunk — questionsCount upserted correctly', async () => {
|
||||||
|
await repo.insertChunk({
|
||||||
|
agentId: testAgentId,
|
||||||
|
agentName: 'test-agent',
|
||||||
|
sessionNumber: 1,
|
||||||
|
content: JSON.stringify({ type: 'tool_use', name: 'AskUserQuestion', input: { questions: [{}, {}] } }),
|
||||||
|
});
|
||||||
|
const metrics = await repo.findMetricsByAgentIds([testAgentId]);
|
||||||
|
expect(metrics).toEqual([{
|
||||||
|
agentId: testAgentId,
|
||||||
|
questionsCount: 2,
|
||||||
|
subagentsCount: 0,
|
||||||
|
compactionsCount: 0,
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Agent tool chunk — subagentsCount incremented', async () => {
|
||||||
|
await repo.insertChunk({
|
||||||
|
agentId: testAgentId,
|
||||||
|
agentName: 'test-agent',
|
||||||
|
sessionNumber: 1,
|
||||||
|
content: JSON.stringify({ type: 'tool_use', name: 'Agent' }),
|
||||||
|
});
|
||||||
|
const metrics = await repo.findMetricsByAgentIds([testAgentId]);
|
||||||
|
expect(metrics).toEqual([{
|
||||||
|
agentId: testAgentId,
|
||||||
|
questionsCount: 0,
|
||||||
|
subagentsCount: 1,
|
||||||
|
compactionsCount: 0,
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Compaction event — compactionsCount incremented', async () => {
|
||||||
|
await repo.insertChunk({
|
||||||
|
agentId: testAgentId,
|
||||||
|
agentName: 'test-agent',
|
||||||
|
sessionNumber: 1,
|
||||||
|
content: JSON.stringify({ type: 'system', subtype: 'init', source: 'compact' }),
|
||||||
|
});
|
||||||
|
const metrics = await repo.findMetricsByAgentIds([testAgentId]);
|
||||||
|
expect(metrics).toEqual([{
|
||||||
|
agentId: testAgentId,
|
||||||
|
questionsCount: 0,
|
||||||
|
subagentsCount: 0,
|
||||||
|
compactionsCount: 1,
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Irrelevant chunk type — no metrics row created', async () => {
|
||||||
|
await repo.insertChunk({
|
||||||
|
agentId: testAgentId,
|
||||||
|
agentName: 'test-agent',
|
||||||
|
sessionNumber: 1,
|
||||||
|
content: JSON.stringify({ type: 'text', text: 'hello' }),
|
||||||
|
});
|
||||||
|
const metrics = await repo.findMetricsByAgentIds([testAgentId]);
|
||||||
|
expect(metrics).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Malformed JSON chunk — chunk persisted, metrics row absent', async () => {
|
||||||
|
await repo.insertChunk({
|
||||||
|
agentId: testAgentId,
|
||||||
|
agentName: 'test-agent',
|
||||||
|
sessionNumber: 1,
|
||||||
|
content: 'not-valid-json',
|
||||||
|
});
|
||||||
|
const chunks = await repo.findByAgentId(testAgentId);
|
||||||
|
expect(chunks).toHaveLength(1);
|
||||||
|
const metrics = await repo.findMetricsByAgentIds([testAgentId]);
|
||||||
|
expect(metrics).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Multiple inserts, same agent — counts accumulate additively', async () => {
|
||||||
|
// 3 Agent tool chunks
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await repo.insertChunk({
|
||||||
|
agentId: testAgentId,
|
||||||
|
agentName: 'test-agent',
|
||||||
|
sessionNumber: 1,
|
||||||
|
content: JSON.stringify({ type: 'tool_use', name: 'Agent' }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 1 AskUserQuestion with 2 questions
|
||||||
|
await repo.insertChunk({
|
||||||
|
agentId: testAgentId,
|
||||||
|
agentName: 'test-agent',
|
||||||
|
sessionNumber: 1,
|
||||||
|
content: JSON.stringify({ type: 'tool_use', name: 'AskUserQuestion', input: { questions: [{}, {}] } }),
|
||||||
|
});
|
||||||
|
const metrics = await repo.findMetricsByAgentIds([testAgentId]);
|
||||||
|
expect(metrics).toEqual([{
|
||||||
|
agentId: testAgentId,
|
||||||
|
questionsCount: 2,
|
||||||
|
subagentsCount: 3,
|
||||||
|
compactionsCount: 0,
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findMetricsByAgentIds with empty array — returns []', async () => {
|
||||||
|
const metrics = await repo.findMetricsByAgentIds([]);
|
||||||
|
expect(metrics).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findMetricsByAgentIds with agentId that has no metrics row — returns []', async () => {
|
||||||
|
await repo.insertChunk({
|
||||||
|
agentId: testAgentId,
|
||||||
|
agentName: 'test-agent',
|
||||||
|
sessionNumber: 1,
|
||||||
|
content: JSON.stringify({ type: 'text', text: 'hello' }),
|
||||||
|
});
|
||||||
|
const metrics = await repo.findMetricsByAgentIds([testAgentId]);
|
||||||
|
expect(metrics).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,10 +4,10 @@
|
|||||||
* Implements LogChunkRepository interface using Drizzle ORM.
|
* Implements LogChunkRepository interface using Drizzle ORM.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { eq, asc, max, inArray } from 'drizzle-orm';
|
import { eq, asc, max, inArray, sql } from 'drizzle-orm';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import type { DrizzleDatabase } from '../../index.js';
|
import type { DrizzleDatabase } from '../../index.js';
|
||||||
import { agentLogChunks } from '../../schema.js';
|
import { agentLogChunks, agentMetrics } from '../../schema.js';
|
||||||
import type { LogChunkRepository } from '../log-chunk-repository.js';
|
import type { LogChunkRepository } from '../log-chunk-repository.js';
|
||||||
|
|
||||||
export class DrizzleLogChunkRepository implements LogChunkRepository {
|
export class DrizzleLogChunkRepository implements LogChunkRepository {
|
||||||
@@ -19,13 +19,58 @@ export class DrizzleLogChunkRepository implements LogChunkRepository {
|
|||||||
sessionNumber: number;
|
sessionNumber: number;
|
||||||
content: string;
|
content: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await this.db.insert(agentLogChunks).values({
|
// better-sqlite3 is synchronous — transaction callback must be sync, use .run() not await
|
||||||
id: nanoid(),
|
this.db.transaction((tx) => {
|
||||||
agentId: data.agentId,
|
// 1. Always insert the chunk row first
|
||||||
agentName: data.agentName,
|
tx.insert(agentLogChunks).values({
|
||||||
sessionNumber: data.sessionNumber,
|
id: nanoid(),
|
||||||
content: data.content,
|
agentId: data.agentId,
|
||||||
createdAt: new Date(),
|
agentName: data.agentName,
|
||||||
|
sessionNumber: data.sessionNumber,
|
||||||
|
content: data.content,
|
||||||
|
createdAt: new Date(),
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
// 2. Parse content and determine metric increments
|
||||||
|
// Wrap only the parse + upsert block — chunk insert is not rolled back on parse failure
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data.content);
|
||||||
|
let deltaQuestions = 0;
|
||||||
|
let deltaSubagents = 0;
|
||||||
|
let deltaCompactions = 0;
|
||||||
|
|
||||||
|
if (parsed.type === 'tool_use' && parsed.name === 'AskUserQuestion') {
|
||||||
|
deltaQuestions = parsed.input?.questions?.length ?? 0;
|
||||||
|
} else if (parsed.type === 'tool_use' && parsed.name === 'Agent') {
|
||||||
|
deltaSubagents = 1;
|
||||||
|
} else if (parsed.type === 'system' && parsed.subtype === 'init' && parsed.source === 'compact') {
|
||||||
|
deltaCompactions = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Only upsert if there is something to increment
|
||||||
|
if (deltaQuestions > 0 || deltaSubagents > 0 || deltaCompactions > 0) {
|
||||||
|
tx.insert(agentMetrics)
|
||||||
|
.values({
|
||||||
|
agentId: data.agentId,
|
||||||
|
questionsCount: deltaQuestions,
|
||||||
|
subagentsCount: deltaSubagents,
|
||||||
|
compactionsCount: deltaCompactions,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: agentMetrics.agentId,
|
||||||
|
set: {
|
||||||
|
questionsCount: sql`${agentMetrics.questionsCount} + ${deltaQuestions}`,
|
||||||
|
subagentsCount: sql`${agentMetrics.subagentsCount} + ${deltaSubagents}`,
|
||||||
|
compactionsCount: sql`${agentMetrics.compactionsCount} + ${deltaCompactions}`,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Malformed JSON — skip metric upsert, chunk insert already committed within transaction
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,4 +114,22 @@ export class DrizzleLogChunkRepository implements LogChunkRepository {
|
|||||||
|
|
||||||
return result[0]?.maxSession ?? 0;
|
return result[0]?.maxSession ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findMetricsByAgentIds(agentIds: string[]): Promise<{
|
||||||
|
agentId: string;
|
||||||
|
questionsCount: number;
|
||||||
|
subagentsCount: number;
|
||||||
|
compactionsCount: number;
|
||||||
|
}[]> {
|
||||||
|
if (agentIds.length === 0) return [];
|
||||||
|
return this.db
|
||||||
|
.select({
|
||||||
|
agentId: agentMetrics.agentId,
|
||||||
|
questionsCount: agentMetrics.questionsCount,
|
||||||
|
subagentsCount: agentMetrics.subagentsCount,
|
||||||
|
compactionsCount: agentMetrics.compactionsCount,
|
||||||
|
})
|
||||||
|
.from(agentMetrics)
|
||||||
|
.where(inArray(agentMetrics.agentId, agentIds));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,4 +27,16 @@ export interface LogChunkRepository {
|
|||||||
deleteByAgentId(agentId: string): Promise<void>;
|
deleteByAgentId(agentId: string): Promise<void>;
|
||||||
|
|
||||||
getSessionCount(agentId: string): Promise<number>;
|
getSessionCount(agentId: string): Promise<number>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch-fetch pre-computed metrics for multiple agent IDs.
|
||||||
|
* Returns one row per agent that has metrics. Agents with no
|
||||||
|
* matching row in agent_metrics are omitted (not returned as zeros).
|
||||||
|
*/
|
||||||
|
findMetricsByAgentIds(agentIds: string[]): Promise<{
|
||||||
|
agentId: string;
|
||||||
|
questionsCount: number;
|
||||||
|
subagentsCount: number;
|
||||||
|
compactionsCount: number;
|
||||||
|
}[]>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -514,6 +514,21 @@ export const agentLogChunks = sqliteTable('agent_log_chunks', {
|
|||||||
export type AgentLogChunk = InferSelectModel<typeof agentLogChunks>;
|
export type AgentLogChunk = InferSelectModel<typeof agentLogChunks>;
|
||||||
export type NewAgentLogChunk = InferInsertModel<typeof agentLogChunks>;
|
export type NewAgentLogChunk = InferInsertModel<typeof agentLogChunks>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AGENT METRICS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const agentMetrics = sqliteTable('agent_metrics', {
|
||||||
|
agentId: text('agent_id').primaryKey(),
|
||||||
|
questionsCount: integer('questions_count').notNull().default(0),
|
||||||
|
subagentsCount: integer('subagents_count').notNull().default(0),
|
||||||
|
compactionsCount: integer('compactions_count').notNull().default(0),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AgentMetrics = InferSelectModel<typeof agentMetrics>;
|
||||||
|
export type NewAgentMetrics = InferInsertModel<typeof agentMetrics>;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// CONVERSATIONS (inter-agent communication)
|
// CONVERSATIONS (inter-agent communication)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
7
apps/server/drizzle/0037_eager_devos.sql
Normal file
7
apps/server/drizzle/0037_eager_devos.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE `agent_metrics` (
|
||||||
|
`agent_id` text PRIMARY KEY NOT NULL,
|
||||||
|
`questions_count` integer DEFAULT 0 NOT NULL,
|
||||||
|
`subagents_count` integer DEFAULT 0 NOT NULL,
|
||||||
|
`compactions_count` integer DEFAULT 0 NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL
|
||||||
|
);
|
||||||
1
apps/server/drizzle/0038_worthless_princess_powerful.sql
Normal file
1
apps/server/drizzle/0038_worthless_princess_powerful.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `initiatives` ADD `quality_review` integer DEFAULT false NOT NULL;
|
||||||
1981
apps/server/drizzle/meta/0036_snapshot.json
Normal file
1981
apps/server/drizzle/meta/0036_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"id": "ef1d4ce7-a9c4-4e86-9cac-2e9cbbd0e688",
|
"id": "eb30417e-d030-457f-911e-6566dce54fc9",
|
||||||
"prevId": "c84e499f-7df8-4091-b2a5-6b12847898bd",
|
"prevId": "f85b9df3-dead-4c46-90ac-cf36bcaa6eb4",
|
||||||
"tables": {
|
"tables": {
|
||||||
"accounts": {
|
"accounts": {
|
||||||
"name": "accounts",
|
"name": "accounts",
|
||||||
@@ -155,6 +155,54 @@
|
|||||||
"uniqueConstraints": {},
|
"uniqueConstraints": {},
|
||||||
"checkConstraints": {}
|
"checkConstraints": {}
|
||||||
},
|
},
|
||||||
|
"agent_metrics": {
|
||||||
|
"name": "agent_metrics",
|
||||||
|
"columns": {
|
||||||
|
"agent_id": {
|
||||||
|
"name": "agent_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"questions_count": {
|
||||||
|
"name": "questions_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"subagents_count": {
|
||||||
|
"name": "subagents_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"compactions_count": {
|
||||||
|
"name": "compactions_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
"agents": {
|
"agents": {
|
||||||
"name": "agents",
|
"name": "agents",
|
||||||
"columns": {
|
"columns": {
|
||||||
@@ -1137,14 +1185,6 @@
|
|||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
"default": "'review_per_phase'"
|
"default": "'review_per_phase'"
|
||||||
},
|
},
|
||||||
"quality_review": {
|
|
||||||
"name": "quality_review",
|
|
||||||
"type": "integer",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": false
|
|
||||||
},
|
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
|
|||||||
2037
apps/server/drizzle/meta/0038_snapshot.json
Normal file
2037
apps/server/drizzle/meta/0038_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -264,8 +264,15 @@
|
|||||||
{
|
{
|
||||||
"idx": 37,
|
"idx": 37,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
|
"when": 1772828694292,
|
||||||
|
"tag": "0037_eager_devos",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 38,
|
||||||
|
"version": "6",
|
||||||
"when": 1772829916655,
|
"when": 1772829916655,
|
||||||
"tag": "0037_worthless_princess_powerful",
|
"tag": "0038_worthless_princess_powerful",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import type { AgentRepository } from '../db/repositories/agent-repository.js';
|
|||||||
import type { AgentManager } from '../agent/types.js';
|
import type { AgentManager } from '../agent/types.js';
|
||||||
import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js';
|
import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js';
|
||||||
import type { ConflictResolutionService } from '../coordination/conflict-resolution-service.js';
|
import type { ConflictResolutionService } from '../coordination/conflict-resolution-service.js';
|
||||||
import { phaseBranchName, taskBranchName } from '../git/branch-naming.js';
|
import { phaseBranchName, taskBranchName, isPlanningCategory } from '../git/branch-naming.js';
|
||||||
import { ensureProjectClone } from '../git/project-clones.js';
|
import { ensureProjectClone } from '../git/project-clones.js';
|
||||||
import { createModuleLogger } from '../logger/index.js';
|
import { createModuleLogger } from '../logger/index.js';
|
||||||
import { phaseMetaCache, fileDiffCache } from '../review/diff-cache.js';
|
import { phaseMetaCache, fileDiffCache } from '../review/diff-cache.js';
|
||||||
@@ -708,6 +708,23 @@ export class ExecutionOrchestrator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up stale duplicate planning tasks (e.g. a crashed detail task
|
||||||
|
// that was reset to pending, then a new detail task was created and completed).
|
||||||
|
const tasksAfterRecovery = await this.taskRepository.findByPhaseId(phase.id);
|
||||||
|
const completedPlanningNames = new Set<string>();
|
||||||
|
for (const t of tasksAfterRecovery) {
|
||||||
|
if (isPlanningCategory(t.category) && t.status === 'completed') {
|
||||||
|
completedPlanningNames.add(`${t.category}:${t.phaseId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const t of tasksAfterRecovery) {
|
||||||
|
if (isPlanningCategory(t.category) && t.status === 'pending' && completedPlanningNames.has(`${t.category}:${t.phaseId}`)) {
|
||||||
|
await this.taskRepository.update(t.id, { status: 'completed', summary: 'Superseded by retry' });
|
||||||
|
tasksRecovered++;
|
||||||
|
log.info({ taskId: t.id, category: t.category }, 'recovered stale duplicate planning task');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Re-read tasks after recovery updates and check if phase is now fully done
|
// Re-read tasks after recovery updates and check if phase is now fully done
|
||||||
const updatedTasks = await this.taskRepository.findByPhaseId(phase.id);
|
const updatedTasks = await this.taskRepository.findByPhaseId(phase.id);
|
||||||
const allDone = updatedTasks.every((t) => t.status === 'completed');
|
const allDone = updatedTasks.every((t) => t.status === 'completed');
|
||||||
|
|||||||
@@ -46,8 +46,17 @@ export class SimpleGitBranchManager implements BranchManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await git.branch([branch, baseBranch]);
|
try {
|
||||||
log.info({ repoPath, branch, baseBranch }, 'branch created');
|
await git.branch([branch, baseBranch]);
|
||||||
|
log.info({ repoPath, branch, baseBranch }, 'branch created');
|
||||||
|
} catch (err) {
|
||||||
|
// Handle TOCTOU race: branch may have been created between the exists check and now
|
||||||
|
if (err instanceof Error && err.message.includes('already exists')) {
|
||||||
|
log.debug({ repoPath, branch }, 'branch created by concurrent process');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async mergeBranch(repoPath: string, sourceBranch: string, targetBranch: string): Promise<MergeResult> {
|
async mergeBranch(repoPath: string, sourceBranch: string, targetBranch: string): Promise<MergeResult> {
|
||||||
|
|||||||
131
apps/server/scripts/backfill-metrics.test.ts
Normal file
131
apps/server/scripts/backfill-metrics.test.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* Tests for the backfill-metrics script.
|
||||||
|
*
|
||||||
|
* Uses an in-memory test database to verify that backfillMetrics correctly
|
||||||
|
* accumulates counts from agent_log_chunks and upserts into agent_metrics.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { createTestDatabase } from '../db/repositories/drizzle/test-helpers.js';
|
||||||
|
import type { DrizzleDatabase } from '../db/index.js';
|
||||||
|
import { agentLogChunks, agentMetrics } from '../db/index.js';
|
||||||
|
import { backfillMetrics } from './backfill-metrics.js';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
async function insertChunk(db: DrizzleDatabase, agentId: string, content: object | string) {
|
||||||
|
await db.insert(agentLogChunks).values({
|
||||||
|
id: nanoid(),
|
||||||
|
agentId,
|
||||||
|
agentName: 'test-agent',
|
||||||
|
sessionNumber: 1,
|
||||||
|
content: typeof content === 'string' ? content : JSON.stringify(content),
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('backfillMetrics', () => {
|
||||||
|
let db: DrizzleDatabase;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = createTestDatabase();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AskUserQuestion chunks — questionsCount correct', async () => {
|
||||||
|
await insertChunk(db, 'agent-a', { type: 'tool_use', name: 'AskUserQuestion', input: { questions: [{}, {}] } });
|
||||||
|
await insertChunk(db, 'agent-a', { type: 'tool_use', name: 'AskUserQuestion', input: { questions: [{}] } });
|
||||||
|
|
||||||
|
await backfillMetrics(db);
|
||||||
|
|
||||||
|
const rows = await db.select().from(agentMetrics).where(eq(agentMetrics.agentId, 'agent-a'));
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].questionsCount).toBe(3);
|
||||||
|
expect(rows[0].subagentsCount).toBe(0);
|
||||||
|
expect(rows[0].compactionsCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Agent tool chunks — subagentsCount correct', async () => {
|
||||||
|
await insertChunk(db, 'agent-b', { type: 'tool_use', name: 'Agent' });
|
||||||
|
await insertChunk(db, 'agent-b', { type: 'tool_use', name: 'Agent' });
|
||||||
|
|
||||||
|
await backfillMetrics(db);
|
||||||
|
|
||||||
|
const rows = await db.select().from(agentMetrics).where(eq(agentMetrics.agentId, 'agent-b'));
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].questionsCount).toBe(0);
|
||||||
|
expect(rows[0].subagentsCount).toBe(2);
|
||||||
|
expect(rows[0].compactionsCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Compaction chunks — compactionsCount correct', async () => {
|
||||||
|
await insertChunk(db, 'agent-c', { type: 'system', subtype: 'init', source: 'compact' });
|
||||||
|
|
||||||
|
await backfillMetrics(db);
|
||||||
|
|
||||||
|
const rows = await db.select().from(agentMetrics).where(eq(agentMetrics.agentId, 'agent-c'));
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].questionsCount).toBe(0);
|
||||||
|
expect(rows[0].subagentsCount).toBe(0);
|
||||||
|
expect(rows[0].compactionsCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Irrelevant chunk type — no metrics row created', async () => {
|
||||||
|
await insertChunk(db, 'agent-d', { type: 'text', text: 'hello' });
|
||||||
|
|
||||||
|
await backfillMetrics(db);
|
||||||
|
|
||||||
|
const rows = await db.select().from(agentMetrics).where(eq(agentMetrics.agentId, 'agent-d'));
|
||||||
|
expect(rows).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Malformed JSON chunk — skipped, no crash', async () => {
|
||||||
|
await insertChunk(db, 'agent-e', 'not-valid-json');
|
||||||
|
await insertChunk(db, 'agent-e', { type: 'tool_use', name: 'Agent' });
|
||||||
|
|
||||||
|
await expect(backfillMetrics(db)).resolves.not.toThrow();
|
||||||
|
|
||||||
|
const rows = await db.select().from(agentMetrics).where(eq(agentMetrics.agentId, 'agent-e'));
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].subagentsCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Multiple agents — counts isolated per agent', async () => {
|
||||||
|
await insertChunk(db, 'agent-f', { type: 'tool_use', name: 'AskUserQuestion', input: { questions: [{}, {}, {}] } });
|
||||||
|
await insertChunk(db, 'agent-f', { type: 'tool_use', name: 'AskUserQuestion', input: { questions: [{}, {}, {}] } });
|
||||||
|
await insertChunk(db, 'agent-g', { type: 'tool_use', name: 'Agent' });
|
||||||
|
|
||||||
|
await backfillMetrics(db);
|
||||||
|
|
||||||
|
const rowsF = await db.select().from(agentMetrics).where(eq(agentMetrics.agentId, 'agent-f'));
|
||||||
|
expect(rowsF).toHaveLength(1);
|
||||||
|
expect(rowsF[0].questionsCount).toBe(6);
|
||||||
|
expect(rowsF[0].subagentsCount).toBe(0);
|
||||||
|
expect(rowsF[0].compactionsCount).toBe(0);
|
||||||
|
|
||||||
|
const rowsG = await db.select().from(agentMetrics).where(eq(agentMetrics.agentId, 'agent-g'));
|
||||||
|
expect(rowsG).toHaveLength(1);
|
||||||
|
expect(rowsG[0].questionsCount).toBe(0);
|
||||||
|
expect(rowsG[0].subagentsCount).toBe(1);
|
||||||
|
expect(rowsG[0].compactionsCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Empty database — completes without error', async () => {
|
||||||
|
await expect(backfillMetrics(db)).resolves.not.toThrow();
|
||||||
|
|
||||||
|
const rows = await db.select().from(agentMetrics);
|
||||||
|
expect(rows).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Re-run idempotency note — second run doubles counts', async () => {
|
||||||
|
// Documented behavior: run only once against a fresh agent_metrics table
|
||||||
|
await insertChunk(db, 'agent-h', { type: 'tool_use', name: 'Agent' });
|
||||||
|
|
||||||
|
await backfillMetrics(db);
|
||||||
|
const rowsAfterFirst = await db.select().from(agentMetrics).where(eq(agentMetrics.agentId, 'agent-h'));
|
||||||
|
expect(rowsAfterFirst[0].subagentsCount).toBe(1);
|
||||||
|
|
||||||
|
await backfillMetrics(db);
|
||||||
|
const rowsAfterSecond = await db.select().from(agentMetrics).where(eq(agentMetrics.agentId, 'agent-h'));
|
||||||
|
expect(rowsAfterSecond[0].subagentsCount).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
128
apps/server/scripts/backfill-metrics.ts
Normal file
128
apps/server/scripts/backfill-metrics.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* Backfill script for agent_metrics table.
|
||||||
|
*
|
||||||
|
* Reads all existing agent_log_chunks rows and populates agent_metrics with
|
||||||
|
* accumulated counts of questions, subagent spawns, and compaction events.
|
||||||
|
*
|
||||||
|
* Intended to be run once per production database after applying the migration
|
||||||
|
* that introduces the agent_metrics table.
|
||||||
|
*
|
||||||
|
* Idempotency note: Uses ON CONFLICT DO UPDATE with additive increments to match
|
||||||
|
* the ongoing insertChunk write-path behavior. Running against an empty
|
||||||
|
* agent_metrics table is fully safe. Running a second time will double-count —
|
||||||
|
* only run this script once per database, immediately after applying the migration.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { asc, sql } from 'drizzle-orm';
|
||||||
|
import { createDatabase, DrizzleDatabase, agentLogChunks, agentMetrics } from '../db/index.js';
|
||||||
|
|
||||||
|
const BATCH_SIZE = 500;
|
||||||
|
const LOG_EVERY = 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core backfill function. Accepts a DrizzleDatabase for testability.
|
||||||
|
*/
|
||||||
|
export async function backfillMetrics(db: DrizzleDatabase): Promise<void> {
|
||||||
|
const accumulator = new Map<string, { questionsCount: number; subagentsCount: number; compactionsCount: number }>();
|
||||||
|
let offset = 0;
|
||||||
|
let totalChunks = 0;
|
||||||
|
let malformedCount = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const batch = await db
|
||||||
|
.select({ agentId: agentLogChunks.agentId, content: agentLogChunks.content })
|
||||||
|
.from(agentLogChunks)
|
||||||
|
.orderBy(asc(agentLogChunks.createdAt))
|
||||||
|
.limit(BATCH_SIZE)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
if (batch.length === 0) break;
|
||||||
|
|
||||||
|
for (const chunk of batch) {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(chunk.content);
|
||||||
|
} catch {
|
||||||
|
malformedCount++;
|
||||||
|
totalChunks++;
|
||||||
|
if (totalChunks % LOG_EVERY === 0) {
|
||||||
|
console.log(`Processed ${totalChunks} chunks...`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof parsed !== 'object' || parsed === null) {
|
||||||
|
totalChunks++;
|
||||||
|
if (totalChunks % LOG_EVERY === 0) {
|
||||||
|
console.log(`Processed ${totalChunks} chunks...`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const obj = parsed as Record<string, unknown>;
|
||||||
|
const type = obj['type'];
|
||||||
|
const name = obj['name'];
|
||||||
|
|
||||||
|
if (type === 'tool_use' && name === 'AskUserQuestion') {
|
||||||
|
const input = obj['input'] as Record<string, unknown> | undefined;
|
||||||
|
const questions = input?.['questions'];
|
||||||
|
const count = Array.isArray(questions) ? questions.length : 0;
|
||||||
|
if (count > 0) {
|
||||||
|
const entry = accumulator.get(chunk.agentId) ?? { questionsCount: 0, subagentsCount: 0, compactionsCount: 0 };
|
||||||
|
entry.questionsCount += count;
|
||||||
|
accumulator.set(chunk.agentId, entry);
|
||||||
|
}
|
||||||
|
} else if (type === 'tool_use' && name === 'Agent') {
|
||||||
|
const entry = accumulator.get(chunk.agentId) ?? { questionsCount: 0, subagentsCount: 0, compactionsCount: 0 };
|
||||||
|
entry.subagentsCount += 1;
|
||||||
|
accumulator.set(chunk.agentId, entry);
|
||||||
|
} else if (type === 'system' && obj['subtype'] === 'init' && obj['source'] === 'compact') {
|
||||||
|
const entry = accumulator.get(chunk.agentId) ?? { questionsCount: 0, subagentsCount: 0, compactionsCount: 0 };
|
||||||
|
entry.compactionsCount += 1;
|
||||||
|
accumulator.set(chunk.agentId, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
totalChunks++;
|
||||||
|
if (totalChunks % LOG_EVERY === 0) {
|
||||||
|
console.log(`Processed ${totalChunks} chunks...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += BATCH_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert accumulated counts into agent_metrics.
|
||||||
|
// Uses additive ON CONFLICT DO UPDATE to match the ongoing insertChunk behavior.
|
||||||
|
for (const [agentId, counts] of accumulator) {
|
||||||
|
await db
|
||||||
|
.insert(agentMetrics)
|
||||||
|
.values({
|
||||||
|
agentId,
|
||||||
|
questionsCount: counts.questionsCount,
|
||||||
|
subagentsCount: counts.subagentsCount,
|
||||||
|
compactionsCount: counts.compactionsCount,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: agentMetrics.agentId,
|
||||||
|
set: {
|
||||||
|
questionsCount: sql`${agentMetrics.questionsCount} + ${counts.questionsCount}`,
|
||||||
|
subagentsCount: sql`${agentMetrics.subagentsCount} + ${counts.subagentsCount}`,
|
||||||
|
compactionsCount: sql`${agentMetrics.compactionsCount} + ${counts.compactionsCount}`,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Backfill complete: ${accumulator.size} agents updated, ${totalChunks} chunks processed, ${malformedCount} malformed chunks skipped`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CLI wrapper — opens a database from a path, then delegates to backfillMetrics.
|
||||||
|
*/
|
||||||
|
export async function backfillMetricsFromPath(dbPath: string): Promise<void> {
|
||||||
|
const db = createDatabase(dbPath);
|
||||||
|
await backfillMetrics(db);
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import type { LogChunkRepository } from '../db/repositories/log-chunk-repository
|
|||||||
import type { ConversationRepository } from '../db/repositories/conversation-repository.js';
|
import type { ConversationRepository } from '../db/repositories/conversation-repository.js';
|
||||||
import type { ChatSessionRepository } from '../db/repositories/chat-session-repository.js';
|
import type { ChatSessionRepository } from '../db/repositories/chat-session-repository.js';
|
||||||
import type { ReviewCommentRepository } from '../db/repositories/review-comment-repository.js';
|
import type { ReviewCommentRepository } from '../db/repositories/review-comment-repository.js';
|
||||||
|
import type { ErrandRepository } from '../db/repositories/errand-repository.js';
|
||||||
import type { AccountCredentialManager } from '../agent/credentials/types.js';
|
import type { AccountCredentialManager } from '../agent/credentials/types.js';
|
||||||
import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js';
|
import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js';
|
||||||
import type { CoordinationManager } from '../coordination/types.js';
|
import type { CoordinationManager } from '../coordination/types.js';
|
||||||
@@ -82,6 +83,8 @@ export interface TrpcAdapterOptions {
|
|||||||
reviewCommentRepository?: ReviewCommentRepository;
|
reviewCommentRepository?: ReviewCommentRepository;
|
||||||
/** Project sync manager for remote fetch/sync operations */
|
/** Project sync manager for remote fetch/sync operations */
|
||||||
projectSyncManager?: ProjectSyncManager;
|
projectSyncManager?: ProjectSyncManager;
|
||||||
|
/** Errand repository for errand CRUD operations */
|
||||||
|
errandRepository?: ErrandRepository;
|
||||||
/** Absolute path to the workspace root (.cwrc directory) */
|
/** Absolute path to the workspace root (.cwrc directory) */
|
||||||
workspaceRoot?: string;
|
workspaceRoot?: string;
|
||||||
}
|
}
|
||||||
@@ -166,6 +169,7 @@ export function createTrpcHandler(options: TrpcAdapterOptions) {
|
|||||||
chatSessionRepository: options.chatSessionRepository,
|
chatSessionRepository: options.chatSessionRepository,
|
||||||
reviewCommentRepository: options.reviewCommentRepository,
|
reviewCommentRepository: options.reviewCommentRepository,
|
||||||
projectSyncManager: options.projectSyncManager,
|
projectSyncManager: options.projectSyncManager,
|
||||||
|
errandRepository: options.errandRepository,
|
||||||
workspaceRoot: options.workspaceRoot,
|
workspaceRoot: options.workspaceRoot,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -326,6 +326,47 @@ describe('agent.listForRadar', () => {
|
|||||||
expect(row!.subagentsCount).toBe(0);
|
expect(row!.subagentsCount).toBe(0);
|
||||||
expect(row!.compactionsCount).toBe(0);
|
expect(row!.compactionsCount).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns zero counts for agent with no metrics row', async () => {
|
||||||
|
const agents = new MockAgentManager();
|
||||||
|
const ctx = makeCtx(agents);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
// Agent with no log chunks at all — no agent_metrics row will exist
|
||||||
|
agents.addAgent({ id: 'agent-no-chunks', name: 'no-chunks-agent', status: 'running', createdAt: now });
|
||||||
|
|
||||||
|
const caller = createAgentCaller(ctx);
|
||||||
|
const result = await caller.listForRadar({ timeRange: 'all' });
|
||||||
|
|
||||||
|
const row = result.find(r => r.id === 'agent-no-chunks');
|
||||||
|
expect(row).toBeDefined();
|
||||||
|
expect(row!.questionsCount).toBe(0);
|
||||||
|
expect(row!.subagentsCount).toBe(0);
|
||||||
|
expect(row!.compactionsCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listForRadar response does not contain chunk content field', async () => {
|
||||||
|
const agents = new MockAgentManager();
|
||||||
|
const ctx = makeCtx(agents);
|
||||||
|
const { logChunkRepo } = getRepos(ctx);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
agents.addAgent({ id: 'agent-content', name: 'content-agent', status: 'running', createdAt: now });
|
||||||
|
|
||||||
|
await logChunkRepo.insertChunk({
|
||||||
|
agentId: 'agent-content',
|
||||||
|
agentName: 'content-agent',
|
||||||
|
sessionNumber: 1,
|
||||||
|
content: JSON.stringify({ type: 'tool_use', name: 'Agent', input: { description: 'do stuff', prompt: 'some prompt' } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const caller = createAgentCaller(ctx);
|
||||||
|
const result = await caller.listForRadar({ timeRange: 'all' });
|
||||||
|
|
||||||
|
for (const row of result) {
|
||||||
|
expect(row).not.toHaveProperty('content');
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -475,8 +475,8 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
const uniqueTaskIds = [...new Set(filteredAgents.map(a => a.taskId).filter(Boolean) as string[])];
|
const uniqueTaskIds = [...new Set(filteredAgents.map(a => a.taskId).filter(Boolean) as string[])];
|
||||||
const uniqueInitiativeIds = [...new Set(filteredAgents.map(a => a.initiativeId).filter(Boolean) as string[])];
|
const uniqueInitiativeIds = [...new Set(filteredAgents.map(a => a.initiativeId).filter(Boolean) as string[])];
|
||||||
|
|
||||||
const [chunks, messageCounts, taskResults, initiativeResults] = await Promise.all([
|
const [metrics, messageCounts, taskResults, initiativeResults] = await Promise.all([
|
||||||
logChunkRepo.findByAgentIds(matchingIds),
|
logChunkRepo.findMetricsByAgentIds(matchingIds),
|
||||||
conversationRepo.countByFromAgentIds(matchingIds),
|
conversationRepo.countByFromAgentIds(matchingIds),
|
||||||
Promise.all(uniqueTaskIds.map(id => taskRepo.findById(id))),
|
Promise.all(uniqueTaskIds.map(id => taskRepo.findById(id))),
|
||||||
Promise.all(uniqueInitiativeIds.map(id => initiativeRepo.findById(id))),
|
Promise.all(uniqueInitiativeIds.map(id => initiativeRepo.findById(id))),
|
||||||
@@ -486,37 +486,14 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
const taskMap = new Map(taskResults.filter(Boolean).map(t => [t!.id, t!.name]));
|
const taskMap = new Map(taskResults.filter(Boolean).map(t => [t!.id, t!.name]));
|
||||||
const initiativeMap = new Map(initiativeResults.filter(Boolean).map(i => [i!.id, i!.name]));
|
const initiativeMap = new Map(initiativeResults.filter(Boolean).map(i => [i!.id, i!.name]));
|
||||||
const messagesMap = new Map(messageCounts.map(m => [m.agentId, m.count]));
|
const messagesMap = new Map(messageCounts.map(m => [m.agentId, m.count]));
|
||||||
|
const metricsMap = new Map(metrics.map(m => [m.agentId, m]));
|
||||||
// Group chunks by agentId
|
|
||||||
const chunksByAgent = new Map<string, typeof chunks>();
|
|
||||||
for (const chunk of chunks) {
|
|
||||||
const existing = chunksByAgent.get(chunk.agentId);
|
|
||||||
if (existing) {
|
|
||||||
existing.push(chunk);
|
|
||||||
} else {
|
|
||||||
chunksByAgent.set(chunk.agentId, [chunk]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build result rows
|
// Build result rows
|
||||||
return filteredAgents.map(agent => {
|
return filteredAgents.map(agent => {
|
||||||
const agentChunks = chunksByAgent.get(agent.id) ?? [];
|
const agentMetrics = metricsMap.get(agent.id);
|
||||||
let questionsCount = 0;
|
const questionsCount = agentMetrics?.questionsCount ?? 0;
|
||||||
let subagentsCount = 0;
|
const subagentsCount = agentMetrics?.subagentsCount ?? 0;
|
||||||
let compactionsCount = 0;
|
const compactionsCount = agentMetrics?.compactionsCount ?? 0;
|
||||||
|
|
||||||
for (const chunk of agentChunks) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(chunk.content);
|
|
||||||
if (parsed.type === 'tool_use' && parsed.name === 'AskUserQuestion') {
|
|
||||||
questionsCount += parsed.input?.questions?.length ?? 0;
|
|
||||||
} else if (parsed.type === 'tool_use' && parsed.name === 'Agent') {
|
|
||||||
subagentsCount++;
|
|
||||||
} else if (parsed.type === 'system' && parsed.subtype === 'init' && parsed.source === 'compact') {
|
|
||||||
compactionsCount++;
|
|
||||||
}
|
|
||||||
} catch { /* skip malformed */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: agent.id,
|
id: agent.id,
|
||||||
|
|||||||
@@ -337,6 +337,14 @@ export function architectProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up orphaned pending/in_progress detail tasks from previous failed attempts
|
||||||
|
const phaseTasks = await taskRepo.findByPhaseId(input.phaseId);
|
||||||
|
for (const t of phaseTasks) {
|
||||||
|
if (t.category === 'detail' && (t.status === 'pending' || t.status === 'in_progress')) {
|
||||||
|
await taskRepo.update(t.id, { status: 'completed', summary: 'Superseded by retry' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const detailTaskName = input.taskName ?? `Detail: ${phase.name}`;
|
const detailTaskName = input.taskName ?? `Detail: ${phase.name}`;
|
||||||
const task = await taskRepo.create({
|
const task = await taskRepo.create({
|
||||||
phaseId: phase.id,
|
phaseId: phase.id,
|
||||||
|
|||||||
@@ -184,11 +184,11 @@ export function errandProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
.input(z.object({
|
.input(z.object({
|
||||||
projectId: z.string().optional(),
|
projectId: z.string().optional(),
|
||||||
status: z.enum(ErrandStatusValues).optional(),
|
status: z.enum(ErrandStatusValues).optional(),
|
||||||
}))
|
}).optional())
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
return requireErrandRepository(ctx).findAll({
|
return requireErrandRepository(ctx).findAll({
|
||||||
projectId: input.projectId,
|
projectId: input?.projectId,
|
||||||
status: input.status,
|
status: input?.status,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,35 @@
|
|||||||
|
|
||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { Task } from '../../db/schema.js';
|
import type { Phase, Task } from '../../db/schema.js';
|
||||||
import type { ProcedureBuilder } from '../trpc.js';
|
import type { ProcedureBuilder } from '../trpc.js';
|
||||||
import { requirePhaseDispatchManager, requirePhaseRepository, requireTaskRepository } from './_helpers.js';
|
import { requirePhaseDispatchManager, requirePhaseRepository, requireTaskRepository } from './_helpers.js';
|
||||||
|
|
||||||
|
const INTEGRATION_PHASE_NAME = 'Integration';
|
||||||
|
|
||||||
|
const INTEGRATION_TASK_DESCRIPTION = `Verify that all phase branches integrate correctly after merging into the initiative branch.
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Build the project — fix any compilation errors
|
||||||
|
2. Run the full test suite — fix any failing tests
|
||||||
|
3. Run type checking and linting — fix any errors
|
||||||
|
4. Review cross-phase imports and shared interfaces for compatibility
|
||||||
|
5. Smoke test key user flows affected by the merged changes
|
||||||
|
|
||||||
|
Only fix integration issues (type mismatches, conflicting exports, broken tests). Do not refactor or improve existing code.`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find phase IDs that have no dependents (no other phase depends on them).
|
||||||
|
* These are the "end" / "leaf" phases in the dependency graph.
|
||||||
|
*/
|
||||||
|
function findEndPhaseIds(
|
||||||
|
phases: Phase[],
|
||||||
|
edges: Array<{ phaseId: string; dependsOnPhaseId: string }>,
|
||||||
|
): string[] {
|
||||||
|
const dependedOn = new Set(edges.map((e) => e.dependsOnPhaseId));
|
||||||
|
return phases.filter((p) => !dependedOn.has(p.id)).map((p) => p.id);
|
||||||
|
}
|
||||||
|
|
||||||
export function phaseDispatchProcedures(publicProcedure: ProcedureBuilder) {
|
export function phaseDispatchProcedures(publicProcedure: ProcedureBuilder) {
|
||||||
return {
|
return {
|
||||||
queuePhase: publicProcedure
|
queuePhase: publicProcedure
|
||||||
@@ -23,7 +48,40 @@ export function phaseDispatchProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const phaseDispatchManager = requirePhaseDispatchManager(ctx);
|
const phaseDispatchManager = requirePhaseDispatchManager(ctx);
|
||||||
const phaseRepo = requirePhaseRepository(ctx);
|
const phaseRepo = requirePhaseRepository(ctx);
|
||||||
const phases = await phaseRepo.findByInitiativeId(input.initiativeId);
|
const taskRepo = requireTaskRepository(ctx);
|
||||||
|
|
||||||
|
let phases = await phaseRepo.findByInitiativeId(input.initiativeId);
|
||||||
|
const edges = await phaseRepo.findDependenciesByInitiativeId(input.initiativeId);
|
||||||
|
|
||||||
|
// Auto-create Integration phase if multiple end phases exist
|
||||||
|
const existingIntegration = phases.find((p) => p.name === INTEGRATION_PHASE_NAME);
|
||||||
|
if (!existingIntegration) {
|
||||||
|
const endPhaseIds = findEndPhaseIds(phases, edges);
|
||||||
|
if (endPhaseIds.length > 1) {
|
||||||
|
const integrationPhase = await phaseRepo.create({
|
||||||
|
initiativeId: input.initiativeId,
|
||||||
|
name: INTEGRATION_PHASE_NAME,
|
||||||
|
status: 'approved',
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const endPhaseId of endPhaseIds) {
|
||||||
|
await phaseRepo.createDependency(integrationPhase.id, endPhaseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await taskRepo.create({
|
||||||
|
phaseId: integrationPhase.id,
|
||||||
|
initiativeId: input.initiativeId,
|
||||||
|
name: 'Verify integration',
|
||||||
|
description: INTEGRATION_TASK_DESCRIPTION,
|
||||||
|
category: 'verify',
|
||||||
|
status: 'pending',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-fetch so the new phase gets queued in the loop below
|
||||||
|
phases = await phaseRepo.findByInitiativeId(input.initiativeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let queued = 0;
|
let queued = 0;
|
||||||
for (const phase of phases) {
|
for (const phase of phases) {
|
||||||
if (phase.status === 'approved') {
|
if (phase.status === 'approved') {
|
||||||
|
|||||||
@@ -331,8 +331,8 @@ export function ErrandDetailPanel({ errandId, onClose }: ErrandDetailPanelProps)
|
|||||||
{/* Info line */}
|
{/* Info line */}
|
||||||
<div className="px-5 pt-4 text-sm text-muted-foreground">
|
<div className="px-5 pt-4 text-sm text-muted-foreground">
|
||||||
{errand.status === 'merged'
|
{errand.status === 'merged'
|
||||||
? `Merged into ${errand.baseBranch} · ${formatRelativeTime(errand.updatedAt.toISOString())}`
|
? `Merged into ${errand.baseBranch} · ${formatRelativeTime(String(errand.updatedAt))}`
|
||||||
: `Abandoned · ${formatRelativeTime(errand.updatedAt.toISOString())}`}
|
: `Abandoned · ${formatRelativeTime(String(errand.updatedAt))}`}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Read-only diff */}
|
{/* Read-only diff */}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ interface RefineSpawnDialogProps {
|
|||||||
showInstructionInput?: boolean;
|
showInstructionInput?: boolean;
|
||||||
/** Placeholder text for the instruction textarea */
|
/** Placeholder text for the instruction textarea */
|
||||||
instructionPlaceholder?: string;
|
instructionPlaceholder?: string;
|
||||||
|
/** Pre-populate the instruction field (e.g. from a crashed agent's original instruction) */
|
||||||
|
defaultInstruction?: string;
|
||||||
/** Whether the spawn mutation is pending */
|
/** Whether the spawn mutation is pending */
|
||||||
isSpawning: boolean;
|
isSpawning: boolean;
|
||||||
/** Error message if spawn failed */
|
/** Error message if spawn failed */
|
||||||
@@ -38,6 +40,7 @@ export function RefineSpawnDialog({
|
|||||||
description,
|
description,
|
||||||
showInstructionInput = true,
|
showInstructionInput = true,
|
||||||
instructionPlaceholder = "What should the agent focus on? (optional)",
|
instructionPlaceholder = "What should the agent focus on? (optional)",
|
||||||
|
defaultInstruction,
|
||||||
isSpawning,
|
isSpawning,
|
||||||
error,
|
error,
|
||||||
onSpawn,
|
onSpawn,
|
||||||
@@ -53,18 +56,25 @@ export function RefineSpawnDialog({
|
|||||||
onSpawn(finalInstruction);
|
onSpawn(finalInstruction);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openDialog = () => {
|
||||||
|
setInstruction(defaultInstruction ?? "");
|
||||||
|
setShowDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
const handleOpenChange = (open: boolean) => {
|
const handleOpenChange = (open: boolean) => {
|
||||||
setShowDialog(open);
|
if (open) {
|
||||||
if (!open) {
|
setInstruction(defaultInstruction ?? "");
|
||||||
|
} else {
|
||||||
setInstruction("");
|
setInstruction("");
|
||||||
}
|
}
|
||||||
|
setShowDialog(open);
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultTrigger = (
|
const defaultTrigger = (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowDialog(true)}
|
onClick={openDialog}
|
||||||
className="gap-1.5"
|
className="gap-1.5"
|
||||||
>
|
>
|
||||||
<Sparkles className="h-3.5 w-3.5" />
|
<Sparkles className="h-3.5 w-3.5" />
|
||||||
@@ -75,7 +85,7 @@ export function RefineSpawnDialog({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{trigger ? (
|
{trigger ? (
|
||||||
<div onClick={() => setShowDialog(true)}>
|
<div onClick={openDialog}>
|
||||||
{trigger}
|
{trigger}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
import { Loader2, AlertCircle } from "lucide-react";
|
import { Loader2, AlertCircle } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { QuestionForm } from "@/components/QuestionForm";
|
import { QuestionForm } from "@/components/QuestionForm";
|
||||||
@@ -6,6 +6,12 @@ import { ChangeSetBanner } from "@/components/ChangeSetBanner";
|
|||||||
import { RefineSpawnDialog } from "../RefineSpawnDialog";
|
import { RefineSpawnDialog } from "../RefineSpawnDialog";
|
||||||
import { useRefineAgent } from "@/hooks";
|
import { useRefineAgent } from "@/hooks";
|
||||||
|
|
||||||
|
function extractInstruction(prompt: string | null | undefined): string | undefined {
|
||||||
|
if (!prompt) return undefined;
|
||||||
|
const match = prompt.match(/<user_instruction>\n([\s\S]*?)\n<\/user_instruction>/);
|
||||||
|
return match?.[1] || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
interface RefineAgentPanelProps {
|
interface RefineAgentPanelProps {
|
||||||
initiativeId: string;
|
initiativeId: string;
|
||||||
}
|
}
|
||||||
@@ -123,6 +129,11 @@ export function RefineAgentPanel({ initiativeId }: RefineAgentPanelProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Crashed
|
// Crashed
|
||||||
|
const crashedInstruction = useMemo(
|
||||||
|
() => (state === "crashed" ? extractInstruction(agent?.prompt) : undefined),
|
||||||
|
[state, agent?.prompt],
|
||||||
|
);
|
||||||
|
|
||||||
if (state === "crashed") {
|
if (state === "crashed") {
|
||||||
return (
|
return (
|
||||||
<div className="mb-3 rounded-lg border border-destructive/50 bg-destructive/5 px-3 py-2">
|
<div className="mb-3 rounded-lg border border-destructive/50 bg-destructive/5 px-3 py-2">
|
||||||
@@ -134,6 +145,7 @@ export function RefineAgentPanel({ initiativeId }: RefineAgentPanelProps) {
|
|||||||
title="Refine Initiative Content"
|
title="Refine Initiative Content"
|
||||||
description="An agent will review all pages and suggest improvements."
|
description="An agent will review all pages and suggest improvements."
|
||||||
instructionPlaceholder="What should the agent focus on? (optional)"
|
instructionPlaceholder="What should the agent focus on? (optional)"
|
||||||
|
defaultInstruction={crashedInstruction}
|
||||||
isSpawning={spawn.isPending}
|
isSpawning={spawn.isPending}
|
||||||
error={spawn.error?.message}
|
error={spawn.error?.message}
|
||||||
onSpawn={handleSpawn}
|
onSpawn={handleSpawn}
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import { useNavigate } from '@tanstack/react-router'
|
|
||||||
import { Card } from '@/components/ui/card'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipTrigger,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
} from '@/components/ui/tooltip'
|
|
||||||
import { formatRelativeTime } from '@/lib/utils'
|
|
||||||
import type { WaitingForInputItem } from './types'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
items: WaitingForInputItem[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HQWaitingForInputSection({ items }: Props) {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
|
||||||
Waiting for Input
|
|
||||||
</h2>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{items.map((item) => {
|
|
||||||
const truncated =
|
|
||||||
item.questionText.slice(0, 120) +
|
|
||||||
(item.questionText.length > 120 ? '…' : '')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card key={item.agentId} className="p-4 flex items-center justify-between gap-4">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="font-bold text-sm">
|
|
||||||
{item.agentName}
|
|
||||||
{item.initiativeName && (
|
|
||||||
<span className="font-normal text-muted-foreground"> · {item.initiativeName}</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<p className="text-sm text-muted-foreground truncate">{truncated}</p>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent forceMount>{item.questionText}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
waiting {formatRelativeTime(item.waitingSince)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => navigate({ to: '/inbox' })}
|
|
||||||
>
|
|
||||||
Answer
|
|
||||||
</Button>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
66
apps/web/src/layouts/AppLayout.test.tsx
Normal file
66
apps/web/src/layouts/AppLayout.test.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { render, screen, within } from '@testing-library/react'
|
||||||
|
import '@testing-library/jest-dom/vitest'
|
||||||
|
import { vi } from 'vitest'
|
||||||
|
import { AppLayout } from './AppLayout'
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock('@tanstack/react-router', () => ({
|
||||||
|
Link: ({ children, to }: any) => {
|
||||||
|
const content = typeof children === 'function' ? children({ isActive: false }) : children
|
||||||
|
return <a href={to}>{content}</a>
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
vi.mock('@/components/ThemeToggle', () => ({ ThemeToggle: () => null }))
|
||||||
|
vi.mock('@/components/HealthDot', () => ({ HealthDot: () => null }))
|
||||||
|
vi.mock('@/components/NavBadge', () => ({
|
||||||
|
NavBadge: ({ count }: { count: number }) => (
|
||||||
|
count > 0 ? <span data-testid="nav-badge">{count}</span> : null
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockUseQuery = vi.hoisted(() => vi.fn())
|
||||||
|
vi.mock('@/lib/trpc', () => ({
|
||||||
|
trpc: {
|
||||||
|
listAgents: { useQuery: mockUseQuery },
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockUseQuery.mockReturnValue({ data: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('AppLayout navItems', () => {
|
||||||
|
it('renders HQ nav link', () => {
|
||||||
|
render(<AppLayout connectionState="connected">{null}</AppLayout>)
|
||||||
|
expect(screen.getByRole('link', { name: /hq/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render Inbox nav link', () => {
|
||||||
|
render(<AppLayout connectionState="connected">{null}</AppLayout>)
|
||||||
|
expect(screen.queryByRole('link', { name: /inbox/i })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows badge on HQ when agents are waiting_for_input', () => {
|
||||||
|
mockUseQuery.mockReturnValue({
|
||||||
|
data: [
|
||||||
|
{ id: '1', status: 'waiting_for_input' },
|
||||||
|
{ id: '2', status: 'running' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
render(<AppLayout connectionState="connected">{null}</AppLayout>)
|
||||||
|
// NavBadge rendered next to HQ link (count=1)
|
||||||
|
const hqLink = screen.getByRole('link', { name: /hq/i })
|
||||||
|
const badge = within(hqLink).getByTestId('nav-badge')
|
||||||
|
expect(badge).toHaveTextContent('1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show questions badge on any Inbox link (Inbox removed)', () => {
|
||||||
|
mockUseQuery.mockReturnValue({
|
||||||
|
data: [{ id: '1', status: 'waiting_for_input' }],
|
||||||
|
})
|
||||||
|
render(<AppLayout connectionState="connected">{null}</AppLayout>)
|
||||||
|
expect(screen.queryByRole('link', { name: /inbox/i })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -7,11 +7,11 @@ import { trpc } from '@/lib/trpc'
|
|||||||
import type { ConnectionState } from '@/hooks/useConnectionStatus'
|
import type { ConnectionState } from '@/hooks/useConnectionStatus'
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ label: 'HQ', to: '/hq', badgeKey: null },
|
{ label: 'HQ', to: '/hq', badgeKey: 'questions' as const },
|
||||||
{ label: 'Initiatives', to: '/initiatives', badgeKey: null },
|
{ label: 'Initiatives', to: '/initiatives', badgeKey: null },
|
||||||
{ label: 'Agents', to: '/agents', badgeKey: 'running' as const },
|
{ label: 'Agents', to: '/agents', badgeKey: 'running' as const },
|
||||||
|
{ label: 'Errands', to: '/errands', badgeKey: null },
|
||||||
{ label: 'Radar', to: '/radar', badgeKey: null },
|
{ label: 'Radar', to: '/radar', badgeKey: null },
|
||||||
{ label: 'Inbox', to: '/inbox', badgeKey: 'questions' as const },
|
|
||||||
{ label: 'Settings', to: '/settings', badgeKey: null },
|
{ label: 'Settings', to: '/settings', badgeKey: null },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { Route as AgentsRouteImport } from './routes/agents'
|
|||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as SettingsIndexRouteImport } from './routes/settings/index'
|
import { Route as SettingsIndexRouteImport } from './routes/settings/index'
|
||||||
import { Route as InitiativesIndexRouteImport } from './routes/initiatives/index'
|
import { Route as InitiativesIndexRouteImport } from './routes/initiatives/index'
|
||||||
|
import { Route as ErrandsIndexRouteImport } from './routes/errands/index'
|
||||||
import { Route as SettingsProjectsRouteImport } from './routes/settings/projects'
|
import { Route as SettingsProjectsRouteImport } from './routes/settings/projects'
|
||||||
import { Route as SettingsHealthRouteImport } from './routes/settings/health'
|
import { Route as SettingsHealthRouteImport } from './routes/settings/health'
|
||||||
import { Route as InitiativesIdRouteImport } from './routes/initiatives/$id'
|
import { Route as InitiativesIdRouteImport } from './routes/initiatives/$id'
|
||||||
@@ -61,6 +62,11 @@ const InitiativesIndexRoute = InitiativesIndexRouteImport.update({
|
|||||||
path: '/initiatives/',
|
path: '/initiatives/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ErrandsIndexRoute = ErrandsIndexRouteImport.update({
|
||||||
|
id: '/errands/',
|
||||||
|
path: '/errands/',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const SettingsProjectsRoute = SettingsProjectsRouteImport.update({
|
const SettingsProjectsRoute = SettingsProjectsRouteImport.update({
|
||||||
id: '/projects',
|
id: '/projects',
|
||||||
path: '/projects',
|
path: '/projects',
|
||||||
@@ -87,6 +93,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/initiatives/$id': typeof InitiativesIdRoute
|
'/initiatives/$id': typeof InitiativesIdRoute
|
||||||
'/settings/health': typeof SettingsHealthRoute
|
'/settings/health': typeof SettingsHealthRoute
|
||||||
'/settings/projects': typeof SettingsProjectsRoute
|
'/settings/projects': typeof SettingsProjectsRoute
|
||||||
|
'/errands/': typeof ErrandsIndexRoute
|
||||||
'/initiatives/': typeof InitiativesIndexRoute
|
'/initiatives/': typeof InitiativesIndexRoute
|
||||||
'/settings/': typeof SettingsIndexRoute
|
'/settings/': typeof SettingsIndexRoute
|
||||||
}
|
}
|
||||||
@@ -99,6 +106,7 @@ export interface FileRoutesByTo {
|
|||||||
'/initiatives/$id': typeof InitiativesIdRoute
|
'/initiatives/$id': typeof InitiativesIdRoute
|
||||||
'/settings/health': typeof SettingsHealthRoute
|
'/settings/health': typeof SettingsHealthRoute
|
||||||
'/settings/projects': typeof SettingsProjectsRoute
|
'/settings/projects': typeof SettingsProjectsRoute
|
||||||
|
'/errands': typeof ErrandsIndexRoute
|
||||||
'/initiatives': typeof InitiativesIndexRoute
|
'/initiatives': typeof InitiativesIndexRoute
|
||||||
'/settings': typeof SettingsIndexRoute
|
'/settings': typeof SettingsIndexRoute
|
||||||
}
|
}
|
||||||
@@ -113,6 +121,7 @@ export interface FileRoutesById {
|
|||||||
'/initiatives/$id': typeof InitiativesIdRoute
|
'/initiatives/$id': typeof InitiativesIdRoute
|
||||||
'/settings/health': typeof SettingsHealthRoute
|
'/settings/health': typeof SettingsHealthRoute
|
||||||
'/settings/projects': typeof SettingsProjectsRoute
|
'/settings/projects': typeof SettingsProjectsRoute
|
||||||
|
'/errands/': typeof ErrandsIndexRoute
|
||||||
'/initiatives/': typeof InitiativesIndexRoute
|
'/initiatives/': typeof InitiativesIndexRoute
|
||||||
'/settings/': typeof SettingsIndexRoute
|
'/settings/': typeof SettingsIndexRoute
|
||||||
}
|
}
|
||||||
@@ -128,6 +137,7 @@ export interface FileRouteTypes {
|
|||||||
| '/initiatives/$id'
|
| '/initiatives/$id'
|
||||||
| '/settings/health'
|
| '/settings/health'
|
||||||
| '/settings/projects'
|
| '/settings/projects'
|
||||||
|
| '/errands/'
|
||||||
| '/initiatives/'
|
| '/initiatives/'
|
||||||
| '/settings/'
|
| '/settings/'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
@@ -140,6 +150,7 @@ export interface FileRouteTypes {
|
|||||||
| '/initiatives/$id'
|
| '/initiatives/$id'
|
||||||
| '/settings/health'
|
| '/settings/health'
|
||||||
| '/settings/projects'
|
| '/settings/projects'
|
||||||
|
| '/errands'
|
||||||
| '/initiatives'
|
| '/initiatives'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
id:
|
id:
|
||||||
@@ -153,6 +164,7 @@ export interface FileRouteTypes {
|
|||||||
| '/initiatives/$id'
|
| '/initiatives/$id'
|
||||||
| '/settings/health'
|
| '/settings/health'
|
||||||
| '/settings/projects'
|
| '/settings/projects'
|
||||||
|
| '/errands/'
|
||||||
| '/initiatives/'
|
| '/initiatives/'
|
||||||
| '/settings/'
|
| '/settings/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
@@ -165,6 +177,7 @@ export interface RootRouteChildren {
|
|||||||
RadarRoute: typeof RadarRoute
|
RadarRoute: typeof RadarRoute
|
||||||
SettingsRoute: typeof SettingsRouteWithChildren
|
SettingsRoute: typeof SettingsRouteWithChildren
|
||||||
InitiativesIdRoute: typeof InitiativesIdRoute
|
InitiativesIdRoute: typeof InitiativesIdRoute
|
||||||
|
ErrandsIndexRoute: typeof ErrandsIndexRoute
|
||||||
InitiativesIndexRoute: typeof InitiativesIndexRoute
|
InitiativesIndexRoute: typeof InitiativesIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,6 +239,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof InitiativesIndexRouteImport
|
preLoaderRoute: typeof InitiativesIndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/errands/': {
|
||||||
|
id: '/errands/'
|
||||||
|
path: '/errands'
|
||||||
|
fullPath: '/errands/'
|
||||||
|
preLoaderRoute: typeof ErrandsIndexRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/settings/projects': {
|
'/settings/projects': {
|
||||||
id: '/settings/projects'
|
id: '/settings/projects'
|
||||||
path: '/projects'
|
path: '/projects'
|
||||||
@@ -274,6 +294,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
RadarRoute: RadarRoute,
|
RadarRoute: RadarRoute,
|
||||||
SettingsRoute: SettingsRouteWithChildren,
|
SettingsRoute: SettingsRouteWithChildren,
|
||||||
InitiativesIdRoute: InitiativesIdRoute,
|
InitiativesIdRoute: InitiativesIdRoute,
|
||||||
|
ErrandsIndexRoute: ErrandsIndexRoute,
|
||||||
InitiativesIndexRoute: InitiativesIndexRoute,
|
InitiativesIndexRoute: InitiativesIndexRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ function ErrandsPage() {
|
|||||||
{e.agentAlias ?? '—'}
|
{e.agentAlias ?? '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-muted-foreground whitespace-nowrap">
|
<td className="px-4 py-3 text-muted-foreground whitespace-nowrap">
|
||||||
{formatRelativeTime(e.createdAt.toISOString())}
|
{formatRelativeTime(String(e.createdAt))}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -4,9 +4,24 @@ import { render, screen, fireEvent } from '@testing-library/react'
|
|||||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
|
||||||
const mockUseQuery = vi.hoisted(() => vi.fn())
|
const mockUseQuery = vi.hoisted(() => vi.fn())
|
||||||
|
const mockListWaitingAgentsQuery = vi.hoisted(() => vi.fn())
|
||||||
|
const mockListMessagesQuery = vi.hoisted(() => vi.fn())
|
||||||
|
const mockGetAgentQuestionsQuery = vi.hoisted(() => vi.fn())
|
||||||
|
const mockResumeAgentMutation = vi.hoisted(() => vi.fn())
|
||||||
|
const mockStopAgentMutation = vi.hoisted(() => vi.fn())
|
||||||
|
const mockRespondToMessageMutation = vi.hoisted(() => vi.fn())
|
||||||
|
const mockUseUtils = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
vi.mock('@/lib/trpc', () => ({
|
vi.mock('@/lib/trpc', () => ({
|
||||||
trpc: {
|
trpc: {
|
||||||
getHeadquartersDashboard: { useQuery: mockUseQuery },
|
getHeadquartersDashboard: { useQuery: mockUseQuery },
|
||||||
|
listWaitingAgents: { useQuery: mockListWaitingAgentsQuery },
|
||||||
|
listMessages: { useQuery: mockListMessagesQuery },
|
||||||
|
getAgentQuestions: { useQuery: mockGetAgentQuestionsQuery },
|
||||||
|
resumeAgent: { useMutation: mockResumeAgentMutation },
|
||||||
|
stopAgent: { useMutation: mockStopAgentMutation },
|
||||||
|
respondToMessage: { useMutation: mockRespondToMessageMutation },
|
||||||
|
useUtils: mockUseUtils,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -15,8 +30,33 @@ vi.mock('@/hooks', () => ({
|
|||||||
LiveUpdateRule: undefined,
|
LiveUpdateRule: undefined,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/components/hq/HQWaitingForInputSection', () => ({
|
vi.mock('@/components/InboxList', () => ({
|
||||||
HQWaitingForInputSection: ({ items }: any) => <div data-testid="waiting">{items.length}</div>,
|
InboxList: ({ agents, selectedAgentId, onSelectAgent }: any) => (
|
||||||
|
<div data-testid="inbox-list">
|
||||||
|
{agents.map((a: any) => (
|
||||||
|
<button
|
||||||
|
key={a.id}
|
||||||
|
data-testid={`agent-${a.id}`}
|
||||||
|
aria-selected={selectedAgentId === a.id}
|
||||||
|
onClick={() => onSelectAgent(a.id)}
|
||||||
|
>
|
||||||
|
{a.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/InboxDetailPanel', () => ({
|
||||||
|
InboxDetailPanel: ({ agent, onSubmitAnswers, onDismissQuestions, onDismissMessage, onBack }: any) => (
|
||||||
|
<div data-testid="inbox-detail">
|
||||||
|
<span data-testid="selected-agent-name">{agent.name}</span>
|
||||||
|
<button onClick={() => onSubmitAnswers({ q1: 'answer' })}>Submit</button>
|
||||||
|
<button onClick={onDismissQuestions}>Dismiss Questions</button>
|
||||||
|
<button onClick={onDismissMessage}>Dismiss Message</button>
|
||||||
|
<button onClick={onBack}>Back</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/components/hq/HQNeedsReviewSection', () => ({
|
vi.mock('@/components/hq/HQNeedsReviewSection', () => ({
|
||||||
@@ -56,6 +96,16 @@ const emptyData = {
|
|||||||
describe('HeadquartersPage', () => {
|
describe('HeadquartersPage', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
mockListWaitingAgentsQuery.mockReturnValue({ data: [], isLoading: false })
|
||||||
|
mockListMessagesQuery.mockReturnValue({ data: [], isLoading: false })
|
||||||
|
mockGetAgentQuestionsQuery.mockReturnValue({ data: null, isLoading: false, isError: false })
|
||||||
|
mockResumeAgentMutation.mockReturnValue({ mutate: vi.fn(), isPending: false, isError: false })
|
||||||
|
mockStopAgentMutation.mockReturnValue({ mutate: vi.fn(), isPending: false, isError: false })
|
||||||
|
mockRespondToMessageMutation.mockReturnValue({ mutate: vi.fn(), isPending: false, isError: false })
|
||||||
|
mockUseUtils.mockReturnValue({
|
||||||
|
listWaitingAgents: { invalidate: vi.fn() },
|
||||||
|
listMessages: { invalidate: vi.fn() },
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders skeleton loading state', () => {
|
it('renders skeleton loading state', () => {
|
||||||
@@ -68,7 +118,7 @@ describe('HeadquartersPage', () => {
|
|||||||
const skeletons = document.querySelectorAll('[class*="skeleton"], [class*="h-16"]')
|
const skeletons = document.querySelectorAll('[class*="skeleton"], [class*="h-16"]')
|
||||||
expect(skeletons.length).toBeGreaterThan(0)
|
expect(skeletons.length).toBeGreaterThan(0)
|
||||||
// No section components
|
// No section components
|
||||||
expect(screen.queryByTestId('waiting')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('inbox-list')).not.toBeInTheDocument()
|
||||||
expect(screen.queryByTestId('needs-review')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('needs-review')).not.toBeInTheDocument()
|
||||||
expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument()
|
||||||
expect(screen.queryByTestId('blocked')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('blocked')).not.toBeInTheDocument()
|
||||||
@@ -93,24 +143,25 @@ describe('HeadquartersPage', () => {
|
|||||||
render(<HeadquartersPage />)
|
render(<HeadquartersPage />)
|
||||||
|
|
||||||
expect(screen.getByTestId('empty-state')).toBeInTheDocument()
|
expect(screen.getByTestId('empty-state')).toBeInTheDocument()
|
||||||
expect(screen.queryByTestId('waiting')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('inbox-list')).not.toBeInTheDocument()
|
||||||
expect(screen.queryByTestId('needs-review')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('needs-review')).not.toBeInTheDocument()
|
||||||
expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument()
|
||||||
expect(screen.queryByTestId('blocked')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('blocked')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders WaitingForInput section when items exist', () => {
|
it('renders InboxList when waitingForInput items exist', () => {
|
||||||
mockUseQuery.mockReturnValue({
|
mockUseQuery.mockReturnValue({
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isError: false,
|
isError: false,
|
||||||
data: { ...emptyData, waitingForInput: [{ id: '1' }] },
|
data: { ...emptyData, waitingForInput: [{ id: '1' }] },
|
||||||
})
|
})
|
||||||
|
mockListWaitingAgentsQuery.mockReturnValue({
|
||||||
|
data: [{ id: 'a1', name: 'Agent 1', status: 'waiting_for_input', taskId: null, updatedAt: new Date() }],
|
||||||
|
isLoading: false,
|
||||||
|
})
|
||||||
render(<HeadquartersPage />)
|
render(<HeadquartersPage />)
|
||||||
|
|
||||||
expect(screen.getByTestId('waiting')).toBeInTheDocument()
|
expect(screen.getByTestId('inbox-list')).toBeInTheDocument()
|
||||||
expect(screen.queryByTestId('needs-review')).not.toBeInTheDocument()
|
|
||||||
expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument()
|
|
||||||
expect(screen.queryByTestId('blocked')).not.toBeInTheDocument()
|
|
||||||
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -127,9 +178,13 @@ describe('HeadquartersPage', () => {
|
|||||||
blockedPhases: [{ id: '6' }],
|
blockedPhases: [{ id: '6' }],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
mockListWaitingAgentsQuery.mockReturnValue({
|
||||||
|
data: [{ id: 'a1', name: 'Agent 1', status: 'waiting_for_input', taskId: null, updatedAt: new Date() }],
|
||||||
|
isLoading: false,
|
||||||
|
})
|
||||||
render(<HeadquartersPage />)
|
render(<HeadquartersPage />)
|
||||||
|
|
||||||
expect(screen.getByTestId('waiting')).toBeInTheDocument()
|
expect(screen.getByTestId('inbox-list')).toBeInTheDocument()
|
||||||
expect(screen.getByTestId('needs-review')).toBeInTheDocument()
|
expect(screen.getByTestId('needs-review')).toBeInTheDocument()
|
||||||
expect(screen.getByTestId('needs-approval')).toBeInTheDocument()
|
expect(screen.getByTestId('needs-approval')).toBeInTheDocument()
|
||||||
expect(screen.getByTestId('resolving-conflicts')).toBeInTheDocument()
|
expect(screen.getByTestId('resolving-conflicts')).toBeInTheDocument()
|
||||||
@@ -160,4 +215,41 @@ describe('HeadquartersPage', () => {
|
|||||||
expect(screen.getByTestId('needs-review')).toBeInTheDocument()
|
expect(screen.getByTestId('needs-review')).toBeInTheDocument()
|
||||||
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows InboxDetailPanel when an agent is selected from InboxList', async () => {
|
||||||
|
mockUseQuery.mockReturnValue({
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
data: { ...emptyData, waitingForInput: [{ id: '1' }] },
|
||||||
|
})
|
||||||
|
mockListWaitingAgentsQuery.mockReturnValue({
|
||||||
|
data: [{ id: 'a1', name: 'Agent 1', status: 'waiting_for_input', taskId: null, updatedAt: new Date() }],
|
||||||
|
isLoading: false,
|
||||||
|
})
|
||||||
|
render(<HeadquartersPage />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('agent-a1'))
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('inbox-detail')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('selected-agent-name')).toHaveTextContent('Agent 1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears selection when Back is clicked in InboxDetailPanel', async () => {
|
||||||
|
mockUseQuery.mockReturnValue({
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
data: { ...emptyData, waitingForInput: [{ id: '1' }] },
|
||||||
|
})
|
||||||
|
mockListWaitingAgentsQuery.mockReturnValue({
|
||||||
|
data: [{ id: 'a1', name: 'Agent 1', status: 'waiting_for_input', taskId: null, updatedAt: new Date() }],
|
||||||
|
isLoading: false,
|
||||||
|
})
|
||||||
|
render(<HeadquartersPage />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('agent-a1'))
|
||||||
|
expect(screen.getByTestId('inbox-detail')).toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /back/i }))
|
||||||
|
expect(screen.queryByTestId('inbox-detail')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useState } from "react";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { trpc } from "@/lib/trpc";
|
import { trpc } from "@/lib/trpc";
|
||||||
import { useLiveUpdates, type LiveUpdateRule } from "@/hooks";
|
import { useLiveUpdates, type LiveUpdateRule } from "@/hooks";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { HQWaitingForInputSection } from "@/components/hq/HQWaitingForInputSection";
|
import { InboxList } from "@/components/InboxList";
|
||||||
|
import { InboxDetailPanel } from "@/components/InboxDetailPanel";
|
||||||
import { HQNeedsReviewSection } from "@/components/hq/HQNeedsReviewSection";
|
import { HQNeedsReviewSection } from "@/components/hq/HQNeedsReviewSection";
|
||||||
import { HQNeedsApprovalSection } from "@/components/hq/HQNeedsApprovalSection";
|
import { HQNeedsApprovalSection } from "@/components/hq/HQNeedsApprovalSection";
|
||||||
import { HQResolvingConflictsSection } from "@/components/hq/HQResolvingConflictsSection";
|
import { HQResolvingConflictsSection } from "@/components/hq/HQResolvingConflictsSection";
|
||||||
@@ -19,12 +22,108 @@ const HQ_LIVE_UPDATE_RULES: LiveUpdateRule[] = [
|
|||||||
{ prefix: "initiative:", invalidate: ["getHeadquartersDashboard"] },
|
{ prefix: "initiative:", invalidate: ["getHeadquartersDashboard"] },
|
||||||
{ prefix: "phase:", invalidate: ["getHeadquartersDashboard"] },
|
{ prefix: "phase:", invalidate: ["getHeadquartersDashboard"] },
|
||||||
{ prefix: "agent:", invalidate: ["getHeadquartersDashboard"] },
|
{ prefix: "agent:", invalidate: ["getHeadquartersDashboard"] },
|
||||||
|
{ prefix: "agent:", invalidate: ["listWaitingAgents", "listMessages"] },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function HeadquartersPage() {
|
export function HeadquartersPage() {
|
||||||
useLiveUpdates(HQ_LIVE_UPDATE_RULES);
|
useLiveUpdates(HQ_LIVE_UPDATE_RULES);
|
||||||
const query = trpc.getHeadquartersDashboard.useQuery();
|
const query = trpc.getHeadquartersDashboard.useQuery();
|
||||||
|
|
||||||
|
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
const agentsQuery = trpc.listWaitingAgents.useQuery();
|
||||||
|
const messagesQuery = trpc.listMessages.useQuery({});
|
||||||
|
const questionsQuery = trpc.getAgentQuestions.useQuery(
|
||||||
|
{ id: selectedAgentId! },
|
||||||
|
{ enabled: !!selectedAgentId }
|
||||||
|
);
|
||||||
|
|
||||||
|
const resumeAgentMutation = trpc.resumeAgent.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
setSelectedAgentId(null);
|
||||||
|
toast.success("Answer submitted");
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Failed to submit answer");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const dismissQuestionsMutation = trpc.stopAgent.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
setSelectedAgentId(null);
|
||||||
|
toast.success("Questions dismissed");
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Failed to dismiss questions");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const respondToMessageMutation = trpc.respondToMessage.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
setSelectedAgentId(null);
|
||||||
|
toast.success("Response sent");
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Failed to send response");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const agents = agentsQuery.data ?? [];
|
||||||
|
const messages = messagesQuery.data ?? [];
|
||||||
|
const selectedAgent = selectedAgentId
|
||||||
|
? agents.find((a) => a.id === selectedAgentId) ?? null
|
||||||
|
: null;
|
||||||
|
const selectedMessage = selectedAgentId
|
||||||
|
? messages
|
||||||
|
.filter((m) => m.senderId === selectedAgentId)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||||
|
)[0] ?? null
|
||||||
|
: null;
|
||||||
|
const pendingQuestions = questionsQuery.data ?? null;
|
||||||
|
|
||||||
|
function handleRefresh() {
|
||||||
|
void utils.listWaitingAgents.invalidate();
|
||||||
|
void utils.listMessages.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmitAnswers(answers: Record<string, string>) {
|
||||||
|
if (!selectedAgentId) return;
|
||||||
|
resumeAgentMutation.mutate({ id: selectedAgentId, answers });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDismissQuestions() {
|
||||||
|
if (!selectedAgentId) return;
|
||||||
|
dismissQuestionsMutation.mutate({ id: selectedAgentId });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDismiss() {
|
||||||
|
if (!selectedMessage) return;
|
||||||
|
respondToMessageMutation.mutate({
|
||||||
|
id: selectedMessage.id,
|
||||||
|
response: "Acknowledged",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const serializedAgents = agents.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
name: a.name,
|
||||||
|
status: a.status,
|
||||||
|
taskId: a.taskId ?? "",
|
||||||
|
updatedAt: String(a.updatedAt),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const serializedMessages = messages.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
senderId: m.senderId,
|
||||||
|
content: m.content,
|
||||||
|
requiresResponse: m.requiresResponse,
|
||||||
|
status: m.status,
|
||||||
|
createdAt: String(m.createdAt),
|
||||||
|
}));
|
||||||
|
|
||||||
if (query.isLoading) {
|
if (query.isLoading) {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -97,7 +196,80 @@ export function HeadquartersPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{data.waitingForInput.length > 0 && (
|
{data.waitingForInput.length > 0 && (
|
||||||
<HQWaitingForInputSection items={data.waitingForInput} />
|
<div className="space-y-3">
|
||||||
|
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
Waiting for Input
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_400px]">
|
||||||
|
{/* Left: agent list — hidden on mobile when an agent is selected */}
|
||||||
|
<div className={selectedAgent ? "hidden lg:block" : undefined}>
|
||||||
|
<InboxList
|
||||||
|
agents={serializedAgents}
|
||||||
|
messages={serializedMessages}
|
||||||
|
selectedAgentId={selectedAgentId}
|
||||||
|
onSelectAgent={setSelectedAgentId}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Right: detail panel */}
|
||||||
|
{selectedAgent ? (
|
||||||
|
<InboxDetailPanel
|
||||||
|
agent={{
|
||||||
|
id: selectedAgent.id,
|
||||||
|
name: selectedAgent.name,
|
||||||
|
status: selectedAgent.status,
|
||||||
|
taskId: selectedAgent.taskId ?? null,
|
||||||
|
updatedAt: String(selectedAgent.updatedAt),
|
||||||
|
}}
|
||||||
|
message={
|
||||||
|
selectedMessage
|
||||||
|
? {
|
||||||
|
id: selectedMessage.id,
|
||||||
|
content: selectedMessage.content,
|
||||||
|
requiresResponse: selectedMessage.requiresResponse,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
questions={
|
||||||
|
pendingQuestions
|
||||||
|
? pendingQuestions.questions.map((q) => ({
|
||||||
|
id: q.id,
|
||||||
|
question: q.question,
|
||||||
|
options: q.options,
|
||||||
|
multiSelect: q.multiSelect,
|
||||||
|
}))
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
isLoadingQuestions={questionsQuery.isLoading}
|
||||||
|
questionsError={
|
||||||
|
questionsQuery.isError ? questionsQuery.error.message : null
|
||||||
|
}
|
||||||
|
onBack={() => setSelectedAgentId(null)}
|
||||||
|
onSubmitAnswers={handleSubmitAnswers}
|
||||||
|
onDismissQuestions={handleDismissQuestions}
|
||||||
|
onDismissMessage={handleDismiss}
|
||||||
|
isSubmitting={resumeAgentMutation.isPending}
|
||||||
|
isDismissingQuestions={dismissQuestionsMutation.isPending}
|
||||||
|
isDismissingMessage={respondToMessageMutation.isPending}
|
||||||
|
submitError={
|
||||||
|
resumeAgentMutation.isError
|
||||||
|
? resumeAgentMutation.error.message
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
dismissMessageError={
|
||||||
|
respondToMessageMutation.isError
|
||||||
|
? respondToMessageMutation.error.message
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="hidden flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border p-8 lg:flex">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">No agent selected</p>
|
||||||
|
<p className="text-xs text-muted-foreground/70">Select an agent from the list to answer their questions</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{(data.pendingReviewInitiatives.length > 0 ||
|
{(data.pendingReviewInitiatives.length > 0 ||
|
||||||
data.pendingReviewPhases.length > 0) && (
|
data.pendingReviewPhases.length > 0) && (
|
||||||
|
|||||||
@@ -1,269 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { motion } from "motion/react";
|
|
||||||
import { AlertCircle, MessageSquare } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card } from "@/components/ui/card";
|
|
||||||
import { Skeleton } from "@/components/Skeleton";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { trpc } from "@/lib/trpc";
|
|
||||||
import { InboxList } from "@/components/InboxList";
|
|
||||||
import { InboxDetailPanel } from "@/components/InboxDetailPanel";
|
|
||||||
import { useLiveUpdates } from "@/hooks";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/inbox")({
|
export const Route = createFileRoute("/inbox")({
|
||||||
component: InboxPage,
|
beforeLoad: () => {
|
||||||
|
throw redirect({ to: "/hq" });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function InboxPage() {
|
|
||||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Single SSE stream for live updates
|
|
||||||
useLiveUpdates([
|
|
||||||
{ prefix: 'agent:', invalidate: ['listWaitingAgents', 'listMessages'] },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const utils = trpc.useUtils();
|
|
||||||
|
|
||||||
// Data fetching
|
|
||||||
const agentsQuery = trpc.listWaitingAgents.useQuery();
|
|
||||||
const messagesQuery = trpc.listMessages.useQuery({});
|
|
||||||
const questionsQuery = trpc.getAgentQuestions.useQuery(
|
|
||||||
{ id: selectedAgentId! },
|
|
||||||
{ enabled: !!selectedAgentId }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Mutations
|
|
||||||
const resumeAgentMutation = trpc.resumeAgent.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
setSelectedAgentId(null);
|
|
||||||
toast.success("Answer submitted");
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast.error("Failed to submit answer");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const dismissQuestionsMutation = trpc.stopAgent.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
setSelectedAgentId(null);
|
|
||||||
toast.success("Questions dismissed");
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast.error("Failed to dismiss questions");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const respondToMessageMutation = trpc.respondToMessage.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
setSelectedAgentId(null);
|
|
||||||
toast.success("Response sent");
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast.error("Failed to send response");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Find selected agent info
|
|
||||||
const agents = agentsQuery.data ?? [];
|
|
||||||
const messages = messagesQuery.data ?? [];
|
|
||||||
const selectedAgent = selectedAgentId
|
|
||||||
? agents.find((a) => a.id === selectedAgentId) ?? null
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// Find the latest message for the selected agent
|
|
||||||
const selectedMessage = selectedAgentId
|
|
||||||
? messages
|
|
||||||
.filter((m) => m.senderId === selectedAgentId)
|
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
|
||||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
|
||||||
)[0] ?? null
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const pendingQuestions = questionsQuery.data ?? null;
|
|
||||||
|
|
||||||
// Handlers
|
|
||||||
function handleRefresh() {
|
|
||||||
void utils.listWaitingAgents.invalidate();
|
|
||||||
void utils.listMessages.invalidate();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSubmitAnswers(answers: Record<string, string>) {
|
|
||||||
if (!selectedAgentId) return;
|
|
||||||
resumeAgentMutation.mutate({ id: selectedAgentId, answers });
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDismissQuestions() {
|
|
||||||
if (!selectedAgentId) return;
|
|
||||||
dismissQuestionsMutation.mutate({ id: selectedAgentId });
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDismiss() {
|
|
||||||
if (!selectedMessage) return;
|
|
||||||
respondToMessageMutation.mutate({
|
|
||||||
id: selectedMessage.id,
|
|
||||||
response: "Acknowledged",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Loading state
|
|
||||||
if (agentsQuery.isLoading && messagesQuery.isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Skeleton header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Skeleton className="h-6 w-28" />
|
|
||||||
<Skeleton className="h-5 w-8 rounded-full" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-8 w-20" />
|
|
||||||
</div>
|
|
||||||
{/* Skeleton message rows */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
|
||||||
<Card key={i} className="p-4">
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Skeleton className="h-3 w-3 rounded-full" />
|
|
||||||
<Skeleton className="h-4 w-32" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="mt-2 ml-5 h-3 w-full" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-3 w-16" />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error state
|
|
||||||
if (agentsQuery.isError || messagesQuery.isError) {
|
|
||||||
const errorMessage =
|
|
||||||
agentsQuery.error?.message ?? messagesQuery.error?.message ?? "Unknown error";
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center gap-4 py-12">
|
|
||||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
|
||||||
<p className="text-sm text-destructive">
|
|
||||||
Failed to load inbox: {errorMessage}
|
|
||||||
</p>
|
|
||||||
<Button variant="outline" size="sm" onClick={handleRefresh}>
|
|
||||||
Retry
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Serialize agents for InboxList (convert Date to string for wire format)
|
|
||||||
const serializedAgents = agents.map((a) => ({
|
|
||||||
id: a.id,
|
|
||||||
name: a.name,
|
|
||||||
status: a.status,
|
|
||||||
taskId: a.taskId,
|
|
||||||
updatedAt: String(a.updatedAt),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Serialize messages for InboxList
|
|
||||||
const serializedMessages = messages.map((m) => ({
|
|
||||||
id: m.id,
|
|
||||||
senderId: m.senderId,
|
|
||||||
content: m.content,
|
|
||||||
requiresResponse: m.requiresResponse,
|
|
||||||
status: m.status,
|
|
||||||
createdAt: String(m.createdAt),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 8 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.3, ease: [0, 0, 0.2, 1] }}
|
|
||||||
className="space-y-6"
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_400px]">
|
|
||||||
{/* Left: Inbox List -- hidden on mobile when agent selected */}
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 12 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.3, delay: 0.05, ease: [0, 0, 0.2, 1] }}
|
|
||||||
className={selectedAgent ? "hidden lg:block" : undefined}
|
|
||||||
>
|
|
||||||
<InboxList
|
|
||||||
agents={serializedAgents}
|
|
||||||
messages={serializedMessages}
|
|
||||||
selectedAgentId={selectedAgentId}
|
|
||||||
onSelectAgent={setSelectedAgentId}
|
|
||||||
onRefresh={handleRefresh}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Right: Detail Panel */}
|
|
||||||
{selectedAgent && (
|
|
||||||
<InboxDetailPanel
|
|
||||||
agent={{
|
|
||||||
id: selectedAgent.id,
|
|
||||||
name: selectedAgent.name,
|
|
||||||
status: selectedAgent.status,
|
|
||||||
taskId: selectedAgent.taskId,
|
|
||||||
updatedAt: String(selectedAgent.updatedAt),
|
|
||||||
}}
|
|
||||||
message={
|
|
||||||
selectedMessage
|
|
||||||
? {
|
|
||||||
id: selectedMessage.id,
|
|
||||||
content: selectedMessage.content,
|
|
||||||
requiresResponse: selectedMessage.requiresResponse,
|
|
||||||
}
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
questions={
|
|
||||||
pendingQuestions
|
|
||||||
? pendingQuestions.questions.map((q) => ({
|
|
||||||
id: q.id,
|
|
||||||
question: q.question,
|
|
||||||
options: q.options,
|
|
||||||
multiSelect: q.multiSelect,
|
|
||||||
}))
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
isLoadingQuestions={questionsQuery.isLoading}
|
|
||||||
questionsError={
|
|
||||||
questionsQuery.isError ? questionsQuery.error.message : null
|
|
||||||
}
|
|
||||||
onBack={() => setSelectedAgentId(null)}
|
|
||||||
onSubmitAnswers={handleSubmitAnswers}
|
|
||||||
onDismissQuestions={handleDismissQuestions}
|
|
||||||
onDismissMessage={handleDismiss}
|
|
||||||
isSubmitting={resumeAgentMutation.isPending}
|
|
||||||
isDismissingQuestions={dismissQuestionsMutation.isPending}
|
|
||||||
isDismissingMessage={respondToMessageMutation.isPending}
|
|
||||||
submitError={
|
|
||||||
resumeAgentMutation.isError
|
|
||||||
? resumeAgentMutation.error.message
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
dismissMessageError={
|
|
||||||
respondToMessageMutation.isError
|
|
||||||
? respondToMessageMutation.error.message
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Empty detail panel placeholder */}
|
|
||||||
{!selectedAgent && (
|
|
||||||
<div className="hidden flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border p-8 lg:flex">
|
|
||||||
<MessageSquare className="h-10 w-10 text-muted-foreground/30" />
|
|
||||||
<div className="space-y-1 text-center">
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">No message selected</p>
|
|
||||||
<p className="text-xs text-muted-foreground/70">Select an agent from the inbox to view details</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -55,3 +55,27 @@ Migrations 0000–0007 were generated by `drizzle-kit generate`. Migrations 0008
|
|||||||
- **Migration files are immutable.** Once committed, never edit them. Make a new migration instead.
|
- **Migration files are immutable.** Once committed, never edit them. Make a new migration instead.
|
||||||
- **Keep schema.ts in sync.** The schema file is the source of truth for TypeScript types; migrations are the source of truth for database DDL. Both must reflect the same structure.
|
- **Keep schema.ts in sync.** The schema file is the source of truth for TypeScript types; migrations are the source of truth for database DDL. Both must reflect the same structure.
|
||||||
- **Test with `npm test`** after generating migrations to verify they work with in-memory databases.
|
- **Test with `npm test`** after generating migrations to verify they work with in-memory databases.
|
||||||
|
|
||||||
|
## Post-migration backfill scripts
|
||||||
|
|
||||||
|
Some schema additions require a one-time data backfill because SQLite migrations cannot execute Node.js logic (e.g., JSON parsing). In these cases, the migration creates the table structure, and a separate Node.js script populates it from existing data.
|
||||||
|
|
||||||
|
### agent_metrics backfill
|
||||||
|
|
||||||
|
**When to run:** After deploying the migration that creates the `agent_metrics` table (introduced in the Radar Screen Performance initiative). Run this once per production database after upgrading.
|
||||||
|
|
||||||
|
**Command:**
|
||||||
|
```sh
|
||||||
|
cw backfill-metrics
|
||||||
|
# Or with a custom DB path:
|
||||||
|
cw backfill-metrics --db /path/to/codewalkers.db
|
||||||
|
```
|
||||||
|
|
||||||
|
**What it does:**
|
||||||
|
- Reads all existing `agent_log_chunks` rows in batches of 500 (ordered by `createdAt ASC`)
|
||||||
|
- Parses each chunk's `content` JSON to count `AskUserQuestion` tool calls, `Agent` spawns, and compaction events
|
||||||
|
- Upserts the accumulated counts into `agent_metrics` using additive conflict resolution
|
||||||
|
|
||||||
|
**Idempotency:** The script uses `ON CONFLICT DO UPDATE` with additive increments, matching the ongoing write-path behavior. Running it against an empty `agent_metrics` table is fully safe. Running it a second time will double-count — only run it once per database, immediately after applying the migration.
|
||||||
|
|
||||||
|
**Batch size:** 500 rows per query, to avoid loading the full `agent_log_chunks` table into memory. Progress is logged every 1,000 chunks.
|
||||||
|
|||||||
@@ -93,6 +93,18 @@ InitiativeChangesRequestedEvent { initiativeId, phaseId, taskId }
|
|||||||
4. **Auto-queue tasks** — When phase starts (branches confirmed), pending execution tasks are queued (planning-category tasks excluded)
|
4. **Auto-queue tasks** — When phase starts (branches confirmed), pending execution tasks are queued (planning-category tasks excluded)
|
||||||
5. **Events** — `phase:queued`, `phase:started`, `phase:completed`, `phase:blocked`
|
5. **Events** — `phase:queued`, `phase:started`, `phase:completed`, `phase:blocked`
|
||||||
|
|
||||||
|
### Auto-Integration Phase
|
||||||
|
|
||||||
|
When `queueAllPhases` is called (i.e. the user clicks "Execute"), it auto-creates an **Integration** phase if the initiative has multiple end phases (leaf nodes with no dependents). This catches cross-phase incompatibilities before the initiative reaches review.
|
||||||
|
|
||||||
|
- **Trigger**: `queueAllPhases` in `apps/server/trpc/routers/phase-dispatch.ts`
|
||||||
|
- **Guard**: Only created when `endPhaseIds.length > 1` and no existing "Integration" phase
|
||||||
|
- **Status**: Created as `approved` (same pattern as Finalization in orchestrator.ts)
|
||||||
|
- **Dependencies**: Integration depends on all end phases — dispatched last
|
||||||
|
- **Task**: A single `verify` category task instructs the agent to build, run tests, check types, and review cross-phase imports
|
||||||
|
- **Idempotency**: Name-based check prevents duplicates on re-execution
|
||||||
|
- **Coexistence**: Independent of the Finalization phase (different purpose, different trigger)
|
||||||
|
|
||||||
### PhaseDispatchManager Methods
|
### PhaseDispatchManager Methods
|
||||||
|
|
||||||
| Method | Purpose |
|
| Method | Purpose |
|
||||||
@@ -137,8 +149,11 @@ When an agent crashes (`agent:crashed` event), the orchestrator automatically re
|
|||||||
On server restart, `recoverDispatchQueues()` also recovers:
|
On server restart, `recoverDispatchQueues()` also recovers:
|
||||||
- Stuck `in_progress` tasks whose agents are dead (status is not `running` or `waiting_for_input`) — reset to `pending` and re-queued
|
- Stuck `in_progress` tasks whose agents are dead (status is not `running` or `waiting_for_input`) — reset to `pending` and re-queued
|
||||||
- Erroneously `blocked` tasks whose agents completed successfully (status is `idle` or `stopped`) — marked `completed` so the phase can progress. This handles the legacy case where conflict resolution incorrectly blocked already-completed tasks.
|
- Erroneously `blocked` tasks whose agents completed successfully (status is `idle` or `stopped`) — marked `completed` so the phase can progress. This handles the legacy case where conflict resolution incorrectly blocked already-completed tasks.
|
||||||
|
- Stale duplicate planning tasks — if a phase has both a completed and a pending task of the same planning category (e.g. two `detail` tasks from a crash-and-retry), the pending one is marked `completed` with summary "Superseded by retry"
|
||||||
- Fully-completed `in_progress` phases — after task recovery, if all tasks in an `in_progress` phase are completed, triggers `handlePhaseAllTasksDone` to complete/review the phase
|
- Fully-completed `in_progress` phases — after task recovery, if all tasks in an `in_progress` phase are completed, triggers `handlePhaseAllTasksDone` to complete/review the phase
|
||||||
|
|
||||||
|
The `detailPhase` mutation in `architect.ts` also cleans up orphaned pending/in_progress detail tasks before creating new ones, preventing duplicates at the source.
|
||||||
|
|
||||||
Manual retry via `retryBlockedTask()` resets `retryCount` to 0, giving the task a fresh set of automatic retries.
|
Manual retry via `retryBlockedTask()` resets `retryCount` to 0, giving the task a fresh set of automatic retries.
|
||||||
|
|
||||||
### Coalesced Scheduling
|
### Coalesced Scheduling
|
||||||
|
|||||||
Reference in New Issue
Block a user