import { db } from '../db/index.js'; import { invalidateFleet } from '../pipeline/bus.js'; import { provisioningPhases, type ProvisioningPhase, type ProvisioningProgress, type ProvisioningCheck, type ProvisioningCheckpoint, type ProvisioningBlocker, type RecoveryObservation } from '../../provisioning-detail.js'; export type CheckInput = Omit & Partial>; export type CheckpointInput = Omit & { at?: string; phase?: ProvisioningPhase | null }; export interface ProgressUpdate { phase?: ProvisioningPhase | null; blocker?: ProvisioningBlocker | null; /** A complete evaluation; omitted checks are no longer current evidence. */ checks?: CheckInput[]; checkpoint?: CheckpointInput; checkpoints?: CheckpointInput[]; command?: (Omit, 'at'> & { at?: string }) | null; recovery?: (Omit & { at?: string }) | null; recipeStatus?: ProvisioningProgress['recipeStatus']; observedAt?: string | null; event?: { kind: string; message: string; details?: Record }; } const object = (value: unknown): value is Record => !!value && typeof value === 'object' && !Array.isArray(value); const nullableString = (value: unknown) => value === null || typeof value === 'string'; const phase = (value: unknown): value is ProvisioningPhase => provisioningPhases.includes(value as ProvisioningPhase); const scalar = (value: unknown) => nullableString(value) || typeof value === 'number' && Number.isFinite(value); /** Invalid/old snapshots never manufacture evidence. Raw legacy stages remain available. */ export function readProgress(row: { observation_json?: string | null }): ProvisioningProgress | null { if (!row.observation_json) return null; try { const v: unknown = JSON.parse(row.observation_json); if (!object(v) || v.version !== 1 || !(v.phase === null || phase(v.phase)) || !nullableString(v.phaseStartedAt) || !nullableString(v.observedAt) || !Array.isArray(v.checks) || !Array.isArray(v.checkpoints)) return null; if (v.blocker !== null && (!object(v.blocker) || typeof v.blocker.code !== 'string' || typeof v.blocker.message !== 'string' || typeof v.blocker.automatic !== 'boolean' || (v.blocker.href !== undefined && !nullableString(v.blocker.href)) || (v.blocker.label !== undefined && !nullableString(v.blocker.label)))) return null; if (v.checks.length > 10 || !v.checks.every(c => object(c) && ['container', 'game', 'map', 'plutonium-release', 'plutonium-version', 'occupancy', 'stopped'].includes(String(c.key)) && typeof c.label === 'string' && ['pass', 'waiting', 'fail', 'unknown'].includes(String(c.result)) && (c.source === null || ['agent', 'rcon', 'telemetry', 'combined', 'manifest'].includes(String(c.source))) && typeof c.checkedAt === 'string' && nullableString(c.receivedAt) && scalar(c.expected) && scalar(c.actual) && nullableString(c.message))) return null; if (v.checkpoints.length > 100 || !v.checkpoints.every(c => object(c) && typeof c.key === 'string' && typeof c.label === 'string' && typeof c.at === 'string' && (c.phase === null || phase(c.phase)))) return null; if (v.command !== null && (!object(v.command) || !['attempted', 'accepted', 'rejected', 'uncertain'].includes(String(v.command.state)) || typeof v.command.at !== 'string' || typeof v.command.message !== 'string')) return null; if (v.recovery !== null && (!object(v.recovery) || !['held', 'released', 'transferred', 'unknown'].includes(String(v.recovery.state)) || typeof v.recovery.at !== 'string' || typeof v.recovery.message !== 'string' || (v.recovery.operationId !== undefined && !nullableString(v.recovery.operationId)))) return null; if (v.recipeStatus !== null && v.recipeStatus !== 'queued' && v.recipeStatus !== 'attempted') return null; return v as unknown as ProvisioningProgress; } catch { return null; } } /** * Records facts at the existing execution boundary, never performs a probe or command. * Can be nested inside the caller's transaction with its state/legacy-stage writes. */ export function recordProgress(id: string, update: ProgressUpdate): void { const handle = db(); handle.transaction(() => { const row = handle.prepare('SELECT observation_json FROM server_deployments WHERE id = ?').get(id) as { observation_json: string | null } | undefined; if (!row) return; const now = new Date().toISOString(); const previous = readProgress(row); const next: ProvisioningProgress = previous ? { ...previous, checkpoints: [...previous.checkpoints] } : { version: 1, phase: null, phaseStartedAt: null, observedAt: null, blocker: null, checks: [], checkpoints: [], command: null, recovery: null, recipeStatus: null }; const events: { kind: string; message: string; details?: Record }[] = []; if (update.phase !== undefined && update.phase !== next.phase) { next.phase = update.phase; next.phaseStartedAt = update.phase === null ? null : now; next.blocker = null; } next.observedAt = update.observedAt === undefined ? now : update.observedAt; if (update.blocker !== undefined) { const old = next.blocker; next.blocker = update.blocker; // Counts/error wording can change on every poll without a new kind of hold. if (update.blocker && (old?.code !== update.blocker.code || old?.href !== update.blocker.href)) { events.push({ kind: 'blocker', message: update.blocker.message, details: { code: update.blocker.code, href: update.blocker.href ?? null } }); } } if (update.checks !== undefined) { next.checks = update.checks.map(check => ({ ...check, checkedAt: check.checkedAt ?? now, receivedAt: check.receivedAt ?? null, expected: check.expected ?? null, actual: check.actual ?? null, message: check.message ?? null })); } for (const input of [...(update.checkpoints ?? []), ...(update.checkpoint ? [update.checkpoint] : [])]) { if (next.checkpoints.some(c => c.key === input.key)) continue; const point: ProvisioningCheckpoint = { ...input, at: input.at ?? now, phase: input.phase ?? (phase(input.key) ? input.key : next.phase) }; next.checkpoints.push(point); events.push({ kind: 'checkpoint', message: point.label, details: { key: point.key, at: point.at } }); } if (update.command !== undefined) { if (!update.command) next.command = null; else if (next.command?.state !== update.command.state || next.command?.message !== update.command.message) { next.command = { ...update.command, at: update.command.at ?? now }; events.push({ kind: 'command', message: next.command.message, details: { state: next.command.state, at: next.command.at } }); } } if (update.recovery !== undefined) { if (!update.recovery) next.recovery = null; else { const changed = next.recovery?.state !== update.recovery.state || next.recovery?.operationId !== update.recovery.operationId; next.recovery = { ...update.recovery, at: changed ? update.recovery.at ?? now : next.recovery!.at }; if (changed) events.push({ kind: 'recovery', message: next.recovery.message, details: { state: next.recovery.state, at: next.recovery.at, operationId: next.recovery.operationId ?? null } }); } } if (update.recipeStatus !== undefined) next.recipeStatus = update.recipeStatus; if (update.event) events.push(update.event); handle.prepare(`UPDATE server_deployments SET phase_key = ?, phase_started_at = ?, observed_at = ?, observation_json = ? WHERE id = ?`) .run(next.phase, next.phaseStartedAt, next.observedAt, JSON.stringify(next), id); const insert = handle.prepare(`INSERT INTO server_deployment_events (deployment_id, created_at, message, kind, phase_key, details_json) VALUES (?, ?, ?, ?, ?, ?)`); for (const event of events) insert.run(id, now, event.message, event.kind, next.phase, event.details ? JSON.stringify(event.details) : null); if (events.length) invalidateFleet(); })(); }