Files
electric-cherry/src/lib/plutonium.ts
T
josh 981fb27c1c Preserve deleted server history
Retain historical server identities, results, and evidence after deletion. Mark archived entries as Deleted and prevent old work from targeting servers that reuse the same name.

Fixes #16.
2026-09-27 21:48:55 -04:00

99 lines
4.4 KiB
TypeScript

import { validTimeZone } from './time-zone.js';
export interface MaintenanceSchedule {
enabled: boolean;
time: string;
timeZone: string;
nextAt: string | null;
}
export interface PlutoniumRelease {
revision: number | null;
checkedAt: string | null;
error: string | null;
}
export interface PlutoniumVersion {
server: string;
revision: number | null;
checked_at: string | null;
error: string | null;
stopped?: boolean;
update_status?: 'outdated' | 'current' | 'ahead' | 'unknown' | 'stopped';
}
export interface MaintenanceJob {
id: number;
server: string;
server_deleted_at?: string | null;
state: 'queued' | 'restarting' | 'done' | 'failed' | 'cancelled';
target_revision: number | null;
requested_at: string;
restarted_at: string | null;
provisioning_id: string | null;
finished_at: string | null;
detail: string | null;
kind: 'legacy' | 'update' | 'schedule';
origin: 'manual' | 'automatic' | 'schedule';
operation_stage?: string | null;
operation_state?: string | null;
}
export interface PlutoniumStatus {
release: PlutoniumRelease;
schedule: MaintenanceSchedule;
servers: PlutoniumVersion[];
jobs: MaintenanceJob[];
}
/** Accept explicit revision tokens only, never engine versions or build dates. */
export function parsePlutoniumRevision(text: string): number | null {
const match = text.replace(/\^[0-9]/g, '').match(/\br([1-9]\d{2,8})\b/i);
return match ? Number(match[1]) : null;
}
export const PLUTONIUM_MANIFEST_URL = 'https://cdn.plutoniummod.com/updater/prod/info.json';
export function parsePlutoniumManifest(value: unknown): number {
const manifest = value as { product?: unknown; revision?: unknown } | null;
if (!manifest || manifest.product !== 'plutonium-core-prod' || typeof manifest.revision !== 'number'
|| !Number.isSafeInteger(manifest.revision) || manifest.revision < 1) {
throw new Error('The production updater manifest did not contain a valid Plutonium revision.');
}
return manifest.revision;
}
export function nextMaintenanceAt(time: string, now = new Date(), timeZone = 'UTC'): string {
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(time)) throw new Error('Choose a valid time (HH:mm).');
if (!validTimeZone(timeZone)) throw new Error('Choose a valid timezone.');
if (!Number.isFinite(now.getTime())) throw new Error('Invalid schedule date.');
const [hour, minute] = time.split(':').map(Number);
const target = hour * 60 + minute;
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone, year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hourCycle: 'h23'
});
function local(at: number) {
const parts = Object.fromEntries(formatter.formatToParts(at).map(p => [p.type, p.value]));
return { day: `${parts.year}-${parts.month}-${parts.day}`, minute: Number(parts.hour) * 60 + Number(parts.minute) };
}
const today = local(now.getTime()).day;
const start = Math.floor(now.getTime() / 60_000) * 60_000;
const days = new Map<string, { at: number; minute: number }>();
// Bounded minute search avoids assuming whole-hour offsets or 24-hour days.
// A missing wall time runs at the first valid minute after the DST gap;
// a repeated wall time runs only on its first occurrence that local day.
for (let at = start - 26 * 3_600_000; at <= start + 50 * 3_600_000; at += 60_000) {
const wall = local(at);
if (wall.day < today || wall.minute < target) continue;
const previous = days.get(wall.day);
if (!previous || wall.minute < previous.minute) days.set(wall.day, { at, minute: wall.minute });
}
const next = [...days.values()].map(d => d.at).filter(at => at > now.getTime()).sort((a, b) => a - b)[0];
if (next === undefined) throw new Error('Could not calculate the next maintenance time.');
return new Date(next).toISOString();
}
/** Present an existing schedule in the viewing user's zone without rewriting it. */
export function maintenanceTimeInZone(schedule: MaintenanceSchedule, timeZone: string): string {
if (schedule.timeZone === timeZone) return schedule.time;
const at = schedule.nextAt ?? nextMaintenanceAt(schedule.time, new Date(), schedule.timeZone);
return new Intl.DateTimeFormat('en-GB', { timeZone, hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).format(new Date(at));
}