123 lines
8.6 KiB
TypeScript
123 lines
8.6 KiB
TypeScript
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<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', '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<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);
|
|
if (events.length) invalidateFleet();
|
|
})();
|
|
}
|