import { randomUUID } from 'node:crypto'; import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; const STATE_FILE_NAME = 'asset-review.json'; const STATE_SCHEMA_VERSION = 1; export type GameAssetReviewAction = 'approve' | 'discard' | 'replace'; export type GameAssetReviewDecision = 'approved' | 'discarded' | 'replace-requested'; export type GameAssetReviewStatus = 'pending' | 'resolved'; export type GameAssetReviewInvocation = { id: string; candidateIds: string[]; decisions: Record; status: GameAssetReviewStatus; createdAt: string; updatedAt: string; }; export type GameAssetReviewState = { schemaVersion: typeof STATE_SCHEMA_VERSION; updatedAt: string; approvedAssetIds: string[]; discardedAssetIds: string[]; invocations: Record; }; export type GameAssetReviewSnapshot = { invocationId: string; candidateIds: string[]; decisions: Record; status: GameAssetReviewStatus; pendingAssetIds: string[]; approvedAssetIds: string[]; discardedAssetIds: string[]; }; export type GameAssetReviewActionInput = { invocationId: string; assetId: string; action: GameAssetReviewAction; candidateIds?: string[]; }; export type GameAssetReviewActionsInput = { invocationId: string; decisions: Array<{ assetId: string; action: GameAssetReviewAction }>; candidateIds?: string[]; }; export class GameAssetReviewConflictError extends Error { readonly statusCode = 409; } const stateWriteQueues = new Map>(); function assetReviewStatePath(projectPath: string): string { return join(projectPath, '.niancode', STATE_FILE_NAME); } function now(): string { return new Date().toISOString(); } function normalizeIds(value: unknown): string[] { if (!Array.isArray(value)) return []; return [...new Set(value.filter((item): item is string => typeof item === 'string') .map((item) => item.trim()) .filter((item) => item.length > 0 && item.length <= 200))]; } function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } function normalizeDecision(value: unknown): GameAssetReviewDecision | null { return value === 'approved' || value === 'discarded' || value === 'replace-requested' ? value : null; } function normalizeInvocation(value: unknown): GameAssetReviewInvocation | null { if (!isRecord(value) || typeof value.id !== 'string' || !value.id.trim()) return null; const candidateIds = normalizeIds(value.candidateIds); const rawDecisions = isRecord(value.decisions) ? value.decisions : {}; const decisions = Object.fromEntries( Object.entries(rawDecisions) .map(([assetId, decision]) => [assetId, normalizeDecision(decision)] as const) .filter((entry): entry is [string, GameAssetReviewDecision] => entry[1] !== null), ); return { id: value.id.trim(), candidateIds, decisions, status: value.status === 'resolved' ? 'resolved' : 'pending', createdAt: typeof value.createdAt === 'string' ? value.createdAt : now(), updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : now(), }; } function normalizeState(value: unknown): GameAssetReviewState { if (!isRecord(value)) throw new Error('Invalid game asset review state'); const rawInvocations = isRecord(value.invocations) ? value.invocations : {}; const invocations = Object.fromEntries( Object.entries(rawInvocations) .map(([id, invocation]) => [id, normalizeInvocation(invocation)] as const) .filter((entry): entry is [string, GameAssetReviewInvocation] => entry[1] !== null), ); return { schemaVersion: STATE_SCHEMA_VERSION, updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : now(), approvedAssetIds: normalizeIds(value.approvedAssetIds), discardedAssetIds: normalizeIds(value.discardedAssetIds), invocations, }; } function emptyState(): GameAssetReviewState { return { schemaVersion: STATE_SCHEMA_VERSION, updatedAt: now(), approvedAssetIds: [], discardedAssetIds: [], invocations: {}, }; } async function readState(projectPath: string): Promise { try { return normalizeState(JSON.parse(await readFile(assetReviewStatePath(projectPath), 'utf8')) as unknown); } catch (error) { if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') return emptyState(); throw error; } } async function writeState(projectPath: string, state: GameAssetReviewState): Promise { const directory = join(projectPath, '.niancode'); await mkdir(directory, { recursive: true }); const target = assetReviewStatePath(projectPath); const temporary = `${target}.${randomUUID()}.tmp`; await writeFile(temporary, `${JSON.stringify({ ...state, updatedAt: now() }, null, 2)}\n`, 'utf8'); await rename(temporary, target); } async function withStateLock(projectPath: string, task: () => Promise): Promise { const previous = stateWriteQueues.get(projectPath) ?? Promise.resolve(); const operation = previous.catch(() => undefined).then(task); const tail = operation.then(() => undefined, () => undefined); stateWriteQueues.set(projectPath, tail); try { return await operation; } finally { if (stateWriteQueues.get(projectPath) === tail) stateWriteQueues.delete(projectPath); } } function toSnapshot(state: GameAssetReviewState, invocation: GameAssetReviewInvocation): GameAssetReviewSnapshot { const terminalIds = new Set([ ...state.approvedAssetIds, ...state.discardedAssetIds, ...Object.keys(invocation.decisions), ]); return { invocationId: invocation.id, candidateIds: [...invocation.candidateIds], decisions: { ...invocation.decisions }, status: invocation.status, pendingAssetIds: invocation.candidateIds.filter((assetId) => !terminalIds.has(assetId)), approvedAssetIds: [...state.approvedAssetIds], discardedAssetIds: [...state.discardedAssetIds], }; } function ensureInvocation( state: GameAssetReviewState, invocationId: string, candidateIds: string[], ): GameAssetReviewInvocation { const normalizedId = invocationId.trim(); if (!normalizedId || normalizedId.length > 200) throw new Error('Invalid game asset review invocation id'); const existing = state.invocations[normalizedId]; if (existing) { // Older markers could create an empty or unresolvable invocation before // the plan parser or candidate-id fallback had a chance to project the // current plan. Repair only that untouched record from the current Agent // submission; never rewrite an invocation after a user decision exists. const normalizedCandidateIds = normalizeIds(candidateIds); const matchesCurrentPlan = existing.candidateIds.some((id) => normalizedCandidateIds.includes(id)); if (normalizedCandidateIds.length > 0 && !matchesCurrentPlan && Object.keys(existing.decisions).length === 0) { existing.candidateIds = normalizedCandidateIds; existing.status = 'pending'; existing.updatedAt = now(); } return existing; } const timestamp = now(); const invocation: GameAssetReviewInvocation = { id: normalizedId, candidateIds: normalizeIds(candidateIds), decisions: {}, status: 'pending', createdAt: timestamp, updatedAt: timestamp, }; state.invocations[normalizedId] = invocation; return invocation; } export function isGameAssetReviewTerminal(state: GameAssetReviewState, assetId: string): boolean { return state.approvedAssetIds.includes(assetId) || state.discardedAssetIds.includes(assetId); } export async function loadGameAssetReview( projectPath: string, invocationId: string, candidateIds: string[], ): Promise { return await withStateLock(projectPath, async () => { const state = await readState(projectPath); const invocation = ensureInvocation(state, invocationId, candidateIds); await writeState(projectPath, state); return toSnapshot(state, invocation); }); } export async function recordGameAssetReviewAction( projectPath: string, input: GameAssetReviewActionInput, ): Promise { if (!input || typeof input !== 'object') throw new Error('Invalid game asset review action'); if (input.action !== 'approve' && input.action !== 'discard' && input.action !== 'replace') { throw new Error('Invalid game asset review action'); } const invocationId = typeof input.invocationId === 'string' ? input.invocationId.trim() : ''; const assetId = typeof input.assetId === 'string' ? input.assetId.trim() : ''; if (!invocationId || !assetId) throw new Error('Game asset review action requires invocationId and assetId'); return await withStateLock(projectPath, async () => { const state = await readState(projectPath); const invocation = ensureInvocation(state, invocationId, normalizeIds(input.candidateIds)); if (!invocation.candidateIds.includes(assetId)) { throw new GameAssetReviewConflictError('This asset is not part of the review invocation'); } applyGameAssetReviewDecision(state, invocation, assetId, decisionForAction(input.action)); await writeState(projectPath, state); return toSnapshot(state, invocation); }); } function decisionForAction(action: GameAssetReviewAction): GameAssetReviewDecision { return action === 'approve' ? 'approved' : action === 'replace' ? 'replace-requested' : 'discarded'; } function assertDecisionCanBeApplied( state: GameAssetReviewState, invocation: GameAssetReviewInvocation, assetId: string, nextDecision: GameAssetReviewDecision, ): void { if (!invocation.candidateIds.includes(assetId)) { throw new GameAssetReviewConflictError('This asset is not part of the review invocation'); } const existingDecision = invocation.decisions[assetId]; if (existingDecision && existingDecision !== nextDecision) { throw new GameAssetReviewConflictError('This asset has already been reviewed'); } const globallyApproved = state.approvedAssetIds.includes(assetId); const globallyDiscarded = state.discardedAssetIds.includes(assetId); if ((globallyApproved && nextDecision !== 'approved') || (globallyDiscarded && nextDecision === 'approved')) { throw new GameAssetReviewConflictError('This asset already has a final review decision'); } } function updateInvocationStatus(state: GameAssetReviewState, invocation: GameAssetReviewInvocation): void { invocation.status = invocation.candidateIds.every((candidateId) => ( state.approvedAssetIds.includes(candidateId) || state.discardedAssetIds.includes(candidateId) || Boolean(invocation.decisions[candidateId]) )) ? 'resolved' : 'pending'; } function applyGameAssetReviewDecision( state: GameAssetReviewState, invocation: GameAssetReviewInvocation, assetId: string, nextDecision: GameAssetReviewDecision, ): void { assertDecisionCanBeApplied(state, invocation, assetId, nextDecision); const globallyApproved = state.approvedAssetIds.includes(assetId); const globallyDiscarded = state.discardedAssetIds.includes(assetId); invocation.decisions[assetId] = nextDecision; invocation.updatedAt = now(); if (nextDecision === 'approved' && !globallyApproved) state.approvedAssetIds.push(assetId); if (nextDecision !== 'approved' && !globallyDiscarded) state.discardedAssetIds.push(assetId); updateInvocationStatus(state, invocation); } export async function recordGameAssetReviewActions( projectPath: string, input: GameAssetReviewActionsInput, ): Promise { if (!input || typeof input !== 'object' || !Array.isArray(input.decisions) || input.decisions.length === 0) { throw new Error('Game asset review batch requires decisions'); } const invocationId = typeof input.invocationId === 'string' ? input.invocationId.trim() : ''; if (!invocationId) throw new Error('Game asset review batch requires invocationId'); return await withStateLock(projectPath, async () => { const state = await readState(projectPath); const invocation = ensureInvocation(state, invocationId, normalizeIds(input.candidateIds)); const normalizedDecisions: Array<{ assetId: string; decision: GameAssetReviewDecision }> = []; const seen = new Map(); for (const item of input.decisions) { const assetId = typeof item?.assetId === 'string' ? item.assetId.trim() : ''; if (!assetId || !['approve', 'discard', 'replace'].includes(item?.action)) { throw new Error('Invalid game asset review batch decision'); } const decision = decisionForAction(item.action); const previous = seen.get(assetId); if (previous) { if (previous !== decision) throw new GameAssetReviewConflictError('The batch contains conflicting decisions for one asset'); continue; } assertDecisionCanBeApplied(state, invocation, assetId, decision); seen.set(assetId, decision); normalizedDecisions.push({ assetId, decision }); } for (const { assetId, decision } of normalizedDecisions) { applyGameAssetReviewDecision(state, invocation, assetId, decision); } await writeState(projectPath, state); return toSnapshot(state, invocation); }); }