// ============================================================================ // PHASE G1 SEAM — everything G2 needs from the G1 wire, in one file. // ============================================================================ // // G1 (worker + proto + Manager pass-through) and G2 (this package) were // built in parallel, so this started as an adapter over names that did not // exist yet. G1 has since landed, and the three calls now bind to the real // generated surface: // // 1. `inventoryImageDigests(inv)` — reads `NodeInventory.heldImages` // (`common.proto` `held_images`, `{image_ref, tags}` per image). // 2. `prefetchImages(nodeId, refs)` — `NorskManager.prefetchImages`. // 3. `setNodeIdleExpiry(nodeId, ms)` — `NorskManager.setNodeIdleExpiry` // (0 ⇒ clear the override and use the daemon-wide value). // 4. `setNodeTags(nodeId, tags)` — `NorskManager.setNodeTags`, how a // runtime hot-spare spec edit is made to outlive a restart (task 90 // fix-list 46). // // (2), (3) and (4) stay duck-typed against the SDK client rather than // statically bound: an AutoManager pointed at an older Manager keeps // running — it just never gets a node warm on purpose — instead of dying // on a method that isn't there. An embedder (or a test) supplies its own // transport through `AutoSettings.warmNodes`. import type { HeldImage } from "./placement"; import type { NodeInventory } from "./types"; /** The G1 calls AutoManager makes. @public */ export interface WarmNodeTransport { /** Ask the node's worker to pull these image refs now. */ prefetchImages(nodeId: string, refs: string[]): Promise; /** Override how long this node lingers idle before the daemon expires * it. `0` means "use the global `emptyNodeLinger`". */ setNodeIdleExpiry(nodeId: string, ms: number): Promise; /** Merge these tags onto the node's metadata; the daemon persists them. */ setNodeTags(nodeId: string, tags: Record): Promise; /** The images this node advertises holding. */ heldImages(nodeId: string, inv: NodeInventory | undefined): HeldImage[]; } /** * Read the advertised image set off a node inventory, in the shape * `warmth` scores against: the digest out of `image_ref` * (`repository@sha256:`) plus the `repository:tag` names the node * currently answers to for it. * * Written defensively (the field is optional and an older worker sends * nothing) so a node that advertises no images scores cold rather than * throwing. * * @public */ export function inventoryImageDigests(inv: unknown): HeldImage[] { if (inv === null || typeof inv !== "object") return []; const raw = (inv as { heldImages?: unknown }).heldImages; if (!Array.isArray(raw)) return []; const out: HeldImage[] = []; for (const entry of raw) { // The wire `HeldImage` is a message (`imageRef` + `tags`); anything // else is not an image. if (entry === null || typeof entry !== "object") continue; const rec = entry as { imageRef?: unknown; tags?: unknown }; const parsed = typeof rec.imageRef === "string" ? parseImageString(rec.imageRef) : {}; const tags = Array.isArray(rec.tags) ? rec.tags.filter((t): t is string => typeof t === "string") : []; const repoTags = dedupeStrings([...(parsed.repoTags ?? []), ...tags]); if (parsed.digest === undefined && repoTags.length === 0) continue; out.push({ ...(parsed.digest !== undefined ? { digest: parsed.digest } : {}), ...(repoTags.length > 0 ? { repoTags } : {}), }); } return out; } function dedupeStrings(v: string[]): string[] { return [...new Set(v)]; } /** * Split an image string into the two things warmth can match on. A ref may * carry either or both: `repo:tag`, `repo@sha256:...`, or * `repo:tag@sha256:...`. * * @public */ export function parseImageString(ref: string): HeldImage & { repoTag?: string } { const at = ref.indexOf("@"); if (at < 0) return { repoTags: [ref] }; const name = ref.slice(0, at); const digest = ref.slice(at + 1); return { digest, ...(name.length > 0 && name.includes(":") ? { repoTags: [name] } : {}), }; } /** The image set a template needs, as `ImageRef`s placement can score. @public */ export function imageRefsFrom(refs: string[]): { repoTag?: string; digest?: string }[] { return refs.map((r) => { const parsed = parseImageString(r); return { ...(parsed.digest !== undefined ? { digest: parsed.digest } : {}), ...(parsed.repoTags?.[0] !== undefined ? { repoTag: parsed.repoTags[0] } : {}), }; }); } /** * Bind the transport to a Manager client. Duck-typed: when the generated * RPC exists it is used, otherwise the call is reported through * `onUnsupported` and does nothing. * * @public */ export function warmNodeTransport( norsk: unknown, onUnsupported: (what: string, detail: Record) => void, override?: Partial ): WarmNodeTransport { const client = (norsk ?? {}) as Record; const call = async ( method: string, args: unknown[], detail: Record ): Promise => { const fn = client[method]; if (typeof fn !== "function") { onUnsupported(method, detail); return; } await (fn as (...a: unknown[]) => Promise).apply(norsk, args); }; return { prefetchImages: override?.prefetchImages ?? (async (nodeId, refs) => call("prefetchImages", [nodeId, refs], { nodeId, refs })), setNodeIdleExpiry: override?.setNodeIdleExpiry ?? (async (nodeId, ms) => call("setNodeIdleExpiry", [nodeId, ms], { nodeId, ms })), setNodeTags: override?.setNodeTags ?? (async (nodeId, tags) => call("setNodeTags", [nodeId, tags], { nodeId, tags })), heldImages: override?.heldImages ?? ((_nodeId, inv) => inventoryImageDigests(inv)), }; }