From 1680fb012f8f4831c36b433f1816ae8e61fd682a Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 4 Sep 2026 00:37:36 -0400 Subject: [PATCH] feat(fleet): surface machine posture in agent context (#780) --- .../extension/opencode-plugin/stack_state.ts | 158 +++++++++++++-- packages/extension/src/extension.ts | 51 ++++- packages/extension/src/fleet_attach_state.ts | 123 ++++++++++++ .../extension/test/fleet_attach_state.test.ts | 182 ++++++++++++++++++ packages/extension/test/stack_state.test.ts | 174 ++++++++++++++++- 5 files changed, 674 insertions(+), 14 deletions(-) create mode 100644 packages/extension/src/fleet_attach_state.ts create mode 100644 packages/extension/test/fleet_attach_state.test.ts diff --git a/packages/extension/opencode-plugin/stack_state.ts b/packages/extension/opencode-plugin/stack_state.ts index 4488d3f7..8a2e5885 100644 --- a/packages/extension/opencode-plugin/stack_state.ts +++ b/packages/extension/opencode-plugin/stack_state.ts @@ -369,20 +369,156 @@ function readFleetStatus(statusPath?: string): FleetStatusSummary | undefined { }; } +/** Fleet posture state, written by the EXTENSION on attach-state transitions + * (#780). This plugin is a READ-ONLY consumer — no network probes, file + * reads only. Schema lives in src/fleet_attach_state.ts (single writer); + * this strict reader must stay in sync with it. */ +function fleetAttachStateFile(override?: string): string { + if (override) return override; + const env = process.env.AMICO_FLEET_ATTACH_STATE; + if (env && env.trim() !== "") return env.trim(); + return path.join(os.homedir(), ".amico", "ops", "fleet", "attach-state.json"); +} + +/** Stale beyond this → posture claims degrade to timestamped history, never + * current-tense. Kept in step with the issue's suggested 10 min. */ +const ATTACH_STATE_TTL_MS = 10 * 60_000; + +interface FleetAttachState { + hostname: string; + mode: "fleet" | "standalone" | "degraded"; + hubName?: string; + hubBaseUrl?: string; + reachable: boolean; + lastOkAt?: string; + lastRttMs?: number; + since: string; + updatedAt: string; +} + +type AttachStateRead = + | { state: FleetAttachState; fresh: boolean } + | { corrupt: true } + | undefined; + +function readAttachState(override?: string): AttachStateRead { + let raw: unknown; + try { + raw = JSON.parse(fs.readFileSync(fleetAttachStateFile(override), "utf8")); + } catch { + return undefined; // missing file — the honest fallback is the role line + } + if (typeof raw !== "object" || raw === null) return { corrupt: true }; + const o = raw as Record; + const mode = o.mode; + if (mode !== "fleet" && mode !== "standalone" && mode !== "degraded") return { corrupt: true }; + if (typeof o.hostname !== "string" || o.hostname === "") return { corrupt: true }; + if (typeof o.reachable !== "boolean") return { corrupt: true }; + if (typeof o.updatedAt !== "string" || Number.isNaN(Date.parse(o.updatedAt))) return { corrupt: true }; + const updatedAt = o.updatedAt; + const state: FleetAttachState = { + hostname: o.hostname, + mode, + hubName: typeof o.hubName === "string" ? o.hubName : undefined, + hubBaseUrl: typeof o.hubBaseUrl === "string" ? o.hubBaseUrl : undefined, + reachable: o.reachable, + lastOkAt: typeof o.lastOkAt === "string" ? o.lastOkAt : undefined, + lastRttMs: typeof o.lastRttMs === "number" ? o.lastRttMs : undefined, + since: typeof o.since === "string" && !Number.isNaN(Date.parse(o.since)) ? o.since : updatedAt, + updatedAt, + }; + return { state, fresh: Date.now() - Date.parse(updatedAt) <= ATTACH_STATE_TTL_MS }; +} + +function ageMinOf(iso: string | undefined): number | undefined { + if (!iso) return undefined; + const t = Date.parse(iso); + if (Number.isNaN(t)) return undefined; + return Math.max(0, Math.round((Date.now() - t) / 60000)); +} + +function ageStr(min: number): string { + if (min < 60) return `${min} min`; + if (min < 60 * 48) return `${Math.round(min / 60)} h`; + return `${Math.round(min / 1440)} d`; +} + +/** Posture lines appended to the fleet section. Every claim is timestamped or + * explicitly unknown (constraint: never render "healthy" from stale data). */ +function buildPostureLines(read: AttachStateRead): string[] { + if (!read) return []; + if ("corrupt" in read) { + return [ + "- ⚠ Machine posture unknown: `attach-state.json` is corrupt/unreadable — treat any attached-to-hub claim as unverified.", + ]; + } + const { state, fresh } = read; + const hub = + [state.hubName, state.hubBaseUrl ? `\`${state.hubBaseUrl}\`` : ""].filter(Boolean).join(" ") || + "(hub unspecified)"; + if (!fresh) { + const updAge = ageMinOf(state.updatedAt); + const lastVerified = + state.mode === "fleet" + ? `hub ${hub} reachable as of ${ageStr(updAge ?? 0)} ago` + : state.mode === "degraded" + ? `hub ${hub} UNREACHABLE since ${ageStr(ageMinOf(state.since) ?? 0)} ago` + + (state.lastOkAt ? ` (last ok ${ageStr(ageMinOf(state.lastOkAt) ?? 0)} ago)` : "") + : `standalone since ${ageStr(ageMinOf(state.since) ?? 0)} ago`; + return [ + `- ⚠ Machine posture is STALE (updated ${ageStr(updAge ?? 0)} ago, beyond the ${Math.round(ATTACH_STATE_TTL_MS / 60000)} min TTL) — current reachability UNKNOWN; the extension may not be running.`, + `- Last verified: ${lastVerified}.`, + ]; + } + const updAge = ageMinOf(state.updatedAt) ?? 0; + if (state.mode === "fleet") { + const rtt = state.lastRttMs !== undefined ? `, RTT ${state.lastRttMs} ms` : ""; + return [ + `- Posture: machine **${state.hostname}** attached to hub ${hub} — reachable ✓ — last ok ${ageStr(ageMinOf(state.lastOkAt) ?? updAge)} ago${rtt}, updated ${ageStr(updAge)} ago.`, + ]; + } + if (state.mode === "degraded") { + const lastOk = state.lastOkAt ? `; last ok ${ageStr(ageMinOf(state.lastOkAt) ?? 0)} ago` : ""; + return [ + `- Posture: machine **${state.hostname}** — hub ${hub} UNREACHABLE since ${ageStr(ageMinOf(state.since) ?? 0)} ago — degraded${lastOk}. Do not treat the hub as up.`, + ]; + } + return [ + `- Posture: machine **${state.hostname}** — STANDALONE (no hub), went standalone ${ageStr(ageMinOf(state.since) ?? 0)} ago — sessions run locally; do not treat this machine as fleet-attached.`, + ]; +} + /** Lean fleet line + on-demand pointers (the reader's choice: detail loads * from fleet-status.json / the fleet skill only when relevant). Absent - * fleet.json (standalone or no fleet tooling) → "" — nothing to say. */ -function buildFleetSection(opts: { configPath?: string; statusPath?: string } = {}): string { + * fleet.json (standalone or no fleet tooling) → "" — nothing to say, unless + * a posture state file exists (#780: a standalone machine says so + * explicitly even with no role config). */ +function buildFleetSection(opts: { configPath?: string; statusPath?: string; statePath?: string } = {}): string { const role = readFleetRole(opts.configPath); - if (role === null) return ""; - - const roleText = - role === "server" - ? "**server** — this machine is the canonical Amicode server" - : role === "client" - ? "**client** — rides the tunnel to the canonical server" - : `**${role}**`; - const lines = [`## Fleet (live)`, `Role: ${roleText} (\`~/.amico/ops/fleet/fleet.json\`).`]; + const posture = readAttachState(opts.statePath); + if (role === null && !posture) return ""; + + // Live truth wins: a fresh attach-state saying standalone overrides a stale + // "client" role line — never render a machine as attached when it is not. + let roleText: string | null; + if (posture && "state" in posture && posture.fresh && posture.state.mode === "standalone") { + roleText = + `**standalone** — NOT fleet-attached (attach-state.json overrides` + + (role ? ` fleet.json's "${role}" role line)` : " absent fleet.json)"); + } else if (role !== null) { + roleText = + role === "server" + ? "**server** — this machine is the canonical Amicode server" + : role === "client" + ? "**client** — rides the tunnel to the canonical server" + : `**${role}**`; + } else { + roleText = null; + } + + const lines = [`## Fleet (live)`]; + if (roleText !== null) lines.push(`Role: ${roleText} (\`~/.amico/ops/fleet/fleet.json\`).`); + lines.push(...buildPostureLines(posture)); const status = readFleetStatus(opts.statusPath); if (status) { diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 6f8f3c05..4840f85b 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -59,6 +59,7 @@ import { import { probeCommand, formatHealthReport, probeOpencodeTui, type HealthResult } from "./healthcheck"; import { fleetHealthReport, FLEET_GUARD_REL } from "./fleet_health"; import { isFleetClient, getFleetRole, goStandalone, readFleetConfig, migrateLegacyFallback } from "./fleet_fallback"; +import { postureTransition, writeAttachState, readAttachState, recordStandalonePosture, standaloneHubFromConfig } from "./fleet_attach_state"; import { resolveHubTarget, restartHub } from "./hub_ops"; import { registerAmicodeTerminal } from "./terminal"; import { amicodeServiceDisposal, startAmicodeService } from "./amicode_service_wiring"; @@ -573,6 +574,27 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { if (binary !== undefined && fleetClient) { const fleetCfg = readFleetConfig(); const fleetPort = fleetCfg?.canonical?.port ?? 4096; + // #780: the extension is the single writer of attach-state.json (the + // agent-context plugin only reads it). Writes happen on attach-state + // TRANSITIONS (attach / degrade / hub-lost / hub-regained), never per + // poll tick — steady-state claims stay timestamped at their transition. + const postureHub = { + name: fleetCfg?.canonical?.sshAlias ?? fleetCfg?.canonical?.host ?? "canonical", + baseUrl: `http://127.0.0.1:${fleetPort}`, + }; + let posturePrev: ReturnType = readAttachState(); + const recordClientPosture = (up: boolean, rttMs?: number): void => { + try { + const { changed, state } = postureTransition(posturePrev, { up, rttMs }, Date.now(), postureHub); + if (changed && state) { + writeAttachState(state); + posturePrev = state; + opencodeChannel.appendLine(`[fleet] posture: ${state.mode} (attach-state.json updated)`); + } + } catch (e) { + opencodeChannel.appendLine(`[fleet] posture write failed: ${(e as Error).message}`); + } + }; opencodeChannel.appendLine(`[fleet] client mode — guard ${binary} would refuse on ${os.hostname()} — riding tunnel 127.0.0.1:${fleetPort}`); opencodeChannel.appendLine(`[fleet] hint: canonical offline? Palette → Amicode: Fleet — Go Standalone`); // Distiller still arms on the client (uses vendored binary directly, not the guard) @@ -613,12 +635,16 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { let fleetNotified = false; const checkFleet = async () => { fleetChecks++; + let up = false; + let rttMs: number | undefined; try { + const t0 = Date.now(); const r = await fetch(`http://127.0.0.1:${fleetPort}/`, { signal: AbortSignal.timeout(1500), headers: serverAuthHeaders, }); - const up = r.ok || (r.status >= 200 && r.status < 400); + rttMs = Date.now() - t0; + up = r.ok || (r.status >= 200 && r.status < 400); if (up && !fleetReady) { fleetReady = true; fleetNotified = false; @@ -651,6 +677,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { else if (pick === `Show log`) opencodeChannel.show(); }); } + recordClientPosture(up, rttMs); } catch { if (fleetReady) { fleetReady = false; @@ -670,6 +697,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { else if (pick === `Show log`) opencodeChannel.show(); }); } + recordClientPosture(false); } }; fleetClientPoll = setInterval(() => void checkFleet(), 2000); @@ -1319,6 +1347,18 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // Migrate legacy fallback.json on activation. migrateLegacyFallback(); + // #780: a standalone machine (incl. never-fleeted) states its posture + // explicitly in agent context — record it at activation so the state file + // exists before any session asks. Client machines are owned by the attach + // loop; hub-server machines keep the plain role line (dev's call, minimal). + if (getFleetRole() === "standalone") { + try { + recordStandalonePosture({ hub: standaloneHubFromConfig(readFleetConfig()) }); + } catch { + // best-effort — the plugin degrades honestly when the file is absent + } + } + const fleetStatusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 99); ctx.subscriptions.push(fleetStatusItem); const refreshFleetStatus = (): void => { @@ -1351,7 +1391,16 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const cfg = vscode.workspace.getConfiguration("amicode"); const prevBinary = cfg.get("opencodeBinary", ""); const prevPort = cfg.get("opencodePort", 0); + // #780: leaving the fleet is an attach-state transition — persist it so the + // agent context names the machine as standalone with the fallback time. + // Hub identity is captured BEFORE the config write (goStandalone drops it). + const leavingHub = standaloneHubFromConfig(getFleetRole() === "client" ? readFleetConfig() : null); goStandalone({ previousBinary: prevBinary, previousPort: prevPort }); + try { + recordStandalonePosture({ hub: leavingHub }); + } catch (e) { + opencodeChannel.appendLine(`[fleet] posture write failed: ${(e as Error).message}`); + } try { // Clear the fleet guard override → vendored binary, ephemeral port await cfg.update("opencodeBinary", "", vscode.ConfigurationTarget.Global); diff --git a/packages/extension/src/fleet_attach_state.ts b/packages/extension/src/fleet_attach_state.ts new file mode 100644 index 00000000..93ab69ed --- /dev/null +++ b/packages/extension/src/fleet_attach_state.ts @@ -0,0 +1,123 @@ +// Machine-posture state for the agent-context plugin (#780) — the SINGLE +// WRITER of ~/.amico/ops/fleet/attach-state.json, written by the extension's +// fleet attach loop on attach-state TRANSITIONS only (attach, degrade, +// hub-lost, hub-regained — never per poll tick) and on standalone-mode +// changes. The plugin (opencode-plugin/stack_state.ts) is read-only: it +// renders the posture block from this file and does NO network probes. +// +// Schema (the plugin's strict reader in stack_state.ts must stay in sync): +// { hostname, mode: "fleet"|"standalone"|"degraded", hubName?, hubBaseUrl?, +// reachable, lastOkAt?, lastRttMs?, since, updatedAt } // ISO strings +// +// `since` = when the current mode was entered (the fallback time a standalone +// machine names); `lastOkAt` = last successful probe (preserved across +// degrade). `fleet.json` remains the role config — this file is live truth. + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { hostname } from "node:os"; +import { FLEET_DIR } from "./fleet_fallback"; + +export const FLEET_ATTACH_STATE_PATH = path.join(FLEET_DIR, "attach-state.json"); + +export type FleetAttachMode = "fleet" | "standalone" | "degraded"; + +export interface FleetAttachState { + hostname: string; + mode: FleetAttachMode; + hubName?: string; + hubBaseUrl?: string; + reachable: boolean; + /** Last successful probe (ISO). Preserved when the hub degrades. */ + lastOkAt?: string; + lastRttMs?: number; + /** When the current mode was entered (ISO) — the standalone fallback time. */ + since: string; + updatedAt: string; +} + +/** Previous posture from disk. Corrupt or missing → undefined (unknown). */ +export function readAttachState(p: string = FLEET_ATTACH_STATE_PATH): FleetAttachState | undefined { + try { + const parsed = JSON.parse(fs.readFileSync(p, "utf8")) as FleetAttachState; + if (parsed && typeof parsed === "object" && typeof parsed.mode === "string") return parsed; + return undefined; + } catch { + return undefined; + } +} + +/** Atomic write (tmp + rename) — the plugin may read concurrently. */ +export function writeAttachState(state: FleetAttachState, p: string = FLEET_ATTACH_STATE_PATH): void { + fs.mkdirSync(path.dirname(p), { recursive: true }); + const tmp = `${p}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n"); + fs.renameSync(tmp, p); +} + +/** The transition decision for the client attach loop: a write is due ONLY + * when the attach state changes (unknown→attach, attach→degraded, + * degraded→attach). Steady fleet or steady degraded = no write — the file's + * claims stay timestamped at their transition. A corrupt/missing previous + * state is treated as unknown (so the first probe after corruption writes). */ +export function postureTransition( + prev: FleetAttachState | "corrupt" | undefined, + probe: { up: boolean; rttMs?: number }, + nowMs: number, + hub: { name?: string; baseUrl?: string } = {}, + host: string = hostname(), +): { changed: boolean; state?: FleetAttachState } { + const prevMode = prev && prev !== "corrupt" ? prev.mode : undefined; + const mode: FleetAttachMode = probe.up ? "fleet" : "degraded"; + if (prevMode === mode) return { changed: false }; + const now = new Date(nowMs).toISOString(); + const state: FleetAttachState = { + hostname: host, + mode, + ...(hub.name ? { hubName: hub.name } : {}), + ...(hub.baseUrl ? { hubBaseUrl: hub.baseUrl } : {}), + reachable: probe.up, + ...(probe.up + ? { lastOkAt: now, ...(probe.rttMs !== undefined ? { lastRttMs: probe.rttMs } : {}) } + : prev && prev !== "corrupt" && prev.lastOkAt + ? { lastOkAt: prev.lastOkAt, ...(prev.lastRttMs !== undefined ? { lastRttMs: prev.lastRttMs } : {}) } + : {}), + since: now, + updatedAt: now, + }; + return { changed: true, state }; +} + +/** Write (or refresh) the standalone posture. A machine already standalone + * keeps its original `since` — the fallback time must not drift across + * extension restarts. A transition out of fleet/degraded (Go Standalone) + * stamps since=now. */ +export function recordStandalonePosture( + opts: { path?: string; hostname?: string; nowMs?: number; hub?: { name?: string; baseUrl?: string } } = {}, +): FleetAttachState { + const p = opts.path ?? FLEET_ATTACH_STATE_PATH; + const nowMs = opts.nowMs ?? Date.now(); + const now = new Date(nowMs).toISOString(); + const prev = readAttachState(p); + const state: FleetAttachState = { + hostname: opts.hostname ?? hostname(), + mode: "standalone", + ...(opts.hub?.name ? { hubName: opts.hub.name } : {}), + ...(opts.hub?.baseUrl ? { hubBaseUrl: opts.hub.baseUrl } : {}), + reachable: false, + ...(prev?.mode === "standalone" && prev.since ? { since: prev.since } : { since: now }), + updatedAt: now, + }; + writeAttachState(state, p); + return state; +} + +// Default-path convenience for callers that need the standalone fallback hub +// identity from the (possibly stale) fleet config. +export function standaloneHubFromConfig(cfg: { canonical?: { host?: string; port?: number; sshAlias?: string } } | null): { name?: string; baseUrl?: string } { + const name = cfg?.canonical?.sshAlias ?? cfg?.canonical?.host; + return { + ...(name ? { name } : {}), + ...(cfg?.canonical?.port ? { baseUrl: `http://127.0.0.1:${cfg.canonical.port}` } : {}), + }; +} diff --git a/packages/extension/test/fleet_attach_state.test.ts b/packages/extension/test/fleet_attach_state.test.ts new file mode 100644 index 00000000..9aefd2e2 --- /dev/null +++ b/packages/extension/test/fleet_attach_state.test.ts @@ -0,0 +1,182 @@ +// Tests for the extension-side attach-state writer (#780): the single writer +// of ~/.amico/ops/fleet/attach-state.json. The plugin (opencode-plugin/ +// stack_state.ts) only READS this file — see test/stack_state.test.ts for the +// rendering contract. Schema (both sides must agree): +// { hostname, mode: fleet|standalone|degraded, hubName?, hubBaseUrl?, +// reachable, lastOkAt?, lastRttMs?, since, updatedAt } +import { describe, it, expect } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + postureTransition, + writeAttachState, + readAttachState, + recordStandalonePosture, + type FleetAttachState, +} from "../src/fleet_attach_state"; + +function mkTmp(prefix: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +const T0 = Date.parse("2026-09-03T12:00:00.000Z"); +const iso = (ms: number): string => new Date(ms).toISOString(); + +function fleetState(overrides: Partial = {}): FleetAttachState { + return { + hostname: "macbook", + mode: "fleet", + hubName: "erlich", + hubBaseUrl: "http://127.0.0.1:4096", + reachable: true, + lastOkAt: iso(T0 - 5000), + lastRttMs: 3, + since: iso(T0 - 5000), + updatedAt: iso(T0 - 5000), + ...overrides, + }; +} + +describe("postureTransition (transition-only write decision)", () => { + it("unknown → up: attach transition, mode fleet, reachable, rtt recorded", () => { + const { changed, state } = postureTransition(undefined, { up: true, rttMs: 7 }, T0, { + name: "erlich", + baseUrl: "http://127.0.0.1:4096", + }, "macbook"); + expect(changed).toBe(true); + expect(state?.mode).toBe("fleet"); + expect(state?.reachable).toBe(true); + expect(state?.hostname).toBe("macbook"); + expect(state?.hubName).toBe("erlich"); + expect(state?.hubBaseUrl).toBe("http://127.0.0.1:4096"); + expect(state?.lastOkAt).toBe(iso(T0)); + expect(state?.lastRttMs).toBe(7); + expect(state?.since).toBe(iso(T0)); + expect(state?.updatedAt).toBe(iso(T0)); + }); + + it("unknown → down: hub-lost transition, mode degraded, no lastOk", () => { + const { changed, state } = postureTransition(undefined, { up: false }, T0, {}); + expect(changed).toBe(true); + expect(state?.mode).toBe("degraded"); + expect(state?.reachable).toBe(false); + expect(state?.lastOkAt).toBeUndefined(); + }); + + it("fleet → down: degrade transition preserves lastOkAt from the fleet state", () => { + const prev = fleetState(); + const { changed, state } = postureTransition(prev, { up: false }, T0, {}); + expect(changed).toBe(true); + expect(state?.mode).toBe("degraded"); + expect(state?.reachable).toBe(false); + expect(state?.lastOkAt).toBe(prev.lastOkAt); + expect(state?.since).toBe(iso(T0)); + }); + + it("degraded → up: hub-regained transition", () => { + const prev = fleetState({ mode: "degraded", reachable: false, lastOkAt: iso(T0 - 600_000), since: iso(T0 - 600_000) }); + const { changed, state } = postureTransition(prev, { up: true, rttMs: 5 }, T0, {}); + expect(changed).toBe(true); + expect(state?.mode).toBe("fleet"); + expect(state?.reachable).toBe(true); + expect(state?.lastOkAt).toBe(iso(T0)); + expect(state?.since).toBe(iso(T0)); + }); + + it("fleet → fleet: NO transition, no write (steady attach is not a tick)", () => { + const prev = fleetState(); + const { changed, state } = postureTransition(prev, { up: true, rttMs: 9 }, T0, {}); + expect(changed).toBe(false); + expect(state).toBeUndefined(); + }); + + it("degraded → degraded: NO transition, no write", () => { + const prev = fleetState({ mode: "degraded", reachable: false }); + const { changed, state } = postureTransition(prev, { up: false }, T0, {}); + expect(changed).toBe(false); + expect(state).toBeUndefined(); + }); + + it("a corrupt previous state is treated as unknown (transitions out of it)", () => { + const a = postureTransition("corrupt", { up: true }, T0, {}); + expect(a.changed).toBe(true); + expect(a.state?.mode).toBe("fleet"); + const b = postureTransition("corrupt", { up: false }, T0, {}); + expect(b.changed).toBe(true); + expect(b.state?.mode).toBe("degraded"); + }); +}); + +describe("writeAttachState (atomic single-writer write)", () => { + it("writes valid JSON with the full schema; no tmp file left behind", () => { + const dir = mkTmp("attach-state-"); + const p = path.join(dir, "nested", "attach-state.json"); + const state = fleetState(); + writeAttachState(state, p); + const raw = JSON.parse(fs.readFileSync(p, "utf8")) as FleetAttachState; + expect(raw).toEqual(state); + expect(fs.readdirSync(path.dirname(p)).filter((f) => f.endsWith(".tmp"))).toEqual([]); + }); + + it("readAttachState round-trips what was written", () => { + const dir = mkTmp("attach-state-"); + const p = path.join(dir, "attach-state.json"); + writeAttachState(fleetState(), p); + expect(readAttachState(p)).toEqual(fleetState()); + }); + + it("readAttachState: missing file → undefined, corrupt file → undefined (never throws)", () => { + const dir = mkTmp("attach-state-"); + expect(readAttachState(path.join(dir, "absent.json"))).toBeUndefined(); + const bad = path.join(dir, "bad.json"); + fs.writeFileSync(bad, "{not json"); + expect(readAttachState(bad)).toBeUndefined(); + }); +}); + +describe("recordStandalonePosture (standalone machines state so explicitly)", () => { + it("no existing state → writes standalone posture with since=now, reachable=false", () => { + const dir = mkTmp("attach-state-"); + const p = path.join(dir, "attach-state.json"); + const state = recordStandalonePosture({ path: p, hostname: "macbook", nowMs: T0 }); + expect(state.mode).toBe("standalone"); + expect(state.reachable).toBe(false); + expect(state.hostname).toBe("macbook"); + expect(state.since).toBe(iso(T0)); + expect(state.updatedAt).toBe(iso(T0)); + expect(fs.existsSync(p)).toBe(true); + }); + + it("existing standalone state → preserves the original since (fallback time), refreshes updatedAt", () => { + const dir = mkTmp("attach-state-"); + const p = path.join(dir, "attach-state.json"); + writeAttachState(fleetState({ mode: "standalone", reachable: false, since: iso(T0 - 86_400_000) }), p); + const state = recordStandalonePosture({ path: p, hostname: "macbook", nowMs: T0 }); + expect(state.mode).toBe("standalone"); + expect(state.since).toBe(iso(T0 - 86_400_000)); + expect(state.updatedAt).toBe(iso(T0)); + }); + + it("existing fleet/degraded state (Go Standalone) → transitions to standalone with since=now", () => { + const dir = mkTmp("attach-state-"); + const p = path.join(dir, "attach-state.json"); + writeAttachState(fleetState({ mode: "degraded", reachable: false, since: iso(T0 - 60_000) }), p); + const state = recordStandalonePosture({ path: p, hostname: "macbook", nowMs: T0 }); + expect(state.mode).toBe("standalone"); + expect(state.since).toBe(iso(T0)); + expect(state.reachable).toBe(false); + }); + + it("carries hub identity when the (possibly stale) fleet config names one", () => { + const dir = mkTmp("attach-state-"); + const state = recordStandalonePosture({ + path: path.join(dir, "attach-state.json"), + hostname: "macbook", + nowMs: T0, + hub: { name: "erlich", baseUrl: "http://127.0.0.1:4096" }, + }); + expect(state.hubName).toBe("erlich"); + expect(state.hubBaseUrl).toBe("http://127.0.0.1:4096"); + }); +}); diff --git a/packages/extension/test/stack_state.test.ts b/packages/extension/test/stack_state.test.ts index 4696448b..1ebdf84d 100644 --- a/packages/extension/test/stack_state.test.ts +++ b/packages/extension/test/stack_state.test.ts @@ -110,8 +110,12 @@ describe("buildFleetSection (lean fleet line + pointers)", () => { // buildFleetSection is module-private; reach it through buildStackStateBlock's // seams for these unit cases (config + status stubbed, everything else empty). -function fleetSectionWith(opts: { configPath?: string; statusPath?: string }): string { - const stubs = stubAllSeams({ fleetConfig: opts.configPath, fleetStatus: opts.statusPath }); +function fleetSectionWith(opts: { configPath?: string; statusPath?: string; attachStatePath?: string }): string { + const stubs = stubAllSeams({ + fleetConfig: opts.configPath, + fleetStatus: opts.statusPath, + fleetAttachState: opts.attachStatePath, + }); try { const block = buildStackStateBlock() ?? ""; const m = block.match(/## Fleet \(live\)[\s\S]*?(?=\n\n## |\n*$)/); @@ -121,6 +125,169 @@ function fleetSectionWith(opts: { configPath?: string; statusPath?: string }): s } } +// ── Fleet posture block (attach-state.json, #780) ──────────────────────────── + +/** The extension's single-writer schema for attach-state.json. */ +function writeAttachFixture(p: string, state: Record): void { + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, JSON.stringify(state)); +} + +describe("fleet posture block (attach-state.json, #780)", () => { + const NOW = Date.now(); + const minsAgo = (m: number): string => new Date(NOW - m * 60_000).toISOString(); + + it("fresh fleet state renders machine, hub identity + endpoint, reachability, last-ok age", () => { + const dir = mkTmp("fleet-"); + const cfg = path.join(dir, "fleet.json"); + fs.writeFileSync(cfg, JSON.stringify({ role: "client", canonical: { host: "erlich", port: 4096 } })); + const state = path.join(dir, "attach-state.json"); + writeAttachFixture(state, { + hostname: "macbook", + mode: "fleet", + hubName: "erlich", + hubBaseUrl: "http://127.0.0.1:4096", + reachable: true, + lastOkAt: minsAgo(0), + lastRttMs: 3, + since: minsAgo(2), + updatedAt: minsAgo(0), + }); + const s = fleetSectionWith({ configPath: cfg, attachStatePath: state }); + expect(s).toContain("## Fleet (live)"); + expect(s).toContain("macbook"); + expect(s).toContain("erlich"); + expect(s).toContain("http://127.0.0.1:4096"); + expect(s).toContain("reachable ✓"); + expect(s).toContain("last ok 0 min ago"); + }); + + it("degraded state renders UNREACHABLE with the last-ok age — never a healthy claim", () => { + const dir = mkTmp("fleet-"); + const cfg = path.join(dir, "fleet.json"); + fs.writeFileSync(cfg, JSON.stringify({ role: "client", canonical: { host: "erlich", port: 4096 } })); + const state = path.join(dir, "attach-state.json"); + writeAttachFixture(state, { + hostname: "macbook", + mode: "degraded", + hubName: "erlich", + hubBaseUrl: "http://127.0.0.1:4096", + reachable: false, + lastOkAt: minsAgo(45), + since: minsAgo(30), + updatedAt: minsAgo(1), + }); + const s = fleetSectionWith({ configPath: cfg, attachStatePath: state }); + expect(s).toContain("macbook"); + expect(s).toContain("UNREACHABLE"); + expect(s).toContain("last ok 45 min ago"); + expect(s).not.toContain("reachable ✓"); + }); + + it("standalone state says so explicitly with the fallback time and does NOT render the client role line as attached", () => { + const dir = mkTmp("fleet-"); + // Stale config still says client — the state file is live truth and must win. + const cfg = path.join(dir, "fleet.json"); + fs.writeFileSync(cfg, JSON.stringify({ role: "client", canonical: { host: "erlich", port: 4096 } })); + const state = path.join(dir, "attach-state.json"); + writeAttachFixture(state, { + hostname: "macbook", + mode: "standalone", + reachable: false, + since: minsAgo(30), + updatedAt: minsAgo(0), + }); + const s = fleetSectionWith({ configPath: cfg, attachStatePath: state }); + expect(s).toContain("macbook"); + expect(/standalone/i.test(s)).toBe(true); + expect(s).toContain("30 min ago"); + // the stale client role line must not read as if the machine is attached + expect(s).not.toContain("**client** — rides the tunnel"); + }); + + it("stale state file (beyond TTL) → no current reachability claim, staleness said honestly", () => { + const dir = mkTmp("fleet-"); + const cfg = path.join(dir, "fleet.json"); + fs.writeFileSync(cfg, JSON.stringify({ role: "client", canonical: { host: "erlich", port: 4096 } })); + const state = path.join(dir, "attach-state.json"); + writeAttachFixture(state, { + hostname: "macbook", + mode: "fleet", + hubName: "erlich", + hubBaseUrl: "http://127.0.0.1:4096", + reachable: true, + lastOkAt: minsAgo(45), + since: minsAgo(60), + updatedAt: minsAgo(45), + }); + const s = fleetSectionWith({ configPath: cfg, attachStatePath: state }); + expect(s).not.toContain("reachable ✓"); + expect(s).toMatch(/stale/i); + // the last-verified claim survives, timestamped + expect(s).toContain("45 min ago"); + }); + + it("corrupt state file → honest unknown line, never a crash, never a healthy claim", () => { + const dir = mkTmp("fleet-"); + const cfg = path.join(dir, "fleet.json"); + fs.writeFileSync(cfg, JSON.stringify({ role: "client" })); + const state = path.join(dir, "attach-state.json"); + writeAttachFixture(state, {} as Record); // missing required fields = corrupt + fs.writeFileSync(state, "{not json at all"); + const s = fleetSectionWith({ configPath: cfg, attachStatePath: state }); + expect(s).toContain("## Fleet (live)"); + expect(s).toMatch(/corrupt|unknown/i); + expect(s).not.toContain("reachable ✓"); + }); + + it("absent state file → falls back to the legacy role line, no posture block", () => { + const dir = mkTmp("fleet-"); + const cfg = path.join(dir, "fleet.json"); + fs.writeFileSync(cfg, JSON.stringify({ role: "client", canonical: { host: "erlich", port: 4096 } })); + const s = fleetSectionWith({ configPath: cfg, attachStatePath: path.join(dir, "nope.json") }); + expect(s).toContain("**client** — rides the tunnel to the canonical server"); + expect(s).not.toContain("Posture:"); + expect(s).not.toContain("reachable ✓"); + }); + + it("standalone machine with no fleet.json but a fresh standalone state → explicit standalone section", () => { + const dir = mkTmp("fleet-"); + const state = path.join(dir, "attach-state.json"); + writeAttachFixture(state, { + hostname: "macbook", + mode: "standalone", + reachable: false, + since: minsAgo(10), + updatedAt: minsAgo(0), + }); + const s = fleetSectionWith({ configPath: path.join(dir, "absent.json"), attachStatePath: state }); + expect(s).toContain("## Fleet (live)"); + expect(s).toContain("macbook"); + expect(/standalone/i.test(s)).toBe(true); + expect(s).toContain("10 min ago"); + }); + + it("hub server section keeps its existing role line substance (state file present adds posture, changes nothing else)", () => { + const dir = mkTmp("fleet-"); + const cfg = path.join(dir, "fleet.json"); + fs.writeFileSync(cfg, JSON.stringify({ role: "server", canonical: { host: "127.0.0.1", port: 4096 } })); + const state = path.join(dir, "attach-state.json"); + writeAttachFixture(state, { + hostname: "erlich", + mode: "fleet", + hubName: "erlich", + hubBaseUrl: "http://127.0.0.1:4096", + reachable: true, + lastOkAt: minsAgo(0), + since: minsAgo(0), + updatedAt: minsAgo(0), + }); + const s = fleetSectionWith({ configPath: cfg, attachStatePath: state }); + expect(s).toContain("**server** — this machine is the canonical Amicode server"); + expect(s).toContain("## Fleet (live)"); + }); +}); + // ── Mount discovery ────────────────────────────────────────────────────────── describe("mount discovery (marker-only port of mount_store semantics)", () => { @@ -602,6 +769,7 @@ interface SeamOpts { vaultsRoot?: string; fleetConfig?: string; fleetStatus?: string; + fleetAttachState?: string; runsDir?: string; /** Prebuilt fixture vault flavor for the golden-text cases. */ vault?: "profile" | "problems" | "demos" | "memory"; @@ -611,6 +779,7 @@ const SEAM_KEYS = [ "AMICO_VAULTS_ROOT", "AMICO_FLEET_CONFIG", "AMICO_FLEET_STATUS", + "AMICO_FLEET_ATTACH_STATE", "AMICODE_OPS_DIR", "AMICODE_CONNECTIONS_FILE", "AMICODE_PROBLEMS_DIR", @@ -671,6 +840,7 @@ function stubAllSeams(opts: SeamOpts): Record { process.env.AMICO_VAULTS_ROOT = root; process.env.AMICO_FLEET_CONFIG = opts.fleetConfig ?? path.join(fleetDir, "absent-fleet.json"); process.env.AMICO_FLEET_STATUS = opts.fleetStatus ?? path.join(fleetDir, "absent-status.json"); + process.env.AMICO_FLEET_ATTACH_STATE = opts.fleetAttachState ?? path.join(fleetDir, "absent-attach-state.json"); process.env.AMICODE_OPS_DIR = ops; // no solver-mode.json → piccolo/ready → no section process.env.AMICODE_CONNECTIONS_FILE = path.join(conn, "absent.json"); // not connected process.env.AMICODE_PROBLEMS_DIR = problems; // no active problem