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
216 lines
7.8 KiB
TypeScript
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 };
|
|
}
|