Files
Codewalkers/apps/server/trpc/routers/headquarters.ts
Lukas May 28521e1c20 chore: merge main into cw/small-change-flow
Integrates main branch changes (headquarters dashboard, task retry count,
agent prompt persistence, remote sync improvements) with the initiative's
errand agent feature. Both features coexist in the merged result.

Key resolutions:
- Schema: take main's errands table (nullable projectId, no conflictFiles,
  with errandsRelations); migrate to 0035_faulty_human_fly
- Router: keep both errandProcedures and headquartersProcedures
- Errand prompt: take main's simpler version (no question-asking flow)
- Manager: take main's status check (running|idle only, no waiting_for_input)
- Tests: update to match removed conflictFiles field and undefined vs null
2026-03-06 16:48:12 +01:00

215 lines
7.7 KiB
TypeScript

/**
* 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,
};
}),
};
}