/** * Phase Dispatch Router — queue, dispatch, state, child tasks */ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import type { Phase, Task } from '../../db/schema.js'; import type { ProcedureBuilder } from '../trpc.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) { return { queuePhase: publicProcedure .input(z.object({ phaseId: z.string().min(1) })) .mutation(async ({ ctx, input }) => { const phaseDispatchManager = requirePhaseDispatchManager(ctx); await phaseDispatchManager.queuePhase(input.phaseId); return { success: true }; }), queueAllPhases: publicProcedure .input(z.object({ initiativeId: z.string().min(1) })) .mutation(async ({ ctx, input }) => { const phaseDispatchManager = requirePhaseDispatchManager(ctx); const phaseRepo = requirePhaseRepository(ctx); 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; for (const phase of phases) { if (phase.status === 'approved') { await phaseDispatchManager.queuePhase(phase.id); queued++; } } return { success: true, queued }; }), dispatchNextPhase: publicProcedure .mutation(async ({ ctx }) => { const phaseDispatchManager = requirePhaseDispatchManager(ctx); return phaseDispatchManager.dispatchNextPhase(); }), getPhaseQueueState: publicProcedure .query(async ({ ctx }) => { const phaseDispatchManager = requirePhaseDispatchManager(ctx); return phaseDispatchManager.getPhaseQueueState(); }), createChildTasks: publicProcedure .input(z.object({ parentTaskId: z.string().min(1), tasks: z.array(z.object({ number: z.number().int().positive(), name: z.string().min(1), description: z.string(), type: z.enum(['auto']).default('auto'), dependencies: z.array(z.number().int().positive()).optional(), })), })) .mutation(async ({ ctx, input }) => { const taskRepo = requireTaskRepository(ctx); const parentTask = await taskRepo.findById(input.parentTaskId); if (!parentTask) { throw new TRPCError({ code: 'NOT_FOUND', message: `Parent task '${input.parentTaskId}' not found`, }); } if (parentTask.category !== 'detail') { throw new TRPCError({ code: 'BAD_REQUEST', message: `Parent task must have category 'detail', got '${parentTask.category}'`, }); } const numberToId = new Map(); const created: Task[] = []; for (const taskInput of input.tasks) { const task = await taskRepo.create({ parentTaskId: input.parentTaskId, phaseId: parentTask.phaseId, initiativeId: parentTask.initiativeId, name: taskInput.name, description: taskInput.description, type: taskInput.type, order: taskInput.number, status: 'pending', }); numberToId.set(taskInput.number, task.id); created.push(task); } for (const taskInput of input.tasks) { if (taskInput.dependencies && taskInput.dependencies.length > 0) { const taskId = numberToId.get(taskInput.number)!; for (const depNumber of taskInput.dependencies) { const dependsOnTaskId = numberToId.get(depNumber); if (dependsOnTaskId) { await taskRepo.createDependency(taskId, dependsOnTaskId); } } } } return created; }), }; }