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