feat: Add getHeadquartersDashboard tRPC procedure for HQ action items
Aggregates all user-blocking action items into a single composite query: - waitingForInput: agents paused on questions (oldest first) - pendingReviewInitiatives: initiatives awaiting content review - pendingReviewPhases: phases awaiting diff review - planningInitiatives: active initiatives with all phases pending and no running agents - blockedPhases: phases in blocked state with optional last-message snippet Wired into appRouter and covered by 10 unit tests using in-memory Drizzle DB and an inline MockAgentManager (no real processes required). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
320
apps/server/test/unit/headquarters.test.ts
Normal file
320
apps/server/test/unit/headquarters.test.ts
Normal file
@@ -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<string, PendingQuestions> = new Map();
|
||||
|
||||
addAgent(info: Partial<AgentInfo> & Pick<AgentInfo, 'id' | 'name' | 'status'>): 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<AgentInfo[]> {
|
||||
return [...this.agents];
|
||||
}
|
||||
|
||||
async getPendingQuestions(agentId: string): Promise<PendingQuestions | null> {
|
||||
return this.questions.get(agentId) ?? null;
|
||||
}
|
||||
|
||||
async spawn(): Promise<AgentInfo> { throw new Error('Not implemented'); }
|
||||
async stop(): Promise<void> { throw new Error('Not implemented'); }
|
||||
async get(): Promise<AgentInfo | null> { return null; }
|
||||
async getByName(): Promise<AgentInfo | null> { return null; }
|
||||
async resume(): Promise<void> { throw new Error('Not implemented'); }
|
||||
async getResult() { return null; }
|
||||
async delete(): Promise<void> { throw new Error('Not implemented'); }
|
||||
async dismiss(): Promise<void> { throw new Error('Not implemented'); }
|
||||
async resumeForConversation(): Promise<boolean> { return false; }
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Test router
|
||||
// =============================================================================
|
||||
|
||||
const testRouter = router({
|
||||
...headquartersProcedures(publicProcedure),
|
||||
});
|
||||
|
||||
const createCaller = createCallerFactory(testRouter);
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
function makeCtx(agentManager: MockAgentManager, overrides?: Partial<TRPCContext>): 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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
214
apps/server/trpc/routers/headquarters.ts
Normal file
214
apps/server/trpc/routers/headquarters.ts
Normal file
@@ -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<string, Phase[]>();
|
||||
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,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user