Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion packages/extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -493,7 +493,13 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
// #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 },
Expand Down
65 changes: 64 additions & 1 deletion packages/extension/src/sidebar_fleet_section.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 },
Expand All @@ -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);
Expand Down Expand Up @@ -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:
Expand Down
143 changes: 116 additions & 27 deletions packages/extension/src/sidebar_view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -347,15 +347,24 @@ 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<RosterRead>;
/** 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 +
* hostname/config, #1359) — drives the self-row synthesis and the "not
* 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. */
Expand All @@ -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. */
Expand All @@ -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();
Expand All @@ -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<RosterRead> {
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();
Expand Down Expand Up @@ -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);
}
}

/**
Expand Down
49 changes: 49 additions & 0 deletions packages/extension/test/sidebar_fleet_section.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {}): FleetSectionModel {
Expand Down
Loading
Loading