@opengeni/react 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
1
+ import { type ReactNode, useCallback, useEffect, useLayoutEffect, useState } from "react";
2
2
  import {
3
3
  ChevronsLeftRightIcon,
4
4
  Maximize2Icon,
@@ -30,6 +30,9 @@ export type WorkspaceDockProps = {
30
30
  /** Controlled active tab. Falls back to the first tab. */
31
31
  activeTab?: string | undefined;
32
32
  onActiveTabChange?: ((id: string) => void) | undefined;
33
+ /** Controlled collapsed state for hosts that expose their own dock toggle. */
34
+ collapsed?: boolean | undefined;
35
+ onCollapsedChange?: ((collapsed: boolean) => void) | undefined;
33
36
  /** Persisted layout id (localStorage key) for react-resizable-panels. */
34
37
  autoSaveId?: string | undefined;
35
38
  /** Default dock width as a percent of the session area. */
@@ -47,11 +50,15 @@ export type WorkspaceDockProps = {
47
50
  * `autoSaveId`. Maximize is a mode ABOVE the Group (a `fixed inset-0` overlay) —
48
51
  * pushing a Panel to ~100% still fights min sizes and leaves a chat sliver.
49
52
  */
53
+ const useDockLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
54
+
50
55
  export function WorkspaceDock({
51
56
  primary,
52
57
  tabs,
53
58
  activeTab,
54
59
  onActiveTabChange,
60
+ collapsed: collapsedProp,
61
+ onCollapsedChange,
55
62
  autoSaveId = "og.session.dock",
56
63
  defaultSize = 34,
57
64
  minSize = 22,
@@ -59,9 +66,10 @@ export function WorkspaceDock({
59
66
  className,
60
67
  }: WorkspaceDockProps) {
61
68
  const dockPanelRef = usePanelRef();
62
- const [collapsed, setCollapsed] = useState(false);
69
+ const [internalCollapsed, setInternalCollapsed] = useState(false);
63
70
  const [maximized, setMaximized] = useState(false);
64
71
  const [internalTab, setInternalTab] = useState(tabs[0]?.id ?? "");
72
+ const collapsed = collapsedProp ?? internalCollapsed;
65
73
 
66
74
  // Persisted layout (width split) keyed by autoSaveId.
67
75
  const { defaultLayout, onLayoutChanged } = useDefaultLayout({
@@ -71,6 +79,8 @@ export function WorkspaceDock({
71
79
  } as Parameters<typeof useDefaultLayout>[0]);
72
80
 
73
81
  const current = activeTab ?? internalTab;
82
+ const tabIds = tabs.map((tab) => tab.id).join("\u0000");
83
+ const firstTabId = tabs[0]?.id ?? "";
74
84
  const setTab = useCallback(
75
85
  (id: string) => {
76
86
  setInternalTab(id);
@@ -78,13 +88,34 @@ export function WorkspaceDock({
78
88
  },
79
89
  [onActiveTabChange],
80
90
  );
91
+ const setCollapsed = useCallback(
92
+ (next: boolean) => {
93
+ setInternalCollapsed((previous) => (previous === next ? previous : next));
94
+ onCollapsedChange?.(next);
95
+ },
96
+ [onCollapsedChange],
97
+ );
98
+
99
+ useDockLayoutEffect(() => {
100
+ if (collapsedProp === undefined) {
101
+ return;
102
+ }
103
+ if (collapsedProp) {
104
+ dockPanelRef.current?.collapse();
105
+ setMaximized(false);
106
+ } else {
107
+ dockPanelRef.current?.expand();
108
+ }
109
+ }, [collapsedProp, dockPanelRef]);
81
110
 
82
111
  // Keep the active tab valid if the available tabs change.
83
112
  useEffect(() => {
84
- if (tabs.length > 0 && !tabs.some((t) => t.id === current)) {
85
- setTab(tabs[0]!.id);
113
+ if (firstTabId && !tabs.some((t) => t.id === current)) {
114
+ setTab(firstTabId);
86
115
  }
87
- }, [tabs, current, setTab]);
116
+ // Depend on tab identity, not the tab content objects. Session live events
117
+ // rebuild tab JSX frequently; only id changes can invalidate the active tab.
118
+ }, [tabIds, firstTabId, current, setTab]);
88
119
 
89
120
  // Esc restores from maximize.
90
121
  useEffect(() => {
@@ -99,11 +130,11 @@ export function WorkspaceDock({
99
130
  const collapse = useCallback(() => {
100
131
  dockPanelRef.current?.collapse();
101
132
  setCollapsed(true);
102
- }, [dockPanelRef]);
133
+ }, [dockPanelRef, setCollapsed]);
103
134
  const expand = useCallback(() => {
104
135
  dockPanelRef.current?.expand();
105
136
  setCollapsed(false);
106
- }, [dockPanelRef]);
137
+ }, [dockPanelRef, setCollapsed]);
107
138
 
108
139
  const dockChrome = (
109
140
  <DockChrome
@@ -142,16 +173,19 @@ export function WorkspaceDock({
142
173
  defaultSize={`${defaultSize}%`}
143
174
  minSize={`${minSize}%`}
144
175
  maxSize={`${maxSize}%`}
145
- onResize={(size) => {
176
+ onResize={(size, _id, previousSize) => {
146
177
  // `asPercentage` is 0..100; treat a near-zero panel as collapsed.
147
178
  const isCollapsed = size.asPercentage <= 1;
148
- setCollapsed((prev) => (prev !== isCollapsed ? isCollapsed : prev));
179
+ const canInferCollapse = collapsedProp === undefined || previousSize !== undefined;
180
+ if (canInferCollapse && isCollapsed !== collapsed) {
181
+ setCollapsed(isCollapsed);
182
+ }
149
183
  }}
150
184
  className="min-h-0 min-w-0"
151
185
  >
152
186
  {/* Hidden behind the overlay while maximized (avoids double-mounting
153
187
  the surfaces). */}
154
- {!maximized && (
188
+ {!collapsed && !maximized && (
155
189
  <div className="flex h-full min-h-0 min-w-0 flex-col border-l border-[color:var(--og-color-border,var(--color-border,#2a2a2a))] bg-[color:var(--og-color-bg,var(--color-bg,#0d0d0d))]">
156
190
  {dockChrome}
157
191
  </div>
@@ -0,0 +1,179 @@
1
+ import { useCallback, useState } from "react";
2
+ import type { CodexAccount, CodexAccountsResponse, CodexRotationSettings, SessionEvent } from "@opengeni/sdk";
3
+ import { useOpenGeni, type ClientOverride } from "../provider";
4
+ import { useMutationRunner, usePolledValue, useSessionEventTrigger, type SessionEventFeedOptions } from "./internal";
5
+
6
+ /** Events that change which Codex account a session runs on (or just ran). */
7
+ export function isCodexAccountEvent(event: Pick<SessionEvent, "type">): boolean {
8
+ return event.type === "codex.account.switched" || event.type === "turn.started";
9
+ }
10
+
11
+ /**
12
+ * The structural slice of the SDK client the Codex-accounts surface needs. Method
13
+ * NAMES + SIGNATURES match `OpenGeniClient` so the real client satisfies it
14
+ * directly; declared structurally (not a hard Pick) so a test/Geni client can
15
+ * stand in. `getSession` reads the session's pin/last; `pinSessionCodexAccount`
16
+ * is the optional mutation (absent ⇒ the indicator hides the switch affordance).
17
+ */
18
+ export type CodexAccountsClientLike = {
19
+ listCodexAccounts: (workspaceId: string) => Promise<CodexAccountsResponse>;
20
+ getSession?: (workspaceId: string, sessionId: string) => Promise<{ codexPinnedCredentialId?: string | null; codexLastCredentialId?: string | null }>;
21
+ pinSessionCodexAccount?: (workspaceId: string, sessionId: string, target: string) => Promise<{ pinned: string }>;
22
+ /** Optional (absent ⇒ the card hides live refresh): batched live /wham/usage refresh. */
23
+ refreshCodexUsage?: (workspaceId: string) => Promise<{ usage: Record<string, unknown> }>;
24
+ };
25
+
26
+ export type UseCodexAccountsOptions = ClientOverride & SessionEventFeedOptions & {
27
+ pollIntervalMs?: number | undefined;
28
+ /** Scope to a session so the hook resolves the pin + the effective account. */
29
+ sessionId?: string | undefined;
30
+ /** Override the client with one implementing `CodexAccountsClientLike`. */
31
+ codexClient?: CodexAccountsClientLike | undefined;
32
+ };
33
+
34
+ export type UseCodexAccountsResult = {
35
+ accounts: CodexAccount[];
36
+ /** The workspace ACTIVE account (used when a session is unpinned). */
37
+ activeAccountId: string | null;
38
+ /** The session's PINNED account (null ⇒ following workspace active). */
39
+ pinnedAccountId: string | null;
40
+ /** The account the next turn will run on: pin > workspace active. */
41
+ effectiveAccountId: string | null;
42
+ /** The account the session's last turn ACTUALLY ran on (the "Running on:" source). */
43
+ lastAccountId: string | null;
44
+ settings: CodexRotationSettings;
45
+ loading: boolean;
46
+ refresh: () => Promise<void>;
47
+ /**
48
+ * Trigger a LIVE batched /wham/usage refresh across all accounts, then re-read
49
+ * the cached metadata so the new windows land on `accounts`. Modeled on `pin`.
50
+ * No-op (resolves false) when the client can't refresh usage.
51
+ */
52
+ refreshUsage: () => Promise<boolean>;
53
+ /** True while a live usage refresh is in flight (drives the bar skeleton). */
54
+ refreshingUsage: boolean;
55
+ /** Pin (or unpin via "auto") the session's account; returns true on success. */
56
+ pin: (target: string) => Promise<boolean>;
57
+ pinning: boolean;
58
+ /** The target of the in-flight pin (for per-row spinner gating). */
59
+ pinningTarget: string | null;
60
+ mutationError: Error | null;
61
+ };
62
+
63
+ const EMPTY_SETTINGS: CodexRotationSettings = { rotationEnabled: false, rotationStrategy: "most_remaining", activeCredentialId: null };
64
+
65
+ type CodexAccountsState = {
66
+ accounts: CodexAccount[];
67
+ activeAccountId: string | null;
68
+ settings: CodexRotationSettings;
69
+ pinnedAccountId: string | null;
70
+ lastAccountId: string | null;
71
+ };
72
+
73
+ const EMPTY_STATE: CodexAccountsState = {
74
+ accounts: [],
75
+ activeAccountId: null,
76
+ settings: EMPTY_SETTINGS,
77
+ pinnedAccountId: null,
78
+ lastAccountId: null,
79
+ };
80
+
81
+ /**
82
+ * The workspace's Codex accounts + the per-workspace active pointer + (when
83
+ * session-scoped) the session pin and last-ran-on account. Composed like
84
+ * `useMachines`: slow polling (the realtime work is done by the
85
+ * `codex.account.switched` / `turn.started` event trigger) + a `pin` mutation.
86
+ * Dual-consumer safe via the structural `CodexAccountsClientLike` surface.
87
+ */
88
+ export function useCodexAccounts(options: UseCodexAccountsOptions = {}): UseCodexAccountsResult {
89
+ const { client, workspaceId } = useOpenGeni(options);
90
+ const codexClient = (options.codexClient ?? (client as unknown as CodexAccountsClientLike));
91
+ const sessionId = options.sessionId;
92
+ const sharedEvents = options.events;
93
+
94
+ const load = useCallback(async (): Promise<CodexAccountsState> => {
95
+ const accountsP = codexClient.listCodexAccounts(workspaceId);
96
+ const sessionP = sessionId && codexClient.getSession
97
+ ? codexClient.getSession(workspaceId, sessionId).catch(() => null)
98
+ : Promise.resolve(null);
99
+ const [acc, session] = await Promise.all([accountsP, sessionP]);
100
+ return {
101
+ accounts: acc.accounts,
102
+ activeAccountId: acc.activeAccountId,
103
+ settings: acc.settings,
104
+ pinnedAccountId: session?.codexPinnedCredentialId ?? null,
105
+ lastAccountId: session?.codexLastCredentialId ?? null,
106
+ };
107
+ }, [codexClient, workspaceId, sessionId]);
108
+
109
+ const state = usePolledValue(load, { pollIntervalMs: options.pollIntervalMs, enabled: options.enabled });
110
+ const mutation = useMutationRunner();
111
+ const usageMutation = useMutationRunner();
112
+ const [pinningTarget, setPinningTarget] = useState<string | null>(null);
113
+
114
+ // Live flip: a manual switch (P1) or a failover (P3) emits codex.account.switched;
115
+ // turn.started covers the case where the worker recorded the actual account.
116
+ useSessionEventTrigger(
117
+ client,
118
+ workspaceId,
119
+ sessionId,
120
+ isCodexAccountEvent,
121
+ () => void state.refresh(),
122
+ { enabled: options.enabled ?? true, ...(sharedEvents !== undefined ? { events: sharedEvents } : {}) },
123
+ );
124
+
125
+ const pin = useCallback(
126
+ async (target: string): Promise<boolean> => {
127
+ if (!sessionId || !codexClient.pinSessionCodexAccount) {
128
+ return false;
129
+ }
130
+ setPinningTarget(target);
131
+ const result = await mutation.run(async () => {
132
+ await codexClient.pinSessionCodexAccount!(workspaceId, sessionId, target);
133
+ return true;
134
+ });
135
+ setPinningTarget(null);
136
+ if (result) await state.refresh();
137
+ return result === true;
138
+ },
139
+ [codexClient, workspaceId, sessionId, mutation.run, state.refresh],
140
+ );
141
+
142
+ // Live usage refresh: hit the batched provider read, then re-read cached
143
+ // metadata so the fresh windows land on `accounts`. The provider read writes the
144
+ // cache columns server-side; state.refresh() pulls them back.
145
+ const refreshUsage = useCallback(
146
+ async (): Promise<boolean> => {
147
+ if (!codexClient.refreshCodexUsage) {
148
+ return false;
149
+ }
150
+ const result = await usageMutation.run(async () => {
151
+ await codexClient.refreshCodexUsage!(workspaceId);
152
+ return true;
153
+ });
154
+ if (result) await state.refresh();
155
+ return result === true;
156
+ },
157
+ [codexClient, workspaceId, usageMutation.run, state.refresh],
158
+ );
159
+
160
+ const data = state.data ?? EMPTY_STATE;
161
+ const effectiveAccountId = data.pinnedAccountId ?? data.activeAccountId;
162
+
163
+ return {
164
+ accounts: data.accounts,
165
+ activeAccountId: data.activeAccountId,
166
+ pinnedAccountId: data.pinnedAccountId,
167
+ effectiveAccountId,
168
+ lastAccountId: data.lastAccountId,
169
+ settings: data.settings,
170
+ loading: state.loading,
171
+ refresh: state.refresh,
172
+ refreshUsage,
173
+ refreshingUsage: usageMutation.mutating,
174
+ pin,
175
+ pinning: mutation.mutating,
176
+ pinningTarget,
177
+ mutationError: mutation.mutationError,
178
+ };
179
+ }
@@ -7,17 +7,21 @@ import {
7
7
  type DesktopStreamCapability,
8
8
  } from "@opengeni/sdk";
9
9
  import { type RefObject, useEffect, useRef, useState } from "react";
10
+ import { type DesktopWebSocketFactory, useRelayFrameStream } from "./use-relay-frame-stream";
10
11
 
11
12
  export type UseDesktopStreamOptions = {
12
13
  /** The desktop cell of the negotiated capabilities (`capabilities.DesktopStream`). */
13
14
  capability: DesktopStreamCapability | null;
14
- /** The mount target. RFB attaches here on connect. */
15
+ /** The mount target. RFB (or the frame canvas) attaches here on connect. */
15
16
  containerRef: RefObject<HTMLDivElement | null>;
16
17
  /** Read-only by default (v1 ruling H). interactive only when cap.mode allows. */
17
18
  interactive?: boolean | undefined;
18
19
  scaleViewport?: boolean | undefined;
19
20
  /** Custom RFB factory (tests / a WebRTC swap). Defaults to a lazy @novnc/novnc. */
20
21
  rfbFactory?: DesktopRfbFactory | undefined;
22
+ /** Custom socket factory for the `relay-frames` transport (tests). Defaults to
23
+ * `new WebSocket(url)`. Mirrors `rfbFactory` for the frame renderer. */
24
+ webSocketFactory?: DesktopWebSocketFactory | undefined;
21
25
  };
22
26
 
23
27
  export type UseDesktopStreamResult = {
@@ -57,9 +61,26 @@ async function defaultRfbFactory(): Promise<DesktopRfbFactory> {
57
61
  * `interactive` prop → `RFB.viewOnly`. v1 always resolves to read-only. On a
58
62
  * capability `url` change (a rotation), the old RFB disconnects and a fresh one
59
63
  * connects to the new URL — a brief "desktop blink", acceptable on rollover.
64
+ *
65
+ * Transport dispatch: a Modal box negotiates `transport: "vnc-ws"` and drives the
66
+ * noVNC RFB below. A SELF-HOSTED machine negotiates `transport: "relay-frames"`
67
+ * (PNG-per-frame over the relay, view-only) — that path is delegated to
68
+ * `useRelayFrameStream`, which paints a `<canvas>`. Both hooks are ALWAYS called
69
+ * (rules of hooks); each is dormant for the other's transport, and we return the
70
+ * result of whichever owns the surface. The public interface is identical either
71
+ * way, so `DesktopViewer` is transport-agnostic.
60
72
  */
61
73
  export function useDesktopStream(options: UseDesktopStreamOptions): UseDesktopStreamResult {
62
- const { capability, containerRef, interactive, scaleViewport, rfbFactory } = options;
74
+ const { capability, containerRef, interactive, scaleViewport, rfbFactory, webSocketFactory } =
75
+ options;
76
+
77
+ // The self-hosted PNG-frame path. Always invoked (rules of hooks); dormant
78
+ // unless `transport === "relay-frames"`, in which case it owns the surface.
79
+ const frameStream = useRelayFrameStream({
80
+ capability,
81
+ containerRef,
82
+ ...(webSocketFactory ? { webSocketFactory } : {}),
83
+ });
63
84
  const [state, setState] = useState<DesktopConnectionState>("idle");
64
85
  const [error, setError] = useState<Error | null>(null);
65
86
  const [nonce, setNonce] = useState(0);
@@ -210,5 +231,10 @@ export function useDesktopStream(options: UseDesktopStreamOptions): UseDesktopSt
210
231
  // eslint-disable-next-line react-hooks/exhaustive-deps
211
232
  }, [scaleViewport, state]);
212
233
 
234
+ // Delegate to whichever transport owns the surface. For `relay-frames` the
235
+ // noVNC effect above is dormant (it bailed to idle) and the frame renderer
236
+ // drives; for `vnc-ws` (and everything else) the noVNC path drives while the
237
+ // frame renderer stays idle. Same public shape either way.
238
+ if (transport === "relay-frames") return frameStream;
213
239
  return { state, error, reconnect };
214
240
  }
@@ -42,6 +42,8 @@ export type UseGoalResult = {
42
42
  export function useGoal(sessionId: string | null | undefined, options: UseGoalOptions = {}): UseGoalResult {
43
43
  const { client, workspaceId } = useOpenGeni(options);
44
44
  const enabled = (options.enabled ?? true) && Boolean(sessionId);
45
+ const sharedEvents = options.events;
46
+ const sharedFeed = sharedEvents !== undefined;
45
47
  const [goal, setGoal] = useState<SessionGoal | null>(null);
46
48
  const [loading, setLoading] = useState(enabled);
47
49
  const [error, setError] = useState<Error | null>(null);
@@ -87,6 +89,12 @@ export function useGoal(sessionId: string | null | undefined, options: UseGoalOp
87
89
  setLoading(false);
88
90
  return;
89
91
  }
92
+ if (sharedFeed) {
93
+ setLoading(false);
94
+ return () => {
95
+ generation.current += 1;
96
+ };
97
+ }
90
98
  setLoading(true);
91
99
  void load();
92
100
  const pollIntervalMs = options.pollIntervalMs;
@@ -100,12 +108,12 @@ export function useGoal(sessionId: string | null | undefined, options: UseGoalOp
100
108
  clearInterval(timer);
101
109
  generation.current += 1;
102
110
  };
103
- }, [load, enabled, workspaceId, sessionId, options.pollIntervalMs]);
111
+ }, [load, enabled, workspaceId, sessionId, options.pollIntervalMs, sharedFeed]);
104
112
 
105
113
  const scheduleRefresh = useDebouncedCallback(() => void load());
106
114
  useSessionEventTrigger(client, workspaceId, sessionId, isGoalEvent, scheduleRefresh, {
107
115
  enabled,
108
- ...(options.events !== undefined ? { events: options.events } : {}),
116
+ ...(sharedEvents !== undefined ? { events: sharedEvents } : {}),
109
117
  });
110
118
 
111
119
  const pause = useCallback(
@@ -0,0 +1,157 @@
1
+ import { useCallback, useState } from "react";
2
+ import { useOpenGeni, type ClientOverride } from "../provider";
3
+ import { useMutationRunner, usePolledValue } from "./internal";
4
+ import type { MachinesResponse, MachineView, MetricSample } from "../types/machines";
5
+
6
+ /**
7
+ * The slice of the SDK client the Machines surface needs. The method NAMES +
8
+ * SIGNATURES match M10's `OpenGeniClient` (`listMachines`, `machineMetricsSeries`)
9
+ * so the real SDK client satisfies this surface DIRECTLY for the read paths — no
10
+ * adapter needed. It is declared structurally (not a hard `OpenGeniClient` Pick)
11
+ * so a test/demo/Geni-frontend client can stand in, keeping the hook dual-consumer
12
+ * safe (works in apps/web AND the separate Geni frontend).
13
+ *
14
+ * The active-sandbox SWAP is now a typed SDK call (`swapActiveSandbox`, the M7
15
+ * user-authenticated REST equivalent of the `sandbox_swap` MCP tool). The real
16
+ * SDK client satisfies it structurally, so the default attach path is wired
17
+ * WHENEVER a sessionId is in scope (the swap is session-scoped). `attachMachine`
18
+ * stays an OPTIONAL escape hatch for a host that wants to supply its own swap
19
+ * adapter; when neither it nor a sessionId is present, attach is a no-op and the
20
+ * card hides the button.
21
+ */
22
+ export type MachinesClientLike = {
23
+ /** GET /v1/workspaces/:ws/machines — the dashboard list + active pointer. */
24
+ listMachines: (workspaceId: string, options?: { sessionId?: string }) => Promise<MachinesResponse>;
25
+ /** GET .../machines/:enrollmentId/metrics/series — the downsampled history. */
26
+ machineMetricsSeries?: (
27
+ workspaceId: string,
28
+ enrollmentId: string,
29
+ options?: { window?: "15m" | "1h" | "6h" | "24h" },
30
+ ) => Promise<MetricSample[]>;
31
+ /**
32
+ * POST .../sessions/:sessionId/active-sandbox — swap the session's active
33
+ * sandbox to a machine. The default swap path; the real SDK client provides it.
34
+ */
35
+ swapActiveSandbox?: (
36
+ workspaceId: string,
37
+ sessionId: string,
38
+ request: { target: string },
39
+ ) => Promise<unknown>;
40
+ /**
41
+ * Host-supplied swap adapter (an escape hatch). When present it wins over the
42
+ * default `swapActiveSandbox` path. Session-scoped, like the swap it backs.
43
+ */
44
+ attachMachine?: (workspaceId: string, sessionId: string, sandboxId: string) => Promise<unknown>;
45
+ };
46
+
47
+ export type UseMachinesOptions = ClientOverride & {
48
+ pollIntervalMs?: number | undefined;
49
+ enabled?: boolean | undefined;
50
+ /** Scope the list to a session (adds the synthetic Modal group box + pointer). */
51
+ sessionId?: string | undefined;
52
+ /**
53
+ * Override the client with one implementing `MachinesClientLike`. Defaults to
54
+ * the provider client cast to the surface (the real SDK client satisfies the
55
+ * read paths). An app supplies an adapter to wire `attachMachine` (the swap).
56
+ */
57
+ machinesClient?: MachinesClientLike | undefined;
58
+ };
59
+
60
+ export type UseMachinesResult = {
61
+ machines: MachineView[];
62
+ activeSandboxId: string | null;
63
+ activeEpoch: number;
64
+ loading: boolean;
65
+ error: Error | null;
66
+ refresh: () => Promise<void>;
67
+ /** Attach/swap the session's active sandbox to a machine (returns the new pointer). */
68
+ attach: (sandboxId: string) => Promise<boolean>;
69
+ /** Whether the host wired an attach/swap path (drives the card affordance). */
70
+ canAttach: boolean;
71
+ /** Fetch a downsampled metric series for one enrolled machine. */
72
+ fetchSeries: (enrollmentId: string, window?: "15m" | "1h" | "6h" | "24h") => Promise<MetricSample[]>;
73
+ attaching: boolean;
74
+ /** The sandbox id of the in-flight attach (for per-card spinner gating). */
75
+ attachingSandboxId: string | null;
76
+ mutationError: Error | null;
77
+ clearMutationError: () => void;
78
+ };
79
+
80
+ const EMPTY: MachinesResponse = { activeSandboxId: null, activeEpoch: 0, machines: [] };
81
+
82
+ /**
83
+ * The workspace Machines fleet: the selfhosted enrollments + the session's Modal
84
+ * box, each with latest metrics + state, plus the active-sandbox pointer. Polls
85
+ * the M10 `GET /machines` endpoint and exposes attach/swap + a metric-series
86
+ * fetch. Renders via `<MachinesDashboard>`. Dual-consumer safe: it reads only the
87
+ * structural `MachinesClientLike` surface, so it works in apps/web AND the Geni
88
+ * frontend (each provides its own client/adapter).
89
+ */
90
+ export function useMachines(options: UseMachinesOptions = {}): UseMachinesResult {
91
+ const { client, workspaceId } = useOpenGeni(options);
92
+ const machinesClient = (options.machinesClient ?? (client as unknown as MachinesClientLike)) satisfies MachinesClientLike;
93
+ const sessionId = options.sessionId;
94
+
95
+ const load = useCallback(async () => {
96
+ return await machinesClient.listMachines(workspaceId, sessionId ? { sessionId } : undefined);
97
+ }, [machinesClient, workspaceId, sessionId]);
98
+
99
+ const state = usePolledValue(load, { pollIntervalMs: options.pollIntervalMs, enabled: options.enabled });
100
+ const mutation = useMutationRunner();
101
+ // The sandbox id of the in-flight attach (drives the per-card spinner).
102
+ const [attachingSandboxId, setAttachingSandboxId] = useState<string | null>(null);
103
+
104
+ const data = state.data ?? EMPTY;
105
+ // The swap is session-scoped: a host adapter (`attachMachine`) wins; otherwise
106
+ // the default `swapActiveSandbox` path is wired whenever a sessionId is in
107
+ // scope. Either way attach needs a sessionId to point at.
108
+ const canAttach =
109
+ sessionId !== undefined &&
110
+ (typeof machinesClient.attachMachine === "function" ||
111
+ typeof machinesClient.swapActiveSandbox === "function");
112
+
113
+ const attach = useCallback(
114
+ async (sandboxId: string): Promise<boolean> => {
115
+ if (sessionId === undefined) return false;
116
+ const runSwap = machinesClient.attachMachine
117
+ ? () => machinesClient.attachMachine!(workspaceId, sessionId, sandboxId)
118
+ : machinesClient.swapActiveSandbox
119
+ ? () => machinesClient.swapActiveSandbox!(workspaceId, sessionId, { target: sandboxId })
120
+ : null;
121
+ if (!runSwap) return false;
122
+ setAttachingSandboxId(sandboxId);
123
+ const result = await mutation.run(async () => {
124
+ await runSwap();
125
+ return true;
126
+ });
127
+ setAttachingSandboxId(null);
128
+ if (result) await state.refresh();
129
+ return result === true;
130
+ },
131
+ [machinesClient, workspaceId, sessionId, mutation.run, state.refresh],
132
+ );
133
+
134
+ const fetchSeries = useCallback(
135
+ async (enrollmentId: string, window: "15m" | "1h" | "6h" | "24h" = "1h"): Promise<MetricSample[]> => {
136
+ if (!machinesClient.machineMetricsSeries) return [];
137
+ return await machinesClient.machineMetricsSeries(workspaceId, enrollmentId, { window });
138
+ },
139
+ [machinesClient, workspaceId],
140
+ );
141
+
142
+ return {
143
+ machines: data.machines,
144
+ activeSandboxId: data.activeSandboxId,
145
+ activeEpoch: data.activeEpoch,
146
+ loading: state.loading,
147
+ error: state.error,
148
+ refresh: state.refresh,
149
+ attach,
150
+ canAttach,
151
+ fetchSeries,
152
+ attaching: mutation.mutating,
153
+ attachingSandboxId,
154
+ mutationError: mutation.mutationError,
155
+ clearMutationError: mutation.clearMutationError,
156
+ };
157
+ }