fix: Reconcile orphaned changesets when phases are manually deleted
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
This commit is contained in:
@@ -33,4 +33,10 @@ export interface ChangeSetRepository {
|
|||||||
findByInitiativeId(initiativeId: string): Promise<ChangeSet[]>;
|
findByInitiativeId(initiativeId: string): Promise<ChangeSet[]>;
|
||||||
findByAgentId(agentId: string): Promise<ChangeSet[]>;
|
findByAgentId(agentId: string): Promise<ChangeSet[]>;
|
||||||
markReverted(id: string): Promise<ChangeSet>;
|
markReverted(id: string): Promise<ChangeSet>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find applied changesets that have a 'create' entry for the given entity.
|
||||||
|
* Used to reconcile changeset status when entities are manually deleted.
|
||||||
|
*/
|
||||||
|
findAppliedByCreatedEntity(entityType: string, entityId: string): Promise<ChangeSetWithEntries[]>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Implements ChangeSetRepository interface using Drizzle ORM.
|
* Implements ChangeSetRepository interface using Drizzle ORM.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { eq, desc, asc } from 'drizzle-orm';
|
import { eq, desc, asc, and } from 'drizzle-orm';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import type { DrizzleDatabase } from '../../index.js';
|
import type { DrizzleDatabase } from '../../index.js';
|
||||||
import { changeSets, changeSetEntries, type ChangeSet } from '../../schema.js';
|
import { changeSets, changeSetEntries, type ChangeSet } from '../../schema.js';
|
||||||
@@ -94,6 +94,32 @@ export class DrizzleChangeSetRepository implements ChangeSetRepository {
|
|||||||
.orderBy(desc(changeSets.createdAt));
|
.orderBy(desc(changeSets.createdAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findAppliedByCreatedEntity(entityType: string, entityId: string): Promise<ChangeSetWithEntries[]> {
|
||||||
|
// Find changeset entries matching the entity
|
||||||
|
const matchingEntries = await this.db
|
||||||
|
.select({ changeSetId: changeSetEntries.changeSetId })
|
||||||
|
.from(changeSetEntries)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(changeSetEntries.entityType, entityType as any),
|
||||||
|
eq(changeSetEntries.entityId, entityId),
|
||||||
|
eq(changeSetEntries.action, 'create'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const results: ChangeSetWithEntries[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const { changeSetId } of matchingEntries) {
|
||||||
|
if (seen.has(changeSetId)) continue;
|
||||||
|
seen.add(changeSetId);
|
||||||
|
const cs = await this.findByIdWithEntries(changeSetId);
|
||||||
|
if (cs && cs.status === 'applied') {
|
||||||
|
results.push(cs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
async markReverted(id: string): Promise<ChangeSet> {
|
async markReverted(id: string): Promise<ChangeSet> {
|
||||||
const [updated] = await this.db
|
const [updated] = await this.db
|
||||||
.update(changeSets)
|
.update(changeSets)
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ export function changeSetProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark reverted FIRST to avoid ghost state if entity deletion fails partway
|
||||||
|
await repo.markReverted(input.id);
|
||||||
|
|
||||||
// Apply reverts in reverse entry order
|
// Apply reverts in reverse entry order
|
||||||
const reversedEntries = [...cs.entries].reverse();
|
const reversedEntries = [...cs.entries].reverse();
|
||||||
for (const entry of reversedEntries) {
|
for (const entry of reversedEntries) {
|
||||||
@@ -159,8 +162,6 @@ export function changeSetProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await repo.markReverted(input.id);
|
|
||||||
|
|
||||||
ctx.eventBus.emit({
|
ctx.eventBus.emit({
|
||||||
type: 'changeset:reverted' as const,
|
type: 'changeset:reverted' as const,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { TRPCError } from '@trpc/server';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { Phase } from '../../db/schema.js';
|
import type { Phase } from '../../db/schema.js';
|
||||||
import type { ProcedureBuilder } from '../trpc.js';
|
import type { ProcedureBuilder } from '../trpc.js';
|
||||||
import { requirePhaseRepository, requireTaskRepository, requireBranchManager, requireInitiativeRepository, requireProjectRepository, requireExecutionOrchestrator, requireReviewCommentRepository } from './_helpers.js';
|
import { requirePhaseRepository, requireTaskRepository, requireBranchManager, requireInitiativeRepository, requireProjectRepository, requireExecutionOrchestrator, requireReviewCommentRepository, requireChangeSetRepository } from './_helpers.js';
|
||||||
import { phaseBranchName } from '../../git/branch-naming.js';
|
import { phaseBranchName } from '../../git/branch-naming.js';
|
||||||
import { ensureProjectClone } from '../../git/project-clones.js';
|
import { ensureProjectClone } from '../../git/project-clones.js';
|
||||||
|
|
||||||
@@ -98,6 +98,29 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const repo = requirePhaseRepository(ctx);
|
const repo = requirePhaseRepository(ctx);
|
||||||
await repo.delete(input.id);
|
await repo.delete(input.id);
|
||||||
|
|
||||||
|
// Reconcile any applied changesets that created this phase.
|
||||||
|
// If all created phases in a changeset are now deleted, mark it reverted.
|
||||||
|
if (ctx.changeSetRepository) {
|
||||||
|
try {
|
||||||
|
const csRepo = requireChangeSetRepository(ctx);
|
||||||
|
const affectedChangeSets = await csRepo.findAppliedByCreatedEntity('phase', input.id);
|
||||||
|
for (const cs of affectedChangeSets) {
|
||||||
|
const createdPhaseIds = cs.entries
|
||||||
|
.filter(e => e.entityType === 'phase' && e.action === 'create')
|
||||||
|
.map(e => e.entityId);
|
||||||
|
const survivingPhases = await Promise.all(
|
||||||
|
createdPhaseIds.map(id => repo.findById(id)),
|
||||||
|
);
|
||||||
|
if (survivingPhases.every(p => p === null)) {
|
||||||
|
await csRepo.markReverted(cs.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Best-effort reconciliation — don't fail the delete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export function PlanSection({
|
|||||||
(a) =>
|
(a) =>
|
||||||
a.mode === "plan" &&
|
a.mode === "plan" &&
|
||||||
a.initiativeId === initiativeId &&
|
a.initiativeId === initiativeId &&
|
||||||
|
!a.userDismissedAt &&
|
||||||
["running", "waiting_for_input", "idle"].includes(a.status),
|
["running", "waiting_for_input", "idle"].includes(a.status),
|
||||||
)
|
)
|
||||||
.sort(
|
.sort(
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ const INVALIDATION_MAP: Partial<Record<MutationName, QueryName[]>> = {
|
|||||||
|
|
||||||
// --- Phases ---
|
// --- Phases ---
|
||||||
createPhase: ["listPhases", "listInitiativePhaseDependencies"],
|
createPhase: ["listPhases", "listInitiativePhaseDependencies"],
|
||||||
deletePhase: ["listPhases", "listInitiativeTasks", "listInitiativePhaseDependencies"],
|
deletePhase: ["listPhases", "listInitiativeTasks", "listInitiativePhaseDependencies", "listChangeSets"],
|
||||||
updatePhase: ["listPhases", "getPhase"],
|
updatePhase: ["listPhases", "getPhase"],
|
||||||
approvePhase: ["listPhases", "listInitiativeTasks"],
|
approvePhase: ["listPhases", "listInitiativeTasks"],
|
||||||
queuePhase: ["listPhases"],
|
queuePhase: ["listPhases"],
|
||||||
|
|||||||
Reference in New Issue
Block a user