/** * Host facts the wizard and status surfaces need without dragging in systemd * or the tracker. Pure so tests pin the thresholds without a real machine. */ import { existsSync, readFileSync, statSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { availableParallelism } from "node:os"; import { dirname, join, resolve } from "node:path"; import { DEFAULT_CAPS, type HostConstraints } from "./types.ts"; /** 16 GiB — below this, two in-process omp sessions plus the orchestrator are * a measured swap risk on a shared VPS (issue #51: 3–4GB peaks on 7.6GB). */ export const SMALL_HOST_RAM_BYTES = 16 * 1024 ** 3; /** * MemTotal from a `/proc/meminfo` body, in bytes. kB × 1024, matching what * Linux reports; undefined when the line is missing or unparsable. */ export function ramBytesFromMeminfo(text: string): number | undefined { const m = /^MemTotal:\s+(\d+)\s+kB\s*$/m.exec(text); if (m === null) return undefined; const kib = Number(m[1]); if (!Number.isFinite(kib) || kib <= 0) return undefined; return kib * 1024; } /** Darwin `sysctl -n hw.memsize` stdout → bytes. */ export function ramBytesFromSysctl(raw: string): number | undefined { const n = Number(raw.trim()); if (!Number.isFinite(n) || n <= 0) return undefined; return n; } /** * Total installed RAM when the host will say, otherwise undefined. Never * throws: a container without /proc or a locked-down sysctl is "unknown", and * callers fall back to the shipped worker default. */ export function hostRamBytes(): number | undefined { if (process.platform === "linux") { try { return ramBytesFromMeminfo(readFileSync("/proc/meminfo", "utf8")); } catch { return undefined; } } if (process.platform === "darwin") { const ran = spawnSync("sysctl", ["-n", "hw.memsize"], { encoding: "utf8" }); if (ran.status !== 0 || ran.stdout === null) return undefined; return ramBytesFromSysctl(ran.stdout); } return undefined; } /** * Setup default for `maxConcurrentWorkers`. Small hosts get 1; everyone else * keeps {@link DEFAULT_CAPS.maxConcurrentWorkers}. Unknown RAM keeps the * shipped default — guessing low would silently halve throughput on a beefy * box whose /proc we cannot read. */ export function recommendedMaxWorkers(ramBytes: number | undefined): number { if (ramBytes === undefined) return DEFAULT_CAPS.maxConcurrentWorkers; return ramBytes < SMALL_HOST_RAM_BYTES ? 1 : DEFAULT_CAPS.maxConcurrentWorkers; } /** * Warning shown when all configured projects can outgrow this host together. * * Omitting `ram` probes the host. Passing `undefined` explicitly means "RAM is * unknown" and must NOT fall through to a host probe — a default parameter would * treat that `undefined` as "use the default", which is how CI on a small box * made `workerOvercommit(2, undefined)` warn about capacity 1. */ export function workerOvercommit(total: number, ram?: number | undefined): string | undefined { const effective = arguments.length < 2 ? hostRamBytes() : ram; const recommended = recommendedMaxWorkers(effective); return total > recommended ? `Configured worker capacity ${total} exceeds this host's recommended maximum of ${recommended}.` : undefined; } /** * CPU cores this process can actually use — affinity- and quota-aware, which * is the load a worker would really get on a shared host. `undefined` mirrors * {@link hostRamBytes}: the renderer simply omits the count. */ export function hostCoreCount(): number | undefined { try { return availableParallelism(); } catch { return undefined; } } /** What {@link hostFacts} reads off the host; injectable for render tests. */ export interface HostFacts { cores?: number; ramBytes?: number; } /** * The host's numbers through the module's own readers — the same * {@link hostRamBytes} the capacity decision and the wizard use, plus the * core count — so the rendered brief, the worker-count recommendation and * the status surfaces can never disagree about the box they run on. This is * the default the brief renderer reaches for; tests inject facts instead. */ export function hostFacts(): HostFacts { return { cores: hostCoreCount(), ramBytes: hostRamBytes() }; } /** * The usable-size phrase the brief folds into the host line (`4 cores, * 3.2 GB`); empty when the host will not say either number. */ function hostSizePhrase(facts: HostFacts): string { const parts: string[] = []; if (facts.cores !== undefined) parts.push(`${facts.cores} cores`); if (facts.ramBytes !== undefined) parts.push(formatRss(facts.ramBytes)); return parts.join(", "); } /** * The worker-brief host-constraints paragraph (#721): the typed replacement * for host facts hand-written into an untracked agent context file. Cores and * RAM come from {@link hostFacts} (the module's own readers) and are folded * into the operator's description; the PATH and per-repo convention lines are * typed config. It never names a guarded command — the shared-host notice, * derived from `SHARED_HOST_SCRIPTS`, stays the only list of refused suites. * * Empty when nothing renders — no section, and the brief is byte-for-byte * today's for a fleet that never fills the config field. `facts` is a * parameter so tests pin the rendering deterministically; the dispatch default * is {@link hostFacts}. The return has no trailing newline and leads with one * when non-empty, so the template can render it on the line below the * shared-host notice without gluing the two paragraphs. */ export function hostConstraintsNotice( host: HostConstraints | undefined, repoSlug: string, facts: HostFacts = hostFacts(), ): string { if (host === undefined) return ""; const lines: string[] = []; const description = host.description?.trim(); if (description !== undefined && description !== "") { const size = hostSizePhrase(facts); lines.push(`**Host:** ${description}${size === "" ? "" : ` (${size})`}`); } const path = host.path?.trim(); if (path !== undefined && path !== "") { lines.push(`**PATH:** non-interactive invocations (scripts and \`ssh host ''\`) must export PATH="${path}".`); } const convention = host.conventions?.[repoSlug]?.trim(); if (convention !== undefined && convention !== "") { lines.push(`**Convention (${repoSlug}):** ${convention}`); } return lines.length === 0 ? "" : `\n${lines.join("\n\n")}`; } /** Compact binary units for status lines (`3.2 GB`, `430 MB`). */ export function formatRss(bytes: number): string { if (!Number.isFinite(bytes) || bytes < 0) return "?"; const gb = bytes / 1024 ** 3; if (gb >= 1) return `${gb.toFixed(1)} GB`; const mb = bytes / 1024 ** 2; if (mb >= 1) return `${mb.toFixed(0)} MB`; return `${Math.round(bytes / 1024)} KB`; } /** * `rssBytes` from a `/healthz` JSON body. Old daemons omit the field; garbage * answers undefined rather than NaN so status can hide the line. */ export function rssBytesFromHealthz(body: string | undefined): number | undefined { if (body === undefined || body.length === 0) return undefined; try { const parsed: unknown = JSON.parse(body); if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined; const n = (parsed as { rssBytes?: unknown }).rssBytes; if (typeof n !== "number" || !Number.isFinite(n) || n < 0) return undefined; return n; } catch { return undefined; } } // ------------------------------------------------------- harness resolution -- /** * The harness package omp-conductor loads a session from. Held here, beside * the identity that has to reach it, so `omp.ts` (which imports it) and the * binding checks below can never name two different packages. */ export const OMP_HARNESS_PACKAGE = "@oh-my-pi/pi-coding-agent"; /** Native addon package the harness imports at runtime. */ export const OMP_NATIVES_PACKAGE = "@oh-my-pi/pi-natives"; /** * The mount point releases 0.18.1-era bound the operator's install at, kept * only as the retirement target `setup host` and `reconcile-units` remove * (#895). Nothing mounts it any more: worker sessions launch under the fleet * account again (#894), so the harness resolves through ordinary Node/Bun * resolution from this package's own install root. */ export const WORKER_HARNESS_DIR = "/var/lib/omp-worker-harness"; /** * The install root a module path sits in — its nearest ancestor named * `node_modules` — or `undefined` when it has none, which is this package * running from a source checkout rather than an install. */ export function packageNodeModulesRoot(modulePath: string): string | undefined { const parts = resolve(modulePath) .split("/") .filter((part) => part !== ""); const idx = parts.lastIndexOf("node_modules"); return idx < 0 ? undefined : `/${parts.slice(0, idx + 1).join("/")}`; }