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
137 changes: 137 additions & 0 deletions packages/extension/src/amicode_service/attach_lifecycle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// ATTACH LIFECYCLE (#1381, ADR 0027 §3/D5 — the upstream lifecycle coordinator):
// the attach ACTION (attach_action.ts) writes the pointer file and credential
// store; THIS module manages the LIVE resources that action implies:
//
// 1. The SSH forward (via attachment_transport.ts's bringUpSshAttachment)
// 2. The HubProxy registered on FleetPlane.attached (so the D3 resolver
// can route requests to the peer)
// 3. The credential injection (the per-attachment UI client mint read from
// attachment_credential.ts, wired as the HubProxy's credential provider)
//
// Lifecycle rules:
// - One active attachment at a time — re-attach tears down the previous
// (AC5), no stacking.
// - Detach tears down the SSH forward, clears FleetPlane.attached, and
// resets the SSE cursor (AC4).
// - The attach path never writes `role=client` and never installs the
// never-fork guard.
// - Credential injection uses the existing UI client mint (H1); per-peer
// token is H2.
import { HubProxy } from "./hub_proxy";
import {
readAttachmentCredential,
type AttachmentCredentialDeps,
} from "./attachment_credential";
import type { HubCredentialRead } from "./hub_credential";
import type { FleetPlane } from "./server";
import type { AttachmentTarget, AttachmentTransportHandle } from "./attachment_transport";

/** The injectable transport bring-up — the real `bringUpSshAttachment` in
* production, a mock in tests (the test does not need real SSH). */
export type TransportFactory = (opts: {
target: AttachmentTarget;
remotePort: number;
}) => Promise<AttachmentTransportHandle>;

export interface AttachLifecycleOpts {
/** The FleetPlane whose `attached` slot this lifecycle manages. */
plane: FleetPlane;
/** The transport factory (injectable; defaults to bringUpSshAttachment). */
transportFactory: TransportFactory;
/** The port the peer engine listens on (default 43117). */
remotePort?: number;
/** DI for the credential store file (tests). */
credentialFile?: string;
/** The SSE cursor reset callback (AC4 — invoked on detach). */
resetCursorOnSwitch?: () => void;
/** Data-plane timeout for the created HubProxy (ms). */
dataPlaneTimeoutMs?: number;
}

const HUB_MINT_NAME = "hub" as const;

/** Read the per-attachment credential and adapt it to the HubCredentialRead
* shape the HubProxy expects. The `mint` field is carried for type
* compatibility; the proxy uses only `.ok` and `.credential.token`. */
function credentialForAttachment(
machineId: string,
deps: AttachmentCredentialDeps,
): HubCredentialRead {
const read = readAttachmentCredential(machineId, deps);
if (read.ok) {
return { ok: true, mint: HUB_MINT_NAME, credential: read.credential };
}
return { ok: false, mint: HUB_MINT_NAME, reason: read.reason };
}

