Manually deleting phases left their parent changeset as "applied", causing the Plan tab to show a stale "Created N phases" banner with no phases visible. - deletePhase now checks if all phases from a changeset are gone and marks it reverted - PlanSection filters out dismissed agents so dismissed banners stay hidden - revertChangeSet marks reverted before entity deletion to prevent ghost state on partial failure - deletePhase invalidation now includes listChangeSets
175 lines
7.0 KiB
TypeScript
175 lines
7.0 KiB
TypeScript
/**
|
|
* 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 };
|
|
}),
|
|
};
|
|
}
|