/** * Task Router — CRUD, approval, listing by parent/initiative/phase */ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import type { ProcedureBuilder } from '../trpc.js'; import { requireTaskRepository, requireInitiativeRepository, requirePhaseRepository, requireDispatchManager, requireChangeSetRepository, } from './_helpers.js'; export function taskProcedures(publicProcedure: ProcedureBuilder) { return { listTasks: publicProcedure .input(z.object({ parentTaskId: z.string().min(1) })) .query(async ({ ctx, input }) => { const taskRepository = requireTaskRepository(ctx); return taskRepository.findByParentTaskId(input.parentTaskId); }), getTask: publicProcedure .input(z.object({ id: z.string().min(1) })) .query(async ({ ctx, input }) => { const taskRepository = requireTaskRepository(ctx); const task = await taskRepository.findById(input.id); if (!task) { throw new TRPCError({ code: 'NOT_FOUND', message: `Task '${input.id}' not found`, }); } return task; }), updateTaskStatus: publicProcedure .input(z.object({ id: z.string().min(1), status: z.enum(['pending', 'in_progress', 'completed', 'blocked']), })) .mutation(async ({ ctx, input }) => { const taskRepository = requireTaskRepository(ctx); const existing = await taskRepository.findById(input.id); if (!existing) { throw new TRPCError({ code: 'NOT_FOUND', message: `Task '${input.id}' not found`, }); } // Route through dispatchManager when completing — emits task:completed // event so the orchestrator can check phase completion and merge branches if (input.status === 'completed' && ctx.dispatchManager) { await ctx.dispatchManager.completeTask(input.id); return (await taskRepository.findById(input.id))!; } return taskRepository.update(input.id, { status: input.status }); }), createInitiativeTask: publicProcedure .input(z.object({ initiativeId: z.string().min(1), name: z.string().min(1), description: z.string().optional(), category: z.enum(['execute', 'research', 'discuss', 'plan', 'detail', 'refine', 'verify', 'merge', 'review']).optional(), type: z.enum(['auto']).optional(), })) .mutation(async ({ ctx, input }) => { const taskRepository = requireTaskRepository(ctx); const initiativeRepo = requireInitiativeRepository(ctx); const initiative = await initiativeRepo.findById(input.initiativeId); if (!initiative) { throw new TRPCError({ code: 'NOT_FOUND', message: `Initiative '${input.initiativeId}' not found`, }); } return taskRepository.create({ initiativeId: input.initiativeId, name: input.name, description: input.description ?? null, category: input.category ?? 'execute', type: input.type ?? 'auto', status: 'pending', }); }), createPhaseTask: publicProcedure .input(z.object({ phaseId: z.string().min(1), name: z.string().min(1), description: z.string().optional(), category: z.enum(['execute', 'research', 'discuss', 'plan', 'detail', 'refine', 'verify', 'merge', 'review']).optional(), type: z.enum(['auto']).optional(), })) .mutation(async ({ ctx, input }) => { const taskRepository = requireTaskRepository(ctx); const phaseRepo = requirePhaseRepository(ctx); const phase = await phaseRepo.findById(input.phaseId); if (!phase) { throw new TRPCError({ code: 'NOT_FOUND', message: `Phase '${input.phaseId}' not found`, }); } return taskRepository.create({ phaseId: input.phaseId, name: input.name, description: input.description ?? null, category: input.category ?? 'execute', type: input.type ?? 'auto', status: 'pending', }); }), listInitiativeTasks: publicProcedure .input(z.object({ initiativeId: z.string().min(1) })) .query(async ({ ctx, input }) => { const taskRepository = requireTaskRepository(ctx); const tasks = await taskRepository.findByInitiativeId(input.initiativeId); return tasks; }), listPhaseTasks: publicProcedure .input(z.object({ phaseId: z.string().min(1) })) .query(async ({ ctx, input }) => { const taskRepository = requireTaskRepository(ctx); const tasks = await taskRepository.findByPhaseId(input.phaseId); return tasks.filter((t) => t.category !== 'detail'); }), updateTask: publicProcedure .input(z.object({ id: z.string().min(1), name: z.string().min(1).optional(), description: z.string().nullable().optional(), })) .mutation(async ({ ctx, input }) => { const taskRepository = requireTaskRepository(ctx); const existing = await taskRepository.findById(input.id); if (!existing) { throw new TRPCError({ code: 'NOT_FOUND', message: `Task '${input.id}' not found`, }); } const { id, ...data } = input; return taskRepository.update(id, data); }), deleteTask: publicProcedure .input(z.object({ id: z.string().min(1) })) .mutation(async ({ ctx, input }) => { const taskRepository = requireTaskRepository(ctx); await taskRepository.delete(input.id); // Reconcile any applied changesets that created this task. // If all created tasks in a changeset are now deleted, mark it reverted. if (ctx.changeSetRepository) { try { const csRepo = requireChangeSetRepository(ctx); const affectedChangeSets = await csRepo.findAppliedByCreatedEntity('task', input.id); for (const cs of affectedChangeSets) { const createdTaskIds = cs.entries .filter(e => e.entityType === 'task' && e.action === 'create') .map(e => e.entityId); const survivingTasks = await Promise.all( createdTaskIds.map(id => taskRepository.findById(id)), ); if (survivingTasks.every(t => t === null)) { await csRepo.markReverted(cs.id); } } } catch { // Best-effort reconciliation — don't fail the delete } } return { success: true }; }), listPhaseTaskDependencies: publicProcedure .input(z.object({ phaseId: z.string().min(1) })) .query(async ({ ctx, input }) => { const taskRepo = requireTaskRepository(ctx); const tasks = await taskRepo.findByPhaseId(input.phaseId); const edges: Array<{ taskId: string; dependsOn: string[] }> = []; for (const t of tasks) { const deps = await taskRepo.getDependencies(t.id); if (deps.length > 0) edges.push({ taskId: t.id, dependsOn: deps }); } return edges; }), listInitiativeTaskDependencies: publicProcedure .input(z.object({ initiativeId: z.string().min(1) })) .query(async ({ ctx, input }) => { const taskRepo = requireTaskRepository(ctx); const tasks = await taskRepo.findByInitiativeId(input.initiativeId); const edges: Array<{ taskId: string; dependsOn: string[] }> = []; for (const t of tasks) { const deps = await taskRepo.getDependencies(t.id); if (deps.length > 0) edges.push({ taskId: t.id, dependsOn: deps }); } return edges; }), }; }