From e9dbc62417e9a6ad86394d7c5f98f1ed0686f95a Mon Sep 17 00:00:00 2001 From: SoryAK Date: Sun, 20 Sep 2026 14:03:15 -0400 Subject: [PATCH] fix: fail closed on corrupt clients.json and switch on ensure Garbage clients.json no longer looks like an empty catalog, so connect and writes refuse instead of clobbering. Armed ensure stops the occupant on the switch port, then boots the requested model. --- packages/core/src/cli/status.test.ts | 2 + packages/core/src/cli/status.ts | 9 ++ packages/core/src/service/contracts.test.ts | 2 +- packages/core/src/service/contracts.ts | 2 +- .../core/src/service/operator-clients.test.ts | 18 +++- packages/core/src/service/operator-clients.ts | 92 +++++++++++++++---- packages/core/src/service/routes-clients.ts | 15 ++- packages/core/src/service/routes-connect.ts | 17 +++- packages/core/src/service/routes-models.ts | 60 +++++++++--- .../core/src/service/server-clients.test.ts | 35 ++++++- .../core/src/service/server-connect.test.ts | 16 ++++ .../core/src/service/server-models.test.ts | 83 ++++++++++++++++- packages/core/src/service/server.test.ts | 2 + packages/core/src/service/status-snapshot.ts | 14 ++- packages/mcp-server/src/service-client.ts | 7 +- 15 files changed, 328 insertions(+), 46 deletions(-) diff --git a/packages/core/src/cli/status.test.ts b/packages/core/src/cli/status.test.ts index d4912e0..a97b6b3 100644 --- a/packages/core/src/cli/status.test.ts +++ b/packages/core/src/cli/status.test.ts @@ -15,6 +15,7 @@ function emptySnapshot(overrides: Partial = {}): StatusSnapshot sessions: [], }, registry: { blocked: false, count: 0, integrity: "missing" }, + clients: { blocked: false, count: 0, integrity: "missing" }, paths: { baseDir: "/tmp/mba", tcbPath: "/tmp/mba/tcb.json", @@ -79,6 +80,7 @@ describe("statusView", () => { sessions: [], }, registry: { blocked: false, count: 0, integrity: "missing" }, + clients: { blocked: false, count: 0, integrity: "missing" }, loaded: ["qwen3.8-27b"], watches: { modelId: "qwen3.8-27b", diff --git a/packages/core/src/cli/status.ts b/packages/core/src/cli/status.ts index 956a9f9..4474cc2 100644 --- a/packages/core/src/cli/status.ts +++ b/packages/core/src/cli/status.ts @@ -13,6 +13,7 @@ export function statusView(url: string, snapshot: StatusSnapshot) { machine: snapshot.machineOverlay.mode, pairing: snapshot.pairing, registry: snapshot.registry, + clients: snapshot.clients, loaded: loaded.map((m) => m.id), watches: { modelId: watches.modelId, @@ -74,6 +75,7 @@ export async function cmdStatus(json: boolean): Promise { const sessions = snapshot.pairing.sessions; const pairing = snapshot.pairing; const registry = snapshot.registry; + const clients = snapshot.clients; const machine = snapshot.machineOverlay.mode; const watches = snapshot.watches; @@ -100,6 +102,13 @@ export async function cmdStatus(json: boolean): Promise { if (registry.blocked && registry.error) { process.stdout.write(` ${dim(registry.error)}\n`); } + const clientsLabel = clients.blocked + ? `${paint("blocked", RED)} ${dim("corrupt state")}` + : dim("ok"); + process.stdout.write(`${kv("clients.json", clientsLabel)}\n`); + if (clients.blocked && clients.error) { + process.stdout.write(` ${dim(clients.error)}\n`); + } const bits = watches.watches .map((w) => `${w.id} ${w.effective ? paint("on", GRN) : paint("off", RED)}`) .join(" "); diff --git a/packages/core/src/service/contracts.test.ts b/packages/core/src/service/contracts.test.ts index ffe8f44..3d8c16b 100644 --- a/packages/core/src/service/contracts.test.ts +++ b/packages/core/src/service/contracts.test.ts @@ -3,6 +3,6 @@ import { SERVICE_CONTRACT_VERSION } from "./contracts.js"; describe("service contract", () => { it("pins the HTTP JSON version CLI and MCP lock to", () => { - expect(SERVICE_CONTRACT_VERSION).toBe(1); + expect(SERVICE_CONTRACT_VERSION).toBe(2); }); }); diff --git a/packages/core/src/service/contracts.ts b/packages/core/src/service/contracts.ts index bbace59..72c1217 100644 --- a/packages/core/src/service/contracts.ts +++ b/packages/core/src/service/contracts.ts @@ -5,7 +5,7 @@ * Bump `SERVICE_CONTRACT_VERSION` when a field is added, renamed, or dropped. */ -export const SERVICE_CONTRACT_VERSION = 1 as const; +export const SERVICE_CONTRACT_VERSION = 2 as const; export type { StatusCatalogModel, diff --git a/packages/core/src/service/operator-clients.test.ts b/packages/core/src/service/operator-clients.test.ts index 1da32b1..af9f77a 100644 --- a/packages/core/src/service/operator-clients.test.ts +++ b/packages/core/src/service/operator-clients.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync } from "node:fs"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -35,7 +35,7 @@ describe("operator-clients", () => { envelope: ".windsurf/mba.md", ide: "vscode", }); - expect(readOperatorClients(path)).toHaveLength(1); + expect(readOperatorClients(path).clients).toHaveLength(1); const again = addOperatorClient(path, { name: "windsurf", @@ -54,7 +54,7 @@ describe("operator-clients", () => { const gone = removeOperatorClient(path, "windsurf"); expect(gone).toEqual({ ok: true, removed: true }); - expect(readOperatorClients(path)).toEqual([]); + expect(readOperatorClients(path).clients).toEqual([]); }); it("lists built-ins and added rows for GET /clients", () => { @@ -71,4 +71,16 @@ describe("operator-clients", () => { const path = join(mkdtempSync(join(tmpdir(), "mba-clients-")), "clients.json"); expect(removeOperatorClient(path, "cursor")).toMatchObject({ ok: false }); }); + + it("fails closed on corrupt JSON and does not overwrite it", () => { + const dir = mkdtempSync(join(tmpdir(), "mba-clients-")); + const path = join(dir, "clients.json"); + writeFileSync(path, "{ not json", "utf8"); + const state = readOperatorClients(path); + expect(state.kind).toBe("corrupt"); + expect(state.clients).toEqual([]); + const added = addOperatorClient(path, { name: "windsurf", envelope: ".windsurf/mba.md" }); + expect(added).toMatchObject({ ok: false, code: "clients-corrupt" }); + expect(readFileSync(path, "utf8")).toBe("{ not json"); + }); }); diff --git a/packages/core/src/service/operator-clients.ts b/packages/core/src/service/operator-clients.ts index 30dd2be..4b46af4 100644 --- a/packages/core/src/service/operator-clients.ts +++ b/packages/core/src/service/operator-clients.ts @@ -7,7 +7,7 @@ * the family → model ladder, never in `environments/`. */ -import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { dirname, posix } from "node:path"; import { builtInEnvelopeBindings, @@ -63,26 +63,67 @@ function parseClient(value: unknown): OperatorClient | undefined { return ide ? { name, envelope, ide } : { name, envelope }; } -export function readOperatorClients(path: string): OperatorClient[] { - if (!existsSync(path)) return []; +export type ClientsReadResult = + | { readonly kind: "missing"; readonly clients: readonly OperatorClient[] } + | { readonly kind: "valid"; readonly clients: readonly OperatorClient[] } + | { + readonly kind: "corrupt"; + readonly clients: readonly OperatorClient[]; + readonly error: string; + }; + +function corruptClients(error: string): ClientsReadResult { + return { kind: "corrupt", clients: [], error }; +} + +/** + * Read operator clients. Missing and valid-empty are open (built-ins only). + * Corrupt JSON, wrong shape, or an invalid row fail closed — callers must + * not treat that as `[]` and pretend extras were never added. + */ +export function readOperatorClients(path: string): ClientsReadResult { + let text: string; + try { + text = readFileSync(path, "utf8"); + } catch (err) { + if ( + err instanceof Error && + "code" in err && + (err as NodeJS.ErrnoException).code === "ENOENT" + ) { + return { kind: "missing", clients: [] }; + } + return corruptClients("operator clients file could not be read"); + } let raw: unknown; try { - raw = JSON.parse(readFileSync(path, "utf8")) as unknown; + raw = JSON.parse(text) as unknown; } catch { - return []; + return corruptClients("operator clients file is not valid JSON"); } const doc = raw as Partial | null; - if (!doc || !Array.isArray(doc.clients)) return []; + if ( + !doc || + typeof doc !== "object" || + doc.version !== CLIENTS_VERSION || + !Array.isArray(doc.clients) + ) { + return corruptClients("operator clients file has an unsupported shape or version"); + } const rows: OperatorClient[] = []; const seen = new Set(); - for (const item of doc.clients) { + for (const [index, item] of doc.clients.entries()) { const parsed = parseClient(item); - if (!parsed) continue; - if (seen.has(parsed.name)) continue; + if (!parsed) { + return corruptClients(`operator clients file contains an invalid client at index ${index}`); + } + if (seen.has(parsed.name)) { + return corruptClients(`operator clients file contains a duplicate name at index ${index}`); + } seen.add(parsed.name); rows.push(parsed); } - return rows; + return { kind: "valid", clients: rows }; } export function writeOperatorClients(path: string, clients: readonly OperatorClient[]): void { @@ -95,7 +136,7 @@ export function writeOperatorClients(path: string, clients: readonly OperatorCli export type AddClientResult = | { readonly ok: true; readonly client: OperatorClient; readonly updated: boolean } - | { readonly ok: false; readonly error: string }; + | { readonly ok: false; readonly error: string; readonly code?: "clients-corrupt" }; export function addOperatorClient( path: string, @@ -127,7 +168,11 @@ export function addOperatorClient( const taken = new Set( builtInEnvelopeBindings().map((b) => posix.normalize(b.envelope)), ); - const rows = readOperatorClients(path); + const state = readOperatorClients(path); + if (state.kind === "corrupt") { + return { ok: false, code: "clients-corrupt", error: state.error }; + } + const rows = [...state.clients]; for (const row of rows) { if (row.name === name) continue; taken.add(row.envelope); @@ -150,20 +195,26 @@ export function addOperatorClient( export function removeOperatorClient( path: string, nameRaw: string, -): { readonly ok: true; readonly removed: boolean } | { readonly ok: false; readonly error: string } { +): + | { readonly ok: true; readonly removed: boolean } + | { readonly ok: false; readonly error: string; readonly code?: "clients-corrupt" } { const name = nameRaw.trim().toLowerCase(); if (isReservedHarnessName(name)) { return { ok: false, error: `'${name}' is a built-in client` }; } - const rows = readOperatorClients(path); + const state = readOperatorClients(path); + if (state.kind === "corrupt") { + return { ok: false, code: "clients-corrupt", error: state.error }; + } + const rows = state.clients; const next = rows.filter((r) => r.name !== name); if (next.length === rows.length) return { ok: true, removed: false }; writeOperatorClients(path, next); return { ok: true, removed: true }; } -export function operatorEnvelopeBindings(path: string): EnvelopeBinding[] { - return readOperatorClients(path).map(({ name, envelope }) => ({ name, envelope })); +export function operatorEnvelopeBindings(clients: readonly OperatorClient[]): EnvelopeBinding[] { + return clients.map(({ name, envelope }) => ({ name, envelope })); } export interface RegisteredClient { @@ -174,13 +225,20 @@ export interface RegisteredClient { } /** Built-in harnesses plus operator rows. The GET /clients catalog. */ +export function clientsCorruptJson(error: string): { code: "clients-corrupt"; error: string } { + return { + code: "clients-corrupt", + error: `operator clients are corrupt — ${error}`, + }; +} + export function listRegisteredClients(path: string): RegisteredClient[] { const builtIn = builtInEnvelopeBindings().map((b) => ({ name: b.name, envelope: b.envelope, source: "built-in" as const, })); - const added = readOperatorClients(path).map((c) => ({ + const added = readOperatorClients(path).clients.map((c) => ({ name: c.name, envelope: c.envelope, source: "added" as const, diff --git a/packages/core/src/service/routes-clients.ts b/packages/core/src/service/routes-clients.ts index 3bc8462..4c3e2a8 100644 --- a/packages/core/src/service/routes-clients.ts +++ b/packages/core/src/service/routes-clients.ts @@ -5,7 +5,9 @@ import type { Hono } from "hono"; import { addOperatorClient, + clientsCorruptJson, listRegisteredClients, + readOperatorClients, removeOperatorClient, } from "./operator-clients.js"; import type { ServiceRouteContext } from "./route-context.js"; @@ -14,7 +16,12 @@ export function registerClientRoutes(app: Hono, ctx: ServiceRouteContext): void const { paths } = ctx; app.get("/clients", (c) => { - return c.json({ clients: listRegisteredClients(paths.clientsPath) }); + const state = readOperatorClients(paths.clientsPath); + return c.json({ + clients: listRegisteredClients(paths.clientsPath), + integrity: state.kind, + ...(state.kind === "corrupt" ? { error: state.error } : {}), + }); }); app.post("/clients", async (c) => { @@ -42,6 +49,9 @@ export function registerClientRoutes(app: Hono, ctx: ServiceRouteContext): void ide: typeof input.ide === "string" ? input.ide : undefined, }); if (!result.ok) { + if (result.code === "clients-corrupt") { + return c.json(clientsCorruptJson(result.error), 503); + } return c.json({ error: result.error }, 400); } return c.json({ @@ -65,6 +75,9 @@ export function registerClientRoutes(app: Hono, ctx: ServiceRouteContext): void } const result = removeOperatorClient(paths.clientsPath, input.name); if (!result.ok) { + if (result.code === "clients-corrupt") { + return c.json(clientsCorruptJson(result.error), 503); + } return c.json({ error: result.error }, 400); } return c.json({ name: input.name.trim().toLowerCase(), removed: result.removed }); diff --git a/packages/core/src/service/routes-connect.ts b/packages/core/src/service/routes-connect.ts index bee965a..651f877 100644 --- a/packages/core/src/service/routes-connect.ts +++ b/packages/core/src/service/routes-connect.ts @@ -77,14 +77,24 @@ export function registerConnectRoutes(app: Hono, ctx: ServiceRouteContext): void body: { error: `unknown model: ${modelId}`, code: "unknown-model" }, }; } + const clientState = readOperatorClients(paths.clientsPath); + if (clientState.kind === "corrupt") { + return { + status: 503 as const, + body: { + code: "clients-corrupt", + error: `operator clients are corrupt — ${clientState.error}`, + }, + }; + } const harness = harnessName; const ide = (typeof ideOpt === "string" && ideOpt.length > 0 ? ideOpt : undefined) ?? - readOperatorClients(paths.clientsPath).find( + clientState.clients.find( (c) => compactHarnessKey(c.name) === compactHarnessKey(harness), )?.ide ?? defaultIdeForHarness(harness); - const envelopes = operatorEnvelopeBindings(paths.clientsPath); + const envelopes = operatorEnvelopeBindings(clientState.clients); const previousOwner = readEnvelopeOwner(projectRoot, harness, envelopes, ide); const slotPeers = sessionsSharingSlot( sessionState.sessions, @@ -195,9 +205,10 @@ export function registerConnectRoutes(app: Hono, ctx: ServiceRouteContext): void if (outcome.status === 503) { return c.json(outcome.body, 503); } + const clientState = readOperatorClients(paths.clientsPath); restageSlotsAfterRevoke({ adapterDir: opts.adapterDir ?? "", - envelopes: operatorEnvelopeBindings(paths.clientsPath), + envelopes: operatorEnvelopeBindings(clientState.clients), remaining: outcome.next, revoked: outcome.revoked, }); diff --git a/packages/core/src/service/routes-models.ts b/packages/core/src/service/routes-models.ts index da97f9e..3936032 100644 --- a/packages/core/src/service/routes-models.ts +++ b/packages/core/src/service/routes-models.ts @@ -16,7 +16,7 @@ import { readModelWatches, setModelWatch, } from "./model-watches.js"; -import { operatorEnvelopeBindings } from "./operator-clients.js"; +import { operatorEnvelopeBindings, readOperatorClients } from "./operator-clients.js"; import { readSessions, sessionsSharingSlot } from "./sessions.js"; import { restagePairedSlotsForModel, stageModelCard } from "./stage-model-card.js"; import { listCatalogModels, probeModelLoaded } from "./status-snapshot.js"; @@ -29,7 +29,8 @@ import { import { withHouseLock } from "./house-lock.js"; import { bootServer } from "./server-boot.js"; import { readGlobalConfig, defaultStorePaths } from "./config-store.js"; -import { readRegistry, upsertEntry, writeRegistry } from "./upstream-registry.js"; +import { getServerTypeOps } from "./server-types.js"; +import { readRegistry, removeById, upsertEntry, writeRegistry } from "./upstream-registry.js"; import type { MbaServiceAppOptions, ServiceRouteContext } from "./route-context.js"; export function registerModelRoutes(app: Hono, ctx: ServiceRouteContext): void { @@ -67,6 +68,16 @@ export function registerModelRoutes(app: Hono, ctx: ServiceRouteContext): void { 503, ); } + const clientState = readOperatorClients(paths.clientsPath); + if (clientState.kind === "corrupt") { + return c.json( + { + code: "clients-corrupt", + error: `operator clients are corrupt — ${clientState.error}`, + }, + 503, + ); + } const result = await ensureModel({ catalog: readModelCatalog(opts.adapterDir ?? ""), requestedId: input.id, @@ -91,7 +102,7 @@ export function registerModelRoutes(app: Hono, ctx: ServiceRouteContext): void { adapterDir: opts.adapterDir ?? "", modelId: result.id, sessions: sessionState.sessions, - envelopes: operatorEnvelopeBindings(paths.clientsPath), + envelopes: operatorEnvelopeBindings(clientState.clients), }); return c.json(stage.length > 0 ? { ...result, stage } : result); }); @@ -380,7 +391,17 @@ export function registerModelRoutes(app: Hono, ctx: ServiceRouteContext): void { 503, ); } - const envelopes = operatorEnvelopeBindings(paths.clientsPath); + const clientState = readOperatorClients(paths.clientsPath); + if (clientState.kind === "corrupt") { + return c.json( + { + code: "clients-corrupt", + error: `operator clients are corrupt — ${clientState.error}`, + }, + 503, + ); + } + const envelopes = operatorEnvelopeBindings(clientState.clients); const slotPeers = sessionsSharingSlot( sessionState.sessions, input.harness, @@ -421,10 +442,10 @@ export function registerModelRoutes(app: Hono, ctx: ServiceRouteContext): void { * Default switch executor: boots the model IN-DAEMON via the server plane * (ADR-0097 Phase 2), replacing the retired `llama-server-up.sh` shell-out. * - * Port policy (G2): the boot script defaulted to 8080, so we do the same — - * `MBA_SWITCH_PORT` overrides it. A busy port is refused (the boot reports - * `port-busy`, which `ensureModel` surfaces as `failed`); pick a free port - * with `mba servers boot ` for an explicit choice. + * Port policy: `MBA_SWITCH_PORT` (default 8080). A switch is one + * transaction: stop the guest-book entry on that port (and any entry + * already serving this file), then boot. A leftover occupant used to + * return `port-busy` and leave the loaded model unchanged. * * The shared `lifecycleSeams` (G1) is passed through so the booted group is * tracked and killed on daemon exit. Kept at module level so tests can always @@ -441,6 +462,23 @@ async function defaultSwitchExecutor( const port = Number(process.env.MBA_SWITCH_PORT ?? 8080); const registryPath = (opts.paths ?? defaultStorePaths()).upstreamsPath; await withHouseLock(registryPath, async () => { + const registryState = readRegistry(registryPath); + if (registryState.kind === "corrupt") { + throw new Error(`upstream registry is corrupt — ${registryState.error}`); + } + let entries = [...registryState.entries]; + const outgoing = entries.filter((e) => e.port === port || e.modelFile === modelFile); + for (const entry of outgoing) { + const ops = getServerTypeOps(entry.serverType); + if (!ops) { + throw new Error(`unknown server type ${entry.serverType}`); + } + await ops.stop(entry, opts.lifecycleSeams); + entries = [...removeById(entries, entry.id)]; + } + if (outgoing.length > 0) { + writeRegistry(registryPath, entries); + } const result = await bootServer({ modelFile, port, @@ -453,10 +491,6 @@ async function defaultSwitchExecutor( if (!result.ok) { throw new Error(result.error); } - const registryState = readRegistry(registryPath); - if (registryState.kind === "corrupt") { - throw new Error(`upstream registry is corrupt — ${registryState.error}`); - } - writeRegistry(registryPath, upsertEntry(registryState.entries, result.entry)); + writeRegistry(registryPath, upsertEntry(entries, result.entry)); }); } diff --git a/packages/core/src/service/server-clients.test.ts b/packages/core/src/service/server-clients.test.ts index aa12b1c..94a1370 100644 --- a/packages/core/src/service/server-clients.test.ts +++ b/packages/core/src/service/server-clients.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync } from "node:fs"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it, beforeEach } from "vitest"; @@ -36,7 +36,7 @@ describe("GET/POST /clients", () => { ide: "vscode", updated: false, }); - expect(readOperatorClients(paths.clientsPath)).toEqual([ + expect(readOperatorClients(paths.clientsPath).clients).toEqual([ { name: "windsurf", envelope: ".windsurf/mba.md", ide: "vscode" }, ]); @@ -75,7 +75,7 @@ describe("GET/POST /clients", () => { }); expect(gone.status).toBe(200); expect(await gone.json()).toEqual({ name: "windsurf", removed: true }); - expect(readOperatorClients(paths.clientsPath)).toEqual([]); + expect(readOperatorClients(paths.clientsPath).clients).toEqual([]); const builtin = await app.request("/clients/remove", { method: "POST", @@ -84,4 +84,33 @@ describe("GET/POST /clients", () => { }); expect(builtin.status).toBe(400); }); + + it("GET /clients reports corrupt integrity without inventing operator rows", async () => { + mkdirSync(join(paths.clientsPath, ".."), { recursive: true }); + writeFileSync(paths.clientsPath, "{ not json", "utf8"); + const app = createMbaServiceApp({ paths }); + const res = await app.request("/clients"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + clients: Array<{ source: string }>; + integrity: string; + error: string; + }; + expect(body.integrity).toBe("corrupt"); + expect(body.error).toMatch(/not valid JSON/); + expect(body.clients.every((c) => c.source === "built-in")).toBe(true); + }); + + it("POST /clients refuses to overwrite a corrupt file", async () => { + mkdirSync(join(paths.clientsPath, ".."), { recursive: true }); + writeFileSync(paths.clientsPath, "{ not json", "utf8"); + const app = createMbaServiceApp({ paths }); + const res = await app.request("/clients", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "windsurf", envelope: ".windsurf/mba.md" }), + }); + expect(res.status).toBe(503); + expect(await res.json()).toMatchObject({ code: "clients-corrupt" }); + }); }); diff --git a/packages/core/src/service/server-connect.test.ts b/packages/core/src/service/server-connect.test.ts index f6f45c8..8b5ce6e 100644 --- a/packages/core/src/service/server-connect.test.ts +++ b/packages/core/src/service/server-connect.test.ts @@ -326,4 +326,20 @@ describe("POST /connect", () => { ]), ); }); + + it("refuses to connect when clients.json is corrupt", async () => { + mkdirSync(join(paths.clientsPath, ".."), { recursive: true }); + writeFileSync(paths.clientsPath, "{ not json", "utf8"); + const res = await app.request("/connect", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + id: "qwen3-coder-30b", + projectRoot: project, + harness: "cursor", + }), + }); + expect(res.status).toBe(503); + expect(await res.json()).toMatchObject({ code: "clients-corrupt" }); + }); }); diff --git a/packages/core/src/service/server-models.test.ts b/packages/core/src/service/server-models.test.ts index 588dead..01bdc25 100644 --- a/packages/core/src/service/server-models.test.ts +++ b/packages/core/src/service/server-models.test.ts @@ -1,11 +1,14 @@ +import { EventEmitter } from "node:events"; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import type { ChildProcess } from "node:child_process"; import { describe, it, expect, beforeEach, vi } from "vitest"; import { createMbaServiceApp } from "./server.js"; import { defaultStorePaths } from "./config-store.js"; -import { writeRegistry } from "./upstream-registry.js"; +import { readRegistry, writeRegistry } from "./upstream-registry.js"; import { hashToken, writeSessions } from "./sessions.js"; +import type { LifecycleSeams } from "../mba/server-lifecycle.js"; function writeAdapter( dir: string, @@ -321,4 +324,82 @@ bindings: expect(body.models.find((m) => m.id === "qwen3-coder-30b")?.loaded).toBe(true); }); }); + + it("POST /models/ensure stops the occupant on the switch port then boots", async () => { + const qwenFile = join(adapterDir, "qwen", "qwen3-coder", "qwen3-coder-30b", "m.gguf"); + const llamaFile = join(adapterDir, "llama", "llama3", "llama3-8b", "l.gguf"); + writeFileSync(qwenFile, "gguf"); + writeFileSync(llamaFile, "gguf"); + writeRegistry(paths.upstreamsPath, [ + { + id: "llama-cpp-8080", + serverType: "llama.cpp", + modelFile: llamaFile, + port: 8080, + pid: 4242, + startedAt: "2026-08-24T00:00:00.000Z", + }, + ]); + const killCalls: Array<[number, NodeJS.Signals | number | undefined]> = []; + const termSeen = new Set(); + const spawnCalls: unknown[] = []; + const seams: LifecycleSeams = { + spawnImpl: () => { + spawnCalls.push(1); + const child = { + pid: 7777, + kill: vi.fn(), + on: vi.fn(), + once: vi.fn(), + unref: vi.fn(), + stdio: [null, null, null], + stdout: new EventEmitter(), + stderr: new EventEmitter(), + }; + return child as unknown as ChildProcess; + }, + fetchImpl: (async (input: string | URL | Request) => { + const url = String(input); + if (url.includes("/v1/models")) { + return new Response(JSON.stringify({ data: [{ id: llamaFile }] }), { status: 200 }); + } + if (url.includes("/health") || url.includes("/completion")) { + return new Response(JSON.stringify({ status: "ok" }), { status: 200 }); + } + throw new Error(`unexpected fetch ${url}`); + }) as unknown as typeof fetch, + killImpl: (pid, signal) => { + killCalls.push([pid, signal]); + if (signal === "SIGTERM") termSeen.add(pid); + if (signal === 0 && termSeen.has(pid)) return false; + return true; + }, + portCheckImpl: async () => true, + now: () => 1_000_000, + healthDeadlineMs: 1000, + mkdirImpl: vi.fn(), + }; + const app = createMbaServiceApp({ + paths, + adapterDir, + upstreamUrl: "http://127.0.0.1:8080", + switchEnabled: true, + lifecycleSeams: seams, + fetch: seams.fetchImpl, + }); + const res = await app.request("/models/ensure", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: "qwen3-coder-30b" }), + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ status: "switched", id: "qwen3-coder-30b" }); + expect(killCalls.some(([pid]) => pid === 4242 || pid === -4242)).toBe(true); + expect(spawnCalls.length).toBeGreaterThan(0); + const registry = readRegistry(paths.upstreamsPath); + expect(registry.kind).not.toBe("corrupt"); + expect(registry.entries.map((e) => e.id)).toEqual(["llama-cpp-8080"]); + expect(registry.entries[0]?.modelFile).toBe(qwenFile); + expect(registry.entries[0]?.pid).toBe(7777); + }); }); diff --git a/packages/core/src/service/server.test.ts b/packages/core/src/service/server.test.ts index 1c704d5..82b8c0b 100644 --- a/packages/core/src/service/server.test.ts +++ b/packages/core/src/service/server.test.ts @@ -101,6 +101,7 @@ describe("mba service app", () => { uptimeMs: number; pairing: { active: boolean; count: number; integrity: string; blocked: boolean; sessions: unknown[] }; registry: { integrity: string; blocked: boolean; count: number }; + clients: { integrity: string; blocked: boolean; count: number }; paths: { baseDir: string; tcbPath: string }; machineOverlay: { mode: string }; models: unknown[]; @@ -117,6 +118,7 @@ describe("mba service app", () => { sessions: [], }); expect(body.registry).toEqual({ blocked: false, count: 0, integrity: "missing" }); + expect(body.clients).toEqual({ blocked: false, count: 0, integrity: "missing" }); expect(body.paths.baseDir).toBe(paths.baseDir); expect(body.paths.tcbPath).toBe(paths.tcbPath); expect(body).toMatchObject({ diff --git a/packages/core/src/service/status-snapshot.ts b/packages/core/src/service/status-snapshot.ts index 7364b9f..0ebc03f 100644 --- a/packages/core/src/service/status-snapshot.ts +++ b/packages/core/src/service/status-snapshot.ts @@ -15,7 +15,7 @@ import { readModelCatalog, type CatalogEntry } from "./model-catalog.js"; import { notesPreview, readModelShelf, type ModelNotesPreview } from "./model-config.js"; import { isLoadedPath, probeLoadedModel } from "./model-switch.js"; import { defaultWatches, readModelWatches, type ModelWatches } from "./model-watches.js"; -import { operatorEnvelopeBindings } from "./operator-clients.js"; +import { operatorEnvelopeBindings, readOperatorClients } from "./operator-clients.js"; import { pairingActive, publicSessions, @@ -74,11 +74,14 @@ export interface StatusRegistry { readonly error?: string; } +export type StatusClients = StatusRegistry; + export interface StatusSnapshot { readonly version: number; readonly uptimeMs: number; readonly pairing: StatusPairing; readonly registry: StatusRegistry; + readonly clients: StatusClients; readonly paths: { readonly baseDir: string; readonly tcbPath: string; @@ -239,7 +242,8 @@ export async function buildStatusSnapshot(opts: StatusSnapshotOpts): Promise(); const sessionsOut = publicSessions(sessions).map((s) => { const key = `${s.harness}\0${resolve(s.projectRoot)}`; @@ -269,6 +273,12 @@ export async function buildStatusSnapshot(opts: StatusSnapshotOpts): Promise