From a59630306b7f0c9aa38ebcaf214d9afe9f23fc9c Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 21 Sep 2026 08:16:23 -0400 Subject: [PATCH] =?UTF-8?q?fix(fleet):=20sidebar=20client-side=20peer=20vi?= =?UTF-8?q?sibility=20=E2=80=94=20proxy-read=20host=20roster=20+=20synthes?= =?UTF-8?q?ize=20canonical-server=20node=20(#1363)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a client, the sidebar fleet section showed only itself: readRoster unconditionally read the machine-local roster cache (empty on a client, since the roster is host-owned), and nothing rendered the server (never a roster row). Two sidebar-only changes: - readRoster is now role-aware: SERVER/standalone read the local cache synchronously (unchanged); a CLIENT proxy-reads the host's authoritative roster over the existing GET /amicode/roster (forwarded by the local service). Any failure is the honest unreachable state, never a fabricated or stale list. The seam returns a sync|Promise union so every existing synchronous injection is untouched. - buildFleetSectionModel synthesizes the canonical-server node on a client (from fleet.json canonical), mirroring the existing self-row synthesis, so the server is visible; shown as down when the host is unreachable. Sidebar-only: enroll verb, roster schema, and guard are byte-unchanged. The roster-row enrichment (friendly name + device_type) is deferred to ADR 0027 (#1340 / PR #1353), which owns the roster schema. Closes #1363 --- packages/extension/src/extension.ts | 8 +- .../extension/src/sidebar_fleet_section.ts | 65 +++++++- packages/extension/src/sidebar_view.ts | 143 ++++++++++++++---- .../test/sidebar_fleet_section.test.ts | 49 ++++++ packages/extension/test/sidebar_view.test.ts | 109 +++++++++++++ 5 files changed, 345 insertions(+), 29 deletions(-) diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 344464576..e5b332abb 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -493,7 +493,13 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // #1321: the read-only fleet section reads the host-owned roster + this // machine's posture and pushes them to the webview. Manage stays honestly // disabled until the Fleet Manager tab (#1322) registers its command. - const sidebarProvider = new SidebarViewProvider(ctx.extensionUri, undefined, defaultFleetSectionDeps()); + const sidebarProvider = new SidebarViewProvider(ctx.extensionUri, undefined, defaultFleetSectionDeps({ + // #1363 — a CLIENT proxy-reads the host's roster through the live local + // amicode service. Lazy: the service boots AFTER this construction, so read + // the module-level handle at call time (null until up ⇒ honest degrade). + serviceEndpoint: () => + amicodeService ? { origin: new URL(amicodeService.url).origin, authHeader: amicodeService.authHeader } : null, + })); ctx.subscriptions.push( vscode.window.registerWebviewViewProvider("amicode.workspace", sidebarProvider, { webviewOptions: { retainContextWhenHidden: true }, diff --git a/packages/extension/src/sidebar_fleet_section.ts b/packages/extension/src/sidebar_fleet_section.ts index 35f019d3b..73553b70c 100644 --- a/packages/extension/src/sidebar_fleet_section.ts +++ b/packages/extension/src/sidebar_fleet_section.ts @@ -118,6 +118,24 @@ export interface FleetSectionInput { * when local identity can't be resolved. Drives the self-row synthesis and * the "not registered with a fleet" collapse below. */ localDevice?: LocalDeviceInput | null; + /** The canonical server this machine points at (fleet.json `canonical`), + * known independent of the peer roster (#1363). On a CLIENT the server is + * never a roster row — the roster lists reporters (peers/clients) — so + * without this the client would never see the machine it's attached to. + * Synthesized into a server device row below when present, not the local + * machine, and not already carried by the roster. Null/omitted on a server + * or standalone machine (the server is its own self-row; a standalone has + * no canonical). */ + canonicalServer?: CanonicalServerInput | null; +} + +/** The canonical-server seed — the machine a client points at, from fleet.json + * `canonical` (#1363). Deliberately minimal: identity only. `machineId` + * should be the canonical host (or sshAlias) so it de-dupes against a roster + * row for the same server if one ever appears. */ +export interface CanonicalServerInput { + machineId: string; + name: string; } /** The self-row seed — independent of the roster, so a machine always sees @@ -177,9 +195,14 @@ export function buildFleetSectionModel(input: FleetSectionInput): FleetSectionMo // from local fleet.json/config, not a read of the down peer roster — so it // still renders when known (#1359: "still show the self-row"). if (!input.rosterReachable) { + const downDevices: FleetDeviceRow[] = local ? [localDeviceRow(local)] : []; + // The canonical server is a fact known from fleet.json, not a read of the + // down peer roster — so it still renders (as `down`, never fabricated as + // healthy), the same way the self-row does (#1363). + maybeAddCanonicalServer(downDevices, input.canonicalServer ?? null, local, "down"); return { state: "unreachable", - devices: local ? [localDeviceRow(local)] : [], + devices: downDevices, posture: buildPostureBadge(input.posture), manage: { enabled: input.manageAvailable }, troubleshoot: { enabled: input.troubleshootAvailable }, @@ -202,6 +225,11 @@ export function buildFleetSectionModel(input: FleetSectionInput): FleetSectionMo if (local && !devices.some((d) => d.machineId === local.machineId)) { devices.unshift(localDeviceRow(local)); } + // Synthesize the canonical-server row on a client (the server is never a + // roster row — the roster lists reporters), so you always see the machine + // you're attached to. A reachable peer roster means the host answered, so + // the server is `reachable` (#1363). + maybeAddCanonicalServer(devices, input.canonicalServer ?? null, local, "reachable"); // The local machine always renders first — whether it was synthesized // (already unshifted above) or found in the roster at an arbitrary index. const localIdx = devices.findIndex((d) => d.isLocal); @@ -249,6 +277,41 @@ function localDeviceRow(local: LocalDeviceInput): FleetDeviceRow { }; } +/** Add the synthesized canonical-server row to `devices` in place, unless it + * would double a row that already exists — the local machine's own self-row + * (the server viewing itself) or a roster row for the same server (#1363). + * `health` is the caller's honest signal: `reachable` when the host answered + * the roster read, `down` when it didn't. */ +function maybeAddCanonicalServer( + devices: FleetDeviceRow[], + server: CanonicalServerInput | null, + local: LocalDeviceInput | null, + health: RosterHealth, +): void { + if (!server) return; + // The server IS this machine ⇒ it's the self-row, never a synthesized peer. + if (local && local.machineId === server.machineId) return; + // Already carried (a roster row for the same server) ⇒ don't duplicate. + if (devices.some((d) => d.machineId === server.machineId)) return; + devices.push(canonicalServerRow(server, health)); +} + +/** Build the synthesized canonical-server row (#1363). Like the self-row, this + * is a fact known from fleet.json, not a self-report read off disk — so + * `lastSeen` is the literal "now" and there is no capability list. */ +function canonicalServerRow(server: CanonicalServerInput, health: RosterHealth): FleetDeviceRow { + return { + machineId: server.machineId, + name: server.name, + role: "server", + typeLabel: "server", + capabilities: [], + health, + lastSeen: "now", + isLocal: false, + }; +} + // ── DOM render (browser / webview) ─────────────────────────────────────────── /** The single navigation message the section can emit — the READ-ONLY contract: diff --git a/packages/extension/src/sidebar_view.ts b/packages/extension/src/sidebar_view.ts index 0179454b0..809152c9d 100644 --- a/packages/extension/src/sidebar_view.ts +++ b/packages/extension/src/sidebar_view.ts @@ -13,7 +13,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import { execSync } from "node:child_process"; import { handleSidebarMessage, type SidebarMessageHandlers, type SidebarDownMessage, type FileOpRequest, type FileOpResult, type TreeEntry, type TreeRoot } from "./sidebar_bridge"; -import { buildFleetSectionModel, type RosterRowLike, type FleetPostureInput, type LocalDeviceInput } from "./sidebar_fleet_section"; +import { buildFleetSectionModel, type RosterRowLike, type FleetPostureInput, type LocalDeviceInput, type CanonicalServerInput } from "./sidebar_fleet_section"; import { parseRosterDocument, fleetRosterCachePath, fleetTopologyPath } from "@amicode/schema"; import { fleetPostureStateFile } from "./fleet_posture_state"; import { SidebarTreeService, type RawDirEntry } from "./sidebar_tree_service"; @@ -347,8 +347,12 @@ function friendlyHostname(): string | undefined { * proxy (a client) without the provider caring which. Read-only by contract: * there is no write seam here. */export interface FleetSectionDeps { - /** The fleet-wide roster + whether it could be read (false ⇒ host down). */ - readRoster: () => { rows: RosterRowLike[]; reachable: boolean }; + /** The fleet-wide roster + whether it could be read (false ⇒ host down). + * Async by contract (#1363): a SERVER/standalone resolves synchronously from + * the local roster cache; a CLIENT proxy-reads the host over HTTP and so + * returns a Promise. `pushFleetStatus` accepts either — the union keeps every + * existing synchronous injection working unchanged. */ + readRoster: () => RosterRead | Promise; /** This machine's posture (serve-stance + link-health), or null if unknown. */ readPosture: () => FleetPostureInput | null; /** THIS machine's own identity, independent of the roster (fleet.json + @@ -356,6 +360,11 @@ function friendlyHostname(): string | undefined { * registered with a fleet" collapse. Optional for backward compatibility * (older injected deps omit it; treated as null — no self-row). */ readLocalDevice?: () => LocalDeviceInput | null; + /** The canonical server this machine points at (fleet.json `canonical`), + * or null on a server/standalone (#1363). On a CLIENT the server is never a + * roster row, so this drives the server-node synthesis. Optional for + * backward compatibility (older injected deps omit it — no server row). */ + readCanonicalServer?: () => CanonicalServerInput | null; /** Subscribe to posture/roster change; the callback re-pushes the section. */ onPostureChange: (cb: () => void) => vscode.Disposable; /** Whether the Fleet Manager tab (#1322) exists — gates the Manage affordance. */ @@ -367,6 +376,13 @@ function friendlyHostname(): string | undefined { launchSession: (prompt: string) => void; } +/** The roster read result — rows + whether the read succeeded (false ⇒ host + * down / unreachable, an honest degraded state, never a fabricated list). */ +export interface RosterRead { + rows: RosterRowLike[]; + reachable: boolean; +} + /** Options for {@link defaultFleetSectionDeps} — every file path is injectable * so the readers are unit-testable; production leaves them at the canonical * `~/.amico/ops/fleet/*` locations. */ @@ -387,13 +403,28 @@ export interface DefaultFleetDepsOptions { /** Override session launch; default navigates the chat panel to a new session * with the given prompt (same pattern as New Project / New Environment). */ launchSession?: (prompt: string) => void; + /** Override the HTTP client used for a CLIENT's roster proxy-read (#1363); + * default: the global `fetch`. Inject for tests — never hits the network in + * a test run. */ + fetchImpl?: typeof fetch; + /** The live local amicode-service endpoint (origin + engine credential) a + * CLIENT proxy-reads the host roster through (#1363). Lazy — resolved at + * read time because the service may not be up when the deps are constructed; + * returns null when it isn't, and the read degrades to unreachable. Default: + * a null-returning stub (production wires the live handle from extension.ts). */ + serviceEndpoint?: () => { origin: string; authHeader: string } | null; } +/** The proxied route a CLIENT reads the host's authoritative roster from — the + * local service forwards `/amicode/*` to the host (shouldProxyAmicodeToHost). */ +const CLIENT_ROSTER_ROUTE = "/amicode/roster"; + /** - * The production fleet seams (#1321). READ-ONLY: the roster + posture are read - * from their canonical on-disk homes (this machine is the Canonical Server, so - * the roster cache is local; a client's HTTP-proxy read is a follow-up that - * swaps `readRoster`). No write path exists here by contract. + * The production fleet seams (#1321, #1363). READ-ONLY: no write path exists + * here by contract. The roster read is role-aware — a SERVER/standalone reads + * the local roster cache synchronously; a CLIENT proxy-reads the host's + * authoritative roster over `GET /amicode/roster` (forwarded by the local + * service) and synthesizes the canonical-server node it points at. */ export function defaultFleetSectionDeps(opts: DefaultFleetDepsOptions = {}): FleetSectionDeps { const rosterFile = opts.rosterFile ?? fleetRosterCachePath(); @@ -411,17 +442,61 @@ export function defaultFleetSectionDeps(opts: DefaultFleetDepsOptions = {}): Fle } } + function readCanonical(): { host?: string; port?: number; sshAlias?: string } | null { + try { + if (!fs.existsSync(fleetConfigFile)) return null; + const cfg = JSON.parse(fs.readFileSync(fleetConfigFile, "utf8")); + const c = cfg?.canonical; + return c && typeof c === "object" ? c : null; + } catch { + return null; + } + } + + /** The SERVER/standalone path: read the local roster cache synchronously. */ + function readLocalRoster(): RosterRead { + try { + // An absent roster file is a fresh/empty fleet (reachable), NOT host-down. + if (!fs.existsSync(rosterFile)) return { rows: [] as RosterRowLike[], reachable: true }; + const parsed = parseRosterDocument(JSON.parse(fs.readFileSync(rosterFile, "utf8"))); + return { rows: parsed.ok ? parsed.doc.rows : [], reachable: true }; + } catch { + // A genuine I/O error is the honest unreachable signal (AC6). + return { rows: [], reachable: false }; + } + } + + /** The CLIENT path (#1363): proxy-read the host's authoritative roster via + * the local service's `GET /amicode/roster` (forwarded to the host). Any + * failure — no live endpoint, network error, non-200, malformed body — is + * the honest unreachable state, never a fabricated or stale list. */ + async function readHostRoster(): Promise { + const endpoint = (opts.serviceEndpoint ?? (() => null))(); + if (!endpoint) return { rows: [], reachable: false }; + const doFetch = opts.fetchImpl ?? fetch; + try { + const res = await doFetch(`${endpoint.origin}${CLIENT_ROSTER_ROUTE}`, { + headers: { Authorization: endpoint.authHeader }, + }); + if (!res.ok) return { rows: [], reachable: false }; + const body = (await res.json()) as { ok?: boolean; rows?: unknown }; + const parsed = parseRosterDocument(body); + return { rows: parsed.ok ? parsed.doc.rows : [], reachable: true }; + } catch { + return { rows: [], reachable: false }; + } + } + return { - readRoster: () => { - try { - // An absent roster file is a fresh/empty fleet (reachable), NOT host-down. - if (!fs.existsSync(rosterFile)) return { rows: [] as RosterRowLike[], reachable: true }; - const parsed = parseRosterDocument(JSON.parse(fs.readFileSync(rosterFile, "utf8"))); - return { rows: parsed.ok ? parsed.doc.rows : [], reachable: true }; - } catch { - // A genuine I/O error is the honest unreachable signal (AC6). - return { rows: [], reachable: false }; - } + readRoster: () => (readServeStance() === "client" ? readHostRoster() : readLocalRoster()), + readCanonicalServer: () => { + // The server node is a client-only synthesis: a server sees itself as its + // self-row; a standalone has no canonical to point at. + if (readServeStance() !== "client") return null; + const c = readCanonical(); + const id = c?.host ?? c?.sshAlias; + if (!id) return null; + return { machineId: id, name: id }; }, readPosture: () => { const serverMode = readServeStance(); @@ -770,16 +845,30 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { */ private pushFleetStatus(): void { if (!this.fleetDeps) return; - const roster = this.fleetDeps.readRoster(); - const model = buildFleetSectionModel({ - roster: roster.rows, - rosterReachable: roster.reachable, - posture: this.fleetDeps.readPosture(), - manageAvailable: this.fleetDeps.isFleetManagerAvailable(), - troubleshootAvailable: true, - localDevice: this.fleetDeps.readLocalDevice?.() ?? null, - }); - this.postDown({ kind: "fleet-status", model }); + const deps = this.fleetDeps; + const result = deps.readRoster(); + const build = (roster: RosterRead) => { + // Re-check: an async read may resolve after the view/deps were torn down. + if (!this.fleetDeps) return; + const model = buildFleetSectionModel({ + roster: roster.rows, + rosterReachable: roster.reachable, + posture: deps.readPosture(), + manageAvailable: deps.isFleetManagerAvailable(), + troubleshootAvailable: true, + localDevice: deps.readLocalDevice?.() ?? null, + canonicalServer: deps.readCanonicalServer?.() ?? null, + }); + this.postDown({ kind: "fleet-status", model }); + }; + // The SERVER/standalone read is synchronous — post immediately (keeps every + // existing synchronous injection posting in the same tick). The CLIENT read + // is a Promise (the host proxy-read) — build when it resolves (#1363). + if (result instanceof Promise) { + void result.then(build).catch(() => build({ rows: [], reachable: false })); + } else { + build(result); + } } /** diff --git a/packages/extension/test/sidebar_fleet_section.test.ts b/packages/extension/test/sidebar_fleet_section.test.ts index f3f6f02f3..c25e6b9e2 100644 --- a/packages/extension/test/sidebar_fleet_section.test.ts +++ b/packages/extension/test/sidebar_fleet_section.test.ts @@ -212,6 +212,55 @@ describe("buildFleetSectionModel — not registered with a fleet (#1359)", () => }); }); +describe("buildFleetSectionModel — canonical-server synthesis on a client (#1363)", () => { + it("synthesizes a server row from canonicalServer when the roster carries none for it", () => { + const model = buildFleetSectionModel(input({ + roster: [], + localDevice: { machineId: "laptop-01", name: "JJ's Laptop", serveStance: "client", deviceType: "laptop" }, + canonicalServer: { machineId: "jjs-mac-studio", name: "jjs-mac-studio" }, + })); + expect(model.state).toBe("populated"); + const server = model.devices.find((d) => d.machineId === "jjs-mac-studio"); + expect(server).toBeDefined(); + expect(server!.role).toBe("server"); // the canonical node is the server + expect(server!.isLocal).toBe(false); + expect(server!.health).toBe("reachable"); // reachable roster ⇒ server reachable + }); + + it("does not duplicate the server row when the host roster already carries it", () => { + const model = buildFleetSectionModel(input({ + roster: [row({ machine_id: "jjs-mac-studio", name: "Mac Studio", server_mode: "server" })], + localDevice: { machineId: "laptop-01", name: "Laptop", serveStance: "client" }, + canonicalServer: { machineId: "jjs-mac-studio", name: "jjs-mac-studio" }, + })); + const serverRows = model.devices.filter((d) => d.machineId === "jjs-mac-studio"); + expect(serverRows).toHaveLength(1); + }); + + it("does not synthesize a server row for the local machine (the server never doubles its own self-row)", () => { + const model = buildFleetSectionModel(input({ + roster: [], + localDevice: { machineId: "jjs-mac-studio", name: "Mac Studio", serveStance: "server" }, + canonicalServer: { machineId: "jjs-mac-studio", name: "jjs-mac-studio" }, + })); + const serverRows = model.devices.filter((d) => d.machineId === "jjs-mac-studio"); + expect(serverRows).toHaveLength(1); + expect(serverRows[0].isLocal).toBe(true); // it's the self-row, not a synthesized peer + }); + + it("still shows the canonical server (as down) when the host roster is unreachable (#1359-style known fact)", () => { + const model = buildFleetSectionModel(input({ + rosterReachable: false, + localDevice: { machineId: "laptop-01", name: "Laptop", serveStance: "client" }, + canonicalServer: { machineId: "jjs-mac-studio", name: "jjs-mac-studio" }, + })); + expect(model.state).toBe("unreachable"); + const server = model.devices.find((d) => d.machineId === "jjs-mac-studio"); + expect(server).toBeDefined(); + expect(server!.health).toBe("down"); // known to exist, but unreachable — never fabricated as healthy + }); +}); + // ── renderFleetSection (DOM) ───────────────────────────────────────────────── function populatedModel(over: Partial = {}): FleetSectionModel { diff --git a/packages/extension/test/sidebar_view.test.ts b/packages/extension/test/sidebar_view.test.ts index 56cc233af..be91cb4e0 100644 --- a/packages/extension/test/sidebar_view.test.ts +++ b/packages/extension/test/sidebar_view.test.ts @@ -318,6 +318,33 @@ describe("SidebarViewProvider — fleet section host wiring (#1321)", () => { expect(msg.model.posture.linkHealth).toBe("ok"); }); + it("on a client, an async roster read resolves and posts a fleet-status that includes the synthesized canonical-server node (#1363)", async () => { + const { deps, state } = fleetHarness(); + // Simulate the CLIENT path: async roster read (host proxy) + a canonical + // server pointer + a client self-row. The server is NOT in the roster. + state.localDevice = { machineId: "laptop-01", name: "JJ's Laptop", serveStance: "client", deviceType: "laptop" }; + const asyncDeps = { + ...deps, + readRoster: () => Promise.resolve({ rows: [], reachable: true }), + readLocalDevice: () => state.localDevice, + readCanonicalServer: () => ({ machineId: "jjs-mac-studio", name: "jjs-mac-studio" }), + }; + const provider = new SidebarViewProvider(makeExtensionUri(), undefined, asyncDeps); + const view = makeWebviewView(); + resolve(provider, view); + // The async read posts on a later microtask — flush it. + await new Promise((r) => setTimeout(r, 0)); + + const msg = lastFleetStatus(view); + expect(msg).toBeDefined(); + const ids = msg.model.devices.map((d: any) => d.machineId); + expect(ids).toContain("laptop-01"); // the client's own self-row + expect(ids).toContain("jjs-mac-studio"); // the synthesized canonical server + const server = msg.model.devices.find((d: any) => d.machineId === "jjs-mac-studio"); + expect(server.role).toBe("server"); + expect(server.isLocal).toBe(false); + }); + it("re-posts fleet-status with updated health on a posture-change event (AC5, no manual reload)", () => { const { state, deps } = fleetHarness(); const provider = new SidebarViewProvider(makeExtensionUri(), undefined, deps); @@ -482,6 +509,88 @@ describe("defaultFleetSectionDeps — production readers (#1321)", () => { expect(out.rows).toEqual([]); }); + it("readRoster on a CLIENT proxy-reads GET /amicode/roster from the host with the credential (#1363)", async () => { + const fs = await import("node:fs"); + const fleetConfigFile = resolve(tmp, "fleet-client.json"); + fs.writeFileSync(fleetConfigFile, JSON.stringify({ role: "client", canonical: { host: "jjs-mac-studio", port: 4096, sshAlias: "jjs-mac-studio" } })); + const fetchImpl = vi.fn(async () => ({ + ok: true, status: 200, + json: async () => ({ + ok: true, schema_version: 1, error: null, + rows: [{ machine_id: "peer-1", name: "Peer", server_mode: "client", capabilities: [], sshAlias: "p", transport: "ssh", last_report: "t", health: "reachable" }], + }), + })); + const deps = defaultFleetSectionDeps({ + fleetConfigFile, + fetchImpl: fetchImpl as any, + serviceEndpoint: () => ({ origin: "http://127.0.0.1:4095", authHeader: "Bearer x" }), + }); + const out = await deps.readRoster(); + expect(out.reachable).toBe(true); + expect(out.rows).toHaveLength(1); + expect(out.rows[0].machine_id).toBe("peer-1"); + expect(fetchImpl).toHaveBeenCalledWith( + "http://127.0.0.1:4095/amicode/roster", + expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer x" }) }), + ); + }); + + it("readRoster on a CLIENT degrades to reachable=false when the host proxy read throws (#1363)", async () => { + const fs = await import("node:fs"); + const fleetConfigFile = resolve(tmp, "fleet-client-2.json"); + fs.writeFileSync(fleetConfigFile, JSON.stringify({ role: "client", canonical: { host: "h", port: 4096, sshAlias: "h" } })); + const fetchImpl = vi.fn(async () => { throw new Error("ECONNREFUSED"); }); + const deps = defaultFleetSectionDeps({ + fleetConfigFile, + fetchImpl: fetchImpl as any, + serviceEndpoint: () => ({ origin: "http://127.0.0.1:4095", authHeader: "Bearer x" }), + }); + const out = await deps.readRoster(); + expect(out.reachable).toBe(false); + expect(out.rows).toEqual([]); + }); + + it("readRoster on a CLIENT with no live service endpoint degrades to reachable=false (#1363)", async () => { + const fs = await import("node:fs"); + const fleetConfigFile = resolve(tmp, "fleet-client-3.json"); + fs.writeFileSync(fleetConfigFile, JSON.stringify({ role: "client", canonical: { host: "h", port: 4096, sshAlias: "h" } })); + const deps = defaultFleetSectionDeps({ fleetConfigFile, serviceEndpoint: () => null }); + const out = await deps.readRoster(); + expect(out.reachable).toBe(false); + expect(out.rows).toEqual([]); + }); + + it("readRoster on a SERVER reads the LOCAL roster cache and never hits the network (#1363)", async () => { + const fs = await import("node:fs"); + const rosterFile = resolve(tmp, "roster-server.json"); + const fleetConfigFile = resolve(tmp, "fleet-server.json"); + fs.writeFileSync(fleetConfigFile, JSON.stringify({ role: "server", canonical: {} })); + fs.writeFileSync(rosterFile, JSON.stringify({ + schema_version: 1, + rows: [{ machine_id: "a", name: "A", server_mode: "client", capabilities: [], sshAlias: "a", transport: "ssh", last_report: "t", health: "reachable" }], + })); + const fetchImpl = vi.fn(); + const deps = defaultFleetSectionDeps({ rosterFile, fleetConfigFile, fetchImpl: fetchImpl as any }); + const out = await deps.readRoster(); + expect(out.reachable).toBe(true); + expect(out.rows).toHaveLength(1); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("readCanonicalServer returns the canonical server on a CLIENT, null on a SERVER (#1363)", async () => { + const fs = await import("node:fs"); + const clientCfg = resolve(tmp, "fleet-cs-client.json"); + const serverCfg = resolve(tmp, "fleet-cs-server.json"); + fs.writeFileSync(clientCfg, JSON.stringify({ role: "client", canonical: { host: "jjs-mac-studio", port: 4096, sshAlias: "jjs-mac-studio" } })); + fs.writeFileSync(serverCfg, JSON.stringify({ role: "server", canonical: { host: "jjs-mac-studio", port: 4096, sshAlias: "jjs-mac-studio" } })); + const clientDeps = defaultFleetSectionDeps({ fleetConfigFile: clientCfg }); + const serverDeps = defaultFleetSectionDeps({ fleetConfigFile: serverCfg }); + const cs = clientDeps.readCanonicalServer?.(); + expect(cs).not.toBeNull(); + expect(cs!.machineId).toBe("jjs-mac-studio"); + expect(serverDeps.readCanonicalServer?.()).toBeNull(); + }); + it("readPosture folds fleet.json role (Server mode) with the posture-state link-health", async () => { const fs = await import("node:fs"); const postureFile = resolve(tmp, "posture-state.json");