/** * A scenario kept between runs, already brought to a state worth starting from. * * **Bootstrapping a mesh takes minutes and proves the same thing every time.** The tests worth * iterating on are the ones after it — assigning a module, adopting a workload, watching something * fail. A warm instance is raised once, brought to that state, snapshotted, and restored on every * later run in seconds. * * **The danger is precisely the one 04-ISSUES/005 is about**, one level down: a mesh snapshotted * against yesterday's binaries will pass today's tests and report green, and nothing about the * result would say what it was actually run against. So a warm instance records the commits it was * built from, and is refused — not silently rebuilt, refused — when they have moved. * * **Fresh stays the default.** This is for iterating. A run that is meant to mean something raises * from nothing, because "it passes" must not quietly come to mean "it passes against a mesh * somebody bootstrapped last week". */ import { readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs"; import { dirname, join } from "node:path"; import { homedir } from "node:os"; import { list, restore, snapshot, snapshots, destroy } from "./lifecycle/operate.ts"; import type { Against } from "./lastrun.ts"; import { whatWasTested } from "./lastrun.ts"; import type { HeldImage } from "./pinning.ts"; /** The state a warm instance is kept at. One label, because a second is a state nobody named. */ export const label = "warm"; export interface Warm { scenario: string; instanceId: string; /** * The mesh's own images, as loaded onto this instance's machines, by the ID each is held under. * * Kept because they are worked out while raising and a restored instance never raises. Without * them a warm run knows nothing about what its machines hold, and every test naming one of our * images fails for a reason that has nothing to do with what it was testing. */ images: HeldImage[]; /** The commit each repository was at when this was brought to its state. */ against: Against; at: string; } /** Where the record lives: XDG state, beside the run receipt, for the same reason. */ export function recordPath(): string { const state = process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state"); return join(state, "mesh-lab", "warm.json"); } export function remember(warm: Warm): void { const path = recordPath(); mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, JSON.stringify(warm, null, 2) + "\n"); } export function remembered(): Warm | null { try { return JSON.parse(readFileSync(recordPath(), "utf8")) as Warm; } catch { return null; } } export function forget(): void { rmSync(recordPath(), { force: true }); } export type Verdict = | { use: "restore"; instanceId: string } | { use: "raise"; why: string }; /** * judge decides whether a remembered instance may be restored. * * **Every reason to refuse is a reason a test would otherwise pass while meaning nothing**, so * each is named rather than collapsed into "not usable". */ export function judge( warm: Warm | null, scenario: string, standing: string[], hasSnapshot: boolean, against: Against, ): Verdict { if (!warm) return { use: "raise", why: "nothing is being kept warm" }; if (warm.scenario !== scenario) { return { use: "raise", why: `what is kept warm is ${warm.scenario}, and this is ${scenario}` }; } if (!standing.includes(warm.instanceId)) { return { use: "raise", why: `${warm.instanceId} is no longer standing` }; } if (!hasSnapshot) { return { use: "raise", why: `${warm.instanceId} has no ${label} snapshot to return to` }; } // The check that keeps this honest. A mesh built from code that has since moved would pass // today's tests against yesterday's binaries, and say nothing about it. // // **Both directions, because comparing only what is in front of you clears what is not.** The // first version walked the current repositories alone, so running without the environment that // names where they are compared nothing and reported the mesh usable — a warm instance built // from code that had since moved, cleared by a check that had looked at neither. That is // 04-ISSUES/005's rule again: a record that says nothing about something is not a record that // clears it. for (const name of new Set([...Object.keys(warm.against), ...Object.keys(against)])) { const then = warm.against[name]; const now = against[name]; if (then === now) continue; if (!now) { return { use: "raise", why: `${name} was at ${then} when this was warmed, and nothing says where it is now — ` + `so nothing can say whether it moved`, }; } return { use: "raise", why: `${name} was at ${then ?? "nothing recorded"} when this was warmed, ` + `and is now at ${now}`, }; } return { use: "restore", instanceId: warm.instanceId }; } /** What is standing right now, by instance. */ export async function standingNow(): Promise { return (await list()).map((i) => i.instanceId); } /** * ready returns an instance already at its warm state, or says why one must be raised. * * It never raises: raising needs a scenario, images and a bootstrap, and all of that belongs to * whoever is using this rather than here. */ export async function ready( scenario: string, env: NodeJS.ProcessEnv = process.env, ): Promise { const warm = remembered(); const standing = await standingNow(); // Asked only of an instance that still exists: snapshots() throws for one that is gone, and // judge already has the words for that refusal — a remembered instance another scenario (or // another month) left behind must be refused, not crashed into. const has = warm && standing.includes(warm.instanceId) ? (await snapshots(warm.instanceId)).includes(label) : false; return judge(warm, scenario, standing, has, whatWasTested(env)); } /** returnTo puts a warm instance back to its state, and says how long it took. */ export async function returnTo( instanceId: string, log: (message: string) => void = () => {}, ): Promise { const { usableSeconds } = await restore(instanceId, label, 180, log); return usableSeconds; } /** * keep snapshots an instance as the state to come back to, and records what it was built from. * * Called once the caller has brought the scenario to whatever "ready to work" means for it. */ export async function keep( scenario: string, instanceId: string, env: NodeJS.ProcessEnv = process.env, ): Promise { await snapshot(instanceId, label); const warm: Warm = { scenario, instanceId, images: stockOf(instanceId), against: whatWasTested(env), at: new Date().toISOString(), }; remember(warm); return warm; } /** cool destroys what is being kept and forgets it. */ export async function cool(): Promise { const warm = remembered(); forget(); if (!warm) return null; if ((await standingNow()).includes(warm.instanceId)) { await destroy(warm.instanceId); } return warm.instanceId; } /** * What a raised scenario loaded onto its machines, held until it is kept. * * Raising works the images out and snapshotting happens later, so this carries them between the * two without the caller having to hold them. */ const stock = new Map(); export function rememberStock(instanceId: string, images: HeldImage[]): void { stock.set(instanceId, images); } function stockOf(instanceId: string): HeldImage[] { return stock.get(instanceId) ?? []; } /** What a restored instance's machines hold, from when it was warmed. */ export function warmStock(instanceId: string): { images: HeldImage[] } { const warm = remembered(); if (!warm || warm.instanceId !== instanceId) return { images: [] }; return { images: warm.images }; }