When an initiative has multiple end phases (leaf nodes with no dependents), queueAllPhases now auto-creates an Integration phase that depends on all of them. This catches cross-phase incompatibilities (type mismatches, conflicting exports, broken tests) before review.
169 lines
6.1 KiB
TypeScript
169 lines
6.1 KiB
TypeScript
/**
|
|
* 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<number, string>();
|
|
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;
|
|
}),
|
|
};
|
|
}
|