diff --git a/apps/server/test/unit/headquarters.test.ts b/apps/server/test/unit/headquarters.test.ts new file mode 100644 index 0000000..8f079a2 --- /dev/null +++ b/apps/server/test/unit/headquarters.test.ts @@ -0,0 +1,320 @@ +/** + * Unit tests for getHeadquartersDashboard tRPC procedure. + * + * Uses in-memory Drizzle DB + inline MockAgentManager for isolation. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { router, publicProcedure, createCallerFactory } from '../../trpc/trpc.js'; +import { headquartersProcedures } from '../../trpc/routers/headquarters.js'; +import type { TRPCContext } from '../../trpc/context.js'; +import type { AgentManager, AgentInfo, PendingQuestions } from '../../agent/types.js'; +import { createTestDatabase } from '../../db/repositories/drizzle/test-helpers.js'; +import { + DrizzleInitiativeRepository, + DrizzlePhaseRepository, + DrizzleTaskRepository, +} from '../../db/repositories/drizzle/index.js'; + +// ============================================================================= +// MockAgentManager +// ============================================================================= + +class MockAgentManager implements AgentManager { + private agents: AgentInfo[] = []; + private questions: Map = new Map(); + + addAgent(info: Partial & Pick): void { + this.agents.push({ + taskId: null, + initiativeId: null, + sessionId: null, + worktreeId: info.id, + mode: 'execute', + provider: 'claude', + accountId: null, + createdAt: new Date('2025-01-01T00:00:00Z'), + updatedAt: new Date('2025-01-01T00:00:00Z'), + userDismissedAt: null, + exitCode: null, + prompt: null, + ...info, + }); + } + + setQuestions(agentId: string, questions: PendingQuestions): void { + this.questions.set(agentId, questions); + } + + async list(): Promise { + return [...this.agents]; + } + + async getPendingQuestions(agentId: string): Promise { + return this.questions.get(agentId) ?? null; + } + + async spawn(): Promise { throw new Error('Not implemented'); } + async stop(): Promise { throw new Error('Not implemented'); } + async get(): Promise { return null; } + async getByName(): Promise { return null; } + async resume(): Promise { throw new Error('Not implemented'); } + async getResult() { return null; } + async delete(): Promise { throw new Error('Not implemented'); } + async dismiss(): Promise { throw new Error('Not implemented'); } + async resumeForConversation(): Promise { return false; } +} + +// ============================================================================= +// Test router +// ============================================================================= + +const testRouter = router({ + ...headquartersProcedures(publicProcedure), +}); + +const createCaller = createCallerFactory(testRouter); + +// ============================================================================= +// Helpers +// ============================================================================= + +function makeCtx(agentManager: MockAgentManager, overrides?: Partial): TRPCContext { + const db = createTestDatabase(); + return { + eventBus: {} as TRPCContext['eventBus'], + serverStartedAt: null, + processCount: 0, + agentManager, + initiativeRepository: new DrizzleInitiativeRepository(db), + phaseRepository: new DrizzlePhaseRepository(db), + taskRepository: new DrizzleTaskRepository(db), + ...overrides, + }; +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe('getHeadquartersDashboard', () => { + it('empty state — no initiatives, no agents → all arrays empty', async () => { + const agents = new MockAgentManager(); + const caller = createCaller(makeCtx(agents)); + + const result = await caller.getHeadquartersDashboard(); + + expect(result.waitingForInput).toEqual([]); + expect(result.pendingReviewInitiatives).toEqual([]); + expect(result.pendingReviewPhases).toEqual([]); + expect(result.planningInitiatives).toEqual([]); + expect(result.blockedPhases).toEqual([]); + }); + + it('waitingForInput — agent with waiting_for_input status appears', async () => { + const agents = new MockAgentManager(); + const ctx = makeCtx(agents); + const initiativeRepo = ctx.initiativeRepository!; + const initiative = await initiativeRepo.create({ name: 'My Initiative', status: 'active' }); + + agents.addAgent({ + id: 'agent-1', + name: 'jolly-agent', + status: 'waiting_for_input', + initiativeId: initiative.id, + userDismissedAt: null, + updatedAt: new Date('2025-06-01T12:00:00Z'), + }); + agents.setQuestions('agent-1', { + questions: [{ id: 'q1', question: 'Which approach?' }], + }); + + const caller = createCaller(ctx); + const result = await caller.getHeadquartersDashboard(); + + expect(result.waitingForInput).toHaveLength(1); + const item = result.waitingForInput[0]; + expect(item.agentId).toBe('agent-1'); + expect(item.agentName).toBe('jolly-agent'); + expect(item.initiativeId).toBe(initiative.id); + expect(item.initiativeName).toBe('My Initiative'); + expect(item.questionText).toBe('Which approach?'); + }); + + it('waitingForInput — dismissed agent is excluded', async () => { + const agents = new MockAgentManager(); + const ctx = makeCtx(agents); + const initiativeRepo = ctx.initiativeRepository!; + const initiative = await initiativeRepo.create({ name: 'My Initiative', status: 'active' }); + + agents.addAgent({ + id: 'agent-1', + name: 'dismissed-agent', + status: 'waiting_for_input', + initiativeId: initiative.id, + userDismissedAt: new Date(), + }); + agents.setQuestions('agent-1', { + questions: [{ id: 'q1', question: 'Which approach?' }], + }); + + const caller = createCaller(ctx); + const result = await caller.getHeadquartersDashboard(); + + expect(result.waitingForInput).toEqual([]); + }); + + it('pendingReviewInitiatives — initiative with pending_review status appears', async () => { + const agents = new MockAgentManager(); + const ctx = makeCtx(agents); + const initiativeRepo = ctx.initiativeRepository!; + const initiative = await initiativeRepo.create({ name: 'Review Me', status: 'pending_review' }); + + const caller = createCaller(ctx); + const result = await caller.getHeadquartersDashboard(); + + expect(result.pendingReviewInitiatives).toHaveLength(1); + expect(result.pendingReviewInitiatives[0].initiativeId).toBe(initiative.id); + expect(result.pendingReviewInitiatives[0].initiativeName).toBe('Review Me'); + }); + + it('pendingReviewPhases — phase with pending_review status appears', async () => { + const agents = new MockAgentManager(); + const ctx = makeCtx(agents); + const initiativeRepo = ctx.initiativeRepository!; + const phaseRepo = ctx.phaseRepository!; + const initiative = await initiativeRepo.create({ name: 'My Initiative', status: 'active' }); + const phase = await phaseRepo.create({ + initiativeId: initiative.id, + name: 'Phase 1', + status: 'pending_review', + }); + + const caller = createCaller(ctx); + const result = await caller.getHeadquartersDashboard(); + + expect(result.pendingReviewPhases).toHaveLength(1); + const item = result.pendingReviewPhases[0]; + expect(item.initiativeId).toBe(initiative.id); + expect(item.initiativeName).toBe('My Initiative'); + expect(item.phaseId).toBe(phase.id); + expect(item.phaseName).toBe('Phase 1'); + }); + + it('planningInitiatives — all phases pending and no running agents', async () => { + const agents = new MockAgentManager(); + const ctx = makeCtx(agents); + const initiativeRepo = ctx.initiativeRepository!; + const phaseRepo = ctx.phaseRepository!; + const initiative = await initiativeRepo.create({ name: 'Planning Init', status: 'active' }); + const phase1 = await phaseRepo.create({ + initiativeId: initiative.id, + name: 'Phase 1', + status: 'pending', + }); + await phaseRepo.create({ + initiativeId: initiative.id, + name: 'Phase 2', + status: 'pending', + }); + + const caller = createCaller(ctx); + const result = await caller.getHeadquartersDashboard(); + + expect(result.planningInitiatives).toHaveLength(1); + const item = result.planningInitiatives[0]; + expect(item.initiativeId).toBe(initiative.id); + expect(item.initiativeName).toBe('Planning Init'); + expect(item.pendingPhaseCount).toBe(2); + // since = oldest phase createdAt + expect(item.since).toBe(phase1.createdAt.toISOString()); + }); + + it('planningInitiatives — excluded when a running agent exists for the initiative', async () => { + const agents = new MockAgentManager(); + const ctx = makeCtx(agents); + const initiativeRepo = ctx.initiativeRepository!; + const phaseRepo = ctx.phaseRepository!; + const initiative = await initiativeRepo.create({ name: 'Planning Init', status: 'active' }); + await phaseRepo.create({ initiativeId: initiative.id, name: 'Phase 1', status: 'pending' }); + + agents.addAgent({ + id: 'agent-running', + name: 'busy-agent', + status: 'running', + initiativeId: initiative.id, + userDismissedAt: null, + }); + + const caller = createCaller(ctx); + const result = await caller.getHeadquartersDashboard(); + + expect(result.planningInitiatives).toEqual([]); + }); + + it('planningInitiatives — excluded when a phase is not pending', async () => { + const agents = new MockAgentManager(); + const ctx = makeCtx(agents); + const initiativeRepo = ctx.initiativeRepository!; + const phaseRepo = ctx.phaseRepository!; + const initiative = await initiativeRepo.create({ name: 'Mixed Init', status: 'active' }); + await phaseRepo.create({ initiativeId: initiative.id, name: 'Phase 1', status: 'pending' }); + await phaseRepo.create({ initiativeId: initiative.id, name: 'Phase 2', status: 'in_progress' }); + + const caller = createCaller(ctx); + const result = await caller.getHeadquartersDashboard(); + + expect(result.planningInitiatives).toEqual([]); + }); + + it('blockedPhases — phase with blocked status appears', async () => { + const agents = new MockAgentManager(); + const ctx = makeCtx(agents); + const initiativeRepo = ctx.initiativeRepository!; + const phaseRepo = ctx.phaseRepository!; + const initiative = await initiativeRepo.create({ name: 'Blocked Init', status: 'active' }); + const phase = await phaseRepo.create({ + initiativeId: initiative.id, + name: 'Stuck Phase', + status: 'blocked', + }); + + const caller = createCaller(ctx); + const result = await caller.getHeadquartersDashboard(); + + expect(result.blockedPhases).toHaveLength(1); + const item = result.blockedPhases[0]; + expect(item.initiativeId).toBe(initiative.id); + expect(item.initiativeName).toBe('Blocked Init'); + expect(item.phaseId).toBe(phase.id); + expect(item.phaseName).toBe('Stuck Phase'); + expect(item.lastMessage).toBeNull(); + }); + + it('ordering — waitingForInput sorted oldest first', async () => { + const agents = new MockAgentManager(); + const ctx = makeCtx(agents); + + agents.addAgent({ + id: 'agent-newer', + name: 'newer-agent', + status: 'waiting_for_input', + userDismissedAt: null, + updatedAt: new Date('2025-06-02T00:00:00Z'), + }); + agents.addAgent({ + id: 'agent-older', + name: 'older-agent', + status: 'waiting_for_input', + userDismissedAt: null, + updatedAt: new Date('2025-06-01T00:00:00Z'), + }); + + const caller = createCaller(ctx); + const result = await caller.getHeadquartersDashboard(); + + expect(result.waitingForInput).toHaveLength(2); + expect(result.waitingForInput[0].agentId).toBe('agent-older'); + expect(result.waitingForInput[1].agentId).toBe('agent-newer'); + }); +}); diff --git a/apps/server/trpc/router.ts b/apps/server/trpc/router.ts index d1c43fc..43ad5d3 100644 --- a/apps/server/trpc/router.ts +++ b/apps/server/trpc/router.ts @@ -24,6 +24,7 @@ import { subscriptionProcedures } from './routers/subscription.js'; import { previewProcedures } from './routers/preview.js'; import { conversationProcedures } from './routers/conversation.js'; import { chatSessionProcedures } from './routers/chat-session.js'; +import { headquartersProcedures } from './routers/headquarters.js'; // Re-export tRPC primitives (preserves existing import paths) export { router, publicProcedure, middleware, createCallerFactory } from './trpc.js'; @@ -63,6 +64,7 @@ export const appRouter = router({ ...previewProcedures(publicProcedure), ...conversationProcedures(publicProcedure), ...chatSessionProcedures(publicProcedure), + ...headquartersProcedures(publicProcedure), }); export type AppRouter = typeof appRouter; diff --git a/apps/server/trpc/routers/headquarters.ts b/apps/server/trpc/routers/headquarters.ts new file mode 100644 index 0000000..eed001b --- /dev/null +++ b/apps/server/trpc/routers/headquarters.ts @@ -0,0 +1,214 @@ +/** + * Headquarters Router + * + * Provides the composite dashboard query for the Headquarters page, + * aggregating all action items that require user intervention. + */ + +import type { ProcedureBuilder } from '../trpc.js'; +import type { Phase } from '../../db/schema.js'; +import { + requireAgentManager, + requireInitiativeRepository, + requirePhaseRepository, +} from './_helpers.js'; + +export function headquartersProcedures(publicProcedure: ProcedureBuilder) { + return { + getHeadquartersDashboard: publicProcedure.query(async ({ ctx }) => { + const initiativeRepo = requireInitiativeRepository(ctx); + const phaseRepo = requirePhaseRepository(ctx); + const agentManager = requireAgentManager(ctx); + + const [allInitiatives, allAgents] = await Promise.all([ + initiativeRepo.findAll(), + agentManager.list(), + ]); + + // Relevant initiatives: status in ['active', 'pending_review'] + const relevantInitiatives = allInitiatives.filter( + (i) => i.status === 'active' || i.status === 'pending_review', + ); + + // Non-dismissed agents only + const activeAgents = allAgents.filter((a) => !a.userDismissedAt); + + // Fast lookup map: initiative id → initiative + const initiativeMap = new Map(relevantInitiatives.map((i) => [i.id, i])); + + // Batch-fetch all phases for relevant initiatives in parallel + const phasesByInitiative = new Map(); + await Promise.all( + relevantInitiatives.map(async (init) => { + const phases = await phaseRepo.findByInitiativeId(init.id); + phasesByInitiative.set(init.id, phases); + }), + ); + + // ----------------------------------------------------------------------- + // Section 1: waitingForInput + // ----------------------------------------------------------------------- + const waitingAgents = activeAgents.filter((a) => a.status === 'waiting_for_input'); + const pendingQuestionsResults = await Promise.all( + waitingAgents.map((a) => agentManager.getPendingQuestions(a.id)), + ); + + const waitingForInput = waitingAgents + .map((agent, i) => { + const initiative = agent.initiativeId ? initiativeMap.get(agent.initiativeId) : undefined; + return { + agentId: agent.id, + agentName: agent.name, + initiativeId: agent.initiativeId, + initiativeName: initiative?.name ?? null, + questionText: pendingQuestionsResults[i]?.questions[0]?.question ?? '', + waitingSince: agent.updatedAt.toISOString(), + }; + }) + .sort((a, b) => a.waitingSince.localeCompare(b.waitingSince)); + + // ----------------------------------------------------------------------- + // Section 2a: pendingReviewInitiatives + // ----------------------------------------------------------------------- + const pendingReviewInitiatives = relevantInitiatives + .filter((i) => i.status === 'pending_review') + .map((i) => ({ + initiativeId: i.id, + initiativeName: i.name, + since: i.updatedAt.toISOString(), + })) + .sort((a, b) => a.since.localeCompare(b.since)); + + // ----------------------------------------------------------------------- + // Section 2b: pendingReviewPhases + // ----------------------------------------------------------------------- + const pendingReviewPhases: Array<{ + initiativeId: string; + initiativeName: string; + phaseId: string; + phaseName: string; + since: string; + }> = []; + + for (const [initiativeId, phases] of phasesByInitiative) { + const initiative = initiativeMap.get(initiativeId)!; + for (const phase of phases) { + if (phase.status === 'pending_review') { + pendingReviewPhases.push({ + initiativeId, + initiativeName: initiative.name, + phaseId: phase.id, + phaseName: phase.name, + since: phase.updatedAt.toISOString(), + }); + } + } + } + pendingReviewPhases.sort((a, b) => a.since.localeCompare(b.since)); + + // ----------------------------------------------------------------------- + // Section 3: planningInitiatives + // ----------------------------------------------------------------------- + const planningInitiatives: Array<{ + initiativeId: string; + initiativeName: string; + pendingPhaseCount: number; + since: string; + }> = []; + + for (const initiative of relevantInitiatives) { + if (initiative.status !== 'active') continue; + const phases = phasesByInitiative.get(initiative.id) ?? []; + if (phases.length === 0) continue; + + const allPending = phases.every((p) => p.status === 'pending'); + if (!allPending) continue; + + const hasActiveAgent = activeAgents.some( + (a) => + a.initiativeId === initiative.id && + (a.status === 'running' || a.status === 'waiting_for_input'), + ); + if (hasActiveAgent) continue; + + const sortedByCreatedAt = [...phases].sort( + (a, b) => a.createdAt.getTime() - b.createdAt.getTime(), + ); + + planningInitiatives.push({ + initiativeId: initiative.id, + initiativeName: initiative.name, + pendingPhaseCount: phases.length, + since: sortedByCreatedAt[0].createdAt.toISOString(), + }); + } + planningInitiatives.sort((a, b) => a.since.localeCompare(b.since)); + + // ----------------------------------------------------------------------- + // Section 4: blockedPhases + // ----------------------------------------------------------------------- + const blockedPhases: Array<{ + initiativeId: string; + initiativeName: string; + phaseId: string; + phaseName: string; + lastMessage: string | null; + since: string; + }> = []; + + for (const initiative of relevantInitiatives) { + if (initiative.status !== 'active') continue; + const phases = phasesByInitiative.get(initiative.id) ?? []; + + for (const phase of phases) { + if (phase.status !== 'blocked') continue; + + let lastMessage: string | null = null; + try { + if (ctx.taskRepository && ctx.messageRepository) { + const taskRepo = ctx.taskRepository; + const messageRepo = ctx.messageRepository; + const tasks = await taskRepo.findByPhaseId(phase.id); + const phaseAgentIds = allAgents + .filter((a) => tasks.some((t) => t.id === a.taskId)) + .map((a) => a.id); + + if (phaseAgentIds.length > 0) { + const messageLists = await Promise.all( + phaseAgentIds.map((id) => messageRepo.findBySender('agent', id)), + ); + const allMessages = messageLists + .flat() + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + + if (allMessages.length > 0) { + lastMessage = allMessages[0].content.slice(0, 160); + } + } + } + } catch { + // Non-critical: message retrieval failure does not crash the dashboard + } + + blockedPhases.push({ + initiativeId: initiative.id, + initiativeName: initiative.name, + phaseId: phase.id, + phaseName: phase.name, + lastMessage, + since: phase.updatedAt.toISOString(), + }); + } + } + blockedPhases.sort((a, b) => a.since.localeCompare(b.since)); + + return { + waitingForInput, + pendingReviewInitiatives, + pendingReviewPhases, + planningInitiatives, + blockedPhases, + }; + }), + }; +} diff --git a/docs/server-api.md b/docs/server-api.md index 0dd0aca..b064576 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -272,3 +272,27 @@ Persistent chat loop for iterative phase/task refinement via agent. `sendChatMessage` finds or creates an active session, stores the user message, then either resumes the existing agent (if `waiting_for_input`) or spawns a fresh one with full chat history + initiative context. Agent runs in `'chat'` mode and signals `"questions"` after applying changes, staying alive for the next message. Context dependency: `requireChatSessionRepository(ctx)`, `requireAgentManager(ctx)`, `requireInitiativeRepository(ctx)`, `requireTaskRepository(ctx)`. + +## Headquarters Procedures + +Composite dashboard query aggregating all action items that require user intervention. + +| Procedure | Type | Description | +|-----------|------|-------------| +| `getHeadquartersDashboard` | query | Returns 5 typed arrays of action items (no input required) | + +### Return Shape + +```typescript +{ + waitingForInput: Array<{ agentId, agentName, initiativeId, initiativeName, questionText, waitingSince }>; + pendingReviewInitiatives: Array<{ initiativeId, initiativeName, since }>; + pendingReviewPhases: Array<{ initiativeId, initiativeName, phaseId, phaseName, since }>; + planningInitiatives: Array<{ initiativeId, initiativeName, pendingPhaseCount, since }>; + blockedPhases: Array<{ initiativeId, initiativeName, phaseId, phaseName, lastMessage, since }>; +} +``` + +Each array is sorted ascending by timestamp (oldest-first). All timestamps are ISO 8601 strings. `lastMessage` is truncated to 160 chars and is `null` when no messages exist or the message repository is not wired. + +Context dependency: `requireInitiativeRepository(ctx)`, `requirePhaseRepository(ctx)`, `requireAgentManager(ctx)`. Task/message repos are accessed via optional `ctx` fields for `blockedPhases.lastMessage`.