import { provisioningPhases, type ProvisioningDetail, type ProvisioningEvent, type ProvisioningEventPage, type ProvisioningPhase } from '$lib/provisioning-detail.js'; import { actionCompleted, actionLabels, provisioningActions, type ProvisioningAction } from '$lib/provisioning.js'; 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 ProgressGroup { key: string; label: string; steps: ProgressStep[]; state: ProgressState } const phaseLabels: Record = { requested: 'Request recorded', eligibility: 'Check eligibility and capacity', 'startup-queue': 'Wait for a startup slot', prepare: 'Prepare server files', 'container-action': 'Start container', cleanup: 'Remove files and registration', 'verify-container': 'Confirm container readiness', 'verify-game': 'Verify game status', 'verify-map': 'Verify selected engine map', 'verify-plutonium': 'Verify Plutonium revision', 'verify-stopped': 'Confirm container is stopped' }; type GroupDefinition = { key: string; label: string; phases: ProvisioningPhase[] }; const request: GroupDefinition = { key: 'request', label: 'Request and eligibility', phases: ['requested', 'eligibility'] }; const readiness: GroupDefinition = { key: 'verify', label: 'Verify readiness', phases: ['verify-container', 'verify-game', 'verify-map', 'verify-plutonium'] }; function definitions(action: ProvisioningAction): GroupDefinition[] { const command: GroupDefinition = { key: 'command', label: `${actionLabels[action]} on host agent`, phases: ['container-action'] }; if (action === 'create' || action === 'rebuild') return [request, { key: 'create', label: action === 'rebuild' ? 'Rebuild on host agent' : 'Create and start on host agent', phases: ['startup-queue', 'prepare', 'container-action'] }, readiness]; if (action === 'destroy') return [request, command, { key: 'cleanup', label: 'Retire instance', phases: ['cleanup'] }]; return [request, command, { ...readiness, phases: action === 'stop' ? ['verify-stopped'] : ['verify-container', 'verify-game'] }]; } export function provisioningGroups(detail: ProvisioningDetail): ProgressGroup[] { const progress = detail.progress; if (!progress) return []; const groups = definitions(detail.deployment.action); const phases = groups.flatMap(group => group.phases); const current = progress.phase ? phases.indexOf(progress.phase) : -1; const active = activeProvisioningStates.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 index = phases.indexOf(key); const isCurrent = key === progress.phase; const state: ProgressState = check ? ({ pass: 'done', fail: 'failed', waiting: 'waiting', unknown: 'unknown' } as const)[check.result] : checkpoint ? 'done' : isCurrent && ['failed', 'interrupted'].includes(detail.deployment.state) ? 'failed' : isCurrent && active ? progress.blocker || ['waiting', 'queued'].includes(detail.deployment.state) ? 'waiting' : 'current' : current >= 0 && index >= current && detail.deployment.state === 'cancelled' ? 'cancelled' : active && current >= 0 && index > current ? 'pending' : 'unknown'; const label = key === 'startup-queue' ? 'Admit to startup capacity' : 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) }; }); const states = steps.map(step => step.state); const state = (['failed', 'current', 'waiting'] as const).find(value => states.includes(value)) ?? (states.every(value => value === 'done') ? 'done' : states.includes('unknown') ? 'unknown' : states.includes('cancelled') ? 'cancelled' : 'pending'); return { key: group.key, label: group.label, steps, state }; }); } export const progressStateLabels: Record = { done: 'Completed', current: 'In progress', waiting: 'Waiting', failed: 'Needs attention', pending: 'Not started', cancelled: 'Not run', unknown: 'Not recorded' }; export const progressIcons: Record = { done: '✓', current: '•', waiting: '◷', failed: '!', pending: '○', cancelled: '–', unknown: '?' }; export function provisioningOutcome(detail: ProvisioningDetail) { const { deployment: operation, progress } = detail; const active = activeProvisioningStates.includes(operation.state); const failure = ['failed', 'interrupted'].includes(operation.state); const headline = operation.state === 'done' ? actionCompleted[operation.action] : operation.state === 'cancelled' ? `${actionLabels[operation.action]} request cancelled` : failure ? `${actionLabels[operation.action]} ${operation.state === 'interrupted' ? 'interrupted' : 'failed'}` : operation.state === 'warning' ? `${actionLabels[operation.action]} completed with warnings` : progress?.blocker?.message ?? operation.stage; let description: string; if (detail.canResumeCleanup) description = 'The remote deployment was deleted. Resume the remaining local cleanup without repeating container removal.'; else if (detail.canResumeRebuild) description = 'The remote deployment was deleted. Resume local cleanup, then create a fresh instance in the reserved server slot.'; else if (detail.canResumeVerification) description = 'Resume observation of the existing instance. This does not create, restart, or rebuild a container.'; else if (detail.cancellation) description = detail.cancellation.state === 'done' ? 'Creation was cancelled. Its instance files, container, and port reservations have been removed.' : 'Creation was cancelled. Cleanup is tracked separately below; ports remain reserved until the host confirms removal.'; else if (operation.state === 'cancelled') description = 'This request is cancelled. Recorded prior work is preserved below; remaining work will not run.'; else if (failure) description = progress?.command?.state === 'uncertain' ? 'The remote response was uncertain. Inspect the recorded result and server before requesting another operation.' : 'Review the recorded failure and execution evidence before submitting another operation.'; else if (operation.state === 'warning') description = 'The operation finished. Review the recorded warnings below for any remaining issue.'; else if (operation.state === 'done') description = !progress ? 'This operation was recorded as completed. Detailed verification evidence was not recorded.' : operation.action === 'destroy' ? 'The instance removal completed. This record preserves its results after deletion.' : operation.action === 'stop' ? 'The container was confirmed stopped. Its files and registration were retained.' : 'The operation completed its readiness checks. These recorded results describe that attempt; current health is available on the server page.'; else if (active && progress?.phase === 'cleanup' && !progress.blocker) description = operation.action === 'rebuild' ? 'Remote deployment deletion was confirmed. Local cleanup must finish before a fresh instance is created in the reserved slot.' : 'Remote deployment deletion was confirmed. The remaining local files and registration cleanup will continue without repeating container removal.'; else if (progress?.blocker) description = progress.blocker.automatic ? 'The worker will recheck this condition and continue automatically when eligible.' : 'Review the blocking condition below before this operation can continue.'; else if (operation.state === 'queued') description = 'The request is saved and awaiting provisioning evaluation. No capacity blocker has been recorded.'; else if (operation.state === 'waiting') description = 'Waiting for the worker’s eligibility checks. The recorded stage below describes the last known condition.'; else description = operation.action === 'destroy' ? 'Removal is in progress. Confirmed checkpoints and remaining cleanup are recorded below.' : operation.action === 'rebuild' ? 'The old instance is removed before a fresh instance is created in its reserved slot. Follow each checkpoint below.' : 'The worker is executing this request and checking its resulting state.'; return { active, headline, description }; } /** Accept return context only from the local operation surfaces we expose. */ export function provisioningReturn(value: string | null | undefined): { href: string; label: string } { const fallback = { href: '/provisioning', label: 'Back to Provisioning' }; if (!value || !value.startsWith('/') || value.startsWith('//') || value.includes('\\')) return fallback; try { const url = new URL(value, 'https://electric-cherry.invalid'); if (url.origin !== 'https://electric-cherry.invalid') return fallback; if (url.pathname === '/provisioning') { const params = new URLSearchParams(); for (const key of ['view', 'action', 'status', 'source', 'server', 'limit', 'offset']) { const item = url.searchParams.get(key); if (item) params.set(key, item); } return { href: '/provisioning' + (params.size ? `?${params}` : ''), label: 'Back to Provisioning' }; } const match = url.pathname.match(/^\/(campaigns|betas|maintenance|collections\/launches)\/[^/]+$/); if (match) return { href: url.pathname, label: `Back to ${match[1] === 'collections/launches' ? 'collection' : match[1] === 'campaigns' ? 'campaign' : match[1] === 'betas' ? 'beta' : 'maintenance'}` }; } catch { /* Fall back to the local list. */ } return fallback; } export function elapsedTime(start: string | null | undefined, end: number): string { if (!start || !Number.isFinite(Date.parse(start))) return 'Not recorded'; const seconds = Math.max(0, Math.floor((end - Date.parse(start)) / 1000)); if (seconds < 60) return `${seconds}s`; if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; return `${Math.floor(seconds / 3600)}h ${Math.floor(seconds % 3600 / 60)}m`; } const record = (value: unknown): value is Record => !!value && typeof value === 'object' && !Array.isArray(value); const text = (value: unknown): value is string => typeof value === 'string'; const number = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); const nullableText = (value: unknown) => value === null || text(value); const timestamp = (value: unknown): value is string => text(value) && Number.isFinite(Date.parse(value)); const nullableTime = (value: unknown) => value === null || timestamp(value); const scalar = (value: unknown) => value === null || text(value) || number(value); const localLink = (value: unknown) => value === null || (text(value) && value.startsWith('/') && !value.startsWith('//') && !value.includes('\\')); const optionalLink = (value: unknown) => value === undefined || localLink(value); const phase = (value: unknown) => value === null || provisioningPhases.includes(value as ProvisioningPhase); const event = (value: unknown): value is ProvisioningEvent => record(value) && number(value.id) && timestamp(value.created_at) && text(value.message); export function isProvisioningEventPage(value: unknown): value is ProvisioningEventPage { return record(value) && Array.isArray(value.events) && value.events.every(event) && number(value.total) && number(value.totalEvents) && (value.latestId === null || number(value.latestId)) && number(value.watermark) && (value.nextCursor === null || number(value.nextCursor)); } /** Validate every nested shape consumed by the page before replacing its last good snapshot. */ export function isProvisioningDetail(value: unknown, id: string): value is ProvisioningDetail { if (!record(value) || !record(value.deployment)) return false; if (value.canCancel !== undefined && typeof value.canCancel !== 'boolean') return false; if (value.canCancelCreation !== undefined && typeof value.canCancelCreation !== 'boolean') return false; if (value.cancellation !== undefined && value.cancellation !== null && !(record(value.cancellation) && text(value.cancellation.cleanupId) && text(value.cancellation.state) && text(value.cancellation.stage) && nullableText(value.cancellation.error))) return false; if (value.backend !== undefined && !['native'].includes(value.backend as string)) return false; if (value.workerActivity !== undefined && (!Array.isArray(value.workerActivity) || !value.workerActivity.every(item => record(item) && text(item.key) && text(item.label) && timestamp(item.startedAt)))) return false; if (value.agentOperation !== undefined && value.agentOperation !== null && !(record(value.agentOperation) && text(value.agentOperation.id) && text(value.agentOperation.hostId) && text(value.agentOperation.state) && timestamp(value.agentOperation.updatedAt) && timestamp(value.agentOperation.createdAt) && nullableText(value.agentOperation.error))) return false; const operation = value.deployment; if (operation.id !== id || !provisioningActions.includes(operation.action as ProvisioningAction) || !['server', 'state', 'stage', 'triggered_by', 'warnings'].every(key => text(operation[key])) || !timestamp(operation.started_at) || !nullableTime(operation.finished_at) || !nullableTime(operation.server_deleted_at) || !nullableText(operation.error) || !number(operation.server_exists) || (operation.submitted_at !== undefined && !nullableTime(operation.submitted_at)) || (operation.expected_map !== undefined && !nullableText(operation.expected_map))) return false; if (!timestamp(value.generatedAt) || !Array.isArray(value.events) || !value.events.every(event) || !Array.isArray(value.warningMessages) || !value.warningMessages.every(text) || !['canResumeVerification', 'canResumeCleanup', 'canResumeRebuild', 'canCancelRemoval'].every(key => typeof value[key] === 'boolean') || !nullableText(value.blueprintName)) return false; if (!record(value.activity) || !number(value.activity.total) || !(value.activity.latestId === null || number(value.activity.latestId)) || !(value.activity.failureExcerpt === null || event(value.activity.failureExcerpt))) return false; if (!Array.isArray(value.sources) || !value.sources.every(source => record(source) && ['campaign', 'beta', 'maintenance', 'watchdog', 'autoscaling'].includes(source.kind as string) && text(source.source_id) && text(source.label) && localLink(source.href))) return false; if (!(value.collection === null || record(value.collection) && text(value.collection.id) && text(value.collection.name))) return false; if (!record(value.capacity) || !timestamp(value.capacity.evaluatedAt) || !Array.isArray(value.capacity.pools) || !value.capacity.pools.every(pool => record(pool) && ['startup', 'restart'].includes(pool.kind as string) && number(pool.limit) && number(pool.used) && typeof pool.ownsSlot === 'boolean' && (pool.requestOrder === null || number(pool.requestOrder)) && Array.isArray(pool.holders) && pool.holders.every(holder => record(holder) && nullableText(holder.id) && text(holder.server)))) return false; if (value.recipe !== null) { const recipe = value.recipe; if (!record(recipe) || !['t5', 't6'].includes(recipe.game as string) || !timestamp(recipe.capturedAt) || !['mapSlug', 'mapName', 'expectedMap', 'image', 'configFile', 'configSource', 'fingerprint'].every(key => text(recipe[key])) || !Array.isArray(recipe.files) || !recipe.files.every(file => record(file) && ['name', 'kind', 'path', 'sha256'].every(key => text(file[key])) && number(file.size) && (file.version === null || number(file.version)))) return false; } if (value.progress !== null) { const progress = value.progress; if (!record(progress) || progress.version !== 1 || !phase(progress.phase) || !nullableTime(progress.phaseStartedAt) || !nullableTime(progress.observedAt) || ![null, 'queued', 'attempted'].includes(progress.recipeStatus as null | string)) return false; if (!(progress.blocker === null || record(progress.blocker) && text(progress.blocker.code) && text(progress.blocker.message) && typeof progress.blocker.automatic === 'boolean' && optionalLink(progress.blocker.href) && (progress.blocker.label === undefined || nullableText(progress.blocker.label)))) return false; if (!Array.isArray(progress.checks) || !progress.checks.every(check => record(check) && ['container', 'game', 'map', 'plutonium-release', 'plutonium-version', 'occupancy', 'stopped'].includes(check.key as string) && ['pass', 'waiting', 'fail', 'unknown'].includes(check.result as string) && [null, 'agent', 'rcon', 'telemetry', 'combined', 'manifest'].includes(check.source as null | string) && text(check.label) && timestamp(check.checkedAt) && nullableTime(check.receivedAt) && scalar(check.expected) && scalar(check.actual) && nullableText(check.message))) return false; if (!Array.isArray(progress.checkpoints) || !progress.checkpoints.every(checkpoint => record(checkpoint) && text(checkpoint.key) && text(checkpoint.label) && timestamp(checkpoint.at) && phase(checkpoint.phase))) return false; if (!(progress.command === null || record(progress.command) && ['attempted', 'accepted', 'rejected', 'uncertain'].includes(progress.command.state as string) && timestamp(progress.command.at) && text(progress.command.message))) return false; if (!(progress.recovery === null || record(progress.recovery) && ['held', 'released', 'transferred', 'unknown'].includes(progress.recovery.state as string) && timestamp(progress.recovery.at) && text(progress.recovery.message) && (progress.recovery.operationId === undefined || nullableText(progress.recovery.operationId)))) return false; } return true; }