import { useCallback, useRef, useState } from "react"; import type { RemoveEnrollmentRequest, RemoveEnrollmentResponse, MachineOperationPolicy, SwapActiveSandboxResponse, UpdateMachineOperationPolicyRequest, UpdateMachineAgentResponse, } from "@opengeni/sdk"; import { useOpenGeni, type ClientOverride } from "../session-context"; import { useMutationRunner } from "./internal"; import { EMPTY_MACHINES, useSharedMachinesList } from "./shared-machines-list"; import type { MachinesResponse, MachineView, MetricSample } from "../types/machines"; import { useOpenGeniText, type OpenGeniText } from "../messages/runtime"; /** Dashboard list: cheap heartbeat read, keep the pill reasonably fresh. */ export const MACHINES_DASHBOARD_POLL_MS = 10_000; /** Session surfaces share one poll for the same workspace+session. */ export const MACHINES_SESSION_POLL_MS = 15_000; /** Composer polls after the first load shows a machine, or to retry a real error. */ export const MACHINES_COMPOSER_POLL_MS = 30_000; const machinesClientIds = new WeakMap(); let nextMachinesClientId = 1; function machinesClientId(client: object): number { const existing = machinesClientIds.get(client); if (existing !== undefined) return existing; const id = nextMachinesClientId++; machinesClientIds.set(client, id); return id; } /** * The slice of the SDK client the Machines surface needs. The method NAMES + * SIGNATURES match M10's `OpenGeniClient` (`listMachines`, `machineMetricsSeries`) * so the real SDK client satisfies this surface DIRECTLY for the read paths — no * adapter needed. It is declared structurally (not a hard `OpenGeniClient` Pick) * so a test/demo/Geni-frontend client can stand in, keeping the hook dual-consumer * safe (works in apps/web AND the separate Geni frontend). * * The active-sandbox SWAP is now a typed SDK call (`swapActiveSandbox`, the M7 * user-authenticated REST equivalent of the `sandbox_swap` MCP tool). The real * SDK client satisfies it structurally, so the default attach path is wired * WHENEVER a sessionId is in scope (the swap is session-scoped). `attachMachine` * stays an OPTIONAL escape hatch for a host that wants to supply its own swap * adapter; when neither it nor a sessionId is present, attach is a no-op and the * card hides the button. */ export type MachinesClientLike = { /** GET /v1/workspaces/:ws/machines — the dashboard list + active pointer. */ listMachines: ( workspaceId: string, options?: { sessionId?: string; signal?: AbortSignal }, ) => Promise; /** GET .../machines/:enrollmentId/metrics/series — the downsampled history. */ machineMetricsSeries?: ( workspaceId: string, enrollmentId: string, options?: { window?: "15m" | "1h" | "6h" | "24h" }, ) => Promise; /** POST .../enrollments/:enrollmentId/revoke — workspace-admin removal. */ removeEnrollment?: ( workspaceId: string, enrollmentId: string, request?: RemoveEnrollmentRequest, ) => Promise; /** POST .../machines/:enrollmentId/update — signed, generation-fenced update. */ updateMachineAgent?: ( workspaceId: string, enrollmentId: string, ) => Promise; /** PATCH .../machines/:enrollmentId/operation-policy — revision-fenced policy. */ updateMachineOperationPolicy?: ( workspaceId: string, enrollmentId: string, request: UpdateMachineOperationPolicyRequest, ) => Promise; /** * POST .../sessions/:sessionId/active-sandbox — swap the session's active * sandbox to a machine. The default swap path; the real SDK client provides it. */ swapActiveSandbox?: ( workspaceId: string, sessionId: string, request: { target: string }, ) => Promise; /** * Host-supplied swap adapter (an escape hatch). When present it wins over the * default `swapActiveSandbox` path. Session-scoped, like the swap it backs. */ attachMachine?: (workspaceId: string, sessionId: string, sandboxId: string) => Promise; }; export type UseMachinesOptions = ClientOverride & { pollIntervalMs?: number | undefined; enabled?: boolean | undefined; /** Scope the list to a session (adds the synthetic Modal group box + pointer). */ sessionId?: string | undefined; /** * Override the client with one implementing `MachinesClientLike`. Defaults to * the provider client cast to the surface (the real SDK client satisfies the * read paths). An app supplies an adapter to wire `attachMachine` (the swap). */ machinesClient?: MachinesClientLike | undefined; }; export type UseMachinesResult = { sessionMachineAccess?: MachinesResponse["sessionMachineAccess"]; machines: MachineView[]; activeSandboxId: string | null; activeEpoch: number; loading: boolean; error: Error | null; refresh: () => Promise; /** Attach/swap the session's active sandbox to a machine (returns the new pointer). */ attach: (sandboxId: string) => Promise; /** Remove a self-hosted enrollment while retaining history. */ remove: ( enrollmentId: string, request?: RemoveEnrollmentRequest, ) => Promise; canRemove: boolean; removingEnrollmentId: string | null; /** Start/retry the signed self-update for one Connected Machine. */ updateAgent: (enrollmentId: string) => Promise; canUpdateAgent: boolean; updatingEnrollmentId: string | null; /** Update the optional per-enrollment command memory policy. */ updateOperationPolicy: ( enrollmentId: string, request: UpdateMachineOperationPolicyRequest, ) => Promise; canUpdateOperationPolicy: boolean; updatingOperationPolicyEnrollmentId: string | null; /** Whether the host wired an attach/swap path (drives the card affordance). */ canAttach: boolean; /** Fetch a downsampled metric series for one enrolled machine. */ fetchSeries: ( enrollmentId: string, window?: "15m" | "1h" | "6h" | "24h", ) => Promise; attaching: boolean; /** The sandbox id of the in-flight attach (for per-card spinner gating). */ attachingSandboxId: string | null; mutationError: Error | null; clearMutationError: () => void; }; /** * Plain copy for a refused move, chosen by its typed code. The server's * `reason` is written for the agent and for logs, so it is never shown here. */ function swapFailureMessage(t: OpenGeniText, result: SwapActiveSandboxResponse): string { switch (result.code) { case "recovery_in_progress": return t("hooks.useMachines.itsComputerIsStillStarting"); case "recovery_degraded": return t("hooks.useMachines.itsComputerIsntWorking"); case "recovery_unrecoverable": return t("hooks.useMachines.itsComputerCouldntBeRestored"); case "offline_enrollment": return t("hooks.useMachines.thatMachineIsOffline"); case "stale_pointer": return t("hooks.useMachines.thatMachineIsNoLonger"); case "unsupported_backend_context": case "transient_establishment": return t("hooks.useMachines.thatMachineCouldntBeReached"); case "concurrent_swap": return t("hooks.useMachines.itWasMovedSomewhere"); case "personal_machine_permission_required": return t("hooks.useMachines.theMachineSOwnerHas"); case "personal_machine_not_permitted": return t("hooks.useMachines.onlyTheMachineSOwner"); default: return t("hooks.useMachines.tryAgain"); } } /** * The workspace Machines fleet: the selfhosted enrollments + the session's Modal * box, each with latest metrics + state, plus the active-sandbox pointer. Polls * the M10 `GET /machines` endpoint and exposes attach/swap + a metric-series * fetch. Renders via ``. Dual-consumer safe: it reads only the * structural `MachinesClientLike` surface, so it works in apps/web AND the Geni * frontend (each provides its own client/adapter). */ export function useMachines(options: UseMachinesOptions = {}): UseMachinesResult { const t = useOpenGeniText(); const { client, workspaceId } = useOpenGeni(options); const machinesClient = (options.machinesClient ?? (client as unknown as MachinesClientLike)) satisfies MachinesClientLike; const sessionId = options.sessionId; const identityKey = `${workspaceId}\u0000${sessionId ?? ""}\u0000${machinesClientId(machinesClient)}`; const identityRef = useRef(identityKey); identityRef.current = identityKey; const load = useCallback( async (signal?: AbortSignal) => { return await machinesClient.listMachines(workspaceId, { ...(sessionId ? { sessionId } : {}), ...(signal ? { signal } : {}), }); }, [machinesClient, workspaceId, sessionId], ); const { data: loadedData, loading, error, refresh, } = useSharedMachinesList(identityKey, load, { pollIntervalMs: options.pollIntervalMs, enabled: options.enabled, }); const { run, mutating, mutationError, clearMutationError } = useMutationRunner(identityKey); // The sandbox id of the in-flight attach (drives the per-card spinner). const [attachState, setAttachState] = useState<{ identity: string; sandboxId: string | null; }>(() => ({ identity: identityKey, sandboxId: null })); const attachingSandboxId = attachState.identity === identityKey ? attachState.sandboxId : null; const [removeState, setRemoveState] = useState<{ identity: string; enrollmentId: string | null; }>(() => ({ identity: identityKey, enrollmentId: null })); const removingEnrollmentId = removeState.identity === identityKey ? removeState.enrollmentId : null; const [updateState, setUpdateState] = useState<{ identity: string; enrollmentId: string | null; }>(() => ({ identity: identityKey, enrollmentId: null })); const updatingEnrollmentId = updateState.identity === identityKey ? updateState.enrollmentId : null; const [operationPolicyState, setOperationPolicyState] = useState<{ identity: string; enrollmentId: string | null; }>(() => ({ identity: identityKey, enrollmentId: null })); const updatingOperationPolicyEnrollmentId = operationPolicyState.identity === identityKey ? operationPolicyState.enrollmentId : null; const data = loadedData ?? EMPTY_MACHINES; // The swap is session-scoped: a host adapter (`attachMachine`) wins; otherwise // the default `swapActiveSandbox` path is wired whenever a sessionId is in // scope. Either way attach needs a sessionId to point at. const canAttach = sessionId !== undefined && (typeof machinesClient.attachMachine === "function" || typeof machinesClient.swapActiveSandbox === "function"); const canRemove = typeof machinesClient.removeEnrollment === "function"; const canUpdateAgent = typeof machinesClient.updateMachineAgent === "function"; const canUpdateOperationPolicy = typeof machinesClient.updateMachineOperationPolicy === "function"; const attach = useCallback( async (sandboxId: string): Promise => { if (sessionId === undefined) return false; const runSwap = machinesClient.attachMachine ? () => machinesClient.attachMachine!(workspaceId, sessionId, sandboxId) : machinesClient.swapActiveSandbox ? () => machinesClient.swapActiveSandbox!(workspaceId, sessionId, { target: sandboxId }) : null; if (!runSwap) return false; const ownedIdentity = identityKey; setAttachState({ identity: ownedIdentity, sandboxId }); const result = await run(async () => { const response = await runSwap(); if ( response && typeof response === "object" && "swapped" in response && response.swapped === false ) { throw new Error(swapFailureMessage(t, response as SwapActiveSandboxResponse)); } return true; }); if (identityRef.current === ownedIdentity) { setAttachState({ identity: ownedIdentity, sandboxId: null }); if (result) await refresh(); } return result === true; }, [machinesClient, workspaceId, sessionId, identityKey, run, refresh, t], ); const fetchSeries = useCallback( async ( enrollmentId: string, window: "15m" | "1h" | "6h" | "24h" = "1h", ): Promise => { if (!machinesClient.machineMetricsSeries) return []; return await machinesClient.machineMetricsSeries(workspaceId, enrollmentId, { window }); }, [machinesClient, workspaceId], ); const remove = useCallback( async ( enrollmentId: string, request: RemoveEnrollmentRequest = {}, ): Promise => { if (!machinesClient.removeEnrollment) return null; const ownedIdentity = identityKey; setRemoveState({ identity: ownedIdentity, enrollmentId }); const result = await run(() => machinesClient.removeEnrollment!(workspaceId, enrollmentId, request), ); if (identityRef.current === ownedIdentity) { setRemoveState({ identity: ownedIdentity, enrollmentId: null }); if (result?.outcome === "removed" || result?.outcome === "already_removed") { await refresh(); } } return result; }, [machinesClient, workspaceId, identityKey, run, refresh], ); const updateAgent = useCallback( async (enrollmentId: string): Promise => { if (!machinesClient.updateMachineAgent) return null; const ownedIdentity = identityKey; setUpdateState({ identity: ownedIdentity, enrollmentId }); const result = await run(() => machinesClient.updateMachineAgent!(workspaceId, enrollmentId)); if (identityRef.current === ownedIdentity) { setUpdateState({ identity: ownedIdentity, enrollmentId: null }); await refresh(); } return result; }, [machinesClient, workspaceId, identityKey, run, refresh], ); const updateOperationPolicy = useCallback( async ( enrollmentId: string, request: UpdateMachineOperationPolicyRequest, ): Promise => { if (!machinesClient.updateMachineOperationPolicy) return null; const ownedIdentity = identityKey; setOperationPolicyState({ identity: ownedIdentity, enrollmentId }); const result = await run(() => machinesClient.updateMachineOperationPolicy!(workspaceId, enrollmentId, request), ); if (identityRef.current === ownedIdentity) { setOperationPolicyState({ identity: ownedIdentity, enrollmentId: null }); if (result) await refresh(); } return result; }, [machinesClient, workspaceId, identityKey, run, refresh], ); return { machines: data.machines, activeSandboxId: data.activeSandboxId, sessionMachineAccess: data.sessionMachineAccess ?? null, activeEpoch: data.activeEpoch, loading, error, refresh, attach, canAttach, fetchSeries, attaching: mutating && attachingSandboxId !== null, attachingSandboxId, remove, canRemove, removingEnrollmentId, updateAgent, canUpdateAgent, updatingEnrollmentId, updateOperationPolicy, canUpdateOperationPolicy, updatingOperationPolicyEnrollmentId, mutationError, clearMutationError, }; }