340 lines
13 KiB
TypeScript
340 lines
13 KiB
TypeScript
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<string, GameAssetReviewDecision>;
|
|
status: GameAssetReviewStatus;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
};
|
|
|
|
export type GameAssetReviewState = {
|
|
schemaVersion: typeof STATE_SCHEMA_VERSION;
|
|
updatedAt: string;
|
|
approvedAssetIds: string[];
|
|
discardedAssetIds: string[];
|
|
invocations: Record<string, GameAssetReviewInvocation>;
|
|
};
|
|
|
|
export type GameAssetReviewSnapshot = {
|
|
invocationId: string;
|
|
candidateIds: string[];
|
|
decisions: Record<string, GameAssetReviewDecision>;
|
|
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<string, Promise<void>>();
|
|
|
|
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<string, unknown> {
|
|
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<GameAssetReviewState> {
|
|
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<void> {
|
|
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<T>(projectPath: string, task: () => Promise<T>): Promise<T> {
|
|
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<GameAssetReviewSnapshot> {
|
|
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<GameAssetReviewSnapshot> {
|
|
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<GameAssetReviewSnapshot> {
|
|
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<string, GameAssetReviewDecision>();
|
|
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);
|
|
});
|
|
}
|