Complete provisioning job detail pages and lifecycle evidence

This commit is contained in:
2026-09-28 14:17:19 -04:00
parent 5b65520bbc
commit 3fb59cd598
41 changed files with 2866 additions and 346 deletions
+120
View File
@@ -0,0 +1,120 @@
import { db } from '../db/index.js';
import { provisioningPhases, type ProvisioningPhase, type ProvisioningProgress, type ProvisioningCheck,
type ProvisioningCheckpoint, type ProvisioningBlocker, type RecoveryObservation } from '../../provisioning-detail.js';
export type CheckInput = Omit<ProvisioningCheck, 'checkedAt' | 'receivedAt' | 'expected' | 'actual' | 'message'> &
Partial<Pick<ProvisioningCheck, 'checkedAt' | 'receivedAt' | 'expected' | 'actual' | 'message'>>;
export type CheckpointInput = Omit<ProvisioningCheckpoint, 'at' | 'phase'> & { 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<NonNullable<ProvisioningProgress['command']>, 'at'> & { at?: string }) | null;
recovery?: (Omit<RecoveryObservation, 'at'> & { at?: string }) | null;
recipeStatus?: ProvisioningProgress['recipeStatus'];
observedAt?: string | null;
event?: { kind: string; message: string; details?: Record<string, unknown> };
}
const object = (value: unknown): value is Record<string, unknown> => !!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', 'occupancy', 'stopped'].includes(String(c.key))
&& typeof c.label === 'string' && ['pass', 'waiting', 'fail', 'unknown'].includes(String(c.result))
&& (c.source === null || ['komodo', 'rcon', 'telemetry', 'combined'].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<string, unknown> }[] = [];
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);
})();
}