Files
mesh-lab/src/warm.ts
T
jschoubben 8374f4dc19 warm refuses a departed instance instead of crashing into it
ready() asked a gone instance for its snapshots before judge could say 'no longer
standing'; a stale warm.json from any earlier scenario made every warm run fail in
milliseconds. The question is now asked only of an instance that still exists.

https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-17 23:07:50 +02:00

216 lines
7.8 KiB
TypeScript

/**
* 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<string[]> {
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<Verdict> {
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<number> {
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<Warm> {
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<string | null> {
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<string, HeldImage[]>();
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 };
}