Files
Codewalkers/apps/web/src/lib/invalidation.ts
Lukas May e3246baf51 feat: Show resolving_conflict activity state on initiative cards
Add 'resolving_conflict' to InitiativeActivityState and detect active
conflict agents (name starts with conflict-) in deriveInitiativeActivity.
Conflict resolution takes priority over pending_review since the agent
is actively working.

- Add resolving_conflict to shared types and activity derivation
- Include conflict agents in listInitiatives agent filter (name + mode)
- Map resolving_conflict to urgent variant with pulse in InitiativeCard
- Add merge: prefix to INITIATIVE_LIST_RULES for merge event routing
- Add spawnConflictResolutionAgent to INVALIDATION_MAP
- Add getActiveConflictAgent to detail page agent: SSE invalidation
2026-03-06 13:32:37 +01:00

133 lines
5.2 KiB
TypeScript

import type { QueryClient } from "@tanstack/react-query";
import { MutationCache } from "@tanstack/react-query";
import type { AnyQueryProcedure, AnyMutationProcedure } from "@trpc/server";
import type { AppRouter } from "@codewalk-district/shared";
// Strip the [key: string] index signature from RouterRecord so keyof yields
// only the literal procedure names, not `string`.
type RemoveIndexSignature<T> = {
[K in keyof T as string extends K ? never : K]: T[K];
};
type Procedures = RemoveIndexSignature<AppRouter>;
type MutationName = {
[K in keyof Procedures]: Procedures[K] extends AnyMutationProcedure ? K : never;
}[keyof Procedures] & string;
type QueryName = {
[K in keyof Procedures]: Procedures[K] extends AnyQueryProcedure ? K : never;
}[keyof Procedures] & string;
/**
* Centralized invalidation map.
*
* Maps each tRPC mutation name to the query keys that should be invalidated
* when that mutation succeeds. This eliminates scattered `utils.listX.invalidate()`
* calls across every component.
*
* tRPC React Query encodes keys as arrays: the first element is a tuple like
* ["listAgents"], and the mutation key follows the same pattern. We match on
* the procedure name (the last segment of the tRPC path).
*/
const INVALIDATION_MAP: Partial<Record<MutationName, QueryName[]>> = {
// --- Agents ---
stopAgent: ["listAgents", "listWaitingAgents", "listMessages"],
deleteAgent: ["listAgents"],
dismissAgent: ["listAgents"],
resumeAgent: ["listAgents", "listWaitingAgents", "listMessages"],
respondToMessage: ["listWaitingAgents", "listMessages"],
// --- Architect spawns ---
spawnArchitectRefine: ["listAgents"],
spawnArchitectDiscuss: ["listAgents"],
spawnArchitectPlan: ["listAgents"],
spawnArchitectDetail: ["listAgents", "listInitiativeTasks"],
spawnConflictResolutionAgent: ["listAgents", "listInitiatives", "getInitiative"],
// --- Initiatives ---
createInitiative: ["listInitiatives"],
updateInitiative: ["listInitiatives", "getInitiative"],
updateInitiativeProjects: ["getInitiative"],
approveInitiativeReview: ["listInitiatives", "getInitiative"],
requestInitiativeChanges: ["listInitiatives", "getInitiative"],
// --- Phases ---
createPhase: ["listPhases", "listInitiativePhaseDependencies"],
deletePhase: ["listPhases", "listInitiativeTasks", "listInitiativePhaseDependencies", "listChangeSets"],
updatePhase: ["listPhases", "getPhase"],
approvePhase: ["listPhases", "listInitiativeTasks", "listInitiatives"],
requestPhaseChanges: ["listPhases", "listInitiativeTasks", "listPhaseTasks", "getInitiative"],
queuePhase: ["listPhases"],
createPhaseDependency: ["getPhaseDependencies", "listInitiativePhaseDependencies", "listPhaseTaskDependencies"],
removePhaseDependency: ["getPhaseDependencies", "listInitiativePhaseDependencies", "listPhaseTaskDependencies"],
// --- Tasks ---
createPhaseTask: ["listPhaseTasks", "listInitiativeTasks", "listTasks"],
createInitiativeTask: ["listTasks", "listInitiativeTasks"],
createChildTasks: ["listTasks", "listInitiativeTasks", "listPhaseTasks"],
queueTask: ["listTasks", "listInitiativeTasks", "listPhaseTasks"],
deleteTask: ["listTasks", "listInitiativeTasks", "listPhaseTasks", "listChangeSets"],
// --- Change Sets ---
revertChangeSet: ["listPhases", "listPhaseTasks", "listInitiativeTasks", "listPages", "getPage", "listChangeSets", "getRootPage", "getChangeSet"],
// --- Pages ---
// NOTE: getPage omitted — useAutoSave handles optimistic updates for the
// active page, and SSE `page:updated` events cover external changes.
// Including getPage here caused double-invalidation (mutation + SSE) and
// refetch storms that flickered the editor.
updatePage: ["listPages", "getRootPage"],
createPage: ["listPages", "getRootPage"],
deletePage: ["listPages", "getRootPage"],
// --- Projects ---
registerProject: ["listProjects"],
// --- Accounts ---
addAccount: ["listAccounts"],
removeAccount: ["listAccounts"],
refreshAccounts: ["listAccounts"],
markAccountExhausted: ["listAccounts"],
};
/**
* Extract the tRPC procedure name from a mutation key.
*
* tRPC v11 React Query keys look like: [["procedureName"], { type: "mutation" }]
* We want just "procedureName".
*/
function extractProcedureName(mutationKey: unknown): MutationName | null {
if (!Array.isArray(mutationKey)) return null;
const first = mutationKey[0];
if (Array.isArray(first) && typeof first[0] === "string") {
return first[0] as MutationName;
}
return null;
}
/**
* Creates a MutationCache with a global onSuccess handler that automatically
* invalidates the relevant queries for each tRPC mutation.
*/
export function createMutationCache(getQueryClient: () => QueryClient): MutationCache {
return new MutationCache({
onSuccess: (_data, _variables, _context, mutation) => {
const name = extractProcedureName(mutation.options.mutationKey);
if (!name) return;
const queriesToInvalidate = INVALIDATION_MAP[name];
if (!queriesToInvalidate) return;
const queryClient = getQueryClient();
for (const queryName of queriesToInvalidate) {
void queryClient.invalidateQueries({
queryKey: [[queryName]],
});
}
},
});
}