export class AttachLifecycle {
private handle?: AttachmentTransportHandle;
private currentMachineId?: string;
private readonly opts: Required<Pick<AttachLifecycleOpts, "plane" | "transportFactory">> &
AttachLifecycleOpts;

constructor(opts: AttachLifecycleOpts) {
this.opts = opts;
}

/** The currently-attached machine_id, or undefined when idle. */
get attachedMachineId(): string | undefined {
return this.currentMachineId;
}

/** Attach to a peer: spin up the transport, create the HubProxy with
* credential injection, and register it on FleetPlane.attached. If a
* previous attachment exists, it is torn down first (AC5). */
async attach(target: AttachmentTarget): Promise<void> {
// AC5: tear down previous
if (this.handle) {
await this.teardown();
}

const remotePort = this.opts.remotePort ?? 43117;

// AC1: spin up the per-attachment transport
this.handle = await this.opts.transportFactory({
target,
remotePort,
});

const localUrl = this.handle.localUrl;
const machineId = target.machine_id;
const credDeps: AttachmentCredentialDeps = this.opts.credentialFile
? { credentialFile: this.opts.credentialFile }
: {};

// AC2+AC3: create HubProxy with credential injection and register it
const proxy = new HubProxy({
getUrl: () => localUrl,
credential: () => credentialForAttachment(machineId, credDeps),
...(this.opts.dataPlaneTimeoutMs !== undefined
? { timeoutMs: this.opts.dataPlaneTimeoutMs }
: {}),
});

this.opts.plane.attached = proxy;
this.currentMachineId = machineId;
}

/** Detach: tear down the SSH forward, clear FleetPlane.attached, and reset
* the SSE cursor (AC4). Idempotent — a detach with no attachment is a
* no-op, never throws. */
async detach(): Promise<void> {
if (!this.handle) return;
await this.teardown();
this.opts.resetCursorOnSwitch?.();
}

/** Stop the transport and clear the FleetPlane slot (shared by detach and
* the re-attach path). */
private async teardown(): Promise<void> {
if (this.handle) {
await this.handle.stop();
this.handle = undefined;
}
this.opts.plane.attached = undefined;
this.currentMachineId = undefined;
}
}
86 changes: 77 additions & 9 deletions packages/extension/src/amicode_service/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ import { solverModeResponse } from "./solver_mode";
import { rosterReadResponse, rosterReportResponse } from "./roster";
import { attachmentStatusResponse } from "./attachment_pointer";
import { attachActionResponse, detachActionResponse } from "./attach_action";
import type { AttachLifecycle } from "./attach_lifecycle";
import { AttachLifecycle as AttachLifecycleImpl, type TransportFactory } from "./attach_lifecycle";
import { postureResponse, savePostureResponse, dismissPostureResponse } from "./posture";
import {
modelRoutingResponse,
Expand Down Expand Up @@ -276,6 +278,11 @@ export function registerRosterRoutes(server: AmicodeServiceServer): AmicodeServi
// and the pointer still flips.
export interface AttachmentRouteDeps {
resetCursorOnSwitch?: () => void;
/** #1381: the upstream lifecycle coordinator. When present, the attach/detach
* route handlers invoke it to spin up/tear down the SSH forward and
* register/clear the HubProxy on FleetPlane.attached. Absent on a plain
* standalone boot (no fleet plane to manage). */
lifecycle?: AttachLifecycle;
}

export function registerAttachmentRoutes(
Expand All @@ -284,13 +291,43 @@ export function registerAttachmentRoutes(
): AmicodeServiceServer {
server.add("GET", "/amicode/fleet/attachment", () => ({ body: attachmentStatusResponse() }));

server.add("POST", "/amicode/fleet/attach", ({ body }) => ({
body: attachActionResponse(body, { resetCursorOnSwitch: deps.resetCursorOnSwitch }),
}));
server.add("POST", "/amicode/fleet/attach", async ({ body }) => {
const result = attachActionResponse(body, { resetCursorOnSwitch: deps.resetCursorOnSwitch });
// #1381: if the pointer write succeeded and a lifecycle is armed, spin up
// the SSH forward and register the HubProxy. A failed attach (unknown
// machine, bad body) never invokes the lifecycle — no half-started
// transport on a refused pointer.
if (deps.lifecycle) {
try {
const parsed = JSON.parse(result) as { ok?: boolean; pointer?: { sshAlias: string; transport: string; machine_id: string } };
if (parsed.ok && parsed.pointer) {
await deps.lifecycle.attach(parsed.pointer);
}
} catch {
// lifecycle failure does not mask the pointer write's success; the
// pointer is the source of truth for the resolver, the lifecycle is
// the LIVE transport layer on top.
}
}
return { body: result };
});

server.add("POST", "/amicode/fleet/detach", ({ body }) => ({
body: detachActionResponse(body, { resetCursorOnSwitch: deps.resetCursorOnSwitch }),
}));
server.add("POST", "/amicode/fleet/detach", async ({ body }) => {
// #1381: tear down the live transport BEFORE clearing the pointer/credential
// — the order matters: the resolver reads the pointer per-request, so
// clearing the pointer first would route to local while the SSH forward is
// still running (a brief window of stale routing). Teardown first, then
// clear.
if (deps.lifecycle) {
try {
await deps.lifecycle.detach();
} catch {
// lifecycle teardown failure is tolerated — proceed to clear the
// pointer so the resolver falls back to local.
}
}
return { body: detachActionResponse(body, { resetCursorOnSwitch: deps.resetCursorOnSwitch }) };
});

return server;
}
Expand Down Expand Up @@ -525,6 +562,14 @@ export function createAmicodeService(
/** #1378 (D3 resolver wiring): the keeper's upstream for the peer
* branch. /amicode/roster resolves here regardless of attachment. */
keeper?: { getUrl: () => string | undefined };
/** #1381: the SSH transport factory for the attach lifecycle. When
* present, the attach/detach routes dynamically spin up/tear down
* SSH forwards and register HubProxies on FleetPlane.attached.
* Injectable for tests; defaults to bringUpSshAttachment. */
transportFactory?: TransportFactory;
/** #1381: the remote port the peer engine listens on (for the SSH
* forward's -L target). Default 43117. */
attachRemotePort?: number;
};
} = {},
): AmicodeServiceServer {
Expand All @@ -547,6 +592,11 @@ export function createAmicodeService(
// always-on route can reach the store when it exists; a standalone boot
// leaves it undefined and the reset is a no-op (nothing to reset).
let sessionResumeRef: SessionEventResume | undefined;
// #1381: the attach lifecycle — hoisted alongside sessionResumeRef so the
// always-on registerAttachmentRoutes call (below the fleet block) can
// wire it regardless of whether the fleet plane staged. A standalone boot
// leaves it undefined; the route handler checks for its presence.
let attachLifecycle: AttachLifecycleImpl | undefined;
// #391: the fleet plane stages ONLY through the resolver's dispatch. No
// entitlement → this block never arms anything → zero fleet surfaces,
// byte-identical.
Expand Down Expand Up @@ -637,7 +687,7 @@ export function createAmicodeService(
...(opts.fleet.dataPlaneTimeoutMs !== undefined ? { timeoutMs: opts.fleet.dataPlaneTimeoutMs } : {}),
})
: undefined;
server.attachFleetPlane({
const fleetPlaneObj: import("./server").FleetPlane = {
getMode,
hub: new HubProxy({
getUrl: opts.fleet.hub.getUrl,
Expand All @@ -655,7 +705,20 @@ export function createAmicodeService(
hubDownPointer: () => monitor.snapshot().pointer,
...(attachedProxy ? { attached: attachedProxy } : {}),
...(keeperProxy ? { keeper: keeperProxy } : {}),
});
};
server.attachFleetPlane(fleetPlaneObj);
// #1381: create the attach lifecycle for non-client fleet machines.
// Client relays route everything through the hub (never directly to a
// peer), so the lifecycle is only meaningful on engine-armed machines.
// The transportFactory defaults to bringUpSshAttachment when not injected.
if (!isClient && opts.fleet.transportFactory) {
attachLifecycle = new AttachLifecycleImpl({
plane: fleetPlaneObj,
transportFactory: opts.fleet.transportFactory,
remotePort: opts.fleet.attachRemotePort,
dataPlaneTimeoutMs: opts.fleet.dataPlaneTimeoutMs,
});
}
registerFleetRoutes(server, {
getMode,
readCredential,
Expand Down Expand Up @@ -686,7 +749,12 @@ export function createAmicodeService(
// endpoint — ALWAYS-ON (a standalone peer must attach; the entitlement-gated
// fleet block above cannot own these). The SSE-cursor reset is wired to the
// client relay's store when one exists, else a no-op.
registerAttachmentRoutes(server, { resetCursorOnSwitch: () => sessionResumeRef?.reset() });
// #1381: the lifecycle is wired when the fleet plane staged with a transport
// factory — the route handler invokes it on attach/detach.
registerAttachmentRoutes(server, {
resetCursorOnSwitch: () => sessionResumeRef?.reset(),
lifecycle: attachLifecycle,
});
registerPostureRoutes(server);
registerModelRoutingRoutes(server, opts.modelRouting);
return server;
Expand Down
Loading
Loading