Consolidate provisioning progress and evidence (Refs #82)

This commit is contained in:
2026-10-09 11:18:33 -04:00
parent 40ad5f01a9
commit 485a73c422
5 changed files with 235 additions and 101 deletions
+53 -12
View File
@@ -1,4 +1,4 @@
import { provisioningPhases, type ProvisioningCheck, type ProvisioningDeployment, type ProvisioningDetail, type ProvisioningEvent, type ProvisioningEventPage, type ProvisioningPhase } from '$lib/provisioning-detail.js';
import { provisioningPhases, type ProvisioningCheck, type ProvisioningCheckpoint, type ProvisioningDeployment, type ProvisioningDetail, type ProvisioningEvent, type ProvisioningEventPage, type ProvisioningPhase, type ProvisioningProgress } from '$lib/provisioning-detail.js';
import { actionCompleted, actionLabels, provisioningActions, type ProvisioningAction } from '$lib/provisioning.js';
import { systemUsername } from '$lib/auth.js';
import { countLabel } from './plural.js';
@@ -16,7 +16,13 @@ export function provisioningRequester(operation: Pick<ProvisioningDeployment, 'r
export const activeProvisioningStates = ['queued', 'waiting', 'running', 'awaiting-health'];
export type ProgressState = 'done' | 'current' | 'waiting' | 'failed' | 'pending' | 'cancelled' | 'unknown';
export interface ProgressStep { key: ProvisioningPhase; label: string; state: ProgressState; at: string | null }
export interface ProgressStep {
key: ProvisioningPhase; label: string; state: ProgressState; at: string | null;
checks: ProvisioningCheck[];
checkpoints: ProvisioningCheckpoint[];
command: ProvisioningProgress['command'];
agentOperation: ProvisioningDetail['agentOperation'];
}
export interface ProgressGroup { key: string; label: string; steps: ProgressStep[]; state: ProgressState }
const phaseLabels: Record<ProvisioningPhase, string> = {
@@ -38,26 +44,58 @@ function definitions(action: ProvisioningAction): GroupDefinition[] {
return [request, command, { ...readiness, phases: action === 'stop' ? ['verify-stopped'] : ['verify-container', 'verify-game'] }];
}
const checkPhases: Record<ProvisioningCheck['key'], ProvisioningPhase> = {
occupancy: 'eligibility', container: 'verify-container', game: 'verify-game', map: 'verify-map',
'plutonium-release': 'verify-plutonium', 'plutonium-version': 'verify-plutonium', stopped: 'verify-stopped'
};
function checkpointPhase(checkpoint: ProvisioningCheckpoint): ProvisioningPhase {
if (provisioningPhases.includes(checkpoint.key as ProvisioningPhase)) return checkpoint.key as ProvisioningPhase;
// Receipts can arrive after the worker has moved to another phase.
if (checkpoint.key.startsWith('staging-')) return 'prepare';
if (/^(agent|recovery)-/.test(checkpoint.key)) return 'container-action';
return checkpoint.phase ?? 'container-action';
}
export function provisioningGroups(detail: ProvisioningDetail): ProgressGroup[] {
const progress = detail.progress;
if (!progress) return [];
const groups = definitions(detail.deployment.action);
if (!progress && !detail.agentOperation) return [];
const commandGroup: GroupDefinition = { key: 'command', label: `${actionLabels[detail.deployment.action]} on host agent`, phases: ['container-action'] };
const groups = (progress ? definitions(detail.deployment.action) : [commandGroup]).map(group => ({ ...group, phases: [...group.phases] }));
// Keep recorded evidence even when an older operation used a different phase layout.
const recordedPhases = [...(progress?.checks.map(check => checkPhases[check.key]) ?? []),
...(progress?.checkpoints.map(checkpointPhase) ?? []), ...(progress?.phase ? [progress.phase] : [])];
for (const phase of recordedPhases) {
if (groups.some(group => group.phases.includes(phase))) continue;
let group = groups.find(group => group.key === (phase.startsWith('verify-') ? 'verify' : phase === 'cleanup' ? 'cleanup' : 'command'));
if (!group) {
group = { key: phase === 'cleanup' ? 'cleanup' : 'verify', label: phase === 'cleanup' ? 'Retire instance' : 'Verify readiness', phases: [] };
const verifyIndex = groups.findIndex(group => group.key === 'verify');
groups.splice(verifyIndex < 0 ? groups.length : verifyIndex, 0, group);
}
group.phases.push(phase);
group.phases.sort((a, b) => provisioningPhases.indexOf(a) - provisioningPhases.indexOf(b));
}
const phases = groups.flatMap(group => group.phases);
const current = progress.phase ? phases.indexOf(progress.phase) : -1;
const current = progress?.phase ? phases.indexOf(progress.phase) : -1;
const active = activeProvisioningStates.includes(detail.deployment.state);
const failure = ['failed', 'interrupted'].includes(detail.deployment.state);
return groups.map(group => {
const steps = group.phases.map((key): ProgressStep => {
const checkpoint = progress.checkpoints.find(item => item.key === key);
const checkKey = key === 'verify-container' ? 'container' : key === 'verify-game' ? 'game' : key === 'verify-map' ? 'map'
: key === 'verify-plutonium' ? 'plutonium-version' : key === 'verify-stopped' ? 'stopped' : null;
const check = checkKey ? progress.checks.find(item => item.key === checkKey) : null;
const checkpoint = progress?.checkpoints.find(item => item.key === key);
const checks = progress?.checks.filter(item => checkPhases[item.key] === key) ?? [];
// A fetched release manifest does not prove the running revision was verified.
const check = checks.find(item => item.key !== 'plutonium-release');
const command = key === 'container-action' ? progress?.command ?? null : null;
const agentOperation = key === 'container-action' ? detail.agentOperation : null;
const index = phases.indexOf(key);
const isCurrent = key === progress.phase;
const isCurrent = key === progress?.phase;
let state: ProgressState = check ? ({ pass: 'done', fail: 'failed', waiting: 'waiting', unknown: 'unknown' } as const)[check.result]
: checkpoint ? 'done'
: agentOperation?.state === 'completed' ? 'done'
: agentOperation?.state === 'failed' || command?.state === 'rejected' ? 'failed'
: isCurrent && failure ? 'failed'
: isCurrent && active ? progress.blocker || ['waiting', 'queued'].includes(detail.deployment.state) ? 'waiting' : 'current'
: isCurrent && active ? progress?.blocker || ['waiting', 'queued'].includes(detail.deployment.state) ? 'waiting' : 'current'
: current >= 0 && index >= current && (failure || detail.deployment.state === 'cancelled') ? 'cancelled'
: active && current >= 0 && index > current ? 'pending' : 'unknown';
// Preserve completed evidence, but outstanding work cannot keep waiting after failure.
@@ -66,7 +104,10 @@ export function provisioningGroups(detail: ProvisioningDetail): ProgressGroup[]
: key === 'prepare' ? 'Prepare launch payload'
: key === 'container-action' ? `${actionLabels[detail.deployment.action]} instance through host agent`
: phaseLabels[key];
return { key, label, state, at: check?.checkedAt ?? checkpoint?.at ?? (isCurrent ? progress.phaseStartedAt : null) };
return { key, label: checkpoint && !check && state === 'done' ? checkpoint.label : label, state,
at: check?.checkedAt ?? checkpoint?.at ?? agentOperation?.updatedAt ?? (isCurrent ? progress?.phaseStartedAt ?? null : null),
checks, checkpoints: progress?.checkpoints.filter(item => item.key !== key && checkpointPhase(item) === key) ?? [],
command, agentOperation };
});
const states = steps.map(step => step.state);
const state = (['failed', 'current', 'waiting'] as const).find(value => states.includes(value))