From b6de6eca6588790ff759e9423db4ebd980e46e52 Mon Sep 17 00:00:00 2001 From: marinom2 Date: Wed, 29 Jul 2026 08:49:35 +0300 Subject: [PATCH 1/4] fix(worker): unblock Linux onboarding and stop silent staking failures The Linux worker flow could not complete, and two failure modes were reported to the user as success. Fixes, in severity order: P0 install could never finish on Linux. The Ollama and Docker installers were invoked as a bare root-requiring `curl | sh` under `set -e`, from a Tauri process with no tty. It either aborted the whole installer or hung forever on an invisible sudo prompt. Both now use the sudo -n -> pkexec ladder this file already used for the systemd drop-in, folded into one escalation so the user sees a single polkit prompt, and fail loudly with a copy-pasteable command instead of hanging. P0 a reverted addSupportedModel was reported as success. `cast send --gas-limit` exits 0 even on a status-0 receipt, so a revert printed "model added on-chain" and the UI reached "worker online" over a staked worker serving nothing. We no longer send when the gas estimate reverts, and we confirm with isEligible() before claiming success. P1 seven of ten catalog models rendered as raw 66-char hashes. A model's on-chain identity is keccak256(tag) and the registry stores no name, so a registration made without its tag string leaves the indexer echoing the id into `name`. keccak is one-way, but a known-tag set can be inverted: the new lib/model-catalog.ts recovers all ten live testnet models, and keeps `known: false` as a first-class state so an unrecovered id is never treated as a servable tag. P1 model sizes were inferred by regex over the display name, and the branch at hardware.ts:171 lacked a `paramsB > 0` guard, so every unparseable name - including a hash - was asserted at exactly 8GB "Standard". That under-stated qwen3-coder-next (51.7GB) and gpt-oss:120b (65.4GB) by 6-8x, and the named gemma4:e2b (7.2GB) by nearly 2x. Sizes now come from measured catalog data. P1 the picker dropped `id` and keyed selection on the display name, so a hash could be selected, staked for, and passed to `ollama pull` and `cast keccak`, which would hash it a second time into an id the registry rejects. It now carries the id, keys on it, and excludes unrecoverable models from selection. P2 preflight told Linux users Ollama would be installed "via brew" and still concluded the install was safe; a failed `ollama pull` warned and continued on to stake and register; and diagnoseFailure had no pattern for privilege errors, the single most likely Linux failure. Verified: tsc --noEmit clean, 590 tests pass, next build succeeds, eslint clean. The ten catalog ids are pinned in tests against the live registry. --- components/onboard/model-picker.tsx | 354 ++++++++++++++++++---- components/update-models.tsx | 71 ++++- lib/hardware.ts | 174 ++++++++++- lib/install-progress.ts | 96 +++++- lib/model-catalog.ts | 147 +++++++++ lib/scriptgen.ts | 442 ++++++++++++++++++++++++---- lib/subgraph.ts | 54 +++- sdk/src/cli.ts | 8 +- sdk/src/index.ts | 7 +- sdk/src/subgraph.ts | 80 ++++- sdk/src/types.ts | 14 + tests/unit/hardware.test.ts | 155 +++++++++- tests/unit/model-catalog.test.ts | 309 +++++++++++++++++++ tests/unit/sdk-consistency.test.ts | 19 ++ 14 files changed, 1766 insertions(+), 164 deletions(-) create mode 100644 lib/model-catalog.ts create mode 100644 tests/unit/model-catalog.test.ts diff --git a/components/onboard/model-picker.tsx b/components/onboard/model-picker.tsx index 9c84f1e..dedbd8d 100644 --- a/components/onboard/model-picker.tsx +++ b/components/onboard/model-picker.tsx @@ -1,33 +1,146 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Box, Check, CircleAlert, AlertTriangle } from "lucide-react"; -import { modelRequirement, modelsMemoryGb } from "@/lib/hardware"; +// OS_VRAM_OVERHEAD_GB / usableVramGb are imported rather than redefined here: +// the desktop's claim on VRAM is one number, and a picker that reserved a +// different amount than the fit helpers would show a plan it then contradicts. +import { modelRequirement, usableVramGb, OS_VRAM_OVERHEAD_GB } from "@/lib/hardware"; +import { lookupModel, residentVramGb, resolveModel, type CatalogEntry } from "@/lib/model-catalog"; import { fromWei, cn } from "@/lib/utils"; import type { NetworkId } from "@/lib/network"; +/** + * A whitelist row exactly as the indexer returns it. + * + * `id` is the on-chain identity - keccak256 of the tag - and the only thing the + * registry actually stores. `name` is a label the indexer bolts on, and when a + * model was whitelisted without its tag string there is nothing to bolt on, so + * it echoes the id back into `name`. Carrying `id` is what lets us tell a real + * tag apart from a digest wearing one's clothes. + */ interface LiveModel { + id: string; name: string; fee: string; // wei max_output_tokens: number; } +/** A live model resolved to an identity we can act on. */ +interface Row { + /** Lowercase on-chain id. Unique per registry row, so it keys the list. */ + id: string; + /** The real Ollama tag. null = the id never resolved, so it is NOT servable. */ + tag: string | null; + label: string; + fee: string; // wei + maxOut: number; + embedding: boolean; + /** Resident GB to keep it warm, or null when we cannot size it honestly. */ + gb: number | null; + /** Short descriptor for the second line, when we have one. */ + note: string | null; +} + +type ServableRow = Row & { tag: string }; + +function isServable(r: Row): r is ServableRow { + return r.tag !== null; +} + +/** Sort key that keeps models we cannot size at the bottom of any ordering. */ +function sizeKey(r: Row): number { + return r.gb ?? Number.MAX_SAFE_INTEGER; +} + +/** One decimal, without dragging a ".0" onto whole numbers. */ +function fmtGb(n: number): string { + return String(Math.round(n * 10) / 10); +} + +/** 16384 -> "16,384". Rendered only after a client fetch, so the locale is fixed. */ +function fmtTokens(n: number): string { + return n.toLocaleString("en-US"); +} + +/** + * Size + descriptor for a model. + * + * The catalog is measured (summed manifest layers, or an observed inference + * peak), so it wins outright. A tag that is real but absent from the catalog - + * a model whitelisted after this build shipped - falls back to hardware.ts's + * name regex, which can only size a name that carries its parameter count. + * Everything else has NO size: the registry stores none, and a keccak digest + * says nothing about the weights behind it. null means "we do not know", and + * the UI prints that instead of inventing a comfortable 8GB. + */ +function describe(tag: string | null, entry?: CatalogEntry): { gb: number | null; note: string | null } { + if (entry) return { gb: residentVramGb(entry), note: entry.note ?? null }; + if (!tag) return { gb: null, note: null }; + const req = modelRequirement(tag); + // Gate on `source`, NOT on `known`: `known` is false for a name-estimated + // model too, so testing it here would throw away the very fallback this + // branch exists for. Only "unknown" means the tag carried no size signal at + // all, and that is the case we refuse to invent a number for. + if (req.source === "unknown") return { gb: null, note: null }; + return { gb: req.vramGb, note: req.tierLabel }; +} + +function toRow(m: LiveModel): Row { + const r = resolveModel(m.name, m.id); + const { gb, note } = describe(r.tag, r.entry); + return { + id: r.id ?? m.id.toLowerCase(), + tag: r.tag, + label: r.label, + fee: m.fee, + maxOut: m.max_output_tokens, + // The registry's own tell for an embedding model: it answers with vectors, + // so its output cap is a single token. The catalog flags the ones we know. + embedding: r.entry?.embedding === true || m.max_output_tokens === 1, + gb, + note, + }; +} + +/** + * Does a stored selection name this row? Setup stores TAGS (they become the + * container's SUPPORTED_MODELS), but a record written before ids and tags were + * told apart can hold the id it was shown. Match on either identity so such a + * record still lights up the right row - we always hand the TAG back out. + */ +function names(stored: string[], r: Row): boolean { + return stored.some((v) => { + const s = v.trim().toLowerCase(); + return s === r.id || (r.tag !== null && s === r.tag.toLowerCase()); + }); +} + /** * Choose which model(s) the worker serves. The list is the selected network's * live whitelist (so it grows as the registry adds models). A worker can serve * several at once, but every model it picks must stay resident in memory at the * same time, so we sum their rough footprints and warn when the set won't fit the * detected machine (a cold-load mid-job is what gets a worker slashed). + * + * Selection is keyed on the on-chain id, never on the displayed name: for most + * of the live registry the indexer's `name` IS the id, and treating that string + * as a tag is what would stake LCAI and register for a model this machine can + * never pull. What leaves through `onChange` is always a real tag. */ export function ModelPicker({ network, vramGb, + vramKnown = true, value, onChange, locked = [], }: { network: NetworkId; vramGb: number; + // False when VRAM could not be read at all. We then check nothing against it, + // rather than silently measuring every model against 0GB and flagging them all. + vramKnown?: boolean; value: string[]; onChange: (models: string[]) => void; // Models that are already committed and can't be unselected here (e.g. a @@ -45,20 +158,39 @@ export function ModelPicker({ .then((j) => { if (!on || !j.ok) return; const live: LiveModel[] = (j.models ?? []) - .filter((m: { is_enabled: boolean; is_whitelisted: boolean }) => m.is_enabled && m.is_whitelisted) - .map((m: LiveModel) => ({ name: m.name, fee: m.fee, max_output_tokens: m.max_output_tokens })); + .filter((m: LiveModel & { is_enabled: boolean; is_whitelisted: boolean }) => m.is_enabled && m.is_whitelisted) + .map((m: LiveModel) => ({ + // Keep the id: it is the identity, and `name` may be a copy of it. + id: (m.id ?? "").toLowerCase(), + name: m.name, + fee: m.fee, + max_output_tokens: m.max_output_tokens, + })); setModels(live); - // Keep selections that are still live; if none remain, pick the lightest - // model that fits the machine. - if (live.length) { - const stillLive = value.filter((v) => live.some((m) => m.name === v)); - if (stillLive.length === 0) { - const fits = live.filter((m) => modelRequirement(m.name).vramGb <= (vramGb || 0)); - const best = (fits.length ? fits : live).sort((a, b) => modelRequirement(a.name).vramGb - modelRequirement(b.name).vramGb)[0]; - onChange([best.name]); - } else if (stillLive.length !== value.length) { - onChange(stillLive); - } + + // Reconcile the current selection against what's live. Only rows whose + // tag we recovered can survive: a selection we cannot name is one we + // cannot `ollama pull`, and staking for it registers an id the worker + // will never serve. Re-emitting the TAG also heals a stored id. + const rows = live.map(toRow); + const servable = rows.filter(isServable); + if (servable.length === 0) return; // nothing here is safe to pick - say so in the UI + const kept: string[] = []; + for (const v of value) { + const row = servable.find((r) => names([v], r)); + if (row && !kept.includes(row.tag)) kept.push(row.tag); + } + if (kept.length === 0) { + // Auto-pick the lightest model that actually fits, from the servable + // set only. Unsized models sort last: we won't volunteer a model we + // cannot measure over one we can. + const room = vramKnown ? usableVramGb(vramGb) : 0; + const fits: ServableRow[] = room > 0 ? servable.filter((r) => r.gb !== null && r.gb <= room) : []; + const pool: ServableRow[] = fits.length ? fits : servable; + const best = pool.slice().sort((a, b) => sizeKey(a) - sizeKey(b))[0]; + onChange([best.tag]); + } else if (kept.join(",") !== value.join(",")) { + onChange(kept); } }) .catch(() => {}) @@ -69,17 +201,48 @@ export function ModelPicker({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [network]); - const avail = vramGb || 0; - const total = modelsMemoryGb(value); - const over = avail > 0 && total > avail; + // Servable models first, then smallest first - so the rows a user can act on + // lead, and the ones we cannot name sink to the bottom where they belong. + const rows = useMemo( + () => + models.map(toRow).sort((a, b) => Number(isServable(b)) - Number(isServable(a)) || sizeKey(a) - sizeKey(b)), + [models], + ); + + const memKnown = vramKnown && vramGb > 0; + const avail = memKnown ? vramGb : 0; + // What a model can actually have. See OS_VRAM_OVERHEAD_GB in lib/hardware.ts. + const usable = memKnown ? usableVramGb(avail) : 0; + + const selection = useMemo(() => { + let total = 0; + let unsized = 0; + for (const v of value) { + // Prefer the live row; a selection that is no longer whitelisted (but is + // still served by the running worker) still costs memory, so size it from + // the catalog by name rather than dropping it from the sum. + const row = rows.find((r) => names([v], r)); + const gb = row ? row.gb : describe(v, lookupModel(v)).gb; + if (gb === null) unsized += 1; + else total += gb; + } + return { total: Math.round(total * 10) / 10, unsized }; + }, [value, rows]); + + const over = memKnown && selection.total > usable; + const noneServable = rows.length > 0 && !rows.some(isServable); - const toggle = (name: string) => { - if (locked.includes(name)) return; // committed - can't unselect here - if (value.includes(name)) { + const toggle = (r: Row) => { + // An id we could not name is not a tag. Selecting it would stake and + // register for a model that can never be pulled, so there is nothing here + // to toggle - the button is disabled too, this is the belt. + if (!isServable(r)) return; + if (names(locked, r)) return; // committed - can't unselect here + if (names(value, r)) { if (value.length === 1) return; // keep at least one selected - onChange(value.filter((m) => m !== name)); + onChange(value.filter((v) => !names([v], r))); } else { - onChange([...value, name]); + onChange([...value, r.tag]); } }; @@ -91,35 +254,50 @@ export function ModelPicker({ your worker serves every model you pick - {loading && models.length === 0 ? ( + {loading && rows.length === 0 ? (
- ) : models.length === 0 ? ( + ) : rows.length === 0 ? (

No live models on {network} right now. Setup will use the default once one is whitelisted.

) : ( <>
- {models.map((m) => { - const req = modelRequirement(m.name); - const fitsAlone = (vramGb || 0) >= req.vramGb; - const selected = value.includes(m.name); - const tooBig = avail > 0 && req.vramGb > avail; // can't even fit by itself - const isLocked = locked.includes(m.name); + {rows.map((r) => { + const servable = isServable(r); + const selected = names(value, r); + const isLocked = names(locked, r); + // Measured against USABLE memory, not the sticker total - a model + // that only fits the total is the one that gets evicted mid-job. + const tooBig = memKnown && r.gb !== null && r.gb > usable; + // Never let one click stake for a model this machine cannot hold, + // or for an id we cannot turn into a pullable tag. An oversized + // model that is ALREADY selected stays clickable so it can be dropped. + const blocked = !servable || (tooBig && !selected); return ( @@ -160,6 +368,16 @@ export function ModelPicker({ })}
+ {noneServable && ( +

+ + + Every model on {network} is registered by id only, with no published name. There is no tag to pull, so + none of them can be served from here yet. + +

+ )} + {/* memory gate */}
@@ -167,14 +385,36 @@ export function ModelPicker({ Memory to keep {value.length === 1 ? "it" : "them all"} warm - ~{total}GB{avail > 0 && ` of ~${avail}GB`} + ~{fmtGb(selection.total)}GB{memKnown && ` of ~${fmtGb(avail)}GB`}
{over && (

- These models need about {total}GB resident at once, but this machine has about {avail}GB. They would - cold-load between jobs and risk a slash. Deselect one, or run them on a bigger machine. + These models need about {fmtGb(selection.total)}GB resident at once, but only about {fmtGb(usable)}GB is + usable here. They would cold-load between jobs and risk a slash. Deselect one, or run them on a bigger + machine. +

+ )} + {/* The card's total is never all yours - be explicit about what is + left. The `over` warning already quotes the usable figure, so + only state it here when that warning isn't showing. */} + {memKnown && !over && ( +

+ Your desktop session (compositor, browser, this app) holds roughly {fmtGb(OS_VRAM_OVERHEAD_GB)}GB of + that, so plan against about {fmtGb(usable)}GB. +

+ )} + {!memKnown && ( +

+ This machine's memory could not be read, so nothing above is checked against it. Confirm the set + fits before you install. +

+ )} + {selection.unsized > 0 && ( +

+ {selection.unsized === 1 ? "One selected model publishes" : `${selection.unsized} selected models publish`} no + size, so {selection.unsized === 1 ? "it is" : "they are"} not counted in that total.

)} {!over && value.length > 1 && ( diff --git a/components/update-models.tsx b/components/update-models.tsx index 555dafd..79aca83 100644 --- a/components/update-models.tsx +++ b/components/update-models.tsx @@ -8,6 +8,8 @@ import { Button } from "@/components/ui/button"; import { ModelPicker } from "@/components/onboard/model-picker"; import { InstallProgress } from "@/components/onboard/install-progress"; import { useNetwork } from "@/lib/network-context"; +import { autodetect, detectWebGpu } from "@/lib/hardware"; +import { isModelId, lookupModel } from "@/lib/model-catalog"; import { DEFAULT_MODEL, NETWORKS } from "@/lib/network"; import { addModelsCommand, desktopInstallCommand, type OS } from "@/lib/scriptgen"; import { appendCleanLog } from "@/lib/install-log"; @@ -17,6 +19,18 @@ import { getSecret, getWorkerAddr, resolveManagedWorkerAddr, getServedModels, se type Phase = "idle" | "running" | "done" | "failed"; +/** + * Fold a stored model reference back to its servable tag. + * + * Everything downstream - SUPPORTED_MODELS, `ollama pull`, add-models' keccak - + * consumes the TAG, but a record written before model ids and tags were told + * apart can hold the on-chain id instead. The catalog inverts the ones we know; + * anything it cannot invert is returned untouched, so callers can still spot it. + */ +function tagOf(model: string): string { + return lookupModel(model)?.tag ?? model; +} + function keyMatchesAddr(key: string, addr: string): boolean { if (!/^0x[a-fA-F0-9]{40}$/.test(addr)) return false; try { @@ -35,7 +49,10 @@ function keyMatchesAddr(key: string, addr: string): boolean { export function UpdateModels() { const { network } = useNetwork(); const [os, setOs] = useState("macos"); - const [vramGb, setVramGb] = useState(0); + // Memory available to keep models warm. `known` is separate from the number: + // an unread VRAM is 0, and a gate that measures every model against 0GB is a + // gate that lies. See the detection effect below. + const [vram, setVram] = useState<{ gb: number; known: boolean }>({ gb: 0, known: false }); const [sel, setSel] = useState([]); // The set the worker ACTUALLY serves right now (the locked/can't-remove base the // picker adds onto). Authoritative source is the running container's @@ -51,11 +68,34 @@ export function UpdateModels() { const d = detectClientOS(); setOs(d === "windows" ? "windows" : d === "linux" ? "linux" : "macos"); }, []); + // Same precedence the onboarding machine check uses: the desktop shell reads + // VRAM from the OS, and where that comes back empty (no discrete GPU reported, + // an older binary, a failed nvidia-smi) we fall back to the browser's GPU-name + // inference. If BOTH come back empty we record known:false so the picker drops + // its memory gate instead of showing a false one against 0GB. useEffect(() => { - detectNativeHardware().then((hw) => { - if (!hw) return; - setVramGb(hw.unified ? Math.max(hw.ram_gb || 0, hw.vram_gb || 0) : hw.vram_gb || 0); + let on = true; + detectNativeHardware().then(async (hw) => { + if (!on) return; + // Apple Silicon shares one pool, so the GPU can draw on system RAM. + const native = hw ? (hw.unified ? Math.max(hw.ram_gb || 0, hw.vram_gb || 0) : (hw.vram_gb ?? 0)) : 0; + if (native > 0) { + setVram({ gb: native, known: true }); + return; + } + const d = autodetect(); // WebGL renderer -> known-GPU table; unified already floors at 16 + let gb = d.input.vramGb ?? 0; + if (!gb) { + // Some webviews mask the WebGL renderer string but still expose a WebGPU adapter. + const w = await detectWebGpu(); + gb = w.unified ? Math.max(d.input.ramGb ?? 16, 16) : (w.vramGb ?? 0); + } + if (!on) return; + setVram(gb > 0 ? { gb, known: true } : { gb: 0, known: false }); }); + return () => { + on = false; + }; }, []); // Seed from this network's recorded set for an instant render, then reconcile // with the worker actually running here (its container SUPPORTED_MODELS is the @@ -97,11 +137,18 @@ export function UpdateModels() { const append = (line: string) => setLog((l) => appendCleanLog(l, line)); - const additions = sel.filter((m) => !current.includes(m)); - const removals = current.filter((m) => !sel.includes(m)); + // Compare on identity, not on the raw string: an id sitting next to its own + // tag would otherwise read as one addition plus one removal, blocking the + // whole panel over a naming artefact. Both sides compare - and `additions` is + // sent on chain - as real tags. + const additions = sel.filter((m) => !current.some((c) => tagOf(c) === tagOf(m))).map(tagOf); + const removals = current.filter((m) => !sel.some((s) => tagOf(s) === tagOf(m))); // You can ADD models live; removing one isn't safe live (the gateway could still // route its jobs to you), so a set that drops a current model is blocked here. - const canApply = additions.length > 0 && removals.length === 0; + // add-models hashes whatever string it is handed, so an id we could not fold + // back to a tag would register a second, meaningless model on chain and stake + // against it - refuse rather than sign that. + const canApply = additions.length > 0 && removals.length === 0 && !additions.some(isModelId); const apply = useCallback(async () => { if (!canApply) return; @@ -128,9 +175,13 @@ export function UpdateModels() { setPhase("failed"); return; } - setServedModels(network, sel); + // The container pulls SUPPORTED_MODELS verbatim, so fold any id the + // seeded set still carries back to its tag before it goes in (and + // before we record it as this network's served set). + const served = sel.map(tagOf); + setServedModels(network, served); stopRef.current = await runSetupStreamed( - desktopInstallCommand(os, network, sel), + desktopInstallCommand(os, network, served), env, append, (code2) => { @@ -157,7 +208,7 @@ export function UpdateModels() { {phase === "idle" || phase === "done" || phase === "failed" ? ( <> - + {phase === "done" && (

Updated. Give the worker about a minute to re-attest and go live. diff --git a/lib/hardware.ts b/lib/hardware.ts index 323929c..3faa25f 100644 --- a/lib/hardware.ts +++ b/lib/hardware.ts @@ -7,6 +7,7 @@ * stake (5,000 LCAI testnet / 50,000 mainnet), which we surface in the UI. */ import { HARDWARE } from "./network"; +import { MODEL_CATALOG, isModelId, lookupModel, residentVramGb, type CatalogEntry } from "./model-catalog"; export interface MachineInput { cores: number; @@ -151,32 +152,156 @@ export function inferGpu(renderer: string): { vramGb?: number; unified?: boolean export type ModelTier = "light" | "standard" | "large" | "server"; +/** + * Provenance of a size. Only `catalog` is a measurement; the other two are + * inferences the UI must label as such rather than rendering a confident number. + */ +export type ModelSizeSource = "catalog" | "name" | "unknown"; + export interface ModelRequirement { - paramsB: number; // estimated parameter count in billions (0 = unknown) - vramGb: number; // rough resident memory needed to serve it + paramsB: number; // param count in billions parsed from the tag (0 = unknown, incl. MoE) + vramGb: number; // resident memory needed to serve it - always a number, see `known` tier: ModelTier; tierLabel: string; + /** True only when the number is catalog-backed (measured). False = a guess. */ + known: boolean; + source: ModelSizeSource; + /** The catalog row behind a known requirement (note, embedding flag, download size). */ + entry?: CatalogEntry; +} + +/** + * VRAM the machine has already spent before a model loads: the desktop + * compositor, the browser's GPU process, anything else holding a context. On + * this project's reference box - X11 at 4K on a 16GB card - that is ~1.3-1.5GB, + * so a "16GB GPU" really offers ~14.5GB to Ollama. Headless servers pay close to + * zero, which is why this is an explicit subtraction the caller opts into + * (`usableVramGb`) and NOT something baked into the fit checks below. + */ +export const OS_VRAM_OVERHEAD_GB = 1.5; + +/** + * The sticker VRAM number minus the desktop's own claim on it - what a model can + * actually take. Returns 0 for a 0/unknown machine so it keeps the meaning the + * fit helpers give 0 ("we don't know this machine"), and never goes negative on + * a small GPU where the desktop eats most of the card. + */ +export function usableVramGb(totalVramGb: number, overheadGb = OS_VRAM_OVERHEAD_GB): number { + if (!(totalVramGb > 0)) return 0; + return Math.max(0, Math.round((totalVramGb - overheadGb) * 10) / 10); +} + +/** + * Stand-in footprint for a model we cannot size. keccak256 is one-way, so a + * registry id that isn't in the catalog could be ANY model - a 0.6B embedder or + * a 120B MoE. Assuming the largest one we know of is the only assumption that + * cannot quietly overcommit a machine; the old code assumed 8GB and understated + * gpt-oss:120b by 7x. Derived from the catalog, not a literal, so it tracks it. + */ +export const UNKNOWN_MODEL_VRAM_GB = MODEL_CATALOG.reduce((mx, e) => Math.max(mx, residentVramGb(e)), 0); + +/** Tier + human label for a resident footprint, in GPU classes people can buy. */ +function sizeBand(vramGb: number): { tier: ModelTier; tierLabel: string } { + if (vramGb <= 4) return { tier: "light", tierLabel: "Light - runs on most machines" }; + if (vramGb <= 8) return { tier: "standard", tierLabel: "Standard - needs an 8GB GPU / 16GB unified" }; + if (vramGb <= 12) return { tier: "standard", tierLabel: "Standard+ - needs a 12GB GPU" }; + if (vramGb <= 24) return { tier: "large", tierLabel: "Large - needs a 24GB GPU" }; + return { tier: "server", tierLabel: "Server-class - needs a 48GB+ GPU" }; +} + +/** + * Param count out of a tag: the "8" in "llama3-8b", the "2" in "gemma4:e2b". + * + * The right-hand guard is `(?![0-9a-z])` rather than `\b` so a quantization + * suffix like "8B_K_M" still reads as 8B (`_` is a word char, so `\b` missed it). + * There is deliberately NO left-hand guard: "gemma4:e2b" has its digit preceded + * by a letter, and requiring a boundary there would drop it to 0 params. + */ +function paramsFromTag(tag: string): number { + const m = /(\d+(?:\.\d+)?)\s*b(?![0-9a-z])/i.exec(tag); + return m ? parseFloat(m[1]) : 0; +} + +/** Footprint implied by a param count alone - the fallback when we have no measurement. */ +function vramFromParams(paramsB: number): number { + if (paramsB <= 4) return 4; + if (paramsB <= 9) return 8; + if (paramsB <= 15) return 12; + if (paramsB <= 34) return 24; + return 48; } /** - * Rough hardware requirement for a model, inferred from its name (the param - * count, e.g. the "8" in "llama3-8b" or the "2" in "gemma4:e2b"). Used only to - * label models and flag a fit vs the operator's machine - the network never - * gates on this. Unknown names fall back to a standard 8GB assumption. + * Hardware requirement for a model, by tag OR by on-chain id. + * + * Order matters: the measured catalog wins, always. A tag string is a terrible + * size oracle - "qwen3-coder-next" and "glm-4.7-flash" carry no number at all, + * and MoE names actively lie ("gemma4:e2b" reads as 2B but downloads 7.2GB) - so + * the regex is only ever a last resort for a model we've never measured. + * + * Three outcomes, and the caller must distinguish them: + * catalog -> known: true, a measured/derived number the UI can state plainly. + * name -> known: false, a guess from the param count in the tag. + * unknown -> known: false, UNKNOWN_MODEL_VRAM_GB and a label that says so. + * Ids are never regexed: a digest ending in "...0d9b" would otherwise parse as a + * 9B model and get asserted at 8GB, which is precisely the bug this replaces. + * The network never gates on any of this - it only labels models in the UI. */ -export function modelRequirement(name: string): ModelRequirement { - const m = /(\d+(?:\.\d+)?)\s*b\b/i.exec(name); - const paramsB = m ? parseFloat(m[1]) : 0; - if (paramsB > 0 && paramsB <= 4) return { paramsB, vramGb: 4, tier: "light", tierLabel: "Light - runs on most machines" }; - if (paramsB <= 9) return { paramsB, vramGb: 8, tier: "standard", tierLabel: "Standard - needs an 8GB GPU / 16GB unified" }; - if (paramsB <= 15) return { paramsB, vramGb: 12, tier: "standard", tierLabel: "Standard+ - needs a 12GB GPU" }; - if (paramsB <= 34) return { paramsB, vramGb: 24, tier: "large", tierLabel: "Large - needs a 24GB GPU" }; - return { paramsB, vramGb: 48, tier: "server", tierLabel: "Server-class - needs a 48GB+ GPU" }; +export function modelRequirement(nameOrId: string): ModelRequirement { + const raw = (nameOrId ?? "").trim(); + + const entry = lookupModel(raw); + if (entry) { + const vramGb = residentVramGb(entry); + return { paramsB: paramsFromTag(entry.tag), vramGb, ...sizeBand(vramGb), known: true, source: "catalog", entry }; + } + + const paramsB = isModelId(raw) ? 0 : paramsFromTag(raw); + if (paramsB > 0) { + const vramGb = vramFromParams(paramsB); + const band = sizeBand(vramGb); + // Honest about provenance: this is arithmetic on a string, not a measurement. + return { paramsB, vramGb, tier: band.tier, tierLabel: `${band.tierLabel} (estimated from the name)`, known: false, source: "name" }; + } + + return { + paramsB: 0, + vramGb: UNKNOWN_MODEL_VRAM_GB, + ...sizeBand(UNKNOWN_MODEL_VRAM_GB), + tierLabel: "Size unknown - not verified", + known: false, + source: "unknown", + }; } -/** Total resident memory (GB) needed to keep a set of models warm at once. */ +/* + * TWO WAYS TO ASK "does this machine fit these models" + * ---------------------------------------------------- + * `modelsMemoryGb`/`modelsFit` are the ALL-RESIDENT rule and stay the app's + * default. A worker advertises every model it selected, so a job for any of them + * can land at any moment; if that model isn't already in VRAM, Ollama cold-loads + * it (tens of seconds for a 20GB set, longer off a spinning disk) while the job + * clock runs, and a missed deadline is what gets a worker slashed. Requiring the + * whole set resident buys that risk away outright. + * + * `modelFitsAlone`/`largestModelGb` express the weaker SWAP rule: hold only the + * biggest model and page the others in between jobs. That is safe ONLY when + * cold-load time comfortably fits inside the job deadline, which we cannot + * verify from the browser (it depends on disk speed and the deadline the + * requester set). They exist so a caller can label a "could serve, with + * swapping" state - nothing here switches the default over to them. + * + * `availGb` is taken at face value in both. Pass `usableVramGb(total)` if you + * want the desktop's overhead subtracted; these helpers deliberately don't, so a + * headless server isn't charged for a compositor it doesn't run. + */ + +/** Total resident memory (GB) to keep a set of models warm at once (all-resident rule). */ export function modelsMemoryGb(names: string[]): number { - return names.reduce((sum, n) => sum + modelRequirement(n).vramGb, 0); + const sum = names.reduce((total, n) => total + modelRequirement(n).vramGb, 0); + // Measured sizes are decimals now, so sum float noise (6.1 + 46.7 = 52.800000000000004) + // instead of the old whole numbers. Round to the one decimal the catalog claims. + return Math.round(sum * 10) / 10; } /** Whether a machine with `availGb` (discrete VRAM, or the unified pool on Apple @@ -185,6 +310,23 @@ export function modelsFit(names: string[], availGb: number): boolean { return availGb > 0 && names.length > 0 && modelsMemoryGb(names) <= availGb; } +/** + * Whether ONE model fits on its own - the swap-mode question (see above). + * + * For an unsized model this compares against UNKNOWN_MODEL_VRAM_GB, so a true + * here means "clears the largest model we know of", which is the only fit claim + * that's defensible without knowing what the id actually is. Callers still must + * not paint an unsized model as a confident green "fits": check `known` first. + */ +export function modelFitsAlone(nameOrId: string, availGb: number): boolean { + return availGb > 0 && modelRequirement(nameOrId).vramGb <= availGb; +} + +/** The single heaviest model in a set - the floor for swap mode. 0 for an empty set. */ +export function largestModelGb(names: string[]): number { + return names.reduce((mx, n) => Math.max(mx, modelRequirement(n).vramGb), 0); +} + export interface Detected { input: Partial; vramInferred: boolean; diff --git a/lib/install-progress.ts b/lib/install-progress.ts index 0099df6..49050fa 100644 --- a/lib/install-progress.ts +++ b/lib/install-progress.ts @@ -7,6 +7,7 @@ * Pure + deterministic so it's unit-tested: feed it the cleaned log lines and the * run phase, get back the milestones to render. */ +import { lookupModel } from "./model-catalog"; export type StepStatus = "pending" | "active" | "done" | "error"; export type RunPhase = "running" | "done" | "failed"; @@ -120,6 +121,16 @@ function explorerFor(net: "mainnet" | "testnet" | null): string { return `https://${net === "testnet" ? "testnet" : "mainnet"}.lightscan.app`; } +// Root/privilege refusals from the prerequisite stage. The installer shells out to +// the vendor install scripts (get.docker.com, ollama.com/install.sh) and both +// elevate with sudo - but the app runs them with no controlling terminal, so sudo +// can't prompt and dies printing one of its own `sudo: …` lines. The remaining +// alternatives are those scripts' and pkexec's own refusals. Anchored deliberately: +// a bare /sudo/ would also match the advice text the installer itself prints +// ("run once in a terminal: sudo mkdir -p …"), which is not a failure. +const PRIVILEGE_FAIL_RE = + /\bsudo:|superuser permissions|please re-run as root|a password is required|no tty present|a terminal is required to read the password|ability to run commands as root|\bpkexec\b/i; + /** * Turn a known install failure into one plain-English, actionable sentence (shown * above the technical log on failure). Reacts to the actual on-chain error text - @@ -128,6 +139,33 @@ function explorerFor(net: "mainnet" | "testnet" | null): string { */ export function diagnoseFailure(cleaned: string[]): string | null { const text = cleaned.join("\n"); + // Hoisted because several recognisers below have to be honest about the operator's + // money: every step up to 07-register is local setup, so "nothing was staked" is + // only a true thing to say while this is false. `online` guards the fallbacks. + const inRegisterPath = /phase\s*\.?\\?\/?0?7[- ]register|worker:latest\s+(?:status|register)|stopped at .*07-register/i.test(text); + const online = /worker online|✅\s*worker/i.test(text); + // Prerequisite stage, and by far the most likely Linux failure: the vendor install + // scripts need root. `set -e` aborts the whole run here, before a single on-chain + // call is made, so the reassurance is unconditionally true. Docker is installed + // before Ollama, so an "installing Ollama" marker means Docker's own install + // already succeeded - test that one first to attribute the failure correctly. + if (PRIVILEGE_FAIL_RE.test(text)) { + if (/installing Ollama/i.test(text)) { + return ( + "Installing Ollama needs administrator rights and it has no terminal here to ask for your password. " + + "Open a terminal and run: curl -fsSL https://ollama.com/install.sh | sh - then run install again and " + + "LightNode will skip straight past this step. Nothing was staked." + ); + } + if (/installing Docker/i.test(text)) { + return ( + "Installing Docker needs administrator rights and it has no terminal here to ask for your password. " + + "Open a terminal and run: curl -fsSL https://get.docker.com | sh && sudo usermod -aG docker $USER - then " + + "log out and back in (that is what lets LightNode drive Docker without root) and run install again. " + + "Nothing was staked." + ); + } + } if (/AddSupportedModel\b.*\brevert/i.test(text)) { return ( "Your worker staked and registered on-chain (your stake is locked, not lost), but adding the model " + @@ -137,6 +175,34 @@ export function diagnoseFailure(cleaned: string[]): string | null { "or reinstall needed. (If it still won’t take, llama3-8b is the safe fallback.)" ); } + // Our OWN gas-corrected addSupportedModel failed - "model add failed even with + // estimated gas" (bash) / "stopped at …07-register.ps1 - model add failed" (ps1). + // The install only calls it once 07-register has succeeded, so reaching this line + // proves the stake landed. Say that plainly here, or the generic register fallback + // further down would tell an already-staked operator to top up and re-run. + if (/model add failed/i.test(text)) { + return ( + "Your worker is staked and registered on-chain - the only thing that didn’t land is attaching the model to it " + + "(your stake is locked, not lost). This attempt already sent proper gas, so gas isn’t the cause; the usual " + + "reason is that this network’s registry doesn’t list that exact model. Finish from the dashboard: open " + + "“Models this worker serves” and add it there - that works on an already-registered worker, so there’s no " + + "re-stake and no reinstall. (llama3-8b is listed on every network if you need a safe fallback.)" + ); + } + // The dashboard's add-model-on-chain run, not an install: "⛔ failed to add " + // / "one or more models failed to add". That script only ever runs against a worker + // that is ALREADY registered and it never stakes, so the honest framing is "nothing + // changed". Gated on the register path being absent so daemon output during a real + // install can't borrow this message and wrongly promise no stake was placed. + if (/failed to add\b/i.test(text) && !inRegisterPath) { + return ( + "Adding the model on-chain didn’t go through, so this worker still serves exactly the set it served before - " + + "nothing was staked and its registration is untouched. Two things cause this: the worker wallet has no LCAI " + + "left for gas (the add is a transaction the worker signs and pays for itself), or this network’s registry " + + "doesn’t list that exact model. Send the worker a little LCAI and try again; if it still fails, pick a model " + + "the network lists (llama3-8b is on every network)." + ); + } if (/stopped at 07-register/i.test(text) && /less than|insufficient|balance/i.test(text)) { return "Registration needs a little more LCAI for the stake plus gas. Top up the worker address shown above, then run install again."; } @@ -186,8 +252,7 @@ export function diagnoseFailure(cleaned: string[]): string | null { // network's native gas token, so funding exactly the minimum stake leaves // nothing left to pay for the register tx. Surface the worker address so the // operator can check + top up directly instead of guessing. - const inRegisterPath = /phase\s*\.?\\?\/?0?7[- ]register|worker:latest\s+(?:status|register)|stopped at .*07-register/i.test(text); - const online = /worker online|✅\s*worker/i.test(text); + // // If the pre-register funding gate already CONFIRMED the wallet held enough // LCAI ("✓ worker wallet funded (… LCAI)"), a later register failure is NOT a // balance problem. Telling a funded operator to "top up" wastes their money and @@ -221,6 +286,33 @@ export function diagnoseFailure(cleaned: string[]): string | null { "gas) and run install again - your existing worker key is reused, no reset needed." ); } + // A failed `ollama pull` reaches the log ONLY as the installer's own + // "⚠ download exited (continuing)" line - the pull runs detached with + // its output in a temp file that is then deleted, so the underlying reason (nearly + // always no free disk, sometimes a dropped connection) never gets here. We name the + // size from the catalog instead, since "you need 65.4 GB free" is the actionable + // part. Deliberately last: the install carries on after this line, so a download + // failure is usually the cause of a LATER stop, and whatever actually stopped the + // run should speak first. Reaching here means nothing on-chain was attempted. + const pullFail = text.match(/(\S+)[ \t]+download exited\b/i); + if (pullFail && !inRegisterPath && !online) { + const entry = lookupModel(pullFail[1]); + const sizeBit = entry + ? ` ${entry.tag} is a ${entry.downloadGb} GB download, so you need at least that much free on top of what Docker and the worker image take.` + : ""; + // The installer prints this itself when the disk is under 15 GB free; if it did, + // we already know the answer rather than listing space as one of two guesses. + const lowDisk = /Only ~?\s*\d+\s*GB free/i.test(text) + ? " The installer already flagged this machine as low on disk, so that is almost certainly why." + : ""; + return ( + "The AI model didn’t finish downloading, so there was nothing for the worker to serve." + + sizeBit + + lowDisk + + " Free up disk space - or go back and pick a smaller model - then run install again; Ollama resumes from " + + "where it stopped, so a retry doesn’t re-download what you already have. Nothing was staked." + ); + } return null; } diff --git a/lib/model-catalog.ts b/lib/model-catalog.ts new file mode 100644 index 0000000..a90e72d --- /dev/null +++ b/lib/model-catalog.ts @@ -0,0 +1,147 @@ +/** + * Canonical model catalog + on-chain id recovery. + * + * WHY THIS EXISTS + * --------------- + * A model's on-chain identity is `id = keccak256(tag)` (see sdk/src/inference.ts). + * The registry stores only that digest - it carries no name, no size, and no + * quantization. When a model is whitelisted without its tag string, the indexer + * has nothing to put in `name`, so it echoes the id back: `name === id`. The UI + * then renders a raw 66-char hash, and `modelRequirement()` - which infers size + * by regex over the *name* - silently falls back to "8GB / Standard" for it. + * + * keccak256 is one-way, so a hash cannot be decoded back to a tag. What we CAN + * do is invert a *known* set: hash every tag we know and match. That is a + * dictionary lookup, not decoding - it recovers today's registrations and will + * not recover a future model whose tag we've never seen. Hence `known: false` + * stays a first-class state everywhere downstream; we never guess. + * + * SIZES + * ----- + * The registry exposes no size field, and the tag string alone cannot size a + * model (nothing in "glm-4.7-flash" or "qwen3-coder-next" implies a parameter + * count, and MoE models decouple params from footprint entirely). So sizes here + * are measured, with provenance recorded per entry: + * - `downloadGb` summed manifest layer bytes from registry.ollama.ai + * - `peakVramGb` observed resident peak during inference, where we have it + * Fit decisions use `peakVramGb` when measured, else a conservative estimate + * from `downloadGb` (weights + KV cache/context/runtime overhead). + */ +import { keccak256, toBytes } from "viem"; + +export interface CatalogEntry { + /** Exact tag as registered on chain - keccak256 of this is the model id. */ + tag: string; + /** Summed Ollama manifest layer bytes (the download). */ + downloadGb: number; + /** Measured peak resident VRAM during inference, when we have a real number. */ + peakVramGb?: number; + /** True for embedding models (max_output_tokens is 1, not a chat model). */ + embedding?: boolean; + /** Human note shown in the UI when useful. */ + note?: string; +} + +/** + * Known tags. Sizes are measured, not inferred. + * + * `downloadGb` = registry.ollama.ai manifest layer sum. + * `peakVramGb` = observed peak during a benchmarked run; absent when we have + * not measured it, in which case `residentVramGb()` estimates conservatively. + */ +export const MODEL_CATALOG: CatalogEntry[] = [ + { tag: "qwen3-embedding:0.6b", downloadGb: 0.6, peakVramGb: 2.6, embedding: true, note: "Embedding model - returns vectors, not chat text" }, + { tag: "llama3-8b", downloadGb: 4.7 }, + { tag: "qwen3-vl:8b", downloadGb: 6.1, peakVramGb: 5.4, note: "Vision" }, + { tag: "gemma4:e2b", downloadGb: 7.2, note: "MoE - 'e2b' is effective params, the download is larger than the name implies" }, + { tag: "gpt-oss:20b", downloadGb: 13.8, peakVramGb: 11.9, note: "Reasoning - MXFP4" }, + { tag: "glm-4.7-flash", downloadGb: 19.0, peakVramGb: 17.8, note: "Coding" }, + { tag: "qwen3-vl:30b", downloadGb: 19.6, peakVramGb: 17.9, note: "Vision" }, + { tag: "llama3-70b", downloadGb: 40.0 }, + { tag: "qwen3-coder-next", downloadGb: 51.7, note: "Coding - large context (16k output)" }, + { tag: "gpt-oss:120b", downloadGb: 65.4, peakVramGb: 59.9, note: "Reasoning - MXFP4, server-class" }, +]; + +/** modelId (lowercase 0x hash) -> catalog entry. Built once, from the tags. */ +export const ENTRY_BY_ID: ReadonlyMap = new Map( + MODEL_CATALOG.map((e) => [keccak256(toBytes(e.tag)).toLowerCase(), e]), +); + +/** tag -> catalog entry. */ +export const ENTRY_BY_TAG: ReadonlyMap = new Map( + MODEL_CATALOG.map((e) => [e.tag.toLowerCase(), e]), +); + +/** The on-chain id for a tag: keccak256(utf8 bytes of the exact tag). */ +export function modelIdForTag(tag: string): string { + return keccak256(toBytes(tag)).toLowerCase(); +} + +/** A 32-byte hex digest, i.e. what the registry uses as a model id. */ +export function isModelId(s: string): boolean { + return /^0x[0-9a-fA-F]{64}$/.test(s.trim()); +} + +/** + * Resolve whatever the indexer gave us into a display name + identity. + * + * `name` from the subgraph is either a real tag or (when the registration + * carried none) the id echoed back. We only ever treat the result as a servable + * tag when `known` is true - an unrecovered id is NOT a tag and must never be + * passed to `ollama pull` or hashed again into a second, bogus id. + */ +export interface ResolvedModel { + /** Safe to show. Either the real tag or a short, readable placeholder. */ + label: string; + /** The real Ollama/registry tag, or null when we could not recover it. */ + tag: string | null; + /** True when `tag` is a real tag we can serve. */ + known: boolean; + /** The on-chain model id, when we have it. */ + id?: string; + entry?: CatalogEntry; +} + +export function resolveModel(name: string, id?: string): ResolvedModel { + const rawId = (id ?? "").toLowerCase(); + // The SHAPE of the string decides, not whether it equals `id`. A 32-byte + // digest is never a plausible registered tag, so any digest in `name` is an + // echoed id. Also requiring `name === id` meant a row whose two fields + // disagreed took the real-tag path below and came back as + // `{ tag: <66-char hash>, known: true }` - a raw hash marked servable, which + // would reach `ollama pull` and be hashed a second time into a bogus id. + // That is precisely the failure this module exists to prevent. + const nameIsId = isModelId(name); + + // Normal case: the indexer gave us a real tag. + if (name && !nameIsId) { + const entry = ENTRY_BY_TAG.get(name.toLowerCase()); + return { label: name, tag: name, known: true, id: rawId || undefined, entry }; + } + + // Degenerate case: name === id (or name is itself a bare digest). Try to + // invert it against the known tags. + const lookupId = rawId || name.toLowerCase(); + const entry = ENTRY_BY_ID.get(lookupId); + if (entry) return { label: entry.tag, tag: entry.tag, known: true, id: lookupId, entry }; + + return { label: `unnamed ${lookupId.slice(0, 10)}…`, tag: null, known: false, id: lookupId }; +} + +/** + * Resident VRAM (GB) needed to keep a model warm. + * + * Prefers a measured peak. Otherwise estimates from the download: weights plus + * KV cache, context and runtime overhead. Rounded to one decimal so the UI does + * not imply precision we do not have. + */ +export function residentVramGb(entry: CatalogEntry): number { + if (entry.peakVramGb != null) return entry.peakVramGb; + return Math.round((entry.downloadGb * 1.15 + 0.7) * 10) / 10; +} + +/** Catalog lookup by tag or id, whichever the caller has. */ +export function lookupModel(tagOrId: string): CatalogEntry | undefined { + const k = tagOrId.trim().toLowerCase(); + return isModelId(k) ? ENTRY_BY_ID.get(k) : ENTRY_BY_TAG.get(k); +} diff --git a/lib/scriptgen.ts b/lib/scriptgen.ts index b4a5474..d51f0f2 100644 --- a/lib/scriptgen.ts +++ b/lib/scriptgen.ts @@ -5,13 +5,16 @@ * operator runs locally, with the production gotchas already handled. */ import { NETWORKS, DEFAULT_MODEL, type NetworkId, type NetworkConfig } from "./network"; +// Sizes are MEASURED in the shared catalog (never inferred from a tag), so the +// generated script can gate a pull on real disk/VRAM numbers instead of guessing. +import { MODEL_CATALOG, lookupModel, residentVramGb } from "./model-catalog"; export type OS = "macos" | "linux" | "windows"; const TOOLKIT = "https://github.com/lightchain-protocol/lightchain-worker-toolkit"; // Bump on every install-script change so the log shows which version actually ran. -export const INSTALLER_REV = "2026-06-07.1"; +export const INSTALLER_REV = "2026-07-29.1"; export interface ScriptBundle { os: OS; @@ -207,9 +210,13 @@ else fi # On-chain economic alerts (best-effort), regardless of the local run-state. econ_alerts -# Keep every served model pinned in Ollama (keep_alive:-1) so none cold-loads -# mid-job. Reads the set from a file (one per line) so a model change is picked up. -while IFS= read -r M; do [ -n "$M" ] && curl -s -m 5 http://127.0.0.1:11434/api/generate -d "{\\"model\\":\\"$M\\",\\"prompt\\":\\"ok\\",\\"keep_alive\\":-1,\\"stream\\":false}" >/dev/null 2>&1 & done < "$HOME/.lightnode/model" 2>/dev/null || true +# Keep every served model warm in Ollama so none cold-loads mid-job. Reads the +# set from a file (one per line) so a model change is picked up, and the +# residency policy from ~/.lightnode/keep-alive so a "swap on demand" operator +# isn't silently re-pinned. Missing/empty file = -1 (pin for ever), which is what +# every install before the knob existed did. +KA="$(cat "$HOME/.lightnode/keep-alive" 2>/dev/null)"; [ -n "$KA" ] || KA=-1 +while IFS= read -r M; do [ -n "$M" ] && curl -s -m 5 http://127.0.0.1:11434/api/generate -d "{\\"model\\":\\"$M\\",\\"prompt\\":\\"ok\\",\\"keep_alive\\":$KA,\\"stream\\":false}" >/dev/null 2>&1 & done < "$HOME/.lightnode/model" 2>/dev/null || true KEEPEOF chmod +x "$HOME/.lightnode/keep-online.sh" if [ "$(uname -s)" = "Darwin" ]; then @@ -315,6 +322,62 @@ export PATH="$HOME/.foundry/bin:/opt/homebrew/bin:/usr/local/bin:$HOME/.docker/b OS="$(uname -s)" if [ "$OS" = "Darwin" ] && ! have brew; then echo "⛔ Install Homebrew first: https://brew.sh"; exit 1; fi +# ── Root escalation that CANNOT hang ───────────────────────────────────────── +# The desktop app runs this installer through \`bash -lc\` with stdin INHERITED +# and no tty. A plain \`sudo\` there either dies ("no tty present") - which under +# the set -e above tears the WHOLE install down - or, when a terminal happens to +# be attached, blocks forever on a password prompt the user never sees. Both are +# why a Linux install could not finish. So the ladder is strictly: +# already root -> sudo -n (never prompts; exits non-zero instead) -> pkexec +# pkexec raises a GRAPHICAL polkit dialog, the only prompt that can reach a user +# who launched us from a desktop icon; with no authentication agent registered it +# fails immediately rather than falling back to a tty prompt. Bare \`sudo\` is +# deliberately NOT in the ladder. Arguments pass through untouched so callers +# never nest quotes, and stdin is closed so a vendor script cannot swallow the +# app's pipe (or block waiting on it). +as_root() { + if [ "$(id -u)" = "0" ]; then "$@" /dev/null; then sudo -n "$@" /dev/null || have pkexec; } + +# ── Model residency knobs (defaults deliberately UNCHANGED) ────────────────── +# OLLAMA_KEEP_ALIVE=-1 pins every served model in memory for ever. That default +# is a financial decision, not a performance one: a job that misses its deadline +# is SLASHED, and cold-loading a 20-60 GB model costs tens of seconds against a +# ~120s budget. The price is that the memory is never handed back, and since +# every selected model is pinned, serving N models needs the SUM of their +# resident footprints. +# A future "swap on demand" mode wants the opposite trade (fit more models than +# VRAM, pay one cold load per switch), so both knobs are read from the +# environment instead of being hardcoded here. Nothing in the app sets them, so +# the generated script behaves exactly as before unless an operator opts in with +# e.g. LIGHTNODE_KEEP_ALIVE=5m LIGHTNODE_MAX_LOADED_MODELS=1 - and that operator +# is trading slash risk for memory, knowingly. +LN_KEEP_ALIVE="\${LIGHTNODE_KEEP_ALIVE:--1}" +LN_MAX_LOADED="\${LIGHTNODE_MAX_LOADED_MODELS:-}" +# JSON form for /api/generate: a bare number must stay bare, a duration string +# ("5m") must be quoted or the request body is invalid JSON. +case "$LN_KEEP_ALIVE" in ""|*[!0-9-]*) LN_KEEP_ALIVE_JSON="\\"$LN_KEEP_ALIVE\\"";; *) LN_KEEP_ALIVE_JSON="$LN_KEEP_ALIVE";; esac + +# The systemd drop-in that makes Ollama reachable from the worker CONTAINER (see +# the Linux note in section 3) plus the residency knobs. Staged into a file WE +# own, so the only privileged step is copying it into place - no quoting games +# inside a root shell - and the same file serves both the fresh-install path and +# the "Ollama was already here" path. +write_ollama_dropin() { + mkdir -p "$HOME/.lightnode" + { echo "[Service]" + echo 'Environment="OLLAMA_HOST=0.0.0.0:11434"' + echo 'Environment="OLLAMA_KEEP_ALIVE='"$LN_KEEP_ALIVE"'"' + [ -n "$LN_MAX_LOADED" ] && echo 'Environment="OLLAMA_MAX_LOADED_MODELS='"$LN_MAX_LOADED"'"' || true + } > "$HOME/.lightnode/ollama-lightnode.conf" +} + # 0) Disk guard. A near-full startup disk makes Docker Desktop's backend crash while # writing its lock files, into an unrecoverable state ("no space left on device"). # Fail fast with a clear message BEFORE we ever start Docker. (df -k is portable; @@ -327,13 +390,66 @@ fi [ -n "$FREE_G" ] && [ "$FREE_G" -lt 15 ] && echo "⚠ Only ~$FREE_G GB free - the model download alone needs several GB; you may run low." # 1) Install only what's missing (idempotent; each is a no-op when present). +# Linux: BOTH vendor installers need root, and BOTH shell out to sudo themselves +# (get.docker.com sets sh_c='sudo -E sh -c'; ollama's install.sh sets SUDO=sudo). +# Piped straight into \`sh\`, that sudo fires from inside a pipe with no tty to +# prompt on - so the install either died or hung on a prompt nobody could see. +# Instead we download the script as the user and hand the whole job to as_root +# ONCE: one visible prompt, no hidden one, and a clean actionable failure. if have docker; then echo "✓ Docker already installed"; else echo "▶ installing Docker" - if [ "$OS" = "Darwin" ]; then brew install --cask docker; else curl -fsSL https://get.docker.com | sh; fi + if [ "$OS" = "Darwin" ]; then brew install --cask docker; else + can_root || { echo "⛔ Docker is not installed, and installing it needs administrator rights this app cannot obtain here (you are not root, passwordless sudo is not configured, and pkexec - the graphical admin prompt - is unavailable)."; echo " Run this ONCE in a terminal, then click Install again:"; echo " curl -fsSL https://get.docker.com | sudo sh && sudo usermod -aG docker $(id -un) && newgrp docker"; exit 1; } + mkdir -p "$HOME/.lightnode" + curl -fsSL https://get.docker.com -o "$HOME/.lightnode/get-docker.sh" || { echo "⛔ could not download the Docker installer from get.docker.com - check your connection, then run install again."; exit 1; } + # One escalated invocation covering every root step: install, add you to the + # docker group, enable the service. Split up, this would ask three times. + cat > "$HOME/.lightnode/.root-docker.sh" </dev/null || true +systemctl enable --now docker 2>/dev/null || true +exit 0 +ROOTDOCKEREOF + echo "… approve the administrator prompt to install Docker" + as_root sh "$HOME/.lightnode/.root-docker.sh" || { echo "⛔ the Docker install did not complete - the administrator prompt was declined, or no polkit agent is running in this session."; echo " Run this ONCE in a terminal, then click Install again:"; echo " curl -fsSL https://get.docker.com | sudo sh && sudo usermod -aG docker $(id -un) && newgrp docker"; exit 1; } + hash -r 2>/dev/null || true + # A new group only applies to a NEW login session, so THIS shell still can't + # reach the socket. Say that now, plainly, instead of failing four minutes + # later in the engine wait with a generic "Docker didn't come up". + for _ in $(seq 1 15); do docker info >/dev/null 2>&1 && break; sleep 1; done + if ! docker info >/dev/null 2>&1 && docker info 2>&1 | grep -qi "permission denied"; then + echo "⛔ Docker is installed and running, but your user was only just added to the 'docker' group and Linux applies group changes at LOGIN. Log out and back in (or reboot), then click Install again. Nothing has been staked." + exit 1 + fi + fi fi if have ollama; then echo "✓ Ollama already installed"; else echo "▶ installing Ollama" - if [ "$OS" = "Darwin" ]; then brew install ollama; else curl -fsSL https://ollama.com/install.sh | sh; fi + if [ "$OS" = "Darwin" ]; then brew install ollama; else + can_root || { echo "⛔ Ollama is not installed, and installing it needs administrator rights this app cannot obtain here (you are not root, passwordless sudo is not configured, and pkexec - the graphical admin prompt - is unavailable)."; echo " Run this ONCE in a terminal, then click Install again:"; echo " curl -fsSL https://ollama.com/install.sh | sh"; exit 1; } + mkdir -p "$HOME/.lightnode" + curl -fsSL https://ollama.com/install.sh -o "$HOME/.lightnode/get-ollama.sh" || { echo "⛔ could not download the Ollama installer from ollama.com - check your connection, then run install again."; exit 1; } + # Stage the drop-in first so ONE escalation does both root jobs: the vendor + # install AND the 0.0.0.0 bind the worker container needs (section 3). Asking + # twice is the difference between one polkit dialog and two. + write_ollama_dropin + cat > "$HOME/.lightnode/.root-ollama.sh" </dev/null || true +systemctl enable ollama 2>/dev/null || true +systemctl restart ollama 2>/dev/null || true +exit 0 +ROOTOLLAMAEOF + echo "… approve the administrator prompt to install Ollama (the same prompt also binds it to 0.0.0.0, which the worker container needs)" + as_root sh "$HOME/.lightnode/.root-ollama.sh" || { echo "⛔ the Ollama install (or the 0.0.0.0 bind that follows it) did not complete - the administrator prompt was declined, or no polkit agent is running in this session."; echo " Run this ONCE in a terminal, then click Install again:"; echo " curl -fsSL https://ollama.com/install.sh | sh"; exit 1; } + hash -r 2>/dev/null || true + have ollama || { echo "⛔ the Ollama installer ran but 'ollama' is still not on PATH. Open a new terminal and run 'ollama --version'; if that fails, reinstall from https://ollama.com/download/linux, then click Install again."; exit 1; } + fi fi if have cast; then echo "✓ Foundry already installed"; else echo "▶ installing Foundry" @@ -348,12 +464,16 @@ hash -r 2>/dev/null || true # 2) Start Docker AND Ollama TOGETHER so Ollama boots during Docker's (much slower) # cold start instead of after it. Keep the model resident (no idle eviction) so it # never cold-loads mid-job - set before starting the server so it's picked up. -export OLLAMA_KEEP_ALIVE=-1 -[ "$OS" = "Darwin" ] && { launchctl setenv OLLAMA_KEEP_ALIVE -1 2>/dev/null || true; } +export OLLAMA_KEEP_ALIVE="$LN_KEEP_ALIVE" +[ -n "$LN_MAX_LOADED" ] && export OLLAMA_MAX_LOADED_MODELS="$LN_MAX_LOADED" || true +[ "$OS" = "Darwin" ] && { launchctl setenv OLLAMA_KEEP_ALIVE "$LN_KEEP_ALIVE" 2>/dev/null || true; [ -n "$LN_MAX_LOADED" ] && launchctl setenv OLLAMA_MAX_LOADED_MODELS "$LN_MAX_LOADED" 2>/dev/null || true; } if ! curl -s http://127.0.0.1:11434/api/tags >/dev/null 2>&1; then echo "▶ starting the Ollama server" + # sudo -n only, never bare sudo: a password prompt here has no tty to appear on + # (see as_root). The nohup fallback needs no privileges at all, so this always + # has a way through. if [ "$OS" = "Darwin" ]; then open -a Ollama 2>/dev/null || brew services start ollama 2>/dev/null || (nohup ollama serve >/dev/null 2>&1 &) - else sudo systemctl start ollama 2>/dev/null || systemctl --user start ollama 2>/dev/null || (nohup ollama serve >/dev/null 2>&1 &); fi + else sudo -n systemctl start ollama 2>/dev/null || systemctl --user start ollama 2>/dev/null || (nohup ollama serve >/dev/null 2>&1 &); fi fi # Engine not on the default socket? Try the common alternates (Docker Desktop / # Colima / Rancher) and pin DOCKER_HOST to whichever answers, before starting it. @@ -382,7 +502,10 @@ if ! docker info >/dev/null 2>&1; then open -a Docker 2>/dev/null || open -a "Docker Desktop" 2>/dev/null || true else echo "▶ starting the Docker engine" - sudo systemctl start docker 2>/dev/null || systemctl --user start docker-desktop 2>/dev/null || true + # Silent paths first (passwordless sudo, then the Docker Desktop user unit); + # a graphical admin prompt only as a last resort. Never bare sudo - it would + # hang on a password prompt with no tty to show it. + sudo -n systemctl start docker 2>/dev/null || systemctl --user start docker-desktop 2>/dev/null || as_root systemctl start docker 2>/dev/null || true fi fi @@ -427,26 +550,60 @@ echo "✓ Ollama server running" # host.docker.internal to the host loopback - bare-metal Linux has no such proxy.) # Bind Ollama to 0.0.0.0 so the bridge can reach it. Idempotent. if [ "$OS" = "Linux" ]; then - if systemctl show ollama 2>/dev/null | grep -q 'OLLAMA_HOST=0.0.0.0'; then - echo "✓ Ollama is reachable from the worker container (0.0.0.0)" - elif systemctl list-unit-files 2>/dev/null | grep -q '^ollama.service'; then + # Judge the LISTEN address, not the unit file: the unit file lies whenever + # Ollama was started some other way (a user session, a nohup, a snap). Returns + # 0 = all interfaces, 1 = loopback only, 2 = can't tell (no ss/netstat), so an + # unknown never becomes a block on a guess. + ollama_bind_state() { + if have ss; then OBS="$(ss -ltn 2>/dev/null)"; elif have netstat; then OBS="$(netstat -ltn 2>/dev/null)"; else return 2; fi + printf '%s' "$OBS" | grep -q ':11434' || return 2 + if printf '%s' "$OBS" | grep -Fq '0.0.0.0:11434' || printf '%s' "$OBS" | grep -Fq '[::]:11434' || printf '%s' "$OBS" | grep -Fq '*:11434'; then return 0; fi + return 1 + } + OB=0; ollama_bind_state || OB=$? + if [ "$OB" = "1" ]; then echo "▶ allowing the worker container to reach Ollama (binding it to 0.0.0.0)" - # Editing the system ollama.service needs root. Try, in order: passwordless - # sudo (silent), pkexec (a GRAPHICAL admin prompt - works from the GUI app - # where there is no terminal for sudo), then sudo (prompts on a real terminal). - OLLPRIV='mkdir -p /etc/systemd/system/ollama.service.d && printf "[Service]\\nEnvironment=\\"OLLAMA_HOST=0.0.0.0:11434\\"\\nEnvironment=\\"OLLAMA_KEEP_ALIVE=-1\\"\\n" > /etc/systemd/system/ollama.service.d/lightnode.conf && systemctl daemon-reload && systemctl restart ollama' - if sudo -n sh -c "$OLLPRIV" 2>/dev/null || { command -v pkexec >/dev/null 2>&1 && pkexec sh -c "$OLLPRIV" 2>/dev/null; } || sudo sh -c "$OLLPRIV" 2>/dev/null; then - for _ in $(seq 1 30); do curl -s http://127.0.0.1:11434/api/tags >/dev/null 2>&1 && break; sleep 1; done - echo "✓ Ollama now listening on 0.0.0.0:11434 - the worker container can reach it" + write_ollama_dropin + if systemctl list-unit-files 2>/dev/null | grep -q '^ollama.service'; then + # Editing the system unit needs root - same no-hang ladder as everything + # else (as_root); a declined prompt is reported, never waited on. + cat > "$HOME/.lightnode/.root-ollama-bind.sh" </dev/null 2>&1 || echo "⚠ could not rebind Ollama automatically (the admin prompt was declined or unavailable)" else - echo "⚠ Ollama only listens on 127.0.0.1, so the Dockerized worker can't reach it and jobs will fail at inference. Approve the admin prompt if one appears, or run once in a terminal: sudo mkdir -p /etc/systemd/system/ollama.service.d && printf '[Service]\\nEnvironment=\"OLLAMA_HOST=0.0.0.0:11434\"\\n' | sudo tee /etc/systemd/system/ollama.service.d/lightnode.conf && sudo systemctl daemon-reload && sudo systemctl restart ollama" + # No systemd unit: Ollama is a plain process we can restart ourselves, so + # this path needs no privileges at all. + pkill -f 'ollama serve' 2>/dev/null || true; sleep 1 + OLLAMA_HOST=0.0.0.0:11434 OLLAMA_KEEP_ALIVE="$LN_KEEP_ALIVE" nohup ollama serve >/dev/null 2>&1 & fi - else - echo "▶ restarting Ollama bound to 0.0.0.0 so the worker container can reach it" - pkill -f 'ollama serve' 2>/dev/null || true; sleep 1 - OLLAMA_HOST=0.0.0.0:11434 OLLAMA_KEEP_ALIVE=-1 nohup ollama serve >/dev/null 2>&1 & for _ in $(seq 1 30); do curl -s http://127.0.0.1:11434/api/tags >/dev/null 2>&1 && break; sleep 1; done - echo "✓ Ollama listening on 0.0.0.0:11434" + OB=0; ollama_bind_state || OB=$? + fi + case "$OB" in + 0) echo "✓ Ollama listens on all interfaces - the worker container can reach it" ;; + 1) echo "⛔ Ollama still only listens on 127.0.0.1, so the Dockerized worker cannot reach it and EVERY job would fail at inference - a staked worker that earns nothing and can be slashed. Install stops here; nothing has been staked." + echo " Fix it once in a terminal, then click Install again:" + echo " sudo mkdir -p /etc/systemd/system/ollama.service.d" + echo " printf '[Service]\\nEnvironment=\\"OLLAMA_HOST=0.0.0.0:11434\\"\\n' | sudo tee /etc/systemd/system/ollama.service.d/lightnode.conf" + echo " sudo systemctl daemon-reload && sudo systemctl restart ollama" + exit 1 ;; + *) echo "⚠ could not confirm which address Ollama listens on (no ss/netstat here). If jobs fail at inference with 'connection refused' to 172.17.0.1:11434, bind Ollama to 0.0.0.0." ;; + esac + # Belt and braces: the container talks to the bridge GATEWAY, so a host firewall + # can still block a correctly-bound Ollama. WARN only - on Docker Desktop for + # Linux the daemon lives in a VM where this address legitimately differs from + # the one the host sees, and a false block there is worse than a warning. + if [ "$OB" = "0" ]; then + BRIDGE_GW="$(docker network inspect bridge --format '{{range .IPAM.Config}}{{.Gateway}}{{end}}' 2>/dev/null | head -1)"; [ -n "$BRIDGE_GW" ] || BRIDGE_GW=172.17.0.1 + curl -s -m 5 "http://$BRIDGE_GW:11434/api/tags" >/dev/null 2>&1 || echo "⚠ Ollama is bound to all interfaces but did not answer on the docker bridge gateway ($BRIDGE_GW:11434). If jobs fail at inference a host firewall is blocking it - e.g. sudo ufw allow in on docker0 to any port 11434" fi fi @@ -474,6 +631,17 @@ function unixInstall(network: NetworkId, models: string[]): string { const list = models.length ? models : [DEFAULT_MODEL]; const supported = list.join(","); // SUPPORTED_MODELS the worker advertises const shellList = list.map((m) => `"${m}"`).join(" "); // for `for M in ...` loops + // Download sizes, straight from the shared catalog so they can never drift from + // what the UI showed. A tag we don't know gets no size and simply skips the + // disk gate - `known: false` stays first-class, we never guess a footprint. + const sizeCases = MODEL_CATALOG.map((e) => ` "${e.tag}") echo ${e.downloadGb};;`).join("\n"); + // Resident VRAM the whole selected set needs. Every served model is pinned + // (keep_alive), so it is the SUM that has to fit, not the largest. Only emitted + // when every selected tag is in the catalog - a partial sum would understate it. + const entries = list.map((m) => lookupModel(m)); + const vramNeedGb = entries.every((e) => e !== undefined) + ? Math.round(entries.reduce((s, e) => s + residentVramGb(e!), 0) * 10) / 10 + : 0; return [ "set -e", "exec 2>&1", // surface stderr (git clone, cast, etc.) in the streamed log @@ -482,6 +650,10 @@ function unixInstall(network: NetworkId, models: string[]): string { SMART_PREREQS, // The app's working dir may be "/" (non-writable). Work in a real home dir. 'mkdir -p "$HOME/.lightnode" && cd "$HOME/.lightnode" && echo "✓ workdir: $HOME/.lightnode"', + // Persist the resolved residency policy so the watchdog re-warms with the SAME + // one the install used (it defaults to -1 when the file is absent, so every + // pre-existing install keeps pinning exactly as before). + `printf '%s\\n' "$LN_KEEP_ALIVE_JSON" > "$HOME/.lightnode/keep-alive"`, // Changing the served set? Unload any previously-served model that is NOT in // the new set (each is pinned with keep_alive:-1 and never evicts on its own), // so its memory is freed instead of sitting resident. @@ -519,10 +691,66 @@ function unixInstall(network: NetworkId, models: string[]): string { " sleep 2", " done", ' wait "$PM_PID" 2>/dev/null || true', - " PM_RC=\"$(grep -oE '__PULLRC__:[0-9]+' \"$PM_LOG\" | tail -1 | cut -d: -f2)\"; rm -f \"$PM_LOG\"", - ' if [ "${PM_RC:-1}" = "0" ]; then echo "✓ downloaded $PM_NAME"; else echo "⚠ $PM_NAME download exited ${PM_RC:-?} (continuing)"; fi', + // The exit code is reported, but it is NOT the gate. `ollama pull` can exit 0 + // on a partially-written model, and a scraped marker can be missed entirely - + // so the only thing we trust is asking Ollama what it actually has, below. + " PM_RC=\"$(grep -oE '__PULLRC__:[0-9]+' \"$PM_LOG\" | tail -1 | cut -d: -f2)\"; PM_TAIL=\"$(tr '\\r' '\\n' < \"$PM_LOG\" 2>/dev/null | grep -v '^ *$' | tail -2 | tr '\\n' ' ')\"; rm -f \"$PM_LOG\"", + ' if [ "${PM_RC:-1}" = "0" ]; then echo "✓ downloaded $PM_NAME"; else echo "⚠ $PM_NAME download exited ${PM_RC:-?}: $PM_TAIL"; fi', + "}", + // Is the model REALLY in Ollama under its exact on-chain name? `ollama list` + // prints NAME as tag[:latest], so an implicit :latest counts. Whole-line FIXED + // compare against the NAME column only: a substring match would accept + // "llama3-8b-instruct" for "llama3-8b", and a regex would treat the "." in + // "glm-4.7-flash" as a wildcard. + "model_present() {", + " ollama list 2>/dev/null | awk 'NR>1 {print $1}' | sed 's/:latest$//' | grep -qxF \"$(printf '%s' \"$1\" | sed 's/:latest$//')\"", "}", - `for M in ${shellList}; do TAG="$(printf '%s' "$M" | sed -E 's/-([0-9.]+[bB])$/:\\1/')"; if ollama list 2>/dev/null | grep -qiE "(^|[[:space:]])$M(:latest)?([[:space:]]|$)"; then echo "✓ model $M present"; else pull_model "$M" "$TAG"; [ "$TAG" != "$M" ] && ollama cp "$TAG" "$M" >/dev/null 2>&1 && echo "✓ aliased $TAG -> $M" || true; fi; done`, + // Measured download size per known tag (generated from lib/model-catalog.ts). + // Empty for a tag we don't know, which just skips the disk gate. + "model_download_gb() {", + ' case "$1" in', + sizeCases, + ' *) echo "";;', + " esac", + "}", + ...(vramNeedGb > 0 + ? [ + // Fit check - WARN, never a block: Ollama still runs an oversized model, + // it just spills to the CPU at a fraction of the speed, which is the most + // common cause of a missed deadline (and a slash). The operator may also + // have swapped GPUs since we measured. + `LN_VRAM_NEED=${vramNeedGb}`, + `GPU_MB="$(nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>/dev/null | head -1)"`, + `if [ -n "$GPU_MB" ] && awk -v g="$GPU_MB" -v n="$LN_VRAM_NEED" 'BEGIN{exit !(g/1024 < n)}'; then GPU_GB="$(awk -v g="$GPU_MB" 'BEGIN{printf "%.1f", g/1024}')"; echo "⚠ the model(s) you picked need about $LN_VRAM_NEED GB resident (all served models stay loaded at once) and this GPU reports $GPU_GB GB. Whatever does not fit runs on the CPU - far slower, and slow jobs miss their deadline, which is slashable on mainnet. Consider serving fewer or smaller models."; fi`, + ] + : []), + // Pull each missing model, then GATE ON PRESENCE. The old code only warned + // ("continuing") on a failed pull and walked straight into staking + register, + // producing a staked worker advertising a model it cannot run - every job it + // wins then fails. Real tags like qwen3-coder-next (51.7 GB) and gpt-oss:120b + // (65.4 GB) make that easy to hit, so the failure has to be loud and terminal. + 'MISSING_MODELS=""', + `for M in ${shellList}; do`, + ' if model_present "$M"; then echo "✓ model $M present"; continue; fi', + ` TAG="$(printf '%s' "$M" | sed -E 's/-([0-9.]+[bB])$/:\\1/')"`, + ' NEED_GB="$(model_download_gb "$M")"', + ' if [ -n "$NEED_GB" ]; then', + ` FREE_NOW="$(df -k "$HOME" 2>/dev/null | awk 'NR==2 {print int($4/1048576)}')"`, + ` if [ -n "$FREE_NOW" ] && awk -v f="$FREE_NOW" -v n="$NEED_GB" 'BEGIN{exit !(f < n + 2)}'; then echo "⛔ $M is a ~$NEED_GB GB download and only $FREE_NOW GB is free here. Free up space or pick a smaller model, then run install again - nothing has been staked."; exit 1; fi`, + ' echo "▶ $M is a ~$NEED_GB GB download"', + " fi", + ' pull_model "$M" "$TAG"', + ' [ "$TAG" != "$M" ] && ollama cp "$TAG" "$M" >/dev/null 2>&1 && echo "✓ aliased $TAG -> $M" || true', + ' if model_present "$M"; then echo "✓ model $M ready"; else MISSING_MODELS="$MISSING_MODELS $M"; fi', + "done", + 'if [ -n "$MISSING_MODELS" ]; then', + ' echo "⛔ these selected model(s) are NOT on this machine after the download:$MISSING_MODELS"', + ' echo " A worker advertises what it serves, so registering now would stake your LCAI on a model that fails every job it wins (slashable on mainnet). Install stops here."', + ' echo " Nothing was staked or registered - your funds are untouched."', + ' echo " The download error is above; the usual causes are out of disk, out of memory, or a tag that does not exist in the Ollama registry."', + ' echo " Check by hand with: ollama pull then ollama list"', + " exit 1", + "fi", `if [ -d lightchain-worker-toolkit ]; then echo "✓ toolkit present - updating"; (cd lightchain-worker-toolkit && git pull --ff-only || true); else git clone ${TOOLKIT}.git; fi`, "cd lightchain-worker-toolkit/scripts/bash", "[ -f secrets.env ] || cp secrets.example.sh secrets.env", @@ -670,21 +898,49 @@ function unixInstall(network: NetworkId, models: string[]): string { // staked but whose model-add failed inside the daemon's one-shot register (the // daemon under-sets the gas limit -> OutOfGas). We send addSupportedModel // ourselves with gas = estimate x1.5, which lands. No-op if already eligible. + // + // Two rules make the result trustworthy, and both were previously broken: + // 1. NEVER send on a reverting estimate. `cast estimate` runs the call + // against current state from the same sender, so a revert there means the + // tx would revert too. The old fallback to a fixed 300000 gas sent anyway. + // 2. NEVER believe the exit code. `cast send --gas-limit N` exits 0 even when + // the receipt comes back status 0 (reverted, gas burned), so a reverted + // add printed "✓ model added" and the install went on to announce "worker + // online" over a staked worker with zero eligible models. The registry is + // the only witness: read isEligible back. [ "add_selected_model_onchain() {", ' [ -z "$MODEL_ID" ] && return 0', ` if cast call "${workerRegistry}" "isEligible(address,bytes32)(bool)" "$WORKER_ADDR" "$MODEL_ID" --rpc-url "${rpc}" 2>/dev/null | grep -qi true; then return 0; fi`, - ` AM_EST="$(cast estimate --from "$WORKER_ADDR" "${workerRegistry}" "addSupportedModel(bytes32)" "$MODEL_ID" --rpc-url "${rpc}" 2>/dev/null)"`, - ` case "\${AM_EST:-}" in ""|*[!0-9]*) AM_GAS=300000;; *) AM_GAS="$(python3 -c 'import sys; print(int(int(sys.argv[1])*3//2))' "$AM_EST")";; esac`, + // stderr is merged so a revert reason can be shown; the estimate itself is + // the one all-digits line, so a foundry warning can't be mistaken for it. + ` AM_RAW="$(cast estimate --from "$WORKER_ADDR" "${workerRegistry}" "addSupportedModel(bytes32)" "$MODEL_ID" --rpc-url "${rpc}" 2>&1)"`, + ` AM_EST="$(printf '%s' "$AM_RAW" | grep -oE '^[0-9]+$' | tail -1)"`, + ' if [ -z "$AM_EST" ]; then', + ` echo "⛔ the on-chain model add would revert, so it was NOT sent: $(printf %s "$AM_RAW" | tr "\\n" " " | cut -c1-160)"`, + ' echo " The worker is staked but is NOT serving the selected model, so it would earn nothing. Check the model is whitelisted on this network, then run install again."', + " return 1", + " fi", + ` AM_GAS="$(python3 -c 'import sys; print(int(int(sys.argv[1])*3//2))' "$AM_EST")"`, ' echo "▶ adding the selected model on-chain with proper gas (gas-limit $AM_GAS) - the daemon under-gasses this step"', - ` if cast send "${workerRegistry}" "addSupportedModel(bytes32)" "$MODEL_ID" --private-key "$WORKER_PRIVKEY" --rpc-url "${rpc}" --gas-limit "$AM_GAS" >/dev/null 2>&1; then echo "✓ model added on-chain (worker now serving it)"; return 0; else echo "⛔ model add failed even with estimated gas"; return 1; fi`, + ` AM_OUT="$(cast send "${workerRegistry}" "addSupportedModel(bytes32)" "$MODEL_ID" --private-key "$WORKER_PRIVKEY" --rpc-url "${rpc}" --gas-limit "$AM_GAS" 2>&1)" || { echo "⛔ the model-add tx failed to send: $(printf %s "$AM_OUT" | tr "\\n" " " | cut -c1-160)"; return 1; }`, + // A couple of retries only for RPC read-after-write lag behind a load + // balancer; cast send has already waited for the receipt. + " for _ in 1 2 3 4 5; do", + ` if cast call "${workerRegistry}" "isEligible(address,bytes32)(bool)" "$WORKER_ADDR" "$MODEL_ID" --rpc-url "${rpc}" 2>/dev/null | grep -qi true; then echo "✓ model added on-chain and verified (worker now serving it)"; return 0; fi`, + " sleep 2", + " done", + ' echo "⛔ the model-add tx landed but the registry still does not list this worker as serving the model - it reverted on-chain (receipt status 0)."', + ' echo " Your stake is untouched, and the worker is NOT online until this succeeds. Run install again in a minute; if it keeps failing, the model may not be whitelisted on this network."', + " return 1", "}", ].join("\n"), `for p in ${desktopPhases}; do if [ "$p" = "04-import-key" ] && [ "$SKIP_IMPORT" = "1" ]; then echo "▶ phase 04-import-key (skipped - key already present)"; continue; fi; if [ "$p" = "07-register" ]; then REG_OK="$(cast call "${workerRegistry}" 'isWorkerRegistered(address)(bool)' "$WORKER_ADDR" --rpc-url "${rpc}" 2>/dev/null | awk '{print $1}')"; ELIG_OK="$( [ -n "$MODEL_ID" ] && cast call "${workerRegistry}" 'isEligible(address,bytes32)(bool)' "$WORKER_ADDR" "$MODEL_ID" --rpc-url "${rpc}" 2>/dev/null | awk '{print $1}')"; if [ "$REG_OK" = "true" ] && [ "$ELIG_OK" = "true" ]; then echo "▶ phase 07-register (skipped - already registered AND serving the selected model on-chain)"; continue; fi; if [ "$REG_OK" = "true" ] && [ "$ELIG_OK" != "true" ]; then echo "▶ phase 07-register (already staked from a prior attempt; finishing the model-add the daemon failed - no re-stake)"; add_selected_model_onchain || exit 1; continue; fi; gate_funding || exit 1; fi; if [ "$p" = "07-register" ]; then echo "▶ phase $p"; FORCE=1 "$RUNBASH" "$p.sh" 2>&1 || true; NOW_REG="$(cast call "${workerRegistry}" 'isWorkerRegistered(address)(bool)' "$WORKER_ADDR" --rpc-url "${rpc}" 2>/dev/null | awk '{print $1}')"; if [ "$NOW_REG" != "true" ]; then echo "⛔ stopped at 07-register (worker not registered on-chain after the attempt)"; exit 1; fi; add_selected_model_onchain || exit 1; else echo "▶ phase $p"; FORCE=1 "$RUNBASH" "$p.sh" 2>&1 || { echo "⛔ stopped at $p"; exit 1; }; fi; done${net.sortition ? "\n" + sortitionRunUnix(net) : ""}`, - // Pre-warm: load each served model and pin it (keep_alive:-1) so the first - // real job doesn't pay a cold-load that could exceed the inference timeout. + // Pre-warm: load each served model and hold it under the resolved residency + // policy (-1 = pinned, the default) so the first real job doesn't pay a + // cold-load that could exceed the inference timeout. `echo "▶ pre-warming ${list.join(", ")} (kept resident to avoid cold-load timeouts)"`, - `for M in ${shellList}; do curl -s -m 120 http://127.0.0.1:11434/api/generate -d "{\\"model\\":\\"$M\\",\\"prompt\\":\\"ok\\",\\"keep_alive\\":-1,\\"stream\\":false}" >/dev/null 2>&1 || true; done`, + `for M in ${shellList}; do curl -s -m 120 http://127.0.0.1:11434/api/generate -d "{\\"model\\":\\"$M\\",\\"prompt\\":\\"ok\\",\\"keep_alive\\":$LN_KEEP_ALIVE_JSON,\\"stream\\":false}" >/dev/null 2>&1 || true; done`, 'echo "✅ worker online"', ].join("\n"); } @@ -981,15 +1237,32 @@ $ModelId = (cast keccak "$(("${supported}" -split ',')[0])" 2>$null) # Gas-correct on-chain add of the selected model, to FINISH a worker that staked # but whose model-add failed inside the daemon's one-shot register (the daemon # under-sets the gas limit -> OutOfGas). gas = estimate x1.5. No-op if eligible. +# Same two rules as the bash side: never send on a reverting estimate (the old +# fixed 300000 fallback sent regardless), and never trust the exit code - cast +# send returns 0 for a mined-but-REVERTED tx, so the registry is re-read as the +# only real witness that the worker now serves the model. function Add-SelectedModelOnchain { + # cast writes to stderr routinely; under the install's ErrorActionPreference=Stop + # that would promote an EXPECTED revert into a terminating NativeCommandError and + # kill the install. Function-scoped Continue, judged by exit code (see Resolve-WorkerPassword). + $ErrorActionPreference = 'Continue' if (-not $ModelId) { return $true } $elig = (cast call "${workerRegistry}" "isEligible(address,bytes32)(bool)" $env:WORKER_ADDR $ModelId --rpc-url "${rpc}" 2>$null) if ($elig -match 'true') { return $true } - $est = (cast estimate --from $env:WORKER_ADDR "${workerRegistry}" "addSupportedModel(bytes32)" $ModelId --rpc-url "${rpc}" 2>$null) - $gas = if ($est -match '^[0-9]+$') { [int]([long]$est * 3 / 2) } else { 300000 } + $estRaw = ((cast estimate --from $env:WORKER_ADDR "${workerRegistry}" "addSupportedModel(bytes32)" $ModelId --rpc-url "${rpc}" 2>&1) | Out-String) + $est = ([regex]::Match($estRaw, '(?m)^[0-9]+$')).Value + if (-not $est) { Write-Host "⛔ the on-chain model add would revert, so it was NOT sent: $(($estRaw -replace '\\s+',' ').Trim())"; Write-Host " The worker is staked but is NOT serving the selected model. Check the model is whitelisted on this network, then run install again."; return $false } + $gas = [int]([long]$est * 3 / 2) Write-Host "▶ adding the selected model on-chain with proper gas (gas-limit $gas) - the daemon under-gasses this step" - cast send "${workerRegistry}" "addSupportedModel(bytes32)" $ModelId --private-key $env:WORKER_PRIVKEY --rpc-url "${rpc}" --gas-limit $gas *> $null - if ($LASTEXITCODE -eq 0) { Write-Host "model added on-chain (worker now serving it)"; return $true } else { Write-Host "model add failed even with estimated gas"; return $false } + $sendOut = ((cast send "${workerRegistry}" "addSupportedModel(bytes32)" $ModelId --private-key $env:WORKER_PRIVKEY --rpc-url "${rpc}" --gas-limit $gas 2>&1) | Out-String) + if ($LASTEXITCODE -ne 0) { Write-Host "⛔ the model-add tx failed to send: $(($sendOut -replace '\\s+',' ').Trim())"; return $false } + for ($i = 0; $i -lt 5; $i++) { + $elig2 = (cast call "${workerRegistry}" "isEligible(address,bytes32)(bool)" $env:WORKER_ADDR $ModelId --rpc-url "${rpc}" 2>$null) + if ($elig2 -match 'true') { Write-Host "✓ model added on-chain and verified (worker now serving it)"; return $true } + Start-Sleep -Seconds 2 + } + Write-Host "⛔ the model-add tx landed but the registry still does not list this worker as serving the model - it reverted on-chain (receipt status 0). Your stake is untouched; the worker is NOT online until this succeeds." + return $false } foreach ($p in @('${phases}')) { if (($p -like '*04-import-key*') -and $skipImport) { Write-Host "▶ phase 04-import-key (skipped - key present)"; continue }; if ($p -like '*07-register*') { $regOk = (cast call "${workerRegistry}" "isWorkerRegistered(address)(bool)" $env:WORKER_ADDR --rpc-url "${rpc}" 2>$null); $eligOk = if ($ModelId) { (cast call "${workerRegistry}" "isEligible(address,bytes32)(bool)" $env:WORKER_ADDR $ModelId --rpc-url "${rpc}" 2>$null) } else { "" }; if (($regOk -match 'true') -and ($eligOk -match 'true')) { Write-Host "▶ phase 07-register (skipped - already registered AND serving the selected model on-chain)"; continue }; if (($regOk -match 'true') -and ($eligOk -notmatch 'true')) { Write-Host "▶ phase 07-register (already staked from a prior attempt; finishing the model-add the daemon failed - no re-stake)"; if (-not (Add-SelectedModelOnchain)) { exit 1 }; continue }; if (-not (Wait-Funding)) { exit 1 } }; Write-Host "▶ phase $p"; $global:LASTEXITCODE = 0; $eapPrev = $ErrorActionPreference; $ErrorActionPreference = 'Continue'; try { if ($p -like '*07-register*') { & $p -Force 2>&1 | ForEach-Object { Write-Host $_ }; $nowReg = (cast call "${workerRegistry}" "isWorkerRegistered(address)(bool)" $env:WORKER_ADDR --rpc-url "${rpc}" 2>$null); if ($nowReg -notmatch 'true') { throw "worker not registered on-chain after the attempt" }; if (-not (Add-SelectedModelOnchain)) { throw "model add failed" } } else { & $p 2>&1 | ForEach-Object { Write-Host $_ }; if ($LASTEXITCODE -ne 0) { throw "exit code $LASTEXITCODE" } } } catch { Write-Host "⛔ stopped at $p - $($_.Exception.Message)"; exit 1 } finally { $ErrorActionPreference = $eapPrev } } # Pre-warm each served model and pin it so the first job doesn't pay a cold load. @@ -1584,18 +1857,26 @@ export function addModelsCommand(os: OS, network: NetworkId, modelsToAdd: string `Write-Host "adding model(s) on-chain: ${modelsToAdd.join(", ")} (no re-stake)..."`, // Direct WorkerRegistry.addSupportedModel(bytes32) with gas = estimate x1.5, // NOT the worker binary's add-models (which under-sets gas and OutOfGas- - // reverts - the same daemon bug that breaks a one-shot install). + // reverts - the same daemon bug that breaks a one-shot install). A reverting + // ESTIMATE means the send would revert too, so it is never sent; and because + // `cast send --gas-limit N` exits 0 even on receipt status 0, success is only + // claimed after re-reading isEligible from the registry. `$WREG = "${workerRegistry}"; $RPC = "${rpc}"; $addFail = 0`, `foreach ($M in @(${modelsToAdd.map((m) => `'${m}'`).join(",")})) {`, ` $MID = (cast keccak "$M")`, ` $elig = (cast call $WREG "isEligible(address,bytes32)(bool)" $env:WORKER_ADDR $MID --rpc-url $RPC 2>$null)`, ` if ($elig -match 'true') { Write-Host " - $M already served on-chain - skipping"; continue }`, - ` $est = (cast estimate --from $env:WORKER_ADDR $WREG "addSupportedModel(bytes32)" $MID --rpc-url $RPC 2>$null)`, - ` $gas = if ($est -match '^[0-9]+$') { [int]([long]$est * 3 / 2) } else { 300000 }`, - ` cast send $WREG "addSupportedModel(bytes32)" $MID --private-key $env:WORKER_PRIVKEY --rpc-url $RPC --gas-limit $gas *> $null`, - ` if ($LASTEXITCODE -eq 0) { Write-Host " added $M (gas limit $gas)" } else { Write-Host " failed to add $M"; $addFail = 1 }`, + ` $estRaw = ((cast estimate --from $env:WORKER_ADDR $WREG "addSupportedModel(bytes32)" $MID --rpc-url $RPC 2>&1) | Out-String)`, + ` $est = ([regex]::Match($estRaw, '(?m)^[0-9]+$')).Value`, + ` if (-not $est) { Write-Host " adding $M would revert on-chain, so nothing was sent: $(($estRaw -replace '\\s+',' ').Trim())"; $addFail = 1; continue }`, + ` $gas = [int]([long]$est * 3 / 2)`, + ` $sendOut = ((cast send $WREG "addSupportedModel(bytes32)" $MID --private-key $env:WORKER_PRIVKEY --rpc-url $RPC --gas-limit $gas 2>&1) | Out-String)`, + ` if ($LASTEXITCODE -ne 0) { Write-Host " the add tx for $M failed to send: $(($sendOut -replace '\\s+',' ').Trim())"; $addFail = 1; continue }`, + ` $seen = $false`, + ` for ($i = 0; $i -lt 5; $i++) { $e2 = (cast call $WREG "isEligible(address,bytes32)(bool)" $env:WORKER_ADDR $MID --rpc-url $RPC 2>$null); if ($e2 -match 'true') { $seen = $true; break }; Start-Sleep -Seconds 2 }`, + ` if ($seen) { Write-Host " added $M (gas limit $gas) - verified on-chain" } else { Write-Host " the add tx for $M landed but the registry still does not list it - it reverted on-chain (receipt status 0)"; $addFail = 1 }`, `}`, - `if ($addFail -ne 0) { Write-Host "one or more models failed to add - see above"; exit 1 }`, + `if ($addFail -ne 0) { Write-Host "one or more models failed to add - see above. The worker is NOT serving them; nothing else changed."; exit 1 }`, // Stop the container so the follow-up reinstall recreates it with the new // model set (the install short-circuits on a same-network worker that's Up). 'docker stop lightchain-worker *> $null; Write-Host "added on-chain - restarting the worker with the new set"', @@ -1617,16 +1898,27 @@ export function addModelsCommand(os: OS, network: NetworkId, modelsToAdd: string // subcommand, which sends with an under-set fixed gas limit and OutOfGas- // reverts (the same daemon bug that breaks a one-shot gemma install). modelId // = keccak256(exact tag). Skip a model that's already eligible on-chain. + // + // A reverting ESTIMATE is fatal for that model and is never sent (the old + // fixed-300000 fallback sent anyway), and since `cast send --gas-limit N` + // exits 0 even when the receipt is status 0, the add is only counted once the + // registry itself reports the worker eligible for it. `WREG="${workerRegistry}"`, `ADD_OK=0; ADD_FAIL=0`, `for M in ${modelsToAdd.map((m) => `"${m}"`).join(" ")}; do`, ` MID="$(cast keccak "$M")"`, ` if cast call "$WREG" "isEligible(address,bytes32)(bool)" "$WORKER_ADDR" "$MID" --rpc-url "$RPC_URL" 2>/dev/null | grep -qi true; then echo " • $M already served on-chain - skipping"; continue; fi`, - // estimate, then add a 50% buffer; fall back to a generous 300000 if estimate fails. - ` GAS_EST="$(cast estimate --from "$WORKER_ADDR" "$WREG" "addSupportedModel(bytes32)" "$MID" --rpc-url "$RPC_URL" 2>/dev/null)"; case "\${GAS_EST:-}" in ''|*[!0-9]*) GAS_LIMIT=300000;; *) GAS_LIMIT="$(python3 -c 'import sys; print(int(int(sys.argv[1])*3//2))' "$GAS_EST")";; esac`, - ` if cast send "$WREG" "addSupportedModel(bytes32)" "$MID" --private-key "$WORKER_PRIVKEY" --rpc-url "$RPC_URL" --gas-limit "$GAS_LIMIT" >/dev/null 2>&1; then echo " ✓ added $M (gas limit $GAS_LIMIT)"; ADD_OK=$((ADD_OK+1)); else echo " ⛔ failed to add $M"; ADD_FAIL=$((ADD_FAIL+1)); fi`, + ` EST_RAW="$(cast estimate --from "$WORKER_ADDR" "$WREG" "addSupportedModel(bytes32)" "$MID" --rpc-url "$RPC_URL" 2>&1)"`, + ` GAS_EST="$(printf '%s' "$EST_RAW" | grep -oE '^[0-9]+$' | tail -1)"`, + ` if [ -z "$GAS_EST" ]; then echo " ⛔ adding $M would revert on-chain, so nothing was sent: $(printf %s "$EST_RAW" | tr "\\n" " " | cut -c1-140)"; ADD_FAIL=$((ADD_FAIL+1)); continue; fi`, + // estimate + a 50% buffer (the daemon under-set it -> OutOfGas). + ` GAS_LIMIT="$(python3 -c 'import sys; print(int(int(sys.argv[1])*3//2))' "$GAS_EST")"`, + ` if ! cast send "$WREG" "addSupportedModel(bytes32)" "$MID" --private-key "$WORKER_PRIVKEY" --rpc-url "$RPC_URL" --gas-limit "$GAS_LIMIT" >/dev/null 2>&1; then echo " ⛔ the add tx for $M failed to send"; ADD_FAIL=$((ADD_FAIL+1)); continue; fi`, + ` ADD_SEEN=""`, + ` for _ in 1 2 3 4 5; do if cast call "$WREG" "isEligible(address,bytes32)(bool)" "$WORKER_ADDR" "$MID" --rpc-url "$RPC_URL" 2>/dev/null | grep -qi true; then ADD_SEEN=1; break; fi; sleep 2; done`, + ` if [ -n "$ADD_SEEN" ]; then echo " ✓ added $M (gas limit $GAS_LIMIT) - verified on-chain"; ADD_OK=$((ADD_OK+1)); else echo " ⛔ the add tx for $M landed but the registry still does not list it - it reverted on-chain (receipt status 0)"; ADD_FAIL=$((ADD_FAIL+1)); fi`, `done`, - `[ "$ADD_FAIL" = "0" ] || { echo "⛔ one or more models failed to add - see above"; exit 1; }`, + `[ "$ADD_FAIL" = "0" ] || { echo "⛔ one or more models failed to add - see above. The worker is NOT serving them; nothing else changed."; exit 1; }`, // Stop the container after a successful add so the follow-up reinstall actually // recreates it with the new model set (the install short-circuits on a // same-network worker that's still Up). @@ -1770,11 +2062,14 @@ export function preflightCommand(os: OS, network: NetworkId): string { const AICONFIG_SELECTOR = "0x85ff4862"; const MINSTAKE_SELECTOR = "0xca22dfd1"; // Preflight reports BLOCKS only for things install can't fix on its own (an - // unreachable RPC). Docker + Ollama are downgraded to WARN: install auto-installs - // and auto-starts them (winget on Windows, brew + open on macOS), so failing - // preflight on them would falsely block users from clicking Install when Install - // is exactly what would resolve the situation. The warning still tells the user - // what install will do next. + // unreachable RPC). Docker + Ollama are normally a WARN: install auto-installs + // and auto-starts them (winget on Windows, brew + open on macOS, the official + // scripts on Linux), so failing preflight on them would falsely block users from + // clicking Install when Install is exactly what would resolve the situation. The + // warning still tells the user what install will do next. The ONE exception is + // Linux with no route to root at all (not root, no passwordless sudo, no + // pkexec): there the vendor installers genuinely cannot run, so a WARN would be + // green-lighting an install that must fail - see the OS-aware probes below. if (os === "windows") { return [ '$ErrorActionPreference = "Continue"', @@ -1816,8 +2111,43 @@ export function preflightCommand(os: OS, network: NetworkId): string { "OK=1", APPIMAGE_CURL_HINT_UNIX, // if curl is still broken, name the cause + the .deb fix - 'if command -v docker >/dev/null 2>&1; then if docker info >/dev/null 2>&1; then echo "✓ Docker is running"; else echo "⚠ Docker is installed but not running - install will start Docker Desktop for you"; fi; else echo "⚠ Docker Desktop not installed - install Docker Desktop manually (the installer needs it) then re-run install"; fi', - 'if curl -s -m 5 http://127.0.0.1:11434/api/tags >/dev/null 2>&1; then echo "✓ Ollama is responding (127.0.0.1:11434)"; elif command -v ollama >/dev/null 2>&1; then echo "⚠ Ollama is installed but not responding - install will start it"; else echo "⚠ Ollama not installed - install will set it up via brew"; fi', + // OS + escalation probe for the Docker/Ollama checks below. This unix branch + // serves BOTH macOS and Linux, and used to print the macOS story on Linux + // ("install will set it up via brew") as a WARN that never clears OK - so it + // concluded "safe to install" for an install that could not possibly work. + // What actually decides it on Linux is whether we can get root at all, so the + // probe mirrors the installer's own ladder exactly (root -> sudo -n -> pkexec). + // A missing tool BLOCKS only in that genuinely unfixable case: on a root or + // passwordless box, and on macOS (where the installers are brew and must NOT + // run as root), it stays a WARN so a first-time install is never blocked. + 'PF_OS="$(uname -s)"', + 'PF_ROOT=0; if [ "$(id -u)" = "0" ] || sudo -n true 2>/dev/null || command -v pkexec >/dev/null 2>&1; then PF_ROOT=1; fi', + 'if command -v docker >/dev/null 2>&1; then', + ' if docker info >/dev/null 2>&1; then echo "✓ Docker is running"; elif [ "$PF_OS" = "Darwin" ]; then echo "⚠ Docker is installed but not running - install will start Docker Desktop for you"; else echo "⚠ Docker is installed but its engine is not running - install will try to start it (you may see an admin prompt)"; fi', + 'elif [ "$PF_OS" = "Darwin" ]; then echo "⚠ Docker not installed - install will set it up with Homebrew (brew install --cask docker)"', + 'elif [ "$PF_ROOT" = "1" ]; then echo "⚠ Docker not installed - install will set it up with the official get.docker.com script (approve the administrator prompt when it appears)"', + 'else echo "⛔ Docker is not installed, and installing it needs administrator rights this app cannot obtain (you are not root, passwordless sudo is not configured, and pkexec is unavailable). Run this once in a terminal, then re-run install: curl -fsSL https://get.docker.com | sudo sh && sudo usermod -aG docker $(id -un) && newgrp docker"; OK=0; fi', + 'if curl -s -m 5 http://127.0.0.1:11434/api/tags >/dev/null 2>&1; then echo "✓ Ollama is responding (127.0.0.1:11434)"', + 'elif command -v ollama >/dev/null 2>&1; then echo "⚠ Ollama is installed but not responding - install will start it"', + 'elif [ "$PF_OS" = "Darwin" ]; then echo "⚠ Ollama not installed - install will set it up with Homebrew (brew install ollama)"', + 'elif [ "$PF_ROOT" = "1" ]; then echo "⚠ Ollama not installed - install will set it up with the official Linux installer (approve the administrator prompt when it appears)"', + 'else echo "⛔ Ollama is not installed, and installing it needs administrator rights this app cannot obtain (you are not root, passwordless sudo is not configured, and pkexec is unavailable). Run this once in a terminal, then re-run install: curl -fsSL https://ollama.com/install.sh | sh"; OK=0; fi', + // Linux only: Ollama defaults to listening on 127.0.0.1, which the Dockerized + // worker cannot reach (it comes in via the bridge gateway), so every job would + // fail at inference. Install rebinds it - but only with root, so this is a + // BLOCK exactly when the rebind is impossible, and a WARN otherwise. + 'if [ "$PF_OS" = "Linux" ] && curl -s -m 5 http://127.0.0.1:11434/api/tags >/dev/null 2>&1; then', + ' PF_LISTEN="$(ss -ltn 2>/dev/null || netstat -ltn 2>/dev/null || true)"', + ` if printf '%s' "$PF_LISTEN" | grep -q ':11434' && ! printf '%s' "$PF_LISTEN" | grep -Fq '0.0.0.0:11434' && ! printf '%s' "$PF_LISTEN" | grep -Fq '[::]:11434' && ! printf '%s' "$PF_LISTEN" | grep -Fq '*:11434'; then`, + ' if [ "$PF_ROOT" = "1" ]; then echo "⚠ Ollama only listens on 127.0.0.1 - install will rebind it to 0.0.0.0 (admin prompt) so the worker container can reach it"; else', + ' echo "⛔ Ollama only listens on 127.0.0.1, so the Dockerized worker cannot reach it and every job would fail at inference - and this app has no way to obtain the admin rights to rebind it. Run once in a terminal, then re-run install:"', + ' echo " sudo mkdir -p /etc/systemd/system/ollama.service.d"', + ' echo " printf \'[Service]\\nEnvironment=\\"OLLAMA_HOST=0.0.0.0:11434\\"\\n\' | sudo tee /etc/systemd/system/ollama.service.d/lightnode.conf"', + ' echo " sudo systemctl daemon-reload && sudo systemctl restart ollama"', + ' OK=0', + " fi", + " fi", + "fi", `FREE_G="$(df -k "$HOME" 2>/dev/null | awk 'NR==2 {print int($4/1048576)}')"; if [ "\${FREE_G:-0}" -ge 15 ]; then echo "✓ disk: $FREE_G GB free"; else echo "⚠ disk: only \${FREE_G:-?} GB free - the model + image need ~10 GB"; fi`, `if curl -s -m 8 -X POST "${net.rpc}" -H 'content-type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}' | grep -qE '"result"'; then echo "✓ RPC reachable (${net.rpc})"; else echo "⛔ RPC unreachable (${net.rpc}) - check your connection"; OK=0; fi`, `if curl -s -m 8 -o /dev/null "${net.workerGateway}/" 2>/dev/null; then echo "✓ gateway reachable"; else echo "⚠ gateway probe inconclusive (${net.workerGateway}) - it may still admit the worker"; fi`, diff --git a/lib/subgraph.ts b/lib/subgraph.ts index a05e631..0a8449b 100644 --- a/lib/subgraph.ts +++ b/lib/subgraph.ts @@ -8,6 +8,7 @@ */ import { getAddress } from "viem"; import { NETWORKS, type NetworkId } from "./network"; +import { resolveModel } from "./model-catalog"; /** The subgraph stores checksummed addresses and is case-sensitive on `id`. */ function checksum(address: string): string { @@ -46,11 +47,24 @@ export interface Job { export interface ModelInfo { id: string; + /** + * The model tag, repaired at the boundary by `fetchModels` - see there for why + * the raw indexer value cannot be trusted. INVARIANT: when `unnamed` is falsy + * this is the exact on-chain tag and is safe to hash / pull / serve. + */ name: string; fee: string; // wei max_output_tokens: number; is_whitelisted: boolean; is_enabled: boolean; + /** + * True when `name` is a placeholder, not a tag: the registration carried no + * tag and the id is not one we know. Never hash or `ollama pull` such a name - + * doing so mints a second, bogus model id. Optional so hand-built rows (test + * fixtures, UI skeletons) stay valid and read as "named", which is correct for + * a literal someone typed. + */ + unnamed?: boolean; } const TIMEOUT_MS = 12_000; @@ -126,17 +140,43 @@ export async function fetchRecentJobs(network: NetworkId, first = 1000): Promise } } +/** + * The network's registered models, with names repaired at the boundary. + * + * A model's on-chain identity is `id = keccak256(tag)` and the registry stores + * nothing else - no tag, no name. When a model was whitelisted without its tag + * string the indexer has nothing to put in `name` and echoes the id back + * (`name === id`, true for 7 of the 10 testnet rows today). Returning that + * untouched leaks a raw 66-char hash into every consumer: the models panel, the + * worker dashboard, per-model analytics, and the size heuristics that regex over + * the name. So we repair it here, once, instead of in each renderer. + * + * Recovery is a dictionary lookup over the known catalog (hash the tags we know, + * match the digest) - keccak256 cannot be decoded. A tag we have never seen + * therefore stays unrecovered: it gets a readable placeholder and `unnamed: + * true` rather than a fabricated tag. + */ export async function fetchModels(network: NetworkId): Promise { - const data = await gql<{ modelinfos: ModelInfo[] }>( + // The wire row has no `unnamed` - that flag is ours, added below. + const data = await gql<{ modelinfos: Omit[] }>( network, `{ modelinfos { id name fee max_output_tokens is_whitelisted is_enabled } }`, ); - return data.modelinfos ?? []; + return (data.modelinfos ?? []).map((m) => { + const r = resolveModel(m.name ?? "", m.id); + // `id` is passed through verbatim: it is the subgraph's case-sensitive entity + // key and every caller joins on it (lowercased) - normalizing here would + // silently break `worker(id:)`-style round trips. + return { ...m, name: r.label, unnamed: !r.known }; + }); } /** A model a specific worker serves, joined to its registry info (name/fee/limit). */ export interface ServedModel { + /** Repaired tag, same invariant as `ModelInfo.name`: real tag unless `unnamed`. */ name: string; + /** True when `name` is a placeholder we could not resolve to a tag. */ + unnamed?: boolean; modelId: string; // keccak id, for on-chain isEligible reconciliation fee?: string; // wei maxOutput?: number; @@ -162,8 +202,16 @@ export async function fetchWorkerModels(network: NetworkId, address: string): Pr const byId = new Map(models.map((m) => [m.id.toLowerCase(), m])); return (wm.workermodels ?? []).map((w) => { const info = byId.get(w.model_id.toLowerCase()); + // `info.name` is already repaired by fetchModels, but the join can miss + // entirely: a worker can be registered for an id the registry query didn't + // return (whitelist removed, or the row is newer than the models page). So + // resolve from the id too rather than falling back to a bare hash prefix. + // An `unnamed` registry row is fed in as "" on purpose - passing its + // placeholder back would look like a real tag and re-flag it as known. + const r = resolveModel(info && !info.unnamed ? info.name : "", w.model_id); return { - name: info?.name ?? `${w.model_id.slice(0, 10)}…`, + name: r.label, + unnamed: !r.known, modelId: w.model_id, fee: info?.fee, maxOutput: info?.max_output_tokens, diff --git a/sdk/src/cli.ts b/sdk/src/cli.ts index 36ba0bd..3969615 100644 --- a/sdk/src/cli.ts +++ b/sdk/src/cli.ts @@ -399,7 +399,13 @@ async function main() { if (sub === "profitability") { const addr = positionals[2] ?? (flag("--key") || process.env.PRIVATE_KEY ? privateKeyToAccount(pickKey()).address : die("usage: lightnode worker profitability

[--model llama3-8b]")); const served = await ln.getServedModels(addr); - const modelTag = flag("--model") ?? served.find((s) => s.onchainEligible)?.name ?? served[0]?.name ?? "llama3-8b"; + // Only ever auto-pick a model whose NAME we actually have: this string is + // hashed into a model id by profitability(), so a row the registry never + // named (name === null) must be skipped rather than dragged in - picking + // by position alone would otherwise quote a different model than the + // eligible one it stood in for. + const modelTag = + flag("--model") ?? served.find((s) => s.onchainEligible && s.name)?.name ?? served.find((s) => s.name)?.name ?? "llama3-8b"; const op = readOperator(ln, addr); printJson({ model: modelTag, ...(await op.profitability({ modelTag })) }); break; diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 68cf71f..36b5b03 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -297,7 +297,12 @@ export class LightNode { const info = byId.get(r.model_id.toLowerCase()); return { modelId: r.model_id, - name: info?.name ?? null, + // null, NOT the placeholder. `ServedModel.name` is documented as "from + // the model registry, when known", and callers hash it: `lightnode + // worker profitability` falls back to this name as its --model and + // feeds it to modelId(), so leaking "unnamed 0x1234abcd…" here would + // mint a bogus id. null lets that ?? chain fall through correctly. + name: info && !info.unnamed ? info.name : null, feeWei: info?.fee, maxOutputTokens: info?.max_output_tokens, indexedActive: r.is_active, diff --git a/sdk/src/subgraph.ts b/sdk/src/subgraph.ts index b8a03f6..1a5ab51 100644 --- a/sdk/src/subgraph.ts +++ b/sdk/src/subgraph.ts @@ -1,4 +1,4 @@ -import { createPublicClient, getAddress, http, toHex, pad } from "viem"; +import { createPublicClient, getAddress, http, toHex, pad, keccak256, toBytes } from "viem"; import type { PublicClient } from "viem"; import type { NetworkConfig, Worker, Job, JobTransactions, ModelInfo, NetworkStats, WorkerModel } from "./types.js"; @@ -193,13 +193,87 @@ export async function fetchWorkerModels(cfg: NetworkConfig, address: string, tim return data.workermodels ?? []; } +/** + * Known model tags, for recovering a name the indexer could not supply. + * + * WHY A LOCAL LIST: the web app keeps the full catalog (tags + measured sizes) + * in lib/model-catalog.ts, but this package is published to npm standalone and + * compiles with `rootDir: "src"` / `include: ["src"]` - a `../lib` import is + * outside the program and would simply not exist in `dist/`. So the inversion is + * duplicated here in its minimal form: tags only, no sizes (the SDK never sizes + * a model). Add a tag in both places when the registry gains one. + * + * keccak256 is one-way, so this is a dictionary lookup over tags we KNOW, not a + * decode: hash each tag, match the digest. An id we cannot match stays + * unrecovered and is flagged - it is never guessed. + * + * Exported ONLY so tests/unit/sdk-consistency.test.ts can assert this list has + * not drifted from lib/model-catalog.ts. Not part of the package's public API. + */ +export const KNOWN_MODEL_TAGS: readonly string[] = [ + "qwen3-embedding:0.6b", + "llama3-8b", + "qwen3-vl:8b", + "gemma4:e2b", + "gpt-oss:20b", + "glm-4.7-flash", + "qwen3-vl:30b", + "llama3-70b", + "qwen3-coder-next", + "gpt-oss:120b", +]; + +/** modelId (lowercase keccak256 of the tag) -> tag. Built once, from the list. */ +const TAG_BY_ID: ReadonlyMap = new Map( + KNOWN_MODEL_TAGS.map((t) => [keccak256(toBytes(t)).toLowerCase(), t]), +); + +/** A 32-byte hex digest - i.e. what the registry uses as a model id. */ +function isModelId(s: string): boolean { + return /^0x[0-9a-fA-F]{64}$/.test(s.trim()); +} + +/** + * The real tag for one registry row, or null when we cannot recover it. + * Never returns a guess: null is a first-class answer. + */ +function recoverModelTag(name: string | undefined, id: string): string | null { + const rawId = (id ?? "").toLowerCase(); + const n = (name ?? "").trim(); + // Normal case: the indexer gave us a real tag. "Real" = anything that is not + // the id echoed back, and not a bare digest standing in for one. The SHAPE + // decides, not equality with `id`: a 32-byte digest is never a plausible tag, + // and also requiring `n === rawId` let a row whose two fields disagreed be + // returned as a "real tag" that was actually a raw hash - which callers would + // then hash again into a second, bogus model id. + const nameIsId = isModelId(n); + if (n && !nameIsId) return n; + return TAG_BY_ID.get(rawId || n.toLowerCase()) ?? null; +} + +/** + * The network's registered models, with names repaired at the boundary. + * + * A model's on-chain identity is `id = keccak256(tag)` (see modelId() in + * inference.ts) and the registry stores nothing else - no name, no size. When a + * model was whitelisted without its tag string the indexer echoes the id back as + * `name` (`name === id`, true for most testnet rows today), so returning the row + * untouched leaks a raw 66-char hash to every consumer. We invert it against the + * known tags here, once, rather than in each caller. + */ export async function fetchModels(cfg: NetworkConfig, timeoutMs?: number): Promise { - const data = await gql<{ modelinfos: ModelInfo[] }>( + const data = await gql<{ modelinfos: Omit[] }>( cfg.subgraph, `{ modelinfos { id name fee max_output_tokens is_whitelisted is_enabled } }`, timeoutMs, ); - return data.modelinfos ?? []; + return (data.modelinfos ?? []).map((m) => { + const tag = recoverModelTag(m.name, m.id); + // `id` is passed through verbatim - it is the subgraph's case-sensitive + // entity key and callers join on it. The placeholder deliberately contains a + // space and an ellipsis so it can never be mistaken for an Ollama tag. + return { ...m, name: tag ?? `unnamed ${(m.id ?? "").toLowerCase().slice(0, 10)}…`, unnamed: tag === null }; + }); } export async function fetchWorkers(cfg: NetworkConfig, first = 200, timeoutMs?: number): Promise { diff --git a/sdk/src/types.ts b/sdk/src/types.ts index 29eead2..4e37f1a 100644 --- a/sdk/src/types.ts +++ b/sdk/src/types.ts @@ -90,11 +90,25 @@ export interface JobTransactions { export interface ModelInfo { id: string; // keccak256(model tag) + /** + * Repaired by fetchModels(): the raw indexer value cannot be trusted, because + * a model whitelisted without its tag string has `name === id`. + * INVARIANT: when `unnamed` is falsy, this IS the exact on-chain tag - safe to + * pass to modelId()/inference. Otherwise it is a readable placeholder. + */ name: string; fee: string; // wei max_output_tokens: number; is_whitelisted: boolean; is_enabled: boolean; + /** + * True when `name` is a placeholder rather than a tag: the registration + * carried none and the id is not one we know. Never hash a name flagged here - + * it would mint a second, bogus model id. Optional so that the many literals + * building a ModelInfo (tests, fixtures) keep compiling; fetchModels always + * sets it explicitly. + */ + unnamed?: boolean; } /** diff --git a/tests/unit/hardware.test.ts b/tests/unit/hardware.test.ts index bab2d7c..bd029f3 100644 --- a/tests/unit/hardware.test.ts +++ b/tests/unit/hardware.test.ts @@ -1,5 +1,25 @@ import { describe, it, expect } from "vitest"; -import { inferGpu, assessMachine, workerSharePerJob, modelRequirement, modelsMemoryGb, modelsFit, detectWebGpu, type MachineInput } from "@/lib/hardware"; +import { + inferGpu, + assessMachine, + workerSharePerJob, + modelRequirement, + modelsMemoryGb, + modelsFit, + modelFitsAlone, + largestModelGb, + usableVramGb, + detectWebGpu, + OS_VRAM_OVERHEAD_GB, + UNKNOWN_MODEL_VRAM_GB, + type MachineInput, +} from "@/lib/hardware"; +import { MODEL_CATALOG, modelIdForTag } from "@/lib/model-catalog"; + +// A well-formed 32-byte digest that is NOT in the catalog. It ends in "0d9b" on +// purpose: the old name-regex parsed that as a 9B model and asserted 8GB +// "Standard" for a raw hash. Nothing may read a param count out of an id. +const UNKNOWN_ID = `0x${"0123456789abcdef".repeat(3)}deadbeefcafe0d9b`; describe("detectWebGpu", () => { it("resolves to an empty result when no WebGPU adapter is available", async () => { @@ -9,33 +29,138 @@ describe("detectWebGpu", () => { }); describe("multi-model memory gate", () => { - it("sums the resident footprint of a model set", () => { - expect(modelsMemoryGb(["llama3-8b"])).toBe(8); - expect(modelsMemoryGb(["llama3-8b", "llama3-70b"])).toBe(8 + 48); + it("sums the measured resident footprint of a model set", () => { + // Catalog numbers, not the old name-guess: llama3-8b is a 4.7GB download + // (~6.1GB resident), llama3-70b 40GB (~46.7GB) - not 8 and 48. + expect(modelsMemoryGb(["llama3-8b"])).toBe(6.1); + expect(modelsMemoryGb(["llama3-8b", "llama3-70b"])).toBe(52.8); }); it("fits only when the machine can hold the whole set warm", () => { expect(modelsFit(["llama3-8b"], 16)).toBe(true); - expect(modelsFit(["llama3-8b", "llama3-70b"], 24)).toBe(false); // needs 56, has 24 + expect(modelsFit(["llama3-8b", "llama3-70b"], 24)).toBe(false); // needs 52.8, has 24 expect(modelsFit(["llama3-8b", "llama3-70b"], 64)).toBe(true); expect(modelsFit([], 64)).toBe(false); // nothing selected expect(modelsFit(["llama3-8b"], 0)).toBe(false); // unknown machine }); + it("never calls a set with an unsized model a fit on a normal machine", () => { + // An id we can't invert is treated as the largest model we know of, so a + // 16GB card is honestly told "no" instead of a confident, wrong "yes". + expect(modelsFit([UNKNOWN_ID], 16)).toBe(false); + expect(modelsFit(["llama3-8b", UNKNOWN_ID], 24)).toBe(false); + }); }); describe("modelRequirement", () => { - it("reads the param count from the model name", () => { - expect(modelRequirement("llama3-8b").paramsB).toBe(8); - expect(modelRequirement("llama3-70b").paramsB).toBe(70); - expect(modelRequirement("gemma4:e2b").paramsB).toBe(2); // version '4' ignored, '2b' params - }); - it("tiers by size", () => { - expect(modelRequirement("gemma4:e2b").tier).toBe("light"); + it("takes measured catalog sizes over anything the name implies", () => { + // gemma4:e2b reads as 2B (4GB "Light" under the old regex-only path) but is + // an MoE with a 7.2GB download - it needs ~9GB resident, a whole GPU class up. + const gemma = modelRequirement("gemma4:e2b"); + expect(gemma.paramsB).toBe(2); // version '4' ignored, '2b' params - no left-hand boundary + expect(gemma.vramGb).toBe(9); + expect(gemma.tier).toBe("standard"); + expect(gemma.known).toBe(true); + expect(gemma.source).toBe("catalog"); + // 120B: the param table would have said 48GB; the measured peak is 59.9GB. + const big = modelRequirement("gpt-oss:120b"); + expect(big.paramsB).toBe(120); + expect(big.vramGb).toBe(59.9); + expect(big.vramGb).toBeGreaterThan(48); + expect(big.tier).toBe("server"); + expect(big.known).toBe(true); + }); + it("sizes the models whose tags carry no number at all", () => { + // "qwen3-coder-next" and "glm-4.7-flash" parse to 0 params - only the catalog + // can size them, and one of them is the biggest model on the network. + expect(modelRequirement("qwen3-coder-next").paramsB).toBe(0); + expect(modelRequirement("qwen3-coder-next").vramGb).toBe(60.2); + expect(modelRequirement("glm-4.7-flash").vramGb).toBe(17.8); + expect(modelRequirement("qwen3-coder-next").known).toBe(true); + }); + it("recovers a model handed to us as a bare on-chain id", () => { + // 7 of 10 testnet models come back from the indexer with name === id. + const req = modelRequirement(modelIdForTag("gpt-oss:120b")); + expect(req.known).toBe(true); + expect(req.entry?.tag).toBe("gpt-oss:120b"); + expect(req.vramGb).toBe(59.9); + }); + it("tiers by measured size", () => { + expect(modelRequirement("qwen3-embedding:0.6b").tier).toBe("light"); expect(modelRequirement("llama3-8b").tier).toBe("standard"); + expect(modelRequirement("llama3-8b").vramGb).toBe(6.1); expect(modelRequirement("llama3-70b").tier).toBe("server"); - expect(modelRequirement("llama3-70b").vramGb).toBe(48); + expect(modelRequirement("llama3-70b").vramGb).toBe(46.7); + expect(modelRequirement("llama3-70b").paramsB).toBe(70); + }); + it("marks an unrecoverable id unknown instead of asserting 8GB", () => { + const req = modelRequirement(UNKNOWN_ID); + expect(req.known).toBe(false); + expect(req.source).toBe("unknown"); + expect(req.paramsB).toBe(0); // the trailing "9b" of the digest is NOT a param count + expect(req.vramGb).not.toBe(8); + expect(req.tierLabel).not.toMatch(/^Standard/); + expect(req.tierLabel).toMatch(/unknown/i); + expect(req.entry).toBeUndefined(); + }); + it("assumes an unsized model is as big as the biggest one we know", () => { + // The only assumption that can't quietly overcommit a machine. + const biggestKnown = largestModelGb(MODEL_CATALOG.map((e) => e.tag)); + expect(UNKNOWN_MODEL_VRAM_GB).toBeGreaterThanOrEqual(biggestKnown); + expect(modelRequirement(UNKNOWN_ID).vramGb).toBe(UNKNOWN_MODEL_VRAM_GB); + }); + it("still estimates from the name for a model we've never measured, and says so", () => { + const req = modelRequirement("mistral-7b"); + expect(req.paramsB).toBe(7); + expect(req.vramGb).toBe(8); + expect(req.known).toBe(false); // an estimate is not a measurement + expect(req.source).toBe("name"); + expect(req.tierLabel).toMatch(/estimated from the name/i); + }); + it("parses param counts the old right-hand boundary missed, without a left-hand one", () => { + // '_' is a word char, so the old /b\b/ never matched a quant suffix. + expect(modelRequirement("llama3-8B_K_M").paramsB).toBe(8); + // The digit here is preceded by a letter ('e4b') - a left-hand boundary + // would drop it to 0 and silently size the model as unknown. + expect(modelRequirement("gemma4:e4b").paramsB).toBe(4); + expect(modelRequirement("gemma4:e4b").source).toBe("name"); }); - it("falls back to a standard assumption for unknown names", () => { - expect(modelRequirement("mystery-model").tier).toBe("standard"); +}); + +describe("swap mode vs all-resident", () => { + it("answers 'fits on its own' independently of the all-resident sum", () => { + // The set can't be held warm together, yet each half fits alone - the state + // a caller needs to offer swapping (only safe if cold-load beats the deadline). + expect(modelsFit(["llama3-8b", "gpt-oss:20b"], 16)).toBe(false); // 6.1 + 11.9 = 18 + expect(modelFitsAlone("llama3-8b", 16)).toBe(true); + expect(modelFitsAlone("gpt-oss:20b", 16)).toBe(true); + }); + it("refuses a model bigger than the machine, or an unknown machine", () => { + expect(modelFitsAlone("gpt-oss:120b", 16)).toBe(false); + expect(modelFitsAlone("gpt-oss:120b", 80)).toBe(true); + expect(modelFitsAlone("llama3-8b", 0)).toBe(false); // unknown machine + expect(modelFitsAlone(UNKNOWN_ID, 16)).toBe(false); // unsized -> never a green fit + }); + it("reports the heaviest model in a set", () => { + expect(largestModelGb(["llama3-8b", "llama3-70b"])).toBe(46.7); + expect(largestModelGb([])).toBe(0); + }); +}); + +describe("usableVramGb", () => { + it("subtracts the desktop's own VRAM claim", () => { + expect(OS_VRAM_OVERHEAD_GB).toBeGreaterThan(0); + expect(usableVramGb(16)).toBe(14.5); // 4K X11 desktop already holds ~1.5GB + expect(usableVramGb(16, 0)).toBe(16); // headless server pays nothing + }); + it("never goes negative and keeps 0 meaning 'unknown machine'", () => { + expect(usableVramGb(0)).toBe(0); + expect(usableVramGb(1)).toBe(0); + expect(usableVramGb(-4)).toBe(0); + }); + it("is opt-in - the fit helpers take availGb at face value", () => { + // 6.1 + 9 = 15.1: fits the sticker number, does NOT fit once the desktop is + // paid for. Callers choose which question they're asking. + expect(modelsFit(["llama3-8b", "gemma4:e2b"], 16)).toBe(true); + expect(modelsFit(["llama3-8b", "gemma4:e2b"], usableVramGb(16))).toBe(false); }); }); diff --git a/tests/unit/model-catalog.test.ts b/tests/unit/model-catalog.test.ts new file mode 100644 index 0000000..d1c944b --- /dev/null +++ b/tests/unit/model-catalog.test.ts @@ -0,0 +1,309 @@ +import { describe, it, expect } from "vitest"; +import { + MODEL_CATALOG, + ENTRY_BY_ID, + ENTRY_BY_TAG, + modelIdForTag, + isModelId, + resolveModel, + residentVramGb, + lookupModel, + type CatalogEntry, +} from "@/lib/model-catalog"; + +/** + * The ten model ids the testnet registry actually holds, paired with the tag + * that hashes to each one. + * + * These pins are ground truth, not expectations: the ids were read off chain and + * the tags were confirmed against them. keccak256 is one-way, so a pair is the + * only evidence that a catalog tag is spelled exactly as it was registered - one + * wrong character (":8b" vs "-8b", "4.7" vs "4-7") yields a completely unrelated + * digest and the model silently stops resolving, which is precisely the failure + * lib/model-catalog.ts exists to prevent. If a case here fails, the catalog + * drifted; do not edit the id. + * + * `downloadGb` is the summed registry.ollama.ai manifest layer size, pinned for + * the same reason: sizes here are measured, and nothing in a tag string implies + * a footprint, so a "cleanup" must never be able to re-guess one. + */ +const GROUND_TRUTH: ReadonlyArray<{ tag: string; id: string; downloadGb: number }> = [ + { tag: "qwen3-embedding:0.6b", id: "0xde701c92d38c91686d6f7f44f9b634b3adf16b8e79bb9094abfec66180a18f67", downloadGb: 0.6 }, + { tag: "llama3-8b", id: "0xf4a414fa51803433e9197f32cda96d5cb2ac8269c481eb0262fe2dd11f428848", downloadGb: 4.7 }, + { tag: "qwen3-vl:8b", id: "0xab5055d54803561873a25c21f4cc853371b17b69620b39b2ecca824c259b2ff3", downloadGb: 6.1 }, + { tag: "gemma4:e2b", id: "0x264fdec586bc9c5f17becd6ead7e43cb69aa68a9dd6dea3dbbeca8c8717325d1", downloadGb: 7.2 }, + { tag: "gpt-oss:20b", id: "0x812058e1dbc4b7ee2b5c8db96cd83bdc110740ae43d3fa4ee116e7e38e2ea802", downloadGb: 13.8 }, + { tag: "glm-4.7-flash", id: "0x35f686ade96649d2bf47e024eca280619fc80458c5cdece4804fc3f1561bd542", downloadGb: 19.0 }, + { tag: "qwen3-vl:30b", id: "0x18db253105a3231f058bd6a14970d9230a64a9e54df29e47cc5c6c355c1a84ca", downloadGb: 19.6 }, + { tag: "llama3-70b", id: "0x665d85c3b24f6a5cb91f90ec2e215d6155531158ff7ba81dfd182ecfab1dd4cf", downloadGb: 40.0 }, + { tag: "qwen3-coder-next", id: "0x2484d762220e965130f8e0c0bda116929bd8d4dd281de3c11cc93ac556ccc927", downloadGb: 51.7 }, + { tag: "gpt-oss:120b", id: "0x7519e6b291d1e88ee9c045dce2d1e9db92a3bba4ed967be12426b3c71bbc7c98", downloadGb: 65.4 }, +]; + +/** + * Catalog lookup that fails the test instead of returning undefined, so the + * assertions below stay free of non-null assertions under `strict`. + */ +function requireEntry(tag: string): CatalogEntry { + const entry = ENTRY_BY_TAG.get(tag); + if (!entry) throw new Error(`catalog is missing "${tag}"`); + return entry; +} + +describe("modelIdForTag", () => { + for (const { tag, id } of GROUND_TRUTH) { + it(`hashes ${tag} to its live registry id`, () => { + expect(modelIdForTag(tag)).toBe(id); + }); + } + + it("covers the live registry exactly - nothing missing, nothing invented", () => { + // Coverage matters in both directions. A missing tag means the UI renders a + // raw 66-char hash for a model that IS whitelisted; an extra tag means we + // offer a model no worker can actually be paid for. + expect(MODEL_CATALOG.map((e) => e.tag).sort()).toEqual(GROUND_TRUTH.map((g) => g.tag).sort()); + }); + + it("hashes the tag byte-for-byte - no case folding, no trimming", () => { + // The lookup maps lowercase their *keys*, but the digest is over the tag + // verbatim. Normalising before hashing would mint a second, bogus id that + // matches nothing on chain, so these must stay distinct. + expect(modelIdForTag("LLAMA3-8B")).not.toBe(modelIdForTag("llama3-8b")); + expect(modelIdForTag(" llama3-8b")).not.toBe(modelIdForTag("llama3-8b")); + }); + + it("always emits a lowercase 32-byte digest", () => { + // Every consumer keys maps by this string, so a mixed-case return would turn + // into silent lookup misses rather than a visible error. + for (const entry of MODEL_CATALOG) { + const id = modelIdForTag(entry.tag); + expect(isModelId(id)).toBe(true); + expect(id).toBe(id.toLowerCase()); + } + }); +}); + +describe("catalog indexes", () => { + it("gives every entry a unique id and a unique tag", () => { + // Both maps are built by reducing the array, so a duplicated tag (or the + // vanishingly unlikely keccak collision) would silently drop an entry + // instead of throwing. Size equality is the cheapest way to catch that. + expect(ENTRY_BY_ID.size).toBe(MODEL_CATALOG.length); + expect(ENTRY_BY_TAG.size).toBe(MODEL_CATALOG.length); + }); + + it("keys ENTRY_BY_ID by modelIdForTag(tag) and ENTRY_BY_TAG by the tag", () => { + for (const entry of MODEL_CATALOG) { + expect(ENTRY_BY_ID.get(modelIdForTag(entry.tag))).toBe(entry); + expect(ENTRY_BY_TAG.get(entry.tag.toLowerCase())).toBe(entry); + } + }); +}); + +describe("isModelId", () => { + it("accepts a 32-byte hex digest in either case, padding included", () => { + // GraphQL/env/CLI strings arrive with stray whitespace and inconsistent + // casing; the predicate trims and is case-insensitive on the digest body. + const id = modelIdForTag("llama3-8b"); + expect(isModelId(id)).toBe(true); + expect(isModelId(`0x${id.slice(2).toUpperCase()}`)).toBe(true); + expect(isModelId(` ${id}\n`)).toBe(true); + }); + + it("rejects tags, wrong-length hex and non-hex", () => { + // This predicate is the gate that decides "is this an id or a servable tag", + // so a false positive would send a hash to `ollama pull`. + expect(isModelId("llama3-8b")).toBe(false); + expect(isModelId("gpt-oss:20b")).toBe(false); + expect(isModelId(`0x${"a".repeat(63)}`)).toBe(false); // 31.5 bytes + expect(isModelId(`0x${"a".repeat(65)}`)).toBe(false); // 32.5 bytes + expect(isModelId("a".repeat(64))).toBe(false); // no 0x prefix + expect(isModelId(`0x${"g".repeat(64)}`)).toBe(false); // right length, not hex + expect(isModelId("")).toBe(false); + }); +}); + +describe("resolveModel", () => { + it("passes a real tag straight through", () => { + const id = modelIdForTag("gpt-oss:20b"); + const resolved = resolveModel("gpt-oss:20b", id); + expect(resolved).toMatchObject({ label: "gpt-oss:20b", tag: "gpt-oss:20b", known: true, id }); + expect(resolved.entry).toBe(requireEntry("gpt-oss:20b")); + }); + + it("recovers the tag when the indexer echoes the id back as the name", () => { + // The live testnet bug: most registrations carry no name, so the subgraph + // returns name === id verbatim. Inverting the known set is what keeps the UI + // from printing a hash and modelRequirement() from guessing "8GB / Standard". + for (const { tag, id } of GROUND_TRUTH) { + const resolved = resolveModel(id, id); + expect(resolved.known).toBe(true); + expect(resolved.tag).toBe(tag); + expect(resolved.label).toBe(tag); + expect(resolved.id).toBe(id); + expect(resolved.entry).toBe(ENTRY_BY_ID.get(id)); + } + }); + + it("recovers the tag from a bare digest even with no id alongside it", () => { + // Some call sites only have the name column. A name that is itself a digest + // must still be treated as an id, never as a tag. + const id = modelIdForTag("qwen3-vl:30b"); + expect(resolveModel(id)).toMatchObject({ label: "qwen3-vl:30b", tag: "qwen3-vl:30b", known: true, id }); + }); + + it("normalises an upper-cased id back to the lowercase map key", () => { + const id = modelIdForTag("glm-4.7-flash"); + const shouted = `0x${id.slice(2).toUpperCase()}`; + const resolved = resolveModel(shouted, shouted); + expect(resolved.tag).toBe("glm-4.7-flash"); + expect(resolved.id).toBe(id); + }); + + it("refuses to guess for an id outside the catalog", () => { + // A model whitelisted after this catalog was written. keccak is one-way, so + // we cannot recover its tag - and we must not invent one (it would be pulled + // and fail) nor surface the bare digest as if it were a name. + const unknown = modelIdForTag("some-model-registered-after-this-catalog"); + const resolved = resolveModel(unknown, unknown); + expect(resolved.known).toBe(false); + expect(resolved.tag).toBeNull(); + expect(resolved.entry).toBeUndefined(); + expect(resolved.id).toBe(unknown); + // The label is a short, human-prefixed placeholder - not the 66-char hash. + expect(resolved.label).toMatch(/^unnamed 0x[0-9a-f]{8}…$/); + expect(resolved.label).not.toBe(unknown); + expect(resolved.label.length).toBeLessThan(unknown.length); + // ...but still traceable: the prefix it shows is the real id's prefix. + expect(unknown.startsWith(resolved.label.slice("unnamed ".length, -1))).toBe(true); + }); + + it("treats an unrecognised but real tag as servable, just uncatalogued", () => { + // `known` means "this is a tag we can serve", not "this is in our catalog". + // A newly registered model with a proper name is pullable; we simply have no + // measured size for it, so `entry` stays undefined and callers fall back. + const resolved = resolveModel("mistral-next:12b", modelIdForTag("mistral-next:12b")); + expect(resolved.known).toBe(true); + expect(resolved.tag).toBe("mistral-next:12b"); + expect(resolved.entry).toBeUndefined(); + }); + + it("degrades rather than throws on an empty name", () => { + // A registration with neither name nor id is unusable, but it must not take + // the models page down - it just resolves to "not known". + const resolved = resolveModel(""); + expect(resolved.known).toBe(false); + expect(resolved.tag).toBeNull(); + }); + + it("never returns a bare digest as a servable tag, even when name and id disagree", () => { + // Regression pin. `nameIsId` used to also require `name === id`, so a row + // whose two fields were different digests fell through to the real-tag path + // and came back as { tag: <66-char hash>, known: true } - a raw hash marked + // servable, which would reach `ollama pull` and be hashed a second time into + // a bogus model id. The shape of the string alone must decide. + const nameDigest = modelIdForTag("llama3-8b"); + const idDigest = modelIdForTag("gpt-oss:20b"); + const resolved = resolveModel(nameDigest, idDigest); + expect(resolved.tag).not.toBe(nameDigest); + // `id` is the authoritative identity, so it is what we invert against. + expect(resolved.tag).toBe("gpt-oss:20b"); + expect(resolved.id).toBe(idDigest); + }); + + it("flags a digest-vs-digest row as unknown when the id is not in the catalog", () => { + // Same shape as above, but neither digest resolves. The must-not-happen + // outcome is `known: true` with a hash in `tag`. + const nameDigest = modelIdForTag("llama3-8b"); + const idDigest = modelIdForTag("some-model-registered-after-this-catalog"); + const resolved = resolveModel(nameDigest, idDigest); + expect(resolved.known).toBe(false); + expect(resolved.tag).toBeNull(); + expect(resolved.label).toMatch(/^unnamed 0x[0-9a-f]{8}…$/); + }); +}); + +describe("residentVramGb", () => { + it("prefers the measured peak over any estimate", () => { + // Measured peaks can sit well BELOW the download - gpt-oss ships MXFP4 and + // the qwen3-vl vision towers do not stay resident - so estimating anyway + // would over-reserve and wrongly disqualify machines that fit fine. + const gptOss20b = requireEntry("gpt-oss:20b"); + expect(residentVramGb(gptOss20b)).toBe(11.9); + expect(residentVramGb(gptOss20b)).toBeLessThan(gptOss20b.downloadGb); + + for (const entry of MODEL_CATALOG) { + if (entry.peakVramGb == null) continue; + expect(residentVramGb(entry)).toBe(entry.peakVramGb); + } + }); + + it("estimates conservatively when nothing was measured", () => { + for (const entry of MODEL_CATALOG) { + if (entry.peakVramGb != null) continue; + const resident = residentVramGb(entry); + // Weights alone are never enough: KV cache, context and runtime overhead + // all live in VRAM too, so an estimate must exceed the download. + expect(resident).toBeGreaterThan(entry.downloadGb); + // One decimal, so the UI does not imply precision we do not have. + expect(Math.abs(resident * 10 - Math.round(resident * 10))).toBeLessThan(1e-9); + } + // The exact estimates today, pinned so a change to the overhead formula is a + // deliberate edit rather than a silent shift in who passes the fit check. + expect(residentVramGb(requireEntry("llama3-8b"))).toBe(6.1); + expect(residentVramGb(requireEntry("gemma4:e2b"))).toBe(9); + expect(residentVramGb(requireEntry("llama3-70b"))).toBe(46.7); + expect(residentVramGb(requireEntry("qwen3-coder-next"))).toBe(60.2); + }); +}); + +describe("lookupModel", () => { + it("takes a tag or an id, whichever the caller happens to hold", () => { + const entry = requireEntry("qwen3-embedding:0.6b"); + expect(lookupModel("qwen3-embedding:0.6b")).toBe(entry); + expect(lookupModel(modelIdForTag("qwen3-embedding:0.6b"))).toBe(entry); + }); + + it("normalises the caller's whitespace and casing", () => { + const entry = requireEntry("llama3-8b"); + const id = modelIdForTag("llama3-8b"); + expect(lookupModel(" llama3-8b ")).toBe(entry); + expect(lookupModel("LLAMA3-8B")).toBe(entry); + expect(lookupModel(` 0x${id.slice(2).toUpperCase()}\n`)).toBe(entry); + }); + + it("returns undefined rather than a guess for anything unknown", () => { + expect(lookupModel("not-a-real-model")).toBeUndefined(); + expect(lookupModel(modelIdForTag("not-a-real-model"))).toBeUndefined(); + }); +}); + +describe("catalog facts", () => { + it("keeps the measured download sizes", () => { + // Pinned because the tag string cannot imply a footprint: "gemma4:e2b" is + // 7.2GB despite the "2b", and MoE models decouple params from size entirely. + for (const { tag, downloadGb } of GROUND_TRUTH) { + expect(requireEntry(tag).downloadGb).toBe(downloadGb); + } + }); + + it("marks exactly one entry as an embedding model", () => { + // Embedders cap max_output_tokens at 1 - routing chat work to one returns + // vectors, not text - so this flag is what keeps them out of the chat picker. + expect(MODEL_CATALOG.filter((e) => e.embedding).map((e) => e.tag)).toEqual(["qwen3-embedding:0.6b"]); + }); + + it("is ordered smallest download first", () => { + // The UI renders the catalog in array order, and ascending size is what makes + // "what can my machine actually run" scannable. + const sizes = MODEL_CATALOG.map((e) => e.downloadGb); + expect(sizes).toEqual([...sizes].sort((a, b) => a - b)); + }); + + it("never records a non-positive size", () => { + for (const entry of MODEL_CATALOG) { + expect(entry.downloadGb).toBeGreaterThan(0); + if (entry.peakVramGb != null) expect(entry.peakVramGb).toBeGreaterThan(0); + } + }); +}); diff --git a/tests/unit/sdk-consistency.test.ts b/tests/unit/sdk-consistency.test.ts index ba10148..174dba5 100644 --- a/tests/unit/sdk-consistency.test.ts +++ b/tests/unit/sdk-consistency.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect } from "vitest"; import { NETWORKS as APP } from "@/lib/network"; import { NETWORKS as SDK, WORKER_REGISTRY, REGISTRY_TOPICS } from "../../sdk/src/index"; +import { MODEL_CATALOG } from "@/lib/model-catalog"; +import { KNOWN_MODEL_TAGS } from "../../sdk/src/subgraph"; // The SDK mirrors the app's verified network config (rather than the app importing // the SDK, which would add build coupling). This guard fails the build if they drift, @@ -24,4 +26,21 @@ describe("lightnode-sdk stays in sync with the app's verified config", () => { expect(REGISTRY_TOPICS.registered).toMatch(/^0x[0-9a-f]{64}$/); expect(REGISTRY_TOPICS.exited).toMatch(/^0x[0-9a-f]{64}$/); }); + + /* + * The SDK carries its own copy of the model tags because it publishes to npm + * standalone: it compiles with rootDir "src" and ships only dist/, so a + * `../lib/model-catalog` import would be outside the program and absent from + * the tarball. The copy is tags-only (the SDK never sizes a model). + * + * Duplication is a deliberate packaging tradeoff, so it needs a guard: adding + * a model to lib/model-catalog.ts and forgetting the SDK list means the SDK + * silently stops recovering that model's name and hands callers an "unnamed + * 0x…" placeholder instead of a tag. Order matters too - both lists are + * hashed into id->tag maps, and a diff here is far easier to read than a + * mismatched digest later. + */ + it("SDK KNOWN_MODEL_TAGS has not drifted from lib/model-catalog.ts", () => { + expect(KNOWN_MODEL_TAGS).toEqual(MODEL_CATALOG.map((e) => e.tag)); + }); }); From dfd2bd94a798b35274b003d6413d8925a1ed984e Mon Sep 17 00:00:00 2001 From: marinom2 Date: Wed, 29 Jul 2026 08:49:55 +0300 Subject: [PATCH 2/4] chore(deps): repair the lint toolchain and recalibrate coverage floors Separate from the worker fixes so it can be reviewed (or dropped) on its own. Lint has been unrunnable on this repo independently of any change here. PR #161 (09c625b) bumped eslint 8 -> 10 and eslint-config-next 15 -> 16 together; the revert in dbe1cff rolled the production group back to Next 15 but left the dev group on the new majors. That combination cannot execute: - ESLint 10 dropped eslintrc support, but `next lint` from Next 15 still drives that API, so lint failed on "Unknown options: useEslintrc, extensions, ..." before reading a single file. - ESLint 10 also removed context.getFilename(), which the newest published eslint-plugin-react (7.37.5, pulled in transitively) still calls, and its peer range stops at ^9.7. Pinning eslint to ^9 and eslint-config-next to ^15 realigns the dev group with the production Next version, and the flat config replaces the eslintrc that ESLint 10 would have rejected anyway. `lint` now runs eslint directly rather than through the deprecated `next lint` wrapper. Coverage floors are recalibrated, NOT relaxed in substance. The same dependabot bump moved @vitest/coverage-v8 v2 -> v4, which swapped v8-to-istanbul for AST-aware remapping. It attributes branches in never-executed files far more completely, so an unchanged suite that previously reported ~78% branches now reports ~42%. The old floors are not comparable to the new measurement. These are the honest v4 numbers with ~1pt of slack. The large uncovered surfaces are sdk/src/cli.ts, sdk/src/worker.ts, lib/tauri.ts and lib/use-encrypted-inference.ts, all near 0%; covering those is the way to raise these floors. --- .eslintrc.json | 4 - app/error.tsx | 1 - eslint.config.mjs | 79 ++++ package-lock.json | 1003 +++++++++++++++++++-------------------------- package.json | 7 +- vitest.config.ts | 30 +- 6 files changed, 517 insertions(+), 607 deletions(-) delete mode 100644 .eslintrc.json create mode 100644 eslint.config.mjs diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index f5c8636..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "next/core-web-vitals", - "ignorePatterns": ["node_modules/", ".next/", "tests/e2e/", "playwright-report/", "test-results/"] -} diff --git a/app/error.tsx b/app/error.tsx index d265523..0f34651 100644 --- a/app/error.tsx +++ b/app/error.tsx @@ -6,7 +6,6 @@ import { Button } from "@/components/ui/button"; export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { useEffect(() => { - // eslint-disable-next-line no-console console.error(error); }, [error]); diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..bb6e35d --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,79 @@ +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { FlatCompat } from "@eslint/eslintrc"; + +/** + * ESLint flat config. + * + * WHY THIS REPLACED .eslintrc.json + `next lint` + * ---------------------------------------------- + * A dependabot dev-group bump (09c625b) took eslint 8 -> 10 and + * eslint-config-next 15 -> 16 in one go. The later revert (dbe1cff) rolled the + * PRODUCTION group back to Next 15 but left the dev group where it was, so the + * repo ended up on a combination that cannot run: + * + * - ESLint 10 removed eslintrc support outright, but `next lint` from Next 15 + * still drives that API - so lint died on "Unknown options: useEslintrc, + * extensions, ..." before checking a single file. CI has been red since. + * - ESLint 10 also removed `context.getFilename()`, which eslint-plugin-react + * 7.37.5 (the newest published, pulled in by eslint-config-next) still + * calls. Its peer range stops at ^9.7, so no version of it works on 10. + * + * Hence eslint ^9: it is the newest line eslint-config-next actually supports, + * and flat config is native there. `next lint` is not resurrected because it is + * deprecated and removed in Next 16; the ESLint CLI is the forward path. + * + * WHY eslint-config-next 15, NOT 16 + * --------------------------------- + * eslint-config-next tracks the Next major, and this app is on Next 15.5.18. + * The 16 line ships eslint-plugin-react-hooks v7, whose React-Compiler rules + * (set-state-in-effect, purity, preserve-manual-memoization, immutability) + * flagged 79 pre-existing patterns across the app - rules written for a compiler + * this build does not run. Pairing the config with the framework restores the + * rule set the code was actually written against. Adopting those rules is a + * deliberate refactor to do alongside a Next 16 upgrade, not a side effect of + * repairing lint. + * + * eslint-config-next 15 is eslintrc-format, so FlatCompat bridges it - this is + * the flat-config recipe from Next 15's own ESLint docs. + * + * SCOPE + * ----- + * `next lint` implicitly linted only app/, components/, lib/, src/ and pages/. + * `eslint .` would also walk sdk/, create-lightnode-app/ and desktop/, which are + * separate packages with their own toolchains and CI steps. The ignore list + * keeps the linted surface exactly what it was, so this migration changes the + * runner and not the verdict. + */ +const compat = new FlatCompat({ + baseDirectory: dirname(fileURLToPath(import.meta.url)), +}); + +const config = [ + { + ignores: [ + ".next/**", + "out/**", + "coverage/**", + "next-env.d.ts", + // Excluded by the old config too: the e2e specs run under Playwright's + // globals, not the app's. + "tests/e2e/**", + "playwright-report/**", + "test-results/**", + // Separate packages, each verified by its own CI step (`tsc -p + // sdk/tsconfig.json`, the scaffolder's own typecheck). sdk/dist is build + // output. + "sdk/**", + "create-lightnode-app/**", + "desktop/**", + "wallet/**", + "examples/**", + "scripts/**", + "public/**", + ], + }, + ...compat.extends("next/core-web-vitals"), +]; + +export default config; diff --git a/package-lock.json b/package-lock.json index 8546bdc..f93d99e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,14 +30,15 @@ "wagmi": "^2.19.5" }, "devDependencies": { + "@eslint/eslintrc": "^3.3.1", "@playwright/test": "^1.61.0", "@tailwindcss/postcss": "^4.3.1", "@types/node": "^25.9.3", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", "@vitest/coverage-v8": "^4.1.9", - "eslint": "^10.5.0", - "eslint-config-next": "^16.2.9", + "eslint": "^9.39.5", + "eslint-config-next": "^15.5.18", "husky": "^9.1.7", "tailwindcss": "^4.0.0", "typescript": "^5.7.2", @@ -76,181 +77,6 @@ "url": "https://github.com/sponsors/antfu" } }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", - "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", - "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", - "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", - "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, "node_modules/@babel/helper-string-parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", @@ -271,30 +97,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-validator-option": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", - "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", @@ -320,40 +122,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/template": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", - "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/types": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", @@ -665,68 +433,180 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.23.5", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", - "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^3.0.5", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", - "minimatch": "^10.2.4" + "minimatch": "^3.1.5" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" } }, "node_modules/@eslint/config-helpers": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", - "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.2.1" + "@eslint/core": "^0.17.0" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", - "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, "node_modules/@eslint/object-schema": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", - "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", - "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.2.1", + "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@ethereumjs/common": { @@ -1948,9 +1828,9 @@ "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.9.tgz", - "integrity": "sha512-UZi8+YT/MLgTC9nrrn2Xd4lBYv1B7lVmtWHfPcthAI5Tt/C1LuDe6DfmtCtJ+WQod3ksY4VrKSvk3oMVAnL7qw==", + "version": "15.5.22", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.22.tgz", + "integrity": "sha512-SB8PGBmpAiVmOtMOIfObnI2VA3MN3jUGg0iYTh4MO2efxQIV/IL0U8iYOok/8DldCEKARyCOJJix+Vvgzaic/Q==", "dev": true, "license": "MIT", "dependencies": { @@ -4419,6 +4299,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@rushstack/eslint-patch": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.16.1.tgz", + "integrity": "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==", + "dev": true, + "license": "MIT" + }, "node_modules/@safe-global/safe-apps-provider": { "version": "0.18.6", "resolved": "https://registry.npmjs.org/@safe-global/safe-apps-provider/-/safe-apps-provider-0.18.6.tgz", @@ -6112,13 +5999,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/esrecurse": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", - "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -6235,17 +6115,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz", - "integrity": "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", + "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.61.0", - "@typescript-eslint/type-utils": "8.61.0", - "@typescript-eslint/utils": "8.61.0", - "@typescript-eslint/visitor-keys": "8.61.0", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/type-utils": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -6258,15 +6138,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.61.0", + "@typescript-eslint/parser": "^8.65.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", "dev": true, "license": "MIT", "engines": { @@ -6274,16 +6154,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.0.tgz", - "integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", + "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.61.0", - "@typescript-eslint/types": "8.61.0", - "@typescript-eslint/typescript-estree": "8.61.0", - "@typescript-eslint/visitor-keys": "8.61.0", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", "debug": "^4.4.3" }, "engines": { @@ -6324,14 +6204,14 @@ "license": "MIT" }, "node_modules/@typescript-eslint/project-service": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.0.tgz", - "integrity": "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", + "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.61.0", - "@typescript-eslint/types": "^8.61.0", + "@typescript-eslint/tsconfig-utils": "^8.65.0", + "@typescript-eslint/types": "^8.65.0", "debug": "^4.4.3" }, "engines": { @@ -6371,14 +6251,14 @@ "license": "MIT" }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz", - "integrity": "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", + "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.61.0", - "@typescript-eslint/visitor-keys": "8.61.0" + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6389,9 +6269,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz", - "integrity": "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", + "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", "dev": true, "license": "MIT", "engines": { @@ -6406,15 +6286,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.0.tgz", - "integrity": "sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", + "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.61.0", - "@typescript-eslint/typescript-estree": "8.61.0", - "@typescript-eslint/utils": "8.61.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -6456,9 +6336,9 @@ "license": "MIT" }, "node_modules/@typescript-eslint/types": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.0.tgz", - "integrity": "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", + "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", "dev": true, "license": "MIT", "engines": { @@ -6470,16 +6350,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz", - "integrity": "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", + "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.61.0", - "@typescript-eslint/tsconfig-utils": "8.61.0", - "@typescript-eslint/types": "8.61.0", - "@typescript-eslint/visitor-keys": "8.61.0", + "@typescript-eslint/project-service": "8.65.0", + "@typescript-eslint/tsconfig-utils": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -6523,16 +6403,16 @@ "license": "MIT" }, "node_modules/@typescript-eslint/utils": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.0.tgz", - "integrity": "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", + "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.61.0", - "@typescript-eslint/types": "8.61.0", - "@typescript-eslint/typescript-estree": "8.61.0" + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6547,13 +6427,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz", - "integrity": "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", + "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/types": "8.65.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -9530,9 +9410,9 @@ } }, "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", "bin": { @@ -9606,6 +9486,13 @@ "node": ">= 8" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/aria-query": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", @@ -9950,19 +9837,6 @@ ], "license": "MIT" }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.36", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.36.tgz", - "integrity": "sha512-lVq/Df7LXlO79MVaaUHztSwWiG9oXoWHlgvNS51v8Dpd4+G4/VIy6qYePTw31nAVls33nUtnfezYeLkYAak9dg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/big.js": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-6.2.2.tgz", @@ -9995,16 +9869,16 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/braces": { @@ -10020,40 +9894,6 @@ "node": ">=8" } }, - "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, "node_modules/bs58": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz", @@ -10147,6 +9987,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/camelcase": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", @@ -11326,13 +11176,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/electron-to-chromium": { - "version": "1.5.371", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.371.tgz", - "integrity": "sha512-e9htk9mAYL6AzmkEhSvVVw7IWGSBJ/Bqdn2eRyRLrj1g6sncN4WbFt5qnILYoCktktr45pyjIrOiRvBThQ808w==", - "dev": true, - "license": "ISC" - }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -11615,16 +11458,6 @@ "benchmarks" ] }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -11639,33 +11472,33 @@ } }, "node_modules/eslint": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.5.0.tgz", - "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", "dev": true, "license": "MIT", - "workspaces": [ - "packages/*" - ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.6.0", - "@eslint/core": "^1.2.1", - "@eslint/plugin-kit": "^0.7.2", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", + "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^9.1.2", - "eslint-visitor-keys": "^5.0.1", - "espree": "^11.2.0", - "esquery": "^1.7.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -11675,7 +11508,8 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.4", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -11683,7 +11517,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://eslint.org/donate" @@ -11698,24 +11532,25 @@ } }, "node_modules/eslint-config-next": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.9.tgz", - "integrity": "sha512-olGtBrs07bQchpaJWeqbk9GaMoU0oGmN/pYNEBXSbfgKngb5uHnPe37X6tVeh6DJfaWFQildvinGEOrolo5fmw==", + "version": "15.5.22", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.22.tgz", + "integrity": "sha512-HhZsB3dvBsYwQpnymZm/Ps9NVaSYiSzDSXkR02CjyOcQutCuea3cF7NxE8i8Drs2+Dgw95WUzK2CMBIHd5YbMw==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.2.9", + "@next/eslint-plugin-next": "15.5.22", + "@rushstack/eslint-patch": "^1.10.3", + "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", - "eslint-plugin-import": "^2.32.0", + "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", - "eslint-plugin-react-hooks": "^7.0.0", - "globals": "16.4.0", - "typescript-eslint": "^8.46.0" + "eslint-plugin-react-hooks": "^5.0.0" }, "peerDependencies": { - "eslint": ">=9.0.0", + "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", "typescript": ">=3.3.1" }, "peerDependenciesMeta": { @@ -11990,39 +11825,30 @@ } }, "node_modules/eslint-plugin-react-hooks": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", - "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/core": "^7.24.4", - "@babel/parser": "^7.24.4", - "hermes-parser": "^0.25.1", - "zod": "^3.25.0 || ^4.0.0", - "zod-validation-error": "^3.5.0 || ^4.0.0" - }, "engines": { - "node": ">=18" + "node": ">=10" }, "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "node_modules/eslint-scope": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", - "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@types/esrecurse": "^4.3.1", - "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -12041,14 +11867,49 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -12087,6 +11948,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/eslint/node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -12120,31 +11994,31 @@ } }, "node_modules/espree": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", - "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.16.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^5.0.1" + "eslint-visitor-keys": "^4.2.1" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -12703,16 +12577,6 @@ "node": ">= 0.4" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -12803,19 +12667,6 @@ "node": ">=10.13.0" } }, - "node_modules/globals": { - "version": "16.4.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", - "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/globalthis": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", @@ -13114,23 +12965,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/hermes-estree": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", - "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", - "dev": true, - "license": "MIT" - }, - "node_modules/hermes-parser": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", - "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hermes-estree": "0.25.1" - } - }, "node_modules/hono": { "version": "4.12.22", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.22.tgz", @@ -13231,6 +13065,23 @@ "node": ">= 4" } }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/import-meta-resolve": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", @@ -13915,17 +13766,27 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" + "dependencies": { + "argparse": "^2.0.1" }, - "engines": { - "node": ">=6" + "bin": { + "js-yaml": "bin/js-yaml.js" } }, "node_modules/json-buffer": { @@ -14424,6 +14285,13 @@ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -15511,13 +15379,13 @@ } }, "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -15782,16 +15650,6 @@ "integrity": "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==", "license": "MIT" }, - "node_modules/node-releases": { - "version": "2.0.47", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", - "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -16134,6 +15992,19 @@ "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", "license": "MIT" }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/parse-entities": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", @@ -16793,6 +16664,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -17614,6 +17495,19 @@ "node": ">=4" } }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -18008,30 +17902,6 @@ "node": ">=14.17" } }, - "node_modules/typescript-eslint": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.0.tgz", - "integrity": "sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.61.0", - "@typescript-eslint/parser": "8.61.0", - "@typescript-eslint/typescript-estree": "8.61.0", - "@typescript-eslint/utils": "8.61.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, "node_modules/ufo": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", @@ -18203,37 +18073,6 @@ "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" } }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -18858,13 +18697,6 @@ "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", "license": "ISC" }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, "node_modules/yargs": { "version": "15.4.1", "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", @@ -18922,19 +18754,6 @@ "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/zod-validation-error": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", - "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - } - }, "node_modules/zustand": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.3.tgz", diff --git a/package.json b/package.json index 0df4f18..89240a6 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint", + "lint": "eslint .", "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", @@ -51,8 +51,9 @@ "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", "@vitest/coverage-v8": "^4.1.9", - "eslint": "^10.5.0", - "eslint-config-next": "^16.2.9", + "@eslint/eslintrc": "^3.3.1", + "eslint": "^9.39.5", + "eslint-config-next": "^15.5.18", "husky": "^9.1.7", "tailwindcss": "^4.0.0", "typescript": "^5.7.2", diff --git a/vitest.config.ts b/vitest.config.ts index d10481f..768ef29 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -19,14 +19,30 @@ export default defineConfig({ // report a misleading single-digit number. include: ["sdk/src/**/*.ts", "lib/**/*.ts"], exclude: ["**/*.test.ts", "**/*.d.ts", "sdk/dist/**"], - // Modest floors that lock in current coverage (~56% lines, ~78% branches) - // with a buffer so they aren't brittle. CI fails if a change drops below; - // raise them as coverage improves. + // Modest floors that lock in current coverage with a buffer so they aren't + // brittle. CI fails if a change drops below; raise them as coverage + // improves. + // + // RECALIBRATED for @vitest/coverage-v8 v4. The previous floors (50/70/58/ + // 50, described as "~56% lines, ~78% branches") were measured under + // coverage-v8 v2, which mapped V8 output through `v8-to-istanbul`. v3 + // replaced that with AST-aware remapping (`ast-v8-to-istanbul`), and the + // dependabot bump in 09c625b took this repo v2 -> v4 in one step. The + // remapper attributes branches in never-executed files far more + // completely, so the same suite that reported ~78% branches now reports + // ~42%. Nothing about the tests changed - only the measurement did, and + // the old numbers are not comparable to the new ones. + // + // These are the honest v4 numbers for the current suite (47.9 stmts / + // 41.9 branches / 51.3 funcs / 49.4 lines), floored with ~1pt of slack. + // The big uncovered surfaces are sdk/src/cli.ts, worker.ts, lib/tauri.ts + // and lib/use-encrypted-inference.ts, all at ~0%; covering those is the + // way to raise these, not lowering them further. thresholds: { - statements: 50, - branches: 70, - functions: 58, - lines: 50, + statements: 47, + branches: 40, + functions: 50, + lines: 48, }, }, }, From f8d3d659e0016dba44b57e5bf910d345c3ba0c77 Mon Sep 17 00:00:00 2001 From: marinom2 Date: Wed, 29 Jul 2026 09:31:22 +0300 Subject: [PATCH 3/4] fix(catalog): make resolveModel idempotent and replace guessed VRAM with measurements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems, both found by exercising the code rather than reading it. resolveModel was not idempotent, and the pipeline resolves twice. lib/subgraph resolves what the indexer sent and stores the result; the picker then resolves that stored value again. The old check asked only whether the string LOOKED like a 32-byte digest, and the placeholder from the first pass ("unnamed 0x1234abcd…") does not, so the second pass accepted it as a genuine tag and returned { tag: "unnamed 0x1234abcd…", known: true }. A placeholder marked servable is precisely what this module exists to prevent: it can be selected, staked for, and passed to `ollama pull`, and hashing it yields an id the registry never issued. The fix uses the one thing we can actually prove. Because id = keccak256(tag), a claimed name can be verified against its id instead of trusted on shape. So a name that hashes to the id is the real tag - which also means a correctly-registered model we have never seen still resolves, rather than being discarded for not being in the catalog - and anything else falls back to inverting the id, yielding known:false when we cannot recover it. Resolution is now stable under repetition, and a name/id mismatch resolves by id rather than believing a column that cannot be true. The VRAM figures were guesses and one was badly wrong. Sizes came from the Ollama manifest download scaled for overhead, which is a poor proxy for what actually occupies the card: gemma4:e2b is a 7.2GB download that sits at 1.7GB resident, because only a mixture-of-experts model's active experts stay on the GPU. The estimate said ~9GB - a 5x over-reservation that would wrongly disqualify machines it fits comfortably, and it landed the model in the wrong tier as well. Measured on an RTX 5060 Ti 16GB via Ollama's /api/ps with each model loaded and answering: qwen3-embedding:0.6b 2.3GB (est. was 2.6) qwen3-vl:8b 5.7GB (est. was 5.4) gemma4:e2b 1.7GB (est. was 9.0) gpt-oss:20b 12.7GB (est. was 11.9) All three of the small models were confirmed co-resident at 100% GPU with no CPU spill, so the numbers reflect a real serving set, not a single-model best case. The header now states that download size is not resident size and says why, so the next person does not re-derive the same wrong shortcut. Also drops two scratch test files a tooling pass left behind (tests/unit/zz-scratch-probe.test.ts, tests/unit/zzdump.test.ts). tsc clean, 595 tests pass, eslint clean, next build succeeds. --- lib/model-catalog.ts | 73 ++++++++++++++++++++------------ tests/unit/hardware.test.ts | 21 +++++---- tests/unit/model-catalog.test.ts | 57 ++++++++++++++++++++++++- 3 files changed, 115 insertions(+), 36 deletions(-) diff --git a/lib/model-catalog.ts b/lib/model-catalog.ts index a90e72d..abbe40e 100644 --- a/lib/model-catalog.ts +++ b/lib/model-catalog.ts @@ -46,15 +46,27 @@ export interface CatalogEntry { * Known tags. Sizes are measured, not inferred. * * `downloadGb` = registry.ollama.ai manifest layer sum. - * `peakVramGb` = observed peak during a benchmarked run; absent when we have - * not measured it, in which case `residentVramGb()` estimates conservatively. + * `peakVramGb` = resident VRAM reported by Ollama's /api/ps with the model + * loaded and answering (size_vram), i.e. what actually occupies the card. + * + * DOWNLOAD SIZE IS NOT RESIDENT SIZE. Treating it as one is badly wrong for + * mixture-of-experts models, where only the active experts stay on the GPU: + * gemma4:e2b is a 7.2 GB download that sits at 1.7 GB resident - a 4x + * over-estimate if you scale the download. That is why `residentVramGb()` + * only falls back to a download-derived estimate when we have no measurement, + * and why adding a measurement is always preferable to trusting the fallback. + * + * Measured on an RTX 5060 Ti 16GB (Blackwell, driver 610.43.02, CUDA 13, + * Ollama 0.32.5). Figures are the model's own resident bytes; each loaded + * model additionally costs a few hundred MB of CUDA context, so a set's real + * GPU usage runs above the sum of these numbers - budget headroom accordingly. */ export const MODEL_CATALOG: CatalogEntry[] = [ - { tag: "qwen3-embedding:0.6b", downloadGb: 0.6, peakVramGb: 2.6, embedding: true, note: "Embedding model - returns vectors, not chat text" }, + { tag: "qwen3-embedding:0.6b", downloadGb: 0.6, peakVramGb: 2.3, embedding: true, note: "Embedding model - returns vectors, not chat text" }, { tag: "llama3-8b", downloadGb: 4.7 }, - { tag: "qwen3-vl:8b", downloadGb: 6.1, peakVramGb: 5.4, note: "Vision" }, - { tag: "gemma4:e2b", downloadGb: 7.2, note: "MoE - 'e2b' is effective params, the download is larger than the name implies" }, - { tag: "gpt-oss:20b", downloadGb: 13.8, peakVramGb: 11.9, note: "Reasoning - MXFP4" }, + { tag: "qwen3-vl:8b", downloadGb: 6.1, peakVramGb: 5.7, note: "Vision" }, + { tag: "gemma4:e2b", downloadGb: 7.2, peakVramGb: 1.7, note: "MoE - only the active experts stay resident, so it costs far less VRAM than its download suggests" }, + { tag: "gpt-oss:20b", downloadGb: 13.8, peakVramGb: 12.7, note: "Reasoning - MXFP4" }, { tag: "glm-4.7-flash", downloadGb: 19.0, peakVramGb: 17.8, note: "Coding" }, { tag: "qwen3-vl:30b", downloadGb: 19.6, peakVramGb: 17.9, note: "Vision" }, { tag: "llama3-70b", downloadGb: 40.0 }, @@ -103,29 +115,38 @@ export interface ResolvedModel { } export function resolveModel(name: string, id?: string): ResolvedModel { - const rawId = (id ?? "").toLowerCase(); - // The SHAPE of the string decides, not whether it equals `id`. A 32-byte - // digest is never a plausible registered tag, so any digest in `name` is an - // echoed id. Also requiring `name === id` meant a row whose two fields - // disagreed took the real-tag path below and came back as - // `{ tag: <66-char hash>, known: true }` - a raw hash marked servable, which - // would reach `ollama pull` and be hashed a second time into a bogus id. - // That is precisely the failure this module exists to prevent. - const nameIsId = isModelId(name); + const rawId = (id ?? "").trim().toLowerCase(); + const trimmed = (name ?? "").trim(); + // The id is the identity, and because id = keccak256(tag) we can PROVE + // whether a claimed name is the real preimage rather than trusting it. That + // proof is what makes this function idempotent, which it has to be: the + // subgraph resolves once and stores the result, then the picker resolves the + // stored value again. Without the check, the placeholder produced by the + // first pass ("unnamed 0x1234abcd…") is not a digest, so a shape-only test + // would wave it through as a real tag on the second pass and hand back + // `{ tag: "unnamed 0x1234abcd…", known: true }` - a placeholder marked + // servable, which is exactly the failure this module exists to prevent. + const lookupId = isModelId(rawId) ? rawId : isModelId(trimmed) ? trimmed.toLowerCase() : ""; - // Normal case: the indexer gave us a real tag. - if (name && !nameIsId) { - const entry = ENTRY_BY_TAG.get(name.toLowerCase()); - return { label: name, tag: name, known: true, id: rawId || undefined, entry }; + if (lookupId) { + // A name that hashes to this id is the genuine tag - including for models + // we have never seen, so a correctly-registered future model still works. + if (trimmed && !isModelId(trimmed) && modelIdForTag(trimmed) === lookupId) { + return { label: trimmed, tag: trimmed, known: true, id: lookupId, entry: ENTRY_BY_TAG.get(trimmed.toLowerCase()) }; + } + // Otherwise `name` proves nothing (it is the echoed id, one of our own + // placeholders, or junk). Fall back to inverting the id against the catalog. + const entry = ENTRY_BY_ID.get(lookupId); + if (entry) return { label: entry.tag, tag: entry.tag, known: true, id: lookupId, entry }; + return { label: `unnamed ${lookupId.slice(0, 10)}…`, tag: null, known: false, id: lookupId }; } - // Degenerate case: name === id (or name is itself a bare digest). Try to - // invert it against the known tags. - const lookupId = rawId || name.toLowerCase(); - const entry = ENTRY_BY_ID.get(lookupId); - if (entry) return { label: entry.tag, tag: entry.tag, known: true, id: lookupId, entry }; - - return { label: `unnamed ${lookupId.slice(0, 10)}…`, tag: null, known: false, id: lookupId }; + // No usable id to check against - trust the indexer's name, but never a bare + // digest, which is an echoed id with the id column missing. + if (trimmed && !isModelId(trimmed)) { + return { label: trimmed, tag: trimmed, known: true, id: undefined, entry: ENTRY_BY_TAG.get(trimmed.toLowerCase()) }; + } + return { label: "unnamed model", tag: null, known: false, id: rawId || undefined }; } /** diff --git a/tests/unit/hardware.test.ts b/tests/unit/hardware.test.ts index bd029f3..dd7d668 100644 --- a/tests/unit/hardware.test.ts +++ b/tests/unit/hardware.test.ts @@ -52,12 +52,17 @@ describe("multi-model memory gate", () => { describe("modelRequirement", () => { it("takes measured catalog sizes over anything the name implies", () => { - // gemma4:e2b reads as 2B (4GB "Light" under the old regex-only path) but is - // an MoE with a 7.2GB download - it needs ~9GB resident, a whole GPU class up. + // gemma4:e2b is the case that defeats every shortcut. The name reads as 2B + // (4GB "Light" under the old regex path); the 7.2GB download scaled for + // overhead says ~9GB; the measured resident figure is 1.7GB, because only + // an MoE's active experts stay on the card. Neither guess is within 2x, so + // the measurement has to win. const gemma = modelRequirement("gemma4:e2b"); expect(gemma.paramsB).toBe(2); // version '4' ignored, '2b' params - no left-hand boundary - expect(gemma.vramGb).toBe(9); - expect(gemma.tier).toBe("standard"); + expect(gemma.vramGb).toBe(1.7); + // The tier follows the measurement, so the download-based guess would also + // have mislabelled it: at 1.7GB resident this genuinely is a "light" model. + expect(gemma.tier).toBe("light"); expect(gemma.known).toBe(true); expect(gemma.source).toBe("catalog"); // 120B: the param table would have said 48GB; the measured peak is 59.9GB. @@ -157,10 +162,10 @@ describe("usableVramGb", () => { expect(usableVramGb(-4)).toBe(0); }); it("is opt-in - the fit helpers take availGb at face value", () => { - // 6.1 + 9 = 15.1: fits the sticker number, does NOT fit once the desktop is - // paid for. Callers choose which question they're asking. - expect(modelsFit(["llama3-8b", "gemma4:e2b"], 16)).toBe(true); - expect(modelsFit(["llama3-8b", "gemma4:e2b"], usableVramGb(16))).toBe(false); + // 2.3 + 12.7 = 15.0: fits the sticker number, does NOT fit once the desktop + // is paid for. Callers choose which question they're asking. + expect(modelsFit(["qwen3-embedding:0.6b", "gpt-oss:20b"], 16)).toBe(true); + expect(modelsFit(["qwen3-embedding:0.6b", "gpt-oss:20b"], usableVramGb(16))).toBe(false); }); }); diff --git a/tests/unit/model-catalog.test.ts b/tests/unit/model-catalog.test.ts index d1c944b..1d05cb2 100644 --- a/tests/unit/model-catalog.test.ts +++ b/tests/unit/model-catalog.test.ts @@ -229,7 +229,7 @@ describe("residentVramGb", () => { // the qwen3-vl vision towers do not stay resident - so estimating anyway // would over-reserve and wrongly disqualify machines that fit fine. const gptOss20b = requireEntry("gpt-oss:20b"); - expect(residentVramGb(gptOss20b)).toBe(11.9); + expect(residentVramGb(gptOss20b)).toBe(12.7); expect(residentVramGb(gptOss20b)).toBeLessThan(gptOss20b.downloadGb); for (const entry of MODEL_CATALOG) { @@ -251,7 +251,6 @@ describe("residentVramGb", () => { // The exact estimates today, pinned so a change to the overhead formula is a // deliberate edit rather than a silent shift in who passes the fit check. expect(residentVramGb(requireEntry("llama3-8b"))).toBe(6.1); - expect(residentVramGb(requireEntry("gemma4:e2b"))).toBe(9); expect(residentVramGb(requireEntry("llama3-70b"))).toBe(46.7); expect(residentVramGb(requireEntry("qwen3-coder-next"))).toBe(60.2); }); @@ -307,3 +306,57 @@ describe("catalog facts", () => { } }); }); + +describe("resolveModel is idempotent", () => { + // Resolution happens TWICE on the same row: lib/subgraph resolves what the + // indexer sent and stores the result, then the picker resolves that stored + // value again. So resolve(resolve(x)) must equal resolve(x). A shape-only + // check fails here - the placeholder "unnamed 0x1234abcd…" is not a digest, + // so the second pass would accept it as a genuine tag and hand back + // known:true, letting a model nobody can serve be selected and staked for. + const unknownId = modelIdForTag("a-model-that-was-never-published"); + + it("keeps an unrecoverable model unrecoverable on re-resolution", () => { + const first = resolveModel(unknownId, unknownId); + expect(first.known).toBe(false); + expect(first.tag).toBeNull(); + + const second = resolveModel(first.label, unknownId); + expect(second.known).toBe(false); + expect(second.tag).toBeNull(); + expect(second.label).toBe(first.label); + }); + + it("never reports a placeholder as a servable tag", () => { + const placeholder = resolveModel(unknownId, unknownId).label; + // The placeholder must not survive as something that could reach + // `ollama pull` or be hashed a second time into an id nothing answers. + expect(resolveModel(placeholder, unknownId).tag).toBeNull(); + }); + + it("re-resolving a recovered tag is stable", () => { + const id = modelIdForTag("gpt-oss:20b"); + const first = resolveModel(id, id); + expect(first.tag).toBe("gpt-oss:20b"); + const second = resolveModel(first.label, id); + expect(second).toEqual(first); + }); + + it("accepts a correctly-registered tag we have never seen", () => { + // Future models must still work: the name is proven by hashing it, not by + // being present in our catalog. + const tag = "some-future-model:4b"; + const id = modelIdForTag(tag); + const r = resolveModel(tag, id); + expect(r.known).toBe(true); + expect(r.tag).toBe(tag); + expect(r.entry).toBeUndefined(); // known-good, but no measured sizes + }); + + it("rejects a name that does not hash to the id it came with", () => { + // Mismatched columns are corrupt data, not a tag - trusting the name here + // would register the worker for an id the registry never issued. + const r = resolveModel("llama3-8b", modelIdForTag("gpt-oss:20b")); + expect(r.tag).toBe("gpt-oss:20b"); // the id wins, and it is in the catalog + }); +}); From 551201b9c0da64bf8bae2d4fb057184c1cef12c1 Mon Sep 17 00:00:00 2001 From: marinom2 Date: Wed, 29 Jul 2026 10:19:43 +0300 Subject: [PATCH 4/4] fix(worker): close the gaps an adversarial review found in the first pass A multi-agent review of b6de6ec turned up defects the type checker, the unit suite and the Next build all pass over. Each is verified against the generated shell rather than the TypeScript that emits it. The teardown could kill the installer running it. pgrep/pkill -f match the FULL command line, and the whole install script IS the command line of the shell executing it. So the "is Docker Desktop wedged?" probe always matched itself, and the pkill that followed SIGTERMed its own shell - rc 143, with everything after it silently skipped. The probe now matches a macOS-only binary path and the kill goes to explicit pids from pgrep, never a blanket pattern. A staked worker was told to add funds it already had. diagnoseFailure keyed insufficient-funds on a substring that also appears in unrelated revert text, so a model-registration revert surfaced as "top up your wallet". The recogniser now anchors on the actual emitted strings, and says plainly whether anything was staked - that sentence is the difference between a 30-second fix and a support thread. The Windows path could still stake for a model it never pulled. The bash side gained a presence gate in the first pass; the PowerShell side kept the old warn-and-continue, so the failure just moved platform. The picker's Apply button could be permanently dead. It captured the selection in a closure that a whitelist refetch replaced, so after the fetch resolved the handler wrote a stale set - or nothing. The decision logic moved out to model-picker-logic.ts, which is pure and therefore testable: the unit environment is node and cannot import JSX at all, which is why this class of bug had no coverage. Trust in a model name is now carried, not re-derived. lib/subgraph marks a row `unnamed` when the id could not be inverted, instead of leaving every consumer to re-infer it from the label - re-inference is what let a placeholder round-trip into a servable tag (fixed in f8d3d65). sdk/src/subgraph.ts gets the same treatment and sdk-consistency.test.ts now fails if the two copies drift. Verified: tsc clean, 673 tests pass, eslint clean, next build succeeds, and `bash -n` parses all 18 generated scripts including the 663-line installer. --- components/onboard/model-picker-logic.ts | 227 +++++++++++++++ components/onboard/model-picker.tsx | 264 +++++++---------- components/update-models.tsx | 17 +- lib/install-progress.ts | 174 +++++++++-- lib/scriptgen.ts | 356 ++++++++++++++++++----- lib/subgraph.ts | 42 ++- sdk/src/subgraph.ts | 65 ++++- tests/unit/install-progress.test.ts | 167 +++++++++++ tests/unit/model-picker-logic.test.ts | 147 ++++++++++ tests/unit/scriptgen.test.ts | 191 +++++++++++- tests/unit/sdk-consistency.test.ts | 52 +++- tests/unit/subgraph.test.ts | 160 +++++++++- 12 files changed, 1580 insertions(+), 282 deletions(-) create mode 100644 components/onboard/model-picker-logic.ts create mode 100644 tests/unit/model-picker-logic.test.ts diff --git a/components/onboard/model-picker-logic.ts b/components/onboard/model-picker-logic.ts new file mode 100644 index 0000000..14b8999 --- /dev/null +++ b/components/onboard/model-picker-logic.ts @@ -0,0 +1,227 @@ +/** + * The parts of the model picker that are decisions, not markup. + * + * They live outside the .tsx because they are the parts that can be WRONG in a + * way no rendering test would catch - which selection survives a whitelist + * fetch, whether a set fits the machine - and because the unit suite runs in a + * node environment that cannot import JSX at all. Anything here must stay pure: + * no hooks, no fetch, no React. + */ +import { modelRequirement, usableVramGb, UNKNOWN_MODEL_VRAM_GB } from "@/lib/hardware"; +import { lookupModel, residentVramGb, resolveModel, type CatalogEntry } from "@/lib/model-catalog"; + +/** + * A whitelist row exactly as the indexer returns it. + * + * `id` is the on-chain identity - keccak256 of the tag - and the only thing the + * registry actually stores. `name` is a label the indexer bolts on, and when a + * model was whitelisted without its tag string there is nothing to bolt on, so + * it echoes the id back into `name`. Carrying `id` is what lets us tell a real + * tag apart from a digest wearing one's clothes. + */ +export interface LiveModel { + id: string; + name: string; + fee: string; // wei + max_output_tokens: number; + /** + * The boundary's own verdict on `name`: true when it is a placeholder rather + * than a tag (see ModelInfo.unnamed in lib/subgraph.ts). Optional because a + * hand-built row - a fixture, an older cached response - carries no flag, and + * absence must not read as "unnamed". + */ + unnamed?: boolean; +} + +/** A live model resolved to an identity we can act on. */ +export interface Row { + /** Lowercase on-chain id. Unique per registry row, so it keys the list. */ + id: string; + /** The real Ollama tag. null = the id never resolved, so it is NOT servable. */ + tag: string | null; + label: string; + fee: string; // wei + maxOut: number; + embedding: boolean; + /** Resident GB to keep it warm, or null when we cannot size it honestly. */ + gb: number | null; + /** Short descriptor for the second line, when we have one. */ + note: string | null; +} + +export type ServableRow = Row & { tag: string }; + +export function isServable(r: Row): r is ServableRow { + return r.tag !== null; +} + +/** Sort key that keeps models we cannot size at the bottom of any ordering. */ +export function sizeKey(r: Row): number { + return r.gb ?? Number.MAX_SAFE_INTEGER; +} + +/** + * Size + descriptor for a model. + * + * The catalog is measured (summed manifest layers, or an observed inference + * peak), so it wins outright. A tag that is real but absent from the catalog - + * a model whitelisted after this build shipped - falls back to hardware.ts's + * name regex, which can only size a name that carries its parameter count. + * Everything else has NO size: the registry stores none, and a keccak digest + * says nothing about the weights behind it. null means "we do not know", and + * the UI prints that instead of inventing a comfortable 8GB. + */ +function describe(tag: string | null, entry?: CatalogEntry): { gb: number | null; note: string | null } { + if (entry) return { gb: residentVramGb(entry), note: entry.note ?? null }; + if (!tag) return { gb: null, note: null }; + const req = modelRequirement(tag); + // Gate on `source`, NOT on `known`: `known` is false for a name-estimated + // model too, so testing it here would throw away the very fallback this + // branch exists for. Only "unknown" means the tag carried no size signal at + // all, and that is the case we refuse to invent a number for. + if (req.source === "unknown") return { gb: null, note: null }; + return { gb: req.vramGb, note: req.tierLabel }; +} + +export function toRow(m: LiveModel): Row { + // Carry the boundary's verdict through rather than re-deriving it from the + // string it produced. `resolveModel` is idempotent (it proves a name against + // keccak(name) === id), so re-resolving a placeholder is safe TODAY - but the + // picker should not be the thing that breaks if that ever stops holding, and + // an explicit flag is a fact where a display string is only evidence. Feeding + // "" for an unnamed row is the same convention lib/subgraph.ts uses when it + // joins a worker's models: it asks the id, and nothing else. + const unnamed = m.unnamed === true; + const r = resolveModel(unnamed ? "" : m.name, m.id); + // A row the indexer already told us has no published tag is not servable, + // whatever a second resolution makes of it - and it gets no catalog data + // either, so the row cannot end up unservable and confidently sized at once. + const tag = unnamed ? null : r.tag; + const entry = unnamed ? undefined : r.entry; + const { gb, note } = describe(tag, entry); + return { + id: r.id ?? m.id.toLowerCase(), + tag, + label: r.label, + fee: m.fee, + maxOut: m.max_output_tokens, + // The registry's own tell for an embedding model: it answers with vectors, + // so its output cap is a single token. The catalog flags the ones we know. + embedding: entry?.embedding === true || m.max_output_tokens === 1, + gb, + note, + }; +} + +/** + * Does a stored selection name this row? Setup stores TAGS (they become the + * container's SUPPORTED_MODELS), but a record written before ids and tags were + * told apart can hold the id it was shown. Match on either identity so such a + * record still lights up the right row - we always hand the TAG back out. + */ +export function names(stored: string[], r: Row): boolean { + return stored.some((v) => { + const s = v.trim().toLowerCase(); + return s === r.id || (r.tag !== null && s === r.tag.toLowerCase()); + }); +} + +/** + * Decide the selection that should be in effect once the live whitelist lands. + * + * Rules, in order: + * 1. LOCKED models survive unconditionally. They are what the worker already + * serves, so dropping one is not tidying up - it turns the update panel's + * diff into a REMOVAL, and removals are refused there (you cannot stop + * serving a model live without deregistering), which disables Apply for + * good. A locked model that has since left the whitelist, or whose tag we + * cannot recover, is kept verbatim for exactly that reason. + * 2. A non-locked selection survives only if it maps to a servable live row, + * and is re-emitted as that row's TAG - healing a record that stored an + * on-chain id, and dropping one we could never `ollama pull`. + * 3. Only when that leaves nothing do we auto-pick, and only from servable + * rows: the lightest one that fits `room`, else the lightest overall. + * + * Returns the selection to apply. An empty array means "we have nothing safe to + * offer" - the caller must then leave the existing selection alone rather than + * clearing it. + */ +export function reconcileSelection(rows: Row[], value: string[], locked: string[], room: number): string[] { + const servable = rows.filter(isServable); + const kept: string[] = []; + const push = (t: string) => { + if (!kept.some((k) => k.toLowerCase() === t.toLowerCase())) kept.push(t); + }; + + for (const v of locked) { + // Prefer the live row's tag, then the catalog's, then the string itself - + // never nothing. The first two heal a stored id into something pullable; + // the last keeps a model we cannot name in the set it is already serving. + const row = rows.find((r) => names([v], r)); + push(row?.tag ?? lookupModel(v)?.tag ?? v); + } + for (const v of value) { + const row = servable.find((r) => names([v], r)); + if (row) push(row.tag); + } + + if (kept.length > 0) return kept; + if (servable.length === 0) return []; // nothing here is safe to pick - the UI says so + // Unsized models sort last: we won't volunteer a model we cannot measure + // over one we can. + const fits: ServableRow[] = room > 0 ? servable.filter((r) => r.gb !== null && r.gb <= room) : []; + const pool: ServableRow[] = fits.length ? fits : servable; + return [pool.slice().sort((a, b) => sizeKey(a) - sizeKey(b))[0].tag]; +} + +/** What a selection costs, and how much of that cost we actually know. */ +export interface Footprint { + /** Sum of the sizes we know. A FLOOR when `unsized > 0`, not an estimate. */ + total: number; + /** How many selected models publish no size at all. */ + unsized: number; + /** + * The number the fit check must use: every unsized model charged at + * UNKNOWN_MODEL_VRAM_GB. A keccak id could be a 0.6B embedder or a 120B MoE, + * so counting it as 0 - which is what totalling only known sizes does - is + * the one assumption that can quietly overcommit the machine. + */ + worst: number; +} + +export function selectionFootprint(rows: Row[], value: string[]): Footprint { + let total = 0; + let unsized = 0; + for (const v of value) { + // Prefer the live row; a selection that is no longer whitelisted (but is + // still served by the running worker) still costs memory, so size it from + // the catalog by name rather than dropping it from the sum. + const row = rows.find((r) => names([v], r)); + const gb = row ? row.gb : describe(v, lookupModel(v)).gb; + if (gb === null) unsized += 1; + else total += gb; + } + const round = (n: number) => Math.round(n * 10) / 10; + return { total: round(total), unsized, worst: round(total + unsized * UNKNOWN_MODEL_VRAM_GB) }; +} + +/** + * What we know about this machine's memory - three states, not two. + * + * "The user told us there is no dedicated GPU" is a FACT, and reporting it as + * "your memory could not be read" both misdescribes it and switches off every + * per-model size line the CPU-only user most needs to see. Only a detection + * that came back with nothing is genuinely unknown. + */ +export type MemoryState = + | { kind: "gpu"; avail: number; usable: number } + | { kind: "cpu"; avail: 0; usable: 0 } + | { kind: "unknown"; avail: 0; usable: 0 }; + +export function memoryStateOf(vramGb: number, vramKnown: boolean): MemoryState { + if (!vramKnown) return { kind: "unknown", avail: 0, usable: 0 }; + // A read that says 0 is a machine with no discrete GPU, not a failed read. + if (!(vramGb > 0)) return { kind: "cpu", avail: 0, usable: 0 }; + // What a model can actually have. See OS_VRAM_OVERHEAD_GB in lib/hardware.ts. + return { kind: "gpu", avail: vramGb, usable: usableVramGb(vramGb) }; +} diff --git a/components/onboard/model-picker.tsx b/components/onboard/model-picker.tsx index dedbd8d..ce23ef4 100644 --- a/components/onboard/model-picker.tsx +++ b/components/onboard/model-picker.tsx @@ -1,58 +1,28 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Box, Check, CircleAlert, AlertTriangle } from "lucide-react"; -// OS_VRAM_OVERHEAD_GB / usableVramGb are imported rather than redefined here: -// the desktop's claim on VRAM is one number, and a picker that reserved a -// different amount than the fit helpers would show a plan it then contradicts. -import { modelRequirement, usableVramGb, OS_VRAM_OVERHEAD_GB } from "@/lib/hardware"; -import { lookupModel, residentVramGb, resolveModel, type CatalogEntry } from "@/lib/model-catalog"; +// OS_VRAM_OVERHEAD_GB is imported rather than redefined here: the desktop's +// claim on VRAM is one number, and a picker that reserved a different amount +// than the fit helpers would show a plan it then contradicts. +import { OS_VRAM_OVERHEAD_GB, UNKNOWN_MODEL_VRAM_GB } from "@/lib/hardware"; +// The decisions this picker makes live next door, in plain TypeScript: which +// selection survives a whitelist fetch and whether a set fits are the parts +// that can be silently wrong, and the unit suite cannot import JSX to test them. +import { + isServable, + memoryStateOf, + names, + reconcileSelection, + selectionFootprint, + sizeKey, + toRow, + type LiveModel, + type Row, +} from "@/components/onboard/model-picker-logic"; import { fromWei, cn } from "@/lib/utils"; import type { NetworkId } from "@/lib/network"; -/** - * A whitelist row exactly as the indexer returns it. - * - * `id` is the on-chain identity - keccak256 of the tag - and the only thing the - * registry actually stores. `name` is a label the indexer bolts on, and when a - * model was whitelisted without its tag string there is nothing to bolt on, so - * it echoes the id back into `name`. Carrying `id` is what lets us tell a real - * tag apart from a digest wearing one's clothes. - */ -interface LiveModel { - id: string; - name: string; - fee: string; // wei - max_output_tokens: number; -} - -/** A live model resolved to an identity we can act on. */ -interface Row { - /** Lowercase on-chain id. Unique per registry row, so it keys the list. */ - id: string; - /** The real Ollama tag. null = the id never resolved, so it is NOT servable. */ - tag: string | null; - label: string; - fee: string; // wei - maxOut: number; - embedding: boolean; - /** Resident GB to keep it warm, or null when we cannot size it honestly. */ - gb: number | null; - /** Short descriptor for the second line, when we have one. */ - note: string | null; -} - -type ServableRow = Row & { tag: string }; - -function isServable(r: Row): r is ServableRow { - return r.tag !== null; -} - -/** Sort key that keeps models we cannot size at the bottom of any ordering. */ -function sizeKey(r: Row): number { - return r.gb ?? Number.MAX_SAFE_INTEGER; -} - /** One decimal, without dragging a ".0" onto whole numbers. */ function fmtGb(n: number): string { return String(Math.round(n * 10) / 10); @@ -63,59 +33,6 @@ function fmtTokens(n: number): string { return n.toLocaleString("en-US"); } -/** - * Size + descriptor for a model. - * - * The catalog is measured (summed manifest layers, or an observed inference - * peak), so it wins outright. A tag that is real but absent from the catalog - - * a model whitelisted after this build shipped - falls back to hardware.ts's - * name regex, which can only size a name that carries its parameter count. - * Everything else has NO size: the registry stores none, and a keccak digest - * says nothing about the weights behind it. null means "we do not know", and - * the UI prints that instead of inventing a comfortable 8GB. - */ -function describe(tag: string | null, entry?: CatalogEntry): { gb: number | null; note: string | null } { - if (entry) return { gb: residentVramGb(entry), note: entry.note ?? null }; - if (!tag) return { gb: null, note: null }; - const req = modelRequirement(tag); - // Gate on `source`, NOT on `known`: `known` is false for a name-estimated - // model too, so testing it here would throw away the very fallback this - // branch exists for. Only "unknown" means the tag carried no size signal at - // all, and that is the case we refuse to invent a number for. - if (req.source === "unknown") return { gb: null, note: null }; - return { gb: req.vramGb, note: req.tierLabel }; -} - -function toRow(m: LiveModel): Row { - const r = resolveModel(m.name, m.id); - const { gb, note } = describe(r.tag, r.entry); - return { - id: r.id ?? m.id.toLowerCase(), - tag: r.tag, - label: r.label, - fee: m.fee, - maxOut: m.max_output_tokens, - // The registry's own tell for an embedding model: it answers with vectors, - // so its output cap is a single token. The catalog flags the ones we know. - embedding: r.entry?.embedding === true || m.max_output_tokens === 1, - gb, - note, - }; -} - -/** - * Does a stored selection name this row? Setup stores TAGS (they become the - * container's SUPPORTED_MODELS), but a record written before ids and tags were - * told apart can hold the id it was shown. Match on either identity so such a - * record still lights up the right row - we always hand the TAG back out. - */ -function names(stored: string[], r: Row): boolean { - return stored.some((v) => { - const s = v.trim().toLowerCase(); - return s === r.id || (r.tag !== null && s === r.tag.toLowerCase()); - }); -} - /** * Choose which model(s) the worker serves. The list is the selected network's * live whitelist (so it grows as the registry adds models). A worker can serve @@ -150,6 +67,36 @@ export function ModelPicker({ const [models, setModels] = useState([]); const [loading, setLoading] = useState(true); + const mem = useMemo(() => memoryStateOf(vramGb, vramKnown), [vramGb, vramKnown]); + const onGpu = mem.kind === "gpu"; + const { avail, usable } = mem; + + // Mirrors of the mutable props, for the reconcile below. + // + // That reconcile runs inside an async `.then`, and the effect around it + // deliberately re-runs only when the NETWORK changes - refetching the whole + // whitelist on every click would be absurd. A plain closure therefore pins + // the MOUNT render's props forever, and in the update panel those are + // `value: []` with `locked: []`: the fetch then found nothing to keep, + // auto-picked the lightest model, and emitted it as the whole selection. + // That reads downstream as one addition plus N removals of the models the + // worker is actually serving, and removals disable Apply permanently. Refs + // are the window onto the CURRENT render; adding the props to the dep array + // is not a fix, because it would re-fetch on every selection change. + // + // Assigned during render rather than in an effect on purpose: a promise + // resolving between render and the passive-effect flush would otherwise read + // one render behind. They are pure mirrors of props, so re-assigning them is + // idempotent and safe to repeat. + const valueRef = useRef(value); + const lockedRef = useRef(locked); + const onChangeRef = useRef(onChange); + const roomRef = useRef(usable); + valueRef.current = value; + lockedRef.current = locked; + onChangeRef.current = onChange; + roomRef.current = usable; + useEffect(() => { let on = true; setLoading(true); @@ -165,40 +112,29 @@ export function ModelPicker({ name: m.name, fee: m.fee, max_output_tokens: m.max_output_tokens, + // The boundary already decided whether `name` is a tag or a + // placeholder - carry that verdict instead of re-deriving it. + unnamed: m.unnamed, })); setModels(live); - // Reconcile the current selection against what's live. Only rows whose - // tag we recovered can survive: a selection we cannot name is one we - // cannot `ollama pull`, and staking for it registers an id the worker - // will never serve. Re-emitting the TAG also heals a stored id. + // Reconcile the selection against what's live: heal a stored id into a + // pullable tag, drop anything we could never `ollama pull`, keep every + // locked model whatever happens. Reading the refs, not the closure. const rows = live.map(toRow); - const servable = rows.filter(isServable); - if (servable.length === 0) return; // nothing here is safe to pick - say so in the UI - const kept: string[] = []; - for (const v of value) { - const row = servable.find((r) => names([v], r)); - if (row && !kept.includes(row.tag)) kept.push(row.tag); - } - if (kept.length === 0) { - // Auto-pick the lightest model that actually fits, from the servable - // set only. Unsized models sort last: we won't volunteer a model we - // cannot measure over one we can. - const room = vramKnown ? usableVramGb(vramGb) : 0; - const fits: ServableRow[] = room > 0 ? servable.filter((r) => r.gb !== null && r.gb <= room) : []; - const pool: ServableRow[] = fits.length ? fits : servable; - const best = pool.slice().sort((a, b) => sizeKey(a) - sizeKey(b))[0]; - onChange([best.tag]); - } else if (kept.join(",") !== value.join(",")) { - onChange(kept); - } + const next = reconcileSelection(rows, valueRef.current, lockedRef.current, roomRef.current); + // An empty result means nothing here was safe to offer - leave the + // caller's selection alone rather than clearing it. + if (next.length > 0 && next.join(",") !== valueRef.current.join(",")) onChangeRef.current(next); }) .catch(() => {}) .finally(() => on && setLoading(false)); return () => { on = false; }; - // eslint-disable-next-line react-hooks/exhaustive-deps + // No exhaustive-deps suppression any more: everything mutable this effect + // touches goes through a ref, so `network` really is the whole dependency + // set. The suppression is what used to hide the stale closure above. }, [network]); // Servable models first, then smallest first - so the rows a user can act on @@ -209,27 +145,13 @@ export function ModelPicker({ [models], ); - const memKnown = vramKnown && vramGb > 0; - const avail = memKnown ? vramGb : 0; - // What a model can actually have. See OS_VRAM_OVERHEAD_GB in lib/hardware.ts. - const usable = memKnown ? usableVramGb(avail) : 0; - - const selection = useMemo(() => { - let total = 0; - let unsized = 0; - for (const v of value) { - // Prefer the live row; a selection that is no longer whitelisted (but is - // still served by the running worker) still costs memory, so size it from - // the catalog by name rather than dropping it from the sum. - const row = rows.find((r) => names([v], r)); - const gb = row ? row.gb : describe(v, lookupModel(v)).gb; - if (gb === null) unsized += 1; - else total += gb; - } - return { total: Math.round(total * 10) / 10, unsized }; - }, [value, rows]); + const selection = useMemo(() => selectionFootprint(rows, value), [value, rows]); - const over = memKnown && selection.total > usable; + // Checked against the WORST case, not the known sum: a model we cannot size + // adds 0 to `total`, so gating on that total is how an unsized selection made + // itself invisible to this warning. `worst` charges each one the largest + // footprint we know of, which is what UNKNOWN_MODEL_VRAM_GB is for. + const over = onGpu && selection.worst > usable; const noneServable = rows.length > 0 && !rows.some(isServable); const toggle = (r: Row) => { @@ -269,7 +191,7 @@ export function ModelPicker({ const isLocked = names(locked, r); // Measured against USABLE memory, not the sticker total - a model // that only fits the total is the one that gets evicted mid-job. - const tooBig = memKnown && r.gb !== null && r.gb > usable; + const tooBig = onGpu && r.gb !== null && r.gb > usable; // Never let one click stake for a model this machine cannot hold, // or for an id we cannot turn into a pullable tag. An oversized // model that is ALREADY selected stays clickable so it can be dropped. @@ -323,7 +245,11 @@ export function ModelPicker({ {r.gb === null ? ( size unknown - ) : !memKnown ? ( + ) : mem.kind === "cpu" ? ( + // No GPU to fit it into: the number is real, it just + // lands in system RAM. State it, don't judge it. + ~{fmtGb(r.gb)}GB in system RAM + ) : !onGpu ? ( // We know the model's footprint but not the machine's, // so state the number without a verdict on the fit. ~{fmtGb(r.gb)}GB resident @@ -385,36 +311,62 @@ export function ModelPicker({ Memory to keep {value.length === 1 ? "it" : "them all"} warm - ~{fmtGb(selection.total)}GB{memKnown && ` of ~${fmtGb(avail)}GB`} + {/* "at least" because an unsized model contributes nothing to + this sum - the figure is a floor, and saying "~" would sell + it as an estimate. */} + {selection.unsized > 0 && at least } + ~{fmtGb(selection.total)}GB{onGpu && ` of ~${fmtGb(avail)}GB`}
{over && (

- These models need about {fmtGb(selection.total)}GB resident at once, but only about {fmtGb(usable)}GB is - usable here. They would cold-load between jobs and risk a slash. Deselect one, or run them on a bigger - machine. + {selection.unsized > 0 ? ( + + {selection.unsized === 1 ? "One selected model publishes" : `${selection.unsized} selected models publish`} no + size, so this set cannot be shown to fit: a model we cannot measure could need anything up to ~ + {fmtGb(UNKNOWN_MODEL_VRAM_GB)}GB, and only about {fmtGb(usable)}GB is usable here. Pick models with a + known footprint, or verify by hand before you install - a set that cold-loads mid-job risks a slash. + + ) : ( + + These models need about {fmtGb(selection.total)}GB resident at once, but only about {fmtGb(usable)}GB + is usable here. They would cold-load between jobs and risk a slash. Deselect one, or run them on a + bigger machine. + + )}

)} {/* The card's total is never all yours - be explicit about what is left. The `over` warning already quotes the usable figure, so only state it here when that warning isn't showing. */} - {memKnown && !over && ( + {onGpu && !over && (

Your desktop session (compositor, browser, this app) holds roughly {fmtGb(OS_VRAM_OVERHEAD_GB)}GB of that, so plan against about {fmtGb(usable)}GB.

)} - {!memKnown && ( + {/* Three states, not two: "you told us there is no GPU" is a fact + we should repeat back, not report as a failed reading. */} + {mem.kind === "cpu" && ( +

+ No dedicated GPU, so these run on the CPU out of system RAM - the sizes above are what they need there, + and there is no VRAM figure to check them against. Expect slow inference, which can miss a job deadline. +

+ )} + {mem.kind === "unknown" && (

This machine's memory could not be read, so nothing above is checked against it. Confirm the set fits before you install.

)} - {selection.unsized > 0 && ( + {/* When `over` is showing it has already made this point, in stronger terms. */} + {selection.unsized > 0 && !over && (

{selection.unsized === 1 ? "One selected model publishes" : `${selection.unsized} selected models publish`} no - size, so {selection.unsized === 1 ? "it is" : "they are"} not counted in that total. + size, so {selection.unsized === 1 ? "it is" : "they are"} not in that total - treat it as a floor.{" "} + {selection.unsized === 1 ? "It" : "They"} could need up to ~{fmtGb(UNKNOWN_MODEL_VRAM_GB)}GB + {selection.unsized === 1 ? "" : " each"}, which is the largest model we know of.

)} {!over && value.length > 1 && ( diff --git a/components/update-models.tsx b/components/update-models.tsx index 79aca83..1a890d0 100644 --- a/components/update-models.tsx +++ b/components/update-models.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import { Boxes, Loader2, CheckCircle2, XCircle } from "lucide-react"; +import { Boxes, Loader2, CheckCircle2, XCircle, AlertTriangle } from "lucide-react"; import { privateKeyToAccount } from "viem/accounts"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -149,6 +149,16 @@ export function UpdateModels() { // back to a tag would register a second, meaningless model on chain and stake // against it - refuse rather than sign that. const canApply = additions.length > 0 && removals.length === 0 && !additions.some(isModelId); + // A disabled button with no reason is indistinguishable from a broken one - + // and this panel spent a release genuinely broken (the picker used to drop + // the locked set on mount, which made `removals` non-empty and left Apply + // dead with nothing on screen to explain it). Say which guard is holding. + const blockedReason = + removals.length > 0 + ? `Your selection drops ${removals.join(", ")}. Models can only be added here - deregister and reinstall to serve a smaller set.` + : additions.some(isModelId) + ? "One of the models you picked has no published name, only an on-chain id. It can't be pulled or registered from here." + : null; const apply = useCallback(async () => { if (!canApply) return; @@ -224,6 +234,11 @@ export function UpdateModels() { worker (Operations above), then reinstall and pick the model set you want. Removing one while registered isn't safe, so it can't be unselected here.

+ {blockedReason && ( +

+ {blockedReason} +

+ )} diff --git a/lib/install-progress.ts b/lib/install-progress.ts index 49050fa..117079a 100644 --- a/lib/install-progress.ts +++ b/lib/install-progress.ts @@ -121,15 +121,24 @@ function explorerFor(net: "mainnet" | "testnet" | null): string { return `https://${net === "testnet" ? "testnet" : "mainnet"}.lightscan.app`; } -// Root/privilege refusals from the prerequisite stage. The installer shells out to -// the vendor install scripts (get.docker.com, ollama.com/install.sh) and both -// elevate with sudo - but the app runs them with no controlling terminal, so sudo -// can't prompt and dies printing one of its own `sudo: …` lines. The remaining -// alternatives are those scripts' and pkexec's own refusals. Anchored deliberately: -// a bare /sudo/ would also match the advice text the installer itself prints -// ("run once in a terminal: sudo mkdir -p …"), which is not a failure. +// Root/privilege refusals from the prerequisite stage. +// +// The sudo-flavoured alternatives below are no longer what fires on Linux: since the +// no-hang escalation ladder (`as_root` in lib/scriptgen.ts - root -> `sudo -n` -> pkexec) +// every rung SWALLOWS its own stderr (`sudo -n true 2>/dev/null`, and a dismissed pkexec +// dialog says nothing at all), so not one `sudo: …` line reaches the log on a machine +// where sudo needs a password. What DOES reach it is the installer's own aborts and +// polkit's wording, so those lead the alternation: +// "… needs administrator rights this app cannot obtain here …" (can_root false) +// "… the administrator prompt was declined, or no polkit agent is running …" +// "… (the admin prompt was declined or unavailable)" (the bind retry) +// pkexec/polkit themselves: "Request dismissed", "No authentication agent found", +// "Error executing command as another user: Not authorized". +// The sudo/tty alternatives are kept because macOS still elevates through brew + sudo. +// Anchored deliberately: a bare /sudo/ would also match the advice text the installer +// itself prints ("run once in a terminal: sudo mkdir -p …"), which is not a failure. const PRIVILEGE_FAIL_RE = - /\bsudo:|superuser permissions|please re-run as root|a password is required|no tty present|a terminal is required to read the password|ability to run commands as root|\bpkexec\b/i; + /needs administrator rights this app cannot obtain|admin(?:istrator)? prompt was declined|no polkit agent is running|request dismissed|no authentication agent found|error executing command as another user|\bsudo:|superuser permissions|please re-run as root|a password is required|no tty present|a terminal is required to read the password|ability to run commands as root|\bpkexec\b/i; /** * Turn a known install failure into one plain-English, actionable sentence (shown @@ -144,27 +153,130 @@ export function diagnoseFailure(cleaned: string[]): string | null { // only a true thing to say while this is false. `online` guards the fallbacks. const inRegisterPath = /phase\s*\.?\\?\/?0?7[- ]register|worker:latest\s+(?:status|register)|stopped at .*07-register/i.test(text); const online = /worker online|✅\s*worker/i.test(text); + // FIRST, because it is the one failure where getting the message wrong costs the + // operator money: the installer's OWN gas-corrected on-chain model add failed. + // `add_selected_model_onchain` (bash) / `Add-SelectedModelOnchain` (ps1) print one + // of three aborts, and NONE of them contains the old "model add failed" wording + // this used to key on (that string now survives only as the PowerShell throw text, + // kept as the last alternative): + // "⛔ the on-chain model add would revert, so it was NOT sent: …" + // "⛔ the model-add tx failed to send: …" + // "⛔ the model-add tx landed but the registry still does not list this worker + // as serving the model - it reverted on-chain (receipt status 0)." + // Both callers only ever reach that function on a REGISTERED worker: the fresh path + // runs it after isWorkerRegistered() has come back true, and the re-run path enters + // it precisely BECAUSE the worker was already registered ("phase 07-register (already + // staked from a prior attempt; finishing the model-add the daemon failed - no + // re-stake)"). So the stake exists whenever these lines appear - which is exactly + // why this must win: that re-run branch `continue`s past gate_funding, so + // `fundingConfirmed` stays false and the generic register fallback at the bottom + // would tell an operator holding a full stake to "top up … and run install again". + const modelAdd = text.match( + /the on-chain model add would revert|the model-add tx failed to send|the model-add tx landed but the registry|model add failed/i, + ); + if (modelAdd) { + const why = /would revert/i.test(modelAdd[0]) + ? "The add was simulated first and would have reverted, so it was never sent and no gas was spent - which almost always means this network's registry doesn't whitelist that exact model. " + : /failed to send/i.test(modelAdd[0]) + ? "The add transaction never made it onto the network; the send error is quoted on that line in the log below. " + : /landed but the registry/i.test(modelAdd[0]) + ? "The add transaction was mined but reverted on-chain, so the registry still doesn't list this worker as serving the model. " + : ""; + return ( + "Your worker is already staked and registered on-chain - your stake is locked, not lost, and it does NOT need " + + "topping up to fix this. The only thing that didn't land is attaching the model to the worker. " + + why + + "Finish from the dashboard: open “Models this worker serves” and add it there - that path works on an " + + "already-registered worker, so there is no re-stake and no reinstall. (llama3-8b is listed on every network if " + + "you need a safe fallback.) The worker earns nothing until it serves at least one model, so do this before " + + "leaving it running." + ); + } + // Terminal bind gate (Linux only). The worker runs in Docker and reaches Ollama over + // the bridge gateway, so a loopback-only Ollama means EVERY job fails at inference - + // the installer refuses to stake into that. Matched on the clause that only the two + // ⛔ aborts carry ("…, so the Dockerized worker cannot reach it"): the preflight also + // prints a ⚠ "Ollama only listens on 127.0.0.1 - install will rebind it" line, which + // is a plan, not a failure, and must not trigger this. Both aborts happen inside + // SMART_PREREQS, which scriptgen emits before the phase loop, so nothing is staked. + if (/only listens on 127\.0\.0\.1, so the Dockerized worker cannot reach it/i.test(text)) { + // The auto-rebind asks for root; when that prompt is refused this is the aftermath, + // so name the real cause rather than leaving the operator to guess. + const declined = /could not rebind Ollama automatically/i.test(text) + ? "LightNode tried to fix this itself and the administrator prompt was declined or never appeared. " + : ""; + return ( + "Ollama is only listening on 127.0.0.1, so the worker - which runs inside Docker - cannot reach it and every " + + "job would fail at inference. " + + declined + + "Nothing was staked. Run these once in a terminal, then run install again: sudo mkdir -p " + + "/etc/systemd/system/ollama.service.d && printf '[Service]\\nEnvironment=\"OLLAMA_HOST=0.0.0.0:11434\"\\n' | " + + "sudo tee /etc/systemd/system/ollama.service.d/lightnode.conf && sudo systemctl daemon-reload && sudo " + + "systemctl restart ollama" + ); + } + // Terminal model-presence gate: the pull reported success (or only warned) but Ollama + // still doesn't have the tag under its exact on-chain name. Registering here would + // stake LCAI on a worker that fails - and can be slashed for - every job it wins, so + // the installer stops. This also runs before the phase loop: funds really are untouched. + const missingModels = text.match(/these selected model\(s\) are NOT on this machine after the download:(.*)$/im); + if (missingModels) { + const names = missingModels[1].trim().split(/\s+/).filter(Boolean); + const which = names.length ? ` (${names.join(", ")})` : ""; + // Size is the actionable part when the cause is disk, same reasoning as the + // download-failure branch below - quote it when the catalog knows the model. + const entry = names.length ? lookupModel(names[0]) : undefined; + const sizeBit = entry ? ` ${entry.tag} is a ${entry.downloadGb} GB download, so check you have that much free.` : ""; + return ( + `Ollama still doesn't have the selected model${which} after the download, so the install stopped instead of ` + + "staking a worker that would fail every job it wins. Nothing was staked or registered and your funds are " + + "untouched." + + sizeBit + + " Run `ollama pull` for it in a terminal followed by `ollama list` to see the real error (usually out of disk, " + + "out of memory, or a tag that doesn't exist in the Ollama registry), then run install again - or go back and " + + "pick a smaller model." + ); + } // Prerequisite stage, and by far the most likely Linux failure: the vendor install - // scripts need root. `set -e` aborts the whole run here, before a single on-chain - // call is made, so the reassurance is unconditionally true. Docker is installed - // before Ollama, so an "installing Ollama" marker means Docker's own install + // scripts need root, and on a machine where sudo wants a password the ONLY prompt + // LightNode can raise is the graphical polkit one. Everything here is emitted from + // SMART_PREREQS, which scriptgen places before gate_funding and 07-register, and + // `set -e` aborts the whole run - so the reassurance is unconditionally true. + // Docker is installed before Ollama, so an Ollama marker means Docker's own install // already succeeded - test that one first to attribute the failure correctly. if (PRIVILEGE_FAIL_RE.test(text)) { - if (/installing Ollama/i.test(text)) { + if (/installing Ollama|the Ollama install \(or the 0\.0\.0\.0 bind that follows it\) did not complete|Ollama is not installed, and installing it needs administrator rights/i.test(text)) { return ( - "Installing Ollama needs administrator rights and it has no terminal here to ask for your password. " + + "Installing Ollama needs administrator rights, and the prompt for them was declined or never appeared - " + + "LightNode can only ask through the graphical admin (polkit) dialog, never a password prompt you can't see. " + "Open a terminal and run: curl -fsSL https://ollama.com/install.sh | sh - then run install again and " + "LightNode will skip straight past this step. Nothing was staked." ); } - if (/installing Docker/i.test(text)) { + if (/installing Docker|the Docker install did not complete|Docker is not installed, and installing it needs administrator rights/i.test(text)) { return ( - "Installing Docker needs administrator rights and it has no terminal here to ask for your password. " + - "Open a terminal and run: curl -fsSL https://get.docker.com | sh && sudo usermod -aG docker $USER - then " + - "log out and back in (that is what lets LightNode drive Docker without root) and run install again. " + + "Installing Docker needs administrator rights, and the prompt for them was declined or never appeared - " + + "LightNode can only ask through the graphical admin (polkit) dialog, never a password prompt you can't see. " + + "Open a terminal and run: curl -fsSL https://get.docker.com | sudo sh && sudo usermod -aG docker $(id -un) - " + + "then log out and back in (that is what lets LightNode drive Docker without root) and run install again. " + "Nothing was staked." ); } + // Anything else privilege-shaped falls through to the catch-all at the very bottom + // of this function - NOT here. The Ollama rebind logs "⚠ could not rebind Ollama + // automatically (the admin prompt was declined or unavailable)" and then carries on, + // so a privilege string can be sitting in the log of a run that actually died of + // something else entirely; every specific recogniser has to get first refusal. + } + // Docker installed fine but the group membership it granted isn't live in this + // session, so the socket is still refused. A relog is the only fix; the installer + // stops here rather than dying four minutes later in the engine wait. + if (/only just added to the .?docker.? group/i.test(text)) { + return ( + "Docker is installed and running, but your user was only just added to the 'docker' group and Linux applies " + + "group changes at login - so this session still can't reach it. Log out and back in (or reboot), then run " + + "install again. Nothing was staked." + ); } if (/AddSupportedModel\b.*\brevert/i.test(text)) { return ( @@ -175,20 +287,6 @@ export function diagnoseFailure(cleaned: string[]): string | null { "or reinstall needed. (If it still won’t take, llama3-8b is the safe fallback.)" ); } - // Our OWN gas-corrected addSupportedModel failed - "model add failed even with - // estimated gas" (bash) / "stopped at …07-register.ps1 - model add failed" (ps1). - // The install only calls it once 07-register has succeeded, so reaching this line - // proves the stake landed. Say that plainly here, or the generic register fallback - // further down would tell an already-staked operator to top up and re-run. - if (/model add failed/i.test(text)) { - return ( - "Your worker is staked and registered on-chain - the only thing that didn’t land is attaching the model to it " + - "(your stake is locked, not lost). This attempt already sent proper gas, so gas isn’t the cause; the usual " + - "reason is that this network’s registry doesn’t list that exact model. Finish from the dashboard: open " + - "“Models this worker serves” and add it there - that works on an already-registered worker, so there’s no " + - "re-stake and no reinstall. (llama3-8b is listed on every network if you need a safe fallback.)" - ); - } // The dashboard's add-model-on-chain run, not an install: "⛔ failed to add " // / "one or more models failed to add". That script only ever runs against a worker // that is ALREADY registered and it never stakes, so the honest framing is "nothing @@ -313,6 +411,20 @@ export function diagnoseFailure(cleaned: string[]): string | null { "where it stopped, so a retry doesn’t re-download what you already have. Nothing was staked." ); } + // Catch-all for a privilege refusal we couldn't attribute to Docker or Ollama by name + // (a pkexec/polkit line on its own, an unrecognised escalation abort). Runs dead last + // so it can never outrank a specific recogniser: the optional Ollama rebind leaves + // "⚠ could not rebind Ollama automatically (the admin prompt was declined…)" in the log + // of runs that then fail for a completely different reason. Same money guard as the + // download branch - `!inRegisterPath && !online` is what makes "nothing was staked" true. + if (PRIVILEGE_FAIL_RE.test(text) && !inRegisterPath && !online) { + return ( + "A setup step needed administrator rights and the prompt for them was declined or never appeared - on Linux " + + "LightNode can only ask through the graphical admin (polkit) dialog, never a password prompt you can't see. " + + "Approve it on the next run, or run the command the log suggests once in a terminal, then run install again. " + + "This is local setup, before anything on-chain - nothing was staked." + ); + } return null; } diff --git a/lib/scriptgen.ts b/lib/scriptgen.ts index d51f0f2..f5b6e47 100644 --- a/lib/scriptgen.ts +++ b/lib/scriptgen.ts @@ -14,7 +14,7 @@ export type OS = "macos" | "linux" | "windows"; const TOOLKIT = "https://github.com/lightchain-protocol/lightchain-worker-toolkit"; // Bump on every install-script change so the log shows which version actually ran. -export const INSTALLER_REV = "2026-07-29.1"; +export const INSTALLER_REV = "2026-07-29.2"; export interface ScriptBundle { os: OS; @@ -128,6 +128,28 @@ function sortitionRunWin(net: NetworkConfig): string { ].join("\n"); } +/** + * Find OUR sleep-inhibitor holder by PID, without ever using a `-f` (full command + * line) pattern. + * + * pgrep/pkill -f match the whole command line, and the command line of the shell + * running any of these generated scripts IS the script text (the app runs them as + * `bash -lc "