diff --git a/packages/extension/package.json b/packages/extension/package.json index 108d1c8a..335b1b1f 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -62,6 +62,12 @@ "id": "amicode.workspace", "name": "AMICODE", "type": "webview" + }, + { + "id": "amicode.fleetSessions", + "name": "Fleet Sessions", + "type": "tree", + "when": "amicode.fleetClient" } ] }, diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 6f8f3c05..497d4efe 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -60,6 +60,16 @@ import { probeCommand, formatHealthReport, probeOpencodeTui, type HealthResult } import { fleetHealthReport, FLEET_GUARD_REL } from "./fleet_health"; import { isFleetClient, getFleetRole, goStandalone, readFleetConfig, migrateLegacyFallback } from "./fleet_fallback"; import { resolveHubTarget, restartHub } from "./hub_ops"; +import { + FleetSessionsProvider, + FLEET_CLIENT_CONTEXT_KEY, + FLEET_SESSIONS_LIMIT, + FLEET_SESSIONS_VIEW_ID, + fleetClientContextValue, + fetchHubSessions, + hubDisplayName, + makeReattach, +} from "./fleet_sessions"; import { registerAmicodeTerminal } from "./terminal"; import { amicodeServiceDisposal, startAmicodeService } from "./amicode_service_wiring"; import { registerOpencodeUpdater } from "./opencode_updater_wiring"; @@ -569,6 +579,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // Fleet client: guard would `exit 1` on this host (role=client in fleet.json) — // don't spawn and storm "opencode failed to start within 30s". // Ride the tunnel instead; "Go Standalone" switches to local mode permanently. + // Fleet Sessions view visibility (amicode#779 AC5): role=client only — on + // machines that were never clients the view is absent, not an empty stub. + void vscode.commands.executeCommand("setContext", FLEET_CLIENT_CONTEXT_KEY, fleetClientContextValue(getFleetRole())); const fleetClient = isFleetClientGuard(binary); if (binary !== undefined && fleetClient) { const fleetCfg = readFleetConfig(); @@ -607,6 +620,34 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { authorization: serverAuthHeaders.Authorization, }); ctx.subscriptions.push(sseClient); + // Fleet Sessions (amicode#779): a read-only list of the hub's sessions over + // the tunnel — same GET /session collection route the attach flow uses, + // directory-scoped (workspace folder; fleet-synced repos share paths across + // hub and clients). Refresh on view open (native getChildren) and on hub + // state transitions below — never on a timer. Strictly GET-only. + const fleetSessions = new FleetSessionsProvider({ + hubName: hubDisplayName(fleetCfg), + fetchSessions: () => + fetchHubSessions(`http://127.0.0.1:${fleetPort}`, { + directory: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath, + limit: FLEET_SESSIONS_LIMIT, + authorization: serverAuthHeaders.Authorization, + }), + reattach: makeReattach({ + readyUrl: () => opencodeReadyUrl, + openOrReveal: (url) => + ChatPanel.openOrReveal(ctx, url, serverAuthToken(serverPassword), opencodeProject.projectDir), + onAppReady: (cb) => ChatPanel.onAppReady(cb), + warn: (m) => void vscode.window.showWarningMessage(m), + }), + }); + const fleetSessionsView = vscode.window.createTreeView(FLEET_SESSIONS_VIEW_ID, { treeDataProvider: fleetSessions }); + ctx.subscriptions.push(fleetSessionsView, fleetSessions); + ctx.subscriptions.push( + vscode.commands.registerCommand("amicode.fleetSessions.open", (item: import("./fleet_sessions").FleetSessionsItem) => { + if (item?.session) fleetSessions.reattach(item.session.id); + }), + ); // Poll the tunnel — when the canonical server is reachable the forward answers 200. let fleetReady = false; let fleetChecks = 0; @@ -632,6 +673,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { opencodeChannel.appendLine(sig.ok ? `[fleet] LLM provider: configured (${sig.provider})` : `[fleet] LLM provider: ${sig.reason} → ${sig.fix}`); }); opencodeChannel.appendLine(`[fleet] tunnel up at ${opencodeReadyUrl} — chat attached`); + // Fleet Sessions (amicode#779): attach/regain is a hub state + // transition — re-query the session list (open = native getChildren). + fleetSessions.refresh(); } else if (!up && fleetReady) { fleetReady = false; opencodeReadyUrl = undefined; diff --git a/packages/extension/src/fleet_sessions.ts b/packages/extension/src/fleet_sessions.ts new file mode 100644 index 00000000..0be04a10 --- /dev/null +++ b/packages/extension/src/fleet_sessions.ts @@ -0,0 +1,208 @@ +// fleet_sessions.ts — the read-only Fleet Sessions tree view (amicode#779). +// +// When a fleet client goes standalone, its Sessions dropdown points at the +// empty local store and the canonical hub sessions become unlistable. This +// view lists the hub's sessions over the existing tunnel — the same +// `GET /session?directory=…` collection route the attach/bug-report flows +// use — and reattaches the chat panel to the hub with a clicked session +// pinned (the fork's AmicodeNavigateBridge opens `/session/:id`). +// +// Invariants: +// - Strictly read-only: the ONLY outbound verb here is GET. No create, +// rename, delete, or any other mutation route is ever called. +// - Hub unreachable → one explicit "Sessions live on " item. Never an +// empty list (which would imply there are no sessions). +// - View visibility is gated on the `amicode.fleetClient` context key +// (role=client in ~/.amico/ops/fleet/fleet.json), so machines that were +// never fleet clients don't even see a stub. +// - Refresh on view open (native getChildren) and on hub state transitions +// (the attach poll calls refresh()) — never on a timer. +// +// No session data is persisted client-side beyond the visible list. + +import * as vscode from "vscode"; +import type { FleetConfig } from "./fleet_fallback"; + +export const FLEET_SESSIONS_VIEW_ID = "amicode.fleetSessions"; +export const FLEET_CLIENT_CONTEXT_KEY = "amicode.fleetClient"; +/** The attach flow pins with limit=1; the view lists, so the limit is raised. + * 200 comfortably covers the ~130-session hub this bridge exists for. */ +export const FLEET_SESSIONS_LIMIT = 200; + +export interface HubSession { + id: string; + title: string; + /** Last-updated epoch ms (falls back to created). */ + updated: number; +} + +/** The human hub name for the degraded state: sshAlias → host → "the hub". */ +export function hubDisplayName(cfg: FleetConfig | null | undefined): string { + const alias = cfg?.canonical?.sshAlias?.trim(); + if (alias) return alias; + const host = cfg?.canonical?.host?.trim(); + if (host) return host; + return "the hub"; +} + +/** The view-visibility context value: true only for role=client. */ +export function fleetClientContextValue(role: string): boolean { + return role === "client"; +} + +/** Parse the hub's Session.Info array into view rows: root sessions only + * (children are fork/subagent chatter, same rule the bug-report provenance + * scan applies), title + last-updated, newest first, malformed rows dropped. */ +export function parseHubSessions(body: unknown): HubSession[] { + if (!Array.isArray(body)) return []; + const rows: HubSession[] = []; + for (const s of body) { + if (!s || typeof s !== "object") continue; + const rec = s as Record; + if (typeof rec.id !== "string" || rec.id === "") continue; + if (typeof rec.parentID === "string" && rec.parentID !== "") continue; + const time = (rec.time ?? {}) as Record; + const updated = typeof time.updated === "number" ? time.updated : typeof time.created === "number" ? time.created : 0; + rows.push({ id: rec.id, title: typeof rec.title === "string" ? rec.title : rec.id, updated }); + } + rows.sort((a, b) => b.updated - a.updated); + return rows; +} + +/** GET the hub's session collection over the tunnel. GET-only by construction: + * there is no code path here that can issue anything else. */ +export async function fetchHubSessions( + baseUrl: string | URL, + opts: { directory?: string; limit?: number; authorization?: string; fetchImpl?: typeof fetch } = {}, +): Promise { + const url = new URL("/session", baseUrl); + if (opts.directory) url.searchParams.set("directory", opts.directory); + url.searchParams.set("limit", String(opts.limit ?? FLEET_SESSIONS_LIMIT)); + const doFetch = opts.fetchImpl ?? fetch; + const res = await doFetch(url, { + method: "GET", + headers: opts.authorization ? { Authorization: opts.authorization } : undefined, + }); + if (!res.ok) throw new Error(`hub session list failed (HTTP ${res.status})`); + return parseHubSessions(await res.json()); +} + +/** Compact relative timestamp for a session's last-updated time. */ +export function formatSessionTimestamp(ms: number, now: number = Date.now()): string { + const delta = Math.max(0, now - ms); + if (delta < 60_000) return "just now"; + if (delta < 3_600_000) return `${Math.floor(delta / 60_000)}m ago`; + if (delta < 86_400_000) return `${Math.floor(delta / 3_600_000)}h ago`; + if (delta < 7 * 86_400_000) return `${Math.floor(delta / 86_400_000)}d ago`; + return new Date(ms).toISOString().slice(0, 10); +} + +export type FleetSessionsItemKind = "session" | "degraded" | "empty"; + +/** One tree row. `kind=session` rows are the only ones with a command. */ +export class FleetSessionsItem { + constructor( + public readonly label: string, + public readonly kind: FleetSessionsItemKind, + public readonly description?: string, + public readonly tooltip?: string, + public readonly session?: HubSession, + ) {} +} + +/** Click-to-reattach: open (or reveal) the chat panel against the hub tunnel + * URL and post the fork's navigate envelope for `/session/:id` — dual-send + * (immediate + on app-ready) exactly like the new-project flow, so a freshly + * created panel receives it once the app is mounted. */ +export function makeReattach(deps: { + readyUrl: () => URL | undefined; + openOrReveal: (url: URL) => { postMessage: (msg: unknown) => unknown } | undefined; + onAppReady: (cb: () => void) => void; + warn: (msg: string) => void; +}): (sessionId: string) => void { + return (sessionId: string) => { + const url = deps.readyUrl(); + if (!url) { + deps.warn("Amicode: the hub is unreachable — sessions live on the hub. Reattach the fleet tunnel, then try again."); + return; + } + const panel = deps.openOrReveal(url); + if (!panel) return; + const envelope = { source: "amicode", kind: "navigate", path: `/session/${sessionId}` }; + const send = () => void panel.postMessage(envelope); + send(); + deps.onAppReady(send); + }; +} + +export interface FleetSessionsDeps { + hubName: string; + fetchSessions: () => Promise; + reattach: (sessionId: string) => void; +} + +/** The tree data provider. Read-only by shape: session rows carry exactly one + * command (open), and no mutation context/menu exists anywhere for them. */ +export class FleetSessionsProvider implements vscode.TreeDataProvider { + private readonly _onDidChangeTreeData = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + constructor(private readonly deps: FleetSessionsDeps) {} + + /** Reattach the chat panel to the hub with the given session pinned/open. */ + readonly reattach = (sessionId: string): void => this.deps.reattach(sessionId); + + /** Called on hub state transitions (attach/regain) from the tunnel poll. */ + refresh(): void { + this._onDidChangeTreeData.fire(undefined); + } + + dispose(): void { + this._onDidChangeTreeData.dispose(); + } + + async getChildren(): Promise { + try { + const sessions = await this.deps.fetchSessions(); + if (sessions.length === 0) { + return [new FleetSessionsItem(`No sessions on ${this.deps.hubName} yet`, "empty")]; + } + return sessions.map( + (s) => + new FleetSessionsItem( + s.title, + "session", + formatSessionTimestamp(s.updated), + `${s.title}\nLast updated ${formatSessionTimestamp(s.updated)}\nsession ${s.id}`, + s, + ), + ); + } catch { + // Degraded, not empty: name where the sessions actually live (AC3). + return [ + new FleetSessionsItem( + `Sessions live on ${this.deps.hubName}`, + "degraded", + undefined, + `The fleet tunnel to ${this.deps.hubName} is down, so hub sessions can't be listed. ` + + `Reattach the tunnel (or check Amicode — opencode output) and this view will refresh.`, + ), + ]; + } + } + + getTreeItem(item: FleetSessionsItem): vscode.TreeItem { + const tree = new vscode.TreeItem(item.label); + tree.description = item.description; + tree.tooltip = item.tooltip; + if (item.kind === "session" && item.session) { + tree.command = { + command: "amicode.fleetSessions.open", + title: "Open on hub", + arguments: [item], + }; + tree.contextValue = "fleetSession"; + } + return tree; + } +} diff --git a/packages/extension/test/__mocks__/vscode.ts b/packages/extension/test/__mocks__/vscode.ts index 26d47d88..6c64200e 100644 --- a/packages/extension/test/__mocks__/vscode.ts +++ b/packages/extension/test/__mocks__/vscode.ts @@ -161,9 +161,19 @@ export const Uri = { }, }; export class EventEmitter { - event = () => ({ dispose() {} }); - fire() {} - dispose() {} + private listeners: Array<(e: unknown) => void> = []; + event = (cb: (e: unknown) => void) => { + this.listeners.push(cb); + return { dispose: () => { + this.listeners = this.listeners.filter((l) => l !== cb); + } }; + }; + fire(e?: unknown) { + for (const l of [...this.listeners]) l(e); + } + dispose() { + this.listeners = []; + } } export class Disposable { dispose() {} diff --git a/packages/extension/test/fleet_sessions.test.ts b/packages/extension/test/fleet_sessions.test.ts new file mode 100644 index 00000000..e8f0d35e --- /dev/null +++ b/packages/extension/test/fleet_sessions.test.ts @@ -0,0 +1,298 @@ +// Tests for the read-only Fleet Sessions tree view (amicode#779): list hub +// sessions from a standalone client over the tunnel, click-to-reattach-and-pin, +// an honest degraded state when the hub is unreachable, strict read-only, and +// view absence on machines that have never been fleet clients. + +import { describe, it, expect } from "vitest"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as vscode from "vscode"; +import { + FleetSessionsProvider, + fetchHubSessions, + parseHubSessions, + hubDisplayName, + makeReattach, + fleetClientContextValue, + formatSessionTimestamp, + FLEET_SESSIONS_VIEW_ID, + FLEET_CLIENT_CONTEXT_KEY, + type HubSession, +} from "../src/fleet_sessions"; +import { getFleetRole } from "../src/fleet_fallback"; + +const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, "..", "package.json"), "utf8")) as { + contributes?: { views?: Record>; menus?: unknown }; +}; + +// ── fixtures ──────────────────────────────────────────────────────────────── + +const sessionInfo = (over: Record = {}) => ({ + id: "ses_1", + title: "CZ gate tuning", + directory: "/Users/aaron/armonia/repos/amicode", + time: { created: 1_000, updated: 2_000 }, + ...over, +}); + +type RecordedCall = { url: string; method: string; headers: Record }; +function fetchStub(responses: Array<{ ok?: boolean; status?: number; body?: unknown } | Error>) { + const calls: RecordedCall[] = []; + let i = 0; + const impl = (async (input: unknown, init?: { method?: string; headers?: Record }) => { + calls.push({ + url: String(input), + method: init?.method ?? "GET", + headers: (init?.headers ?? {}) as Record, + }); + const r = responses[Math.min(i, responses.length - 1)]; + i++; + if (r instanceof Error) throw r; + return { + ok: r.ok ?? true, + status: r.status ?? 200, + json: async () => r.body, + } as unknown as Response; + }) as typeof fetch; + return { impl, calls }; +} + +const TREE = async (p: FleetSessionsProvider) => { + const kids = await p.getChildren(); + return kids.map((k) => ({ item: k, tree: p.getTreeItem(k) as vscode.TreeItem })); +}; + +// ── AC1: list the hub's sessions (title, last-updated) over the tunnel ────── + +describe("AC1 — hub session list over the tunnel", () => { + it("fetches GET /session scoped to the project directory with auth, raising the attach flow's limit", async () => { + const { impl, calls } = fetchStub([{ body: [sessionInfo()] }]); + const sessions = await fetchHubSessions("http://127.0.0.1:4096", { + directory: "/Users/aaron/armonia/repos/amicode", + authorization: "Basic dGVzdA==", + fetchImpl: impl, + }); + expect(sessions).toHaveLength(1); + expect(calls).toHaveLength(1); + const url = new URL(calls[0]!.url); + expect(url.origin).toBe("http://127.0.0.1:4096"); + expect(url.pathname).toBe("/session"); + expect(url.searchParams.get("directory")).toBe("/Users/aaron/armonia/repos/amicode"); + // limit raised above the attach flow's 1 — the view lists, not pins + expect(Number(url.searchParams.get("limit"))).toBeGreaterThan(1); + expect(calls[0]!.headers["Authorization"]).toBe("Basic dGVzdA=="); + }); + + it("omits the directory scope when there is no workspace folder (server cwd scope)", async () => { + const { impl, calls } = fetchStub([{ body: [] }]); + await fetchHubSessions("http://127.0.0.1:4096", { fetchImpl: impl }); + expect(new URL(calls[0]!.url).searchParams.has("directory")).toBe(false); + }); + + it("parses Session.Info rows into {id, title, updated}, root sessions only, newest first", () => { + const parsed = parseHubSessions([ + sessionInfo(), + sessionInfo({ id: "ses_2", title: "older", parentID: "ses_1", time: { created: 5, updated: 9_999 } }), + sessionInfo({ id: "ses_3", title: "newer", time: { created: 5, updated: 3_000 } }), + { not: "a session" }, + ]); + expect(parsed.map((s) => s.id)).toEqual(["ses_3", "ses_1"]); + expect(parsed[0]).toMatchObject>({ id: "ses_3", title: "newer", updated: 3_000 }); + }); + + it("renders each session as a tree item: title label, last-updated description, open command", async () => { + const provider = new FleetSessionsProvider({ + hubName: "amico-erlich", + fetchSessions: async () => [{ id: "ses_1", title: "CZ gate tuning", updated: 2_000 }], + reattach: () => {}, + }); + const rows = await TREE(provider); + expect(rows).toHaveLength(1); + expect(rows[0]!.tree.label).toBe("CZ gate tuning"); + expect(rows[0]!.tree.description).toBeTruthy(); // the last-updated timestamp + const cmd = rows[0]!.tree.command as { command: string; arguments?: unknown[] }; + expect(cmd.command).toBe("amicode.fleetSessions.open"); + expect(cmd.arguments?.[0]).toBe(rows[0]!.item); + }); + + it("refreshes by re-fetching — every getChildren goes back to the hub (no stale cache beyond the visible list)", async () => { + let n = 0; + const provider = new FleetSessionsProvider({ + hubName: "hub", + fetchSessions: async () => { + n++; + return []; + }, + reattach: () => {}, + }); + await provider.getChildren(); + await provider.getChildren(); + expect(n).toBe(2); + }); + + it("exposes a refresh() that re-queries the hub on demand (hub state transitions)", async () => { + let n = 0; + const provider = new FleetSessionsProvider({ + hubName: "hub", + fetchSessions: async () => { + n++; + return []; + }, + reattach: () => {}, + }); + const fired: unknown[] = []; + provider.onDidChangeTreeData(() => fired.push(1)); + provider.refresh(); + expect(fired).toHaveLength(1); + await provider.getChildren(); + expect(n).toBe(1); + }); + + it("formats last-updated as a compact relative time", () => { + const now = 1_000_000; + expect(formatSessionTimestamp(now - 30_000, now)).toBe("just now"); + expect(formatSessionTimestamp(now - 5 * 60_000, now)).toBe("5m ago"); + expect(formatSessionTimestamp(now - 3 * 3_600_000, now)).toBe("3h ago"); + expect(formatSessionTimestamp(now - 2 * 86_400_000, now)).toBe("2d ago"); + expect(formatSessionTimestamp(now - 30 * 86_400_000, now)).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); +}); + +// ── AC2: click → reattach to the hub with that session pinned and open ───── + +describe("AC2 — click-to-reattach-and-pin", () => { + const posted: unknown[] = []; + const panel = { postMessage: (m: unknown) => { + posted.push(m); + return Promise.resolve(true); + } }; + + it("opens the panel against the hub tunnel URL and navigates it to the pinned session", async () => { + posted.length = 0; + let openedWith: URL | undefined; + let appReadyCb: (() => void) | undefined; + const reattach = makeReattach({ + readyUrl: () => new URL("http://127.0.0.1:4096"), + openOrReveal: (url) => { + openedWith = url; + return panel; + }, + onAppReady: (cb) => { + appReadyCb = cb; + }, + warn: () => {}, + }); + reattach("ses_42"); + expect(openedWith?.toString()).toBe("http://127.0.0.1:4096/"); + expect(posted).toContainEqual({ source: "amicode", kind: "navigate", path: "/session/ses_42" }); + // dual-send: the fresh-panel path re-posts once the app signals ready + expect(appReadyCb).toBeTruthy(); + const before = posted.length; + appReadyCb!(); + expect(posted.length).toBe(before + 1); + expect(posted).toContainEqual({ source: "amicode", kind: "navigate", path: "/session/ses_42" }); + }); + + it("never opens against a dead tunnel — warns instead", () => { + posted.length = 0; + const warnings: string[] = []; + const reattach = makeReattach({ + readyUrl: () => undefined, + openOrReveal: () => { + throw new Error("must not open"); + }, + onAppReady: () => {}, + warn: (m) => warnings.push(m), + }); + reattach("ses_42"); + expect(posted).toHaveLength(0); + expect(warnings).toHaveLength(1); + }); +}); + +// ── AC3: hub unreachable → explicit degraded state naming where sessions live + +describe("AC3 — degraded state when the hub is unreachable", () => { + it("shows exactly one item naming the hub — never an empty list", async () => { + const { impl } = fetchStub([new Error("tunnel down")]); + const provider = new FleetSessionsProvider({ + hubName: "amico-erlich", + fetchSessions: () => fetchHubSessions("http://127.0.0.1:4096", { fetchImpl: impl }), + reattach: () => {}, + }); + const rows = await TREE(provider); + expect(rows).toHaveLength(1); + expect(rows[0]!.tree.label).toContain("amico-erlich"); + expect(rows[0]!.tree.label).toContain("Sessions live on"); + expect(rows[0]!.tree.tooltip).toBeTruthy(); + expect(rows[0]!.tree.command).toBeUndefined(); // nothing to open while degraded + }); + + it("a genuinely empty hub says so honestly (hub reachable, zero sessions)", async () => { + const { impl } = fetchStub([{ body: [] }]); + const provider = new FleetSessionsProvider({ + hubName: "amico-erlich", + fetchSessions: () => fetchHubSessions("http://127.0.0.1:4096", { fetchImpl: impl }), + reattach: () => {}, + }); + const rows = await TREE(provider); + expect(rows).toHaveLength(1); + expect(rows[0]!.tree.label).toContain("No sessions"); + }); + + it("names the hub from the fleet config's sshAlias", () => { + expect(hubDisplayName({ role: "client", canonical: { sshAlias: "amico-erlich", host: "h" } })).toBe("amico-erlich"); + expect(hubDisplayName({ role: "client", canonical: { host: "10.0.0.2" } })).toBe("10.0.0.2"); + expect(hubDisplayName(null)).toBe("the hub"); + }); +}); + +// ── AC4: strictly read-only ───────────────────────────────────────────────── + +describe("AC4 — the view is strictly read-only", () => { + it("only ever speaks GET to the hub", async () => { + const { impl, calls } = fetchStub([{ body: [sessionInfo()] }, { body: [] }]); + await fetchHubSessions("http://127.0.0.1:4096", { fetchImpl: impl }); + await fetchHubSessions("http://127.0.0.1:4096", { fetchImpl: impl }); + expect(calls.length).toBeGreaterThanOrEqual(2); + for (const c of calls) expect(c.method).toBe("GET"); + }); + + it("exposes no mutation affordances: session items carry no rename/delete context menu", async () => { + const provider = new FleetSessionsProvider({ + hubName: "hub", + fetchSessions: async () => [{ id: "ses_1", title: "t", updated: 1 }], + reattach: () => {}, + }); + const rows = await TREE(provider); + const cmds = rows.map((r) => (r.tree.command as { command?: string } | undefined)?.command); + for (const c of cmds) expect(c).toBe("amicode.fleetSessions.open"); + const pkgStr = JSON.stringify(pkg); + expect(pkgStr).not.toMatch(/fleetSession.*(delete|rename|create|remove)/i); + expect(pkgStr).not.toMatch(/(delete|rename|remove).*[Ff]leet [Ss]ession/); + }); +}); + +// ── AC5: absent (not an empty stub) on machines that were never clients ──── + +describe("AC5 — the view is absent on never-fleet-client machines", () => { + it("the contributed view is gated on the amicode.fleetClient context key", () => { + const views = pkg.contributes?.views?.amicode ?? []; + const view = views.find((v) => v.id === FLEET_SESSIONS_VIEW_ID); + expect(view).toBeTruthy(); + expect(view!.when).toBe(FLEET_CLIENT_CONTEXT_KEY); + }); + + it("the context key is true only for role=client in fleet.json", () => { + expect(fleetClientContextValue("client")).toBe(true); + expect(fleetClientContextValue("standalone")).toBe(false); + expect(fleetClientContextValue("server")).toBe(false); + }); + + it("no fleet.json (never enrolled) reads as standalone → the view stays hidden", () => { + const role = getFleetRole("/nonexistent/fleet.json", () => { + throw new Error("no file"); + }); + expect(fleetClientContextValue(role)).toBe(false); + }); +});