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:
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,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user