/** * Change Set Router — list, get, revert workflows */ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import type { ProcedureBuilder } from '../trpc.js'; import { requireChangeSetRepository, requirePhaseRepository, requireTaskRepository, requirePageRepository, } from './_helpers.js'; export function changeSetProcedures(publicProcedure: ProcedureBuilder) { return { listChangeSets: publicProcedure .input(z.object({ initiativeId: z.string().min(1).optional(), agentId: z.string().min(1).optional(), })) .query(async ({ ctx, input }) => { const repo = requireChangeSetRepository(ctx); if (input.agentId) { return repo.findByAgentId(input.agentId); } if (input.initiativeId) { return repo.findByInitiativeId(input.initiativeId); } throw new TRPCError({ code: 'BAD_REQUEST', message: 'Either agentId or initiativeId is required', }); }), getChangeSet: publicProcedure .input(z.object({ id: z.string().min(1) })) .query(async ({ ctx, input }) => { const repo = requireChangeSetRepository(ctx); const cs = await repo.findByIdWithEntries(input.id); if (!cs) { throw new TRPCError({ code: 'NOT_FOUND', message: `ChangeSet '${input.id}' not found` }); } return cs; }), revertChangeSet: publicProcedure .input(z.object({ id: z.string().min(1), force: z.boolean().optional() })) .mutation(async ({ ctx, input }) => { const repo = requireChangeSetRepository(ctx); const cs = await repo.findByIdWithEntries(input.id); if (!cs) { throw new TRPCError({ code: 'NOT_FOUND', message: `ChangeSet '${input.id}' not found` }); } if (cs.status === 'reverted') { throw new TRPCError({ code: 'BAD_REQUEST', message: 'ChangeSet is already reverted' }); } const phaseRepo = requirePhaseRepository(ctx); const taskRepo = requireTaskRepository(ctx); const pageRepo = requirePageRepository(ctx); // Conflict detection (unless force) if (!input.force) { const conflicts: string[] = []; for (const entry of cs.entries) { if (entry.action === 'create') { if (entry.entityType === 'phase') { const phase = await phaseRepo.findById(entry.entityId); if (phase && phase.status === 'in_progress') { conflicts.push(`Phase "${phase.name}" is in progress`); } } else if (entry.entityType === 'task') { const task = await taskRepo.findById(entry.entityId); if (task && task.status === 'in_progress') { conflicts.push(`Task "${task.name}" is in progress`); } } } else if (entry.action === 'update' && entry.entityType === 'page' && entry.newState) { const page = await pageRepo.findById(entry.entityId); if (page) { const expectedContent = JSON.parse(entry.newState).content; if (page.content !== expectedContent) { conflicts.push(`Page "${page.title}" was modified since change set was applied`); } } } } if (conflicts.length > 0) { return { success: false as const, conflicts }; } } // Mark reverted FIRST to avoid ghost state if entity deletion fails partway await repo.markReverted(input.id); // Apply reverts in reverse entry order const reversedEntries = [...cs.entries].reverse(); for (const entry of reversedEntries) { try { if (entry.action === 'create') { switch (entry.entityType) { case 'phase': try { await phaseRepo.delete(entry.entityId); } catch { /* already deleted */ } break; case 'task': try { await taskRepo.delete(entry.entityId); } catch { /* already deleted */ } break; case 'phase_dependency': { const depData = JSON.parse(entry.newState || '{}'); if (depData.phaseId && depData.dependsOnPhaseId) { try { await phaseRepo.removeDependency(depData.phaseId, depData.dependsOnPhaseId); } catch { /* already removed */ } } break; } } } else if (entry.action === 'update' && entry.previousState) { const prev = JSON.parse(entry.previousState); switch (entry.entityType) { case 'phase': await phaseRepo.update(entry.entityId, { name: prev.name, content: prev.content, }); break; case 'task': await taskRepo.update(entry.entityId, { name: prev.name, description: prev.description, category: prev.category, type: prev.type, }); break; case 'page': await pageRepo.update(entry.entityId, { content: prev.content, title: prev.title, }); ctx.eventBus.emit({ type: 'page:updated', timestamp: new Date(), payload: { pageId: entry.entityId, initiativeId: cs.initiativeId, title: prev.title }, }); break; } } else if (entry.action === 'delete' && entry.previousState) { const prev = JSON.parse(entry.previousState); switch (entry.entityType) { case 'phase': try { await phaseRepo.create({ id: prev.id, initiativeId: prev.initiativeId, name: prev.name, content: prev.content }); } catch { /* already exists */ } break; case 'task': try { await taskRepo.create({ id: prev.id, initiativeId: prev.initiativeId, phaseId: prev.phaseId, parentTaskId: prev.parentTaskId, name: prev.name, description: prev.description, category: prev.category, type: prev.type }); } catch { /* already exists */ } break; case 'page': try { await pageRepo.create({ id: prev.id, initiativeId: prev.initiativeId, parentPageId: prev.parentPageId, title: prev.title, content: prev.content }); } catch { /* already exists */ } break; } } } catch (err) { // Log but continue reverting other entries } } ctx.eventBus.emit({ type: 'changeset:reverted' as const, timestamp: new Date(), payload: { changeSetId: cs.id, initiativeId: cs.initiativeId }, }); return { success: true as const }; }), }; }