@opengeni/react 0.4.0 → 0.6.0

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.
Files changed (40) hide show
  1. package/README.md +109 -2
  2. package/dist/chunk-DEW2ZNF2.js +1238 -0
  3. package/dist/chunk-DEW2ZNF2.js.map +1 -0
  4. package/dist/index.d.ts +191 -61
  5. package/dist/index.js +1501 -1009
  6. package/dist/index.js.map +1 -1
  7. package/dist/machines-BD6h9P_s.d.ts +329 -0
  8. package/dist/machines.d.ts +4 -0
  9. package/dist/machines.js +33 -0
  10. package/dist/machines.js.map +1 -0
  11. package/package.json +8 -2
  12. package/src/client.ts +1 -0
  13. package/src/components/desktop-viewer.tsx +11 -1
  14. package/src/components/enrollment-consent.tsx +245 -0
  15. package/src/components/enrollment-device-flow.tsx +182 -0
  16. package/src/components/fleet-tile.tsx +5 -0
  17. package/src/components/machine-card.tsx +131 -0
  18. package/src/components/machine-dock-bar.tsx +84 -0
  19. package/src/components/machine-metrics.tsx +184 -0
  20. package/src/components/machine-status-pill.tsx +157 -0
  21. package/src/components/machines-dashboard.tsx +151 -0
  22. package/src/components/message-timeline.tsx +106 -8
  23. package/src/components/workspace-dock.tsx +44 -10
  24. package/src/hooks/use-codex-accounts.ts +179 -0
  25. package/src/hooks/use-desktop-stream.ts +28 -2
  26. package/src/hooks/use-goal.ts +10 -2
  27. package/src/hooks/use-machines.ts +157 -0
  28. package/src/hooks/use-relay-frame-stream.ts +335 -0
  29. package/src/hooks/use-session.ts +80 -12
  30. package/src/index.ts +16 -1
  31. package/src/lib/git-patch.ts +10 -4
  32. package/src/lib/relay-wire.ts +116 -0
  33. package/src/machines.ts +50 -0
  34. package/src/timeline/activity-rail.tsx +13 -7
  35. package/src/timeline/index.ts +1 -0
  36. package/src/timeline/parsers.ts +6 -1
  37. package/src/timeline/projection.ts +203 -16
  38. package/src/timeline/turn-summary.tsx +32 -10
  39. package/src/timeline/types.ts +29 -3
  40. package/src/types/machines.ts +67 -0
@@ -0,0 +1,335 @@
1
+ import {
2
+ decodeStreamFrame,
3
+ decodeStreamOpenAck,
4
+ encodeStreamOpen,
5
+ STREAM_KIND_DESKTOP,
6
+ STREAM_ROLE_CLIENT,
7
+ } from "../lib/relay-wire";
8
+ import type { DesktopConnectionState, DesktopStreamCapability } from "@opengeni/sdk";
9
+ import { type RefObject, useEffect, useRef, useState } from "react";
10
+
11
+ /**
12
+ * The minimal WebSocket surface the frame renderer drives. Lets tests (and a
13
+ * future transport swap) inject a fake without a live socket — the exact
14
+ * analogue of `DesktopRfbFactory` for the noVNC path. Structurally a subset of
15
+ * the browser `WebSocket`.
16
+ */
17
+ export interface DesktopWebSocketLike {
18
+ binaryType: string;
19
+ send(data: ArrayBuffer): void;
20
+ close(): void;
21
+ addEventListener(
22
+ type: "open" | "message" | "error" | "close",
23
+ listener: (ev: { data?: unknown } & Record<string, unknown>) => void,
24
+ ): void;
25
+ removeEventListener(
26
+ type: "open" | "message" | "error" | "close",
27
+ listener: (ev: { data?: unknown } & Record<string, unknown>) => void,
28
+ ): void;
29
+ }
30
+
31
+ export type DesktopWebSocketFactory = (url: string) => DesktopWebSocketLike;
32
+
33
+ export type UseRelayFrameStreamOptions = {
34
+ /** The desktop cell of the negotiated capabilities (`capabilities.DesktopStream`). */
35
+ capability: DesktopStreamCapability | null;
36
+ /** The mount target. A `<canvas>` is appended here on connect. */
37
+ containerRef: RefObject<HTMLDivElement | null>;
38
+ /** Custom socket factory (tests / a transport swap). Defaults to `new WebSocket(url)`. */
39
+ webSocketFactory?: DesktopWebSocketFactory | undefined;
40
+ };
41
+
42
+ export type UseRelayFrameStreamResult = {
43
+ state: DesktopConnectionState;
44
+ error: Error | null;
45
+ /** Tear down + reopen the socket (e.g. after a drop, once a fresh url arrives). */
46
+ reconnect: () => void;
47
+ };
48
+
49
+ // Relay datagram tags: byte[0] of every binary message. Mirrors the proven wire
50
+ // protocol in `apps/api/scripts/diagnose-mac-desktop-stream.ts`.
51
+ const TAG_OPEN = 1;
52
+ const TAG_OPENACK = 2;
53
+ const TAG_FRAME = 3;
54
+
55
+ /** Build a relay datagram: a fresh Uint8Array of `body.length + 1` with the tag
56
+ * at [0] and the protobuf body copied at offset 1 (so `.buffer` is exact). */
57
+ function datagram(tag: number, body: Uint8Array): Uint8Array {
58
+ const out = new Uint8Array(body.length + 1);
59
+ out[0] = tag;
60
+ out.set(body, 1);
61
+ return out;
62
+ }
63
+
64
+ function defaultWebSocketFactory(url: string): DesktopWebSocketLike {
65
+ // The browser `WebSocket` is structurally a superset of `DesktopWebSocketLike`
66
+ // (its event maps are stricter); cast so the seam stays loosely typed for fakes.
67
+ return new WebSocket(url) as unknown as DesktopWebSocketLike;
68
+ }
69
+
70
+ /**
71
+ * VIEW-ONLY PNG-frame renderer for a SELF-HOSTED desktop stream — the relay
72
+ * transport (`transport: "relay-frames"`, `client: "frames"`). The self-hosted
73
+ * agent produces one PNG per frame as a protobuf datagram over the relay; there
74
+ * is no RFB/noVNC here. This hook opens the relay DESKTOP channel as a CLIENT
75
+ * (mirroring the proven diagnostic wire protocol), decodes each PNG onto a
76
+ * `<canvas>` mounted into `containerRef`, and drives the connection state
77
+ * machine: idle → connecting (ws opening) → connected (first painted frame) →
78
+ * error (ws error/close/ack rejection).
79
+ *
80
+ * It is DORMANT for any other transport (or no url): it stays idle and touches
81
+ * nothing, so the noVNC path owns the surface. SSR-safe: the socket + DOM attach
82
+ * live inside `useEffect`.
83
+ *
84
+ * Backpressure (critical — frames are ~1.8MB at ~10fps): at most one frame is
85
+ * decoded at a time; a frame arriving mid-decode replaces the pending one
86
+ * (latest-wins), so a slow decode can never build an unbounded queue.
87
+ */
88
+ export function useRelayFrameStream(options: UseRelayFrameStreamOptions): UseRelayFrameStreamResult {
89
+ const { capability, containerRef, webSocketFactory } = options;
90
+ const [state, setState] = useState<DesktopConnectionState>("idle");
91
+ const [error, setError] = useState<Error | null>(null);
92
+ const [nonce, setNonce] = useState(0);
93
+ const stateRef = useRef<DesktopConnectionState>("idle");
94
+ const setBoth = (next: DesktopConnectionState) => {
95
+ stateRef.current = next;
96
+ setState(next);
97
+ };
98
+
99
+ const reconnect = () => setNonce((n) => n + 1);
100
+
101
+ const transport = capability?.transport ?? null;
102
+ const url = capability?.url ?? null;
103
+ const token = capability?.token ?? null;
104
+
105
+ // The factory is read via a ref so swapping it never re-opens the socket (it is
106
+ // not a connect-effect dependency), symmetric with the noVNC `rfbFactory`.
107
+ const factoryRef = useRef(webSocketFactory);
108
+ factoryRef.current = webSocketFactory;
109
+
110
+ useEffect(() => {
111
+ // SSR / no DOM: stay idle and show the placeholder.
112
+ if (typeof window === "undefined") return;
113
+ // This hook OWNS the surface ONLY for the frame transport; anything else (or
114
+ // no live url) → dormant idle so the noVNC path can drive.
115
+ if (transport !== "relay-frames" || !url) {
116
+ setBoth("idle");
117
+ return;
118
+ }
119
+ const container = containerRef.current;
120
+ if (!container) {
121
+ // Mount target not in the DOM yet (tab hidden) — stay idle so a re-attach
122
+ // nudge can re-run this effect once the container exists.
123
+ setBoth("idle");
124
+ return;
125
+ }
126
+
127
+ // Parse the 4 relay query params the channel is keyed on.
128
+ let channel: {
129
+ channelId: string;
130
+ workspaceId: string;
131
+ agentId: string;
132
+ kind: number;
133
+ port: number;
134
+ };
135
+ try {
136
+ const u = new URL(url);
137
+ channel = {
138
+ channelId: u.searchParams.get("channel") ?? "",
139
+ workspaceId: u.searchParams.get("ws") ?? "",
140
+ agentId: u.searchParams.get("agent") ?? "",
141
+ kind: STREAM_KIND_DESKTOP,
142
+ port: Number(u.searchParams.get("port") ?? "0"),
143
+ };
144
+ } catch (cause) {
145
+ setError(cause instanceof Error ? cause : new Error(String(cause)));
146
+ setBoth("error");
147
+ return;
148
+ }
149
+
150
+ setError(null);
151
+ setBoth("connecting");
152
+
153
+ // Canvas mount — mirror the noVNC surface conventions: absolute-fill the
154
+ // panel and CONTAIN the framebuffer (aspect-preserved, centered, letterboxed)
155
+ // — never distorted, never overflowing. `max-*: 100%` + `margin: auto` +
156
+ // intrinsic size gives fit-to-panel without a resize observer.
157
+ const canvas = document.createElement("canvas");
158
+ canvas.setAttribute("data-opengeni-desktop-frames", "");
159
+ const style = canvas.style;
160
+ style.position = "absolute";
161
+ style.top = "0";
162
+ style.left = "0";
163
+ style.right = "0";
164
+ style.bottom = "0";
165
+ style.margin = "auto";
166
+ style.maxWidth = "100%";
167
+ style.maxHeight = "100%";
168
+ style.width = "auto";
169
+ style.height = "auto";
170
+ style.display = "block";
171
+ style.imageRendering = "auto";
172
+ container.appendChild(canvas);
173
+ const ctx = canvas.getContext("2d");
174
+
175
+ let disposed = false;
176
+ let acked = false;
177
+ // Backpressure: decode at most ONE frame at a time; a frame arriving mid-
178
+ // decode replaces `pending` (keep only the LATEST) — never a queue.
179
+ let decoding = false;
180
+ let pending: Uint8Array | null = null;
181
+
182
+ const drainLatest = async () => {
183
+ if (decoding) return;
184
+ decoding = true;
185
+ try {
186
+ while (!disposed && pending) {
187
+ const data = pending;
188
+ pending = null;
189
+ let bmp: ImageBitmap;
190
+ try {
191
+ // Copy the exact frame bytes into a fresh ArrayBuffer-backed view:
192
+ // ts-proto's `bytes()` hands back a subarray VIEW into the larger
193
+ // message buffer, so slicing isolates just this PNG (and satisfies the
194
+ // `BlobPart` typing, which rejects the generic `ArrayBufferLike`).
195
+ bmp = await createImageBitmap(new Blob([new Uint8Array(data)], { type: "image/png" }));
196
+ } catch {
197
+ // A corrupt/partial frame is skipped; latest-wins keeps us live.
198
+ continue;
199
+ }
200
+ if (disposed) {
201
+ bmp.close();
202
+ break;
203
+ }
204
+ // Size the backing store to the frame's natural WxH on the first frame
205
+ // (or when the resolution changes); CSS then scales the element to fit.
206
+ if (canvas.width !== bmp.width || canvas.height !== bmp.height) {
207
+ canvas.width = bmp.width;
208
+ canvas.height = bmp.height;
209
+ }
210
+ ctx?.drawImage(bmp, 0, 0, canvas.width, canvas.height);
211
+ bmp.close();
212
+ // First painted frame → connected.
213
+ if (!disposed && stateRef.current !== "connected") setBoth("connected");
214
+ }
215
+ } finally {
216
+ decoding = false;
217
+ }
218
+ };
219
+
220
+ let ws: DesktopWebSocketLike;
221
+ try {
222
+ ws = (factoryRef.current ?? defaultWebSocketFactory)(url);
223
+ } catch (cause) {
224
+ setError(cause instanceof Error ? cause : new Error(String(cause)));
225
+ setBoth("error");
226
+ canvas.remove();
227
+ return;
228
+ }
229
+ ws.binaryType = "arraybuffer";
230
+
231
+ const onOpen = () => {
232
+ if (disposed) return;
233
+ const body = encodeStreamOpen({
234
+ channel,
235
+ token: token ?? "",
236
+ role: STREAM_ROLE_CLIENT,
237
+ resumeFromSeq: "0",
238
+ });
239
+ try {
240
+ ws.send(datagram(TAG_OPEN, body).buffer as ArrayBuffer);
241
+ } catch {
242
+ // Socket already closing; the close/error handler surfaces it.
243
+ }
244
+ };
245
+
246
+ const onMessage = (ev: { data?: unknown }) => {
247
+ if (disposed) return;
248
+ const data = ev.data;
249
+ if (!(data instanceof ArrayBuffer)) return;
250
+ const buf = new Uint8Array(data);
251
+ if (buf.length === 0) return;
252
+ const tag = buf[0];
253
+ const rest = buf.subarray(1);
254
+ if (tag === TAG_OPENACK) {
255
+ let ack: ReturnType<typeof decodeStreamOpenAck>;
256
+ try {
257
+ ack = decodeStreamOpenAck(rest);
258
+ } catch {
259
+ return;
260
+ }
261
+ if (!ack.accepted) {
262
+ setError(
263
+ new Error(
264
+ ack.error?.message
265
+ ? `desktop stream rejected: ${ack.error.message}`
266
+ : "desktop stream rejected by the relay",
267
+ ),
268
+ );
269
+ setBoth("error");
270
+ try {
271
+ ws.close();
272
+ } catch {
273
+ // already closed
274
+ }
275
+ return;
276
+ }
277
+ // Accepted — stay "connecting" until the first frame paints.
278
+ acked = true;
279
+ } else if (tag === TAG_FRAME) {
280
+ let fr: ReturnType<typeof decodeStreamFrame>;
281
+ try {
282
+ fr = decodeStreamFrame(rest);
283
+ } catch {
284
+ return;
285
+ }
286
+ if (fr.data && fr.data.length > 0) {
287
+ pending = fr.data; // latest-wins backpressure
288
+ void drainLatest();
289
+ }
290
+ }
291
+ };
292
+
293
+ const onError = () => {
294
+ if (disposed) return;
295
+ setError((prev) => prev ?? new Error("desktop stream connection error"));
296
+ setBoth("error");
297
+ };
298
+
299
+ const onClose = () => {
300
+ if (disposed) return;
301
+ setError(
302
+ (prev) =>
303
+ prev ??
304
+ new Error(acked ? "desktop stream closed" : "desktop stream closed before it opened"),
305
+ );
306
+ setBoth("error");
307
+ };
308
+
309
+ ws.addEventListener("open", onOpen);
310
+ ws.addEventListener("message", onMessage);
311
+ ws.addEventListener("error", onError);
312
+ ws.addEventListener("close", onClose);
313
+
314
+ return () => {
315
+ disposed = true;
316
+ pending = null;
317
+ ws.removeEventListener("open", onOpen);
318
+ ws.removeEventListener("message", onMessage);
319
+ ws.removeEventListener("error", onError);
320
+ ws.removeEventListener("close", onClose);
321
+ try {
322
+ ws.close();
323
+ } catch {
324
+ // ignore teardown errors
325
+ }
326
+ canvas.remove();
327
+ };
328
+ // ONLY a real transport change reopens: a fresh url (rotation), a new token,
329
+ // the transport flipping, or a manual reconnect (`nonce`). The factory is read
330
+ // via a ref and must never re-open the socket.
331
+ // eslint-disable-next-line react-hooks/exhaustive-deps
332
+ }, [url, token, transport, nonce]);
333
+
334
+ return { state, error, reconnect };
335
+ }
@@ -1,33 +1,101 @@
1
- import type { Session } from "@opengeni/sdk";
2
- import { useCallback } from "react";
1
+ import type { Session, SessionEvent } from "@opengeni/sdk";
2
+ import { useCallback, useState } from "react";
3
3
  import { useOpenGeni, type ClientOverride } from "../provider";
4
- import { usePolledValue } from "./internal";
4
+ import { useMutationRunner, usePolledValue, useSessionEventTrigger, type SessionEventFeedOptions } from "./internal";
5
5
 
6
- export type UseSessionOptions = ClientOverride & {
7
- /** Re-fetch on an interval (ms). Off by default — pair with `useSessionEvents` for live status. */
8
- pollIntervalMs?: number | undefined;
9
- enabled?: boolean | undefined;
10
- };
6
+ export type UseSessionOptions = ClientOverride &
7
+ SessionEventFeedOptions & {
8
+ /** Re-fetch on an interval (ms). Off by default — pair with `useSessionEvents` for live status. */
9
+ pollIntervalMs?: number | undefined;
10
+ };
11
11
 
12
12
  export type UseSessionResult = {
13
13
  session: Session | null;
14
14
  loading: boolean;
15
15
  error: Error | null;
16
16
  refresh: () => Promise<void>;
17
+ /** Manually rename the session (PATCH, source='user'). Returns the updated session, or null on failure. */
18
+ updateTitle: (title: string) => Promise<Session | null>;
19
+ /** True while a rename is in flight. */
20
+ updating: boolean;
21
+ mutationError: Error | null;
22
+ clearMutationError: () => void;
17
23
  };
18
24
 
19
- /** Fetch one session (with optional polling). */
25
+ /** Event types that change the session title (auto + cross-client renames). */
26
+ export function isTitleEvent(event: Pick<SessionEvent, "type">): boolean {
27
+ return event.type === "session.title_set";
28
+ }
29
+
30
+ /** Fetch one session (with optional polling), live-patching its title on `session.title_set`. */
20
31
  export function useSession(sessionId: string | null | undefined, options: UseSessionOptions = {}): UseSessionResult {
21
32
  const { client, workspaceId } = useOpenGeni(options);
33
+ const enabled = (options.enabled ?? true) && Boolean(sessionId);
34
+ const [override, setOverride] = useState<Session | null>(null);
35
+ const mutation = useMutationRunner();
22
36
  const load = useCallback(async () => {
23
37
  if (!sessionId) {
24
38
  return null;
25
39
  }
26
- return await client.getSession(workspaceId, sessionId);
40
+ const fetched = await client.getSession(workspaceId, sessionId);
41
+ // A fresh server read supersedes any optimistic/event-driven override.
42
+ setOverride(null);
43
+ return fetched;
27
44
  }, [client, workspaceId, sessionId]);
28
45
  const state = usePolledValue(load, {
29
46
  pollIntervalMs: options.pollIntervalMs,
30
- enabled: (options.enabled ?? true) && Boolean(sessionId),
47
+ enabled,
31
48
  });
32
- return { session: state.data ?? null, loading: state.loading, error: state.error, refresh: state.refresh };
49
+
50
+ const base = state.data ?? null;
51
+ // The override only ever carries title/titleSource patches; it is reset on
52
+ // every fresh load so it can never go stale against the server snapshot.
53
+ const session = base && override && override.id === base.id ? { ...base, title: override.title, titleSource: override.titleSource } : base;
54
+
55
+ // Live-patch the title on auto (agent) + cross-client (user/agent) renames so
56
+ // the UI reflects the new title without polling or a full re-fetch.
57
+ const onTitleEvent = useCallback((event: SessionEvent) => {
58
+ const payload = (event.payload ?? {}) as { title?: unknown; source?: unknown };
59
+ const title = payload.title;
60
+ if (typeof title !== "string") {
61
+ return;
62
+ }
63
+ const source: "user" | "agent" | null = payload.source === "user" || payload.source === "agent" ? payload.source : null;
64
+ setOverride((current): Session | null => {
65
+ const next = current ?? base;
66
+ if (!next) {
67
+ return current;
68
+ }
69
+ return { ...next, title, titleSource: source };
70
+ });
71
+ }, [base]);
72
+ useSessionEventTrigger(client, workspaceId, sessionId, isTitleEvent, onTitleEvent, {
73
+ enabled,
74
+ ...(options.events !== undefined ? { events: options.events } : {}),
75
+ });
76
+
77
+ const updateTitle = useCallback(
78
+ async (title: string): Promise<Session | null> => {
79
+ if (!sessionId) {
80
+ return null;
81
+ }
82
+ const result = await mutation.run(() => client.updateSession(workspaceId, sessionId, { title }));
83
+ if (result) {
84
+ setOverride(result);
85
+ }
86
+ return result;
87
+ },
88
+ [client, workspaceId, sessionId, mutation.run],
89
+ );
90
+
91
+ return {
92
+ session,
93
+ loading: state.loading,
94
+ error: state.error,
95
+ refresh: state.refresh,
96
+ updateTitle,
97
+ updating: mutation.mutating,
98
+ mutationError: mutation.mutationError,
99
+ clearMutationError: mutation.clearMutationError,
100
+ };
33
101
  }
package/src/index.ts CHANGED
@@ -9,7 +9,7 @@ export { OpenGeniProvider, useOpenGeni, useOpenGeniClient } from "./provider";
9
9
  export type { ClientOverride, OpenGeniContextValue, OpenGeniProviderProps } from "./provider";
10
10
 
11
11
  // Hooks
12
- export { useSession } from "./hooks/use-session";
12
+ export { useSession, isTitleEvent } from "./hooks/use-session";
13
13
  export type { UseSessionOptions, UseSessionResult } from "./hooks/use-session";
14
14
  export { useSessionEvents } from "./hooks/use-session-events";
15
15
  export type { SessionEventsConnectionState, UseSessionEventsOptions, UseSessionEventsResult } from "./hooks/use-session-events";
@@ -55,6 +55,13 @@ export type {
55
55
  } from "./hooks/use-session-capabilities";
56
56
  export { useDesktopStream } from "./hooks/use-desktop-stream";
57
57
  export type { UseDesktopStreamOptions, UseDesktopStreamResult } from "./hooks/use-desktop-stream";
58
+ export { useRelayFrameStream } from "./hooks/use-relay-frame-stream";
59
+ export type {
60
+ DesktopWebSocketFactory,
61
+ DesktopWebSocketLike,
62
+ UseRelayFrameStreamOptions,
63
+ UseRelayFrameStreamResult,
64
+ } from "./hooks/use-relay-frame-stream";
58
65
  export { useTerminalStream } from "./hooks/use-terminal-stream";
59
66
  export type {
60
67
  TerminalStreamStatus,
@@ -99,6 +106,7 @@ export type {
99
106
  SessionStatusItem,
100
107
  TimelineGroup,
101
108
  TimelineItem,
109
+ TurnEndItem,
102
110
  ToolCallItem,
103
111
  UserMessageItem,
104
112
  WorkerItem,
@@ -221,6 +229,13 @@ export type { DesktopViewerProps } from "./components/desktop-viewer";
221
229
  export { WorkspaceDock } from "./components/workspace-dock";
222
230
  export type { WorkspaceDockProps, WorkspaceTab } from "./components/workspace-dock";
223
231
 
232
+ // Connected-machine UI moved to the "@opengeni/react/machines" subpath; re-exported
233
+ // here for back-compat (#144).
234
+ export * from "./machines";
235
+ // Multi-account Codex (P1): accounts list + active-switch hook.
236
+ export { useCodexAccounts, isCodexAccountEvent } from "./hooks/use-codex-accounts";
237
+ export type { CodexAccountsClientLike, UseCodexAccountsOptions, UseCodexAccountsResult } from "./hooks/use-codex-accounts";
238
+
224
239
  // Sandbox helpers
225
240
  export { gitFileDiffToPatch } from "./lib/git-patch";
226
241
  export { xtermThemeFromTokens } from "./lib/xterm-theme";
@@ -22,10 +22,16 @@ export function gitFileDiffToPatch(file: GitFileDiff): string {
22
22
  lines.push(`+++ b/${newPath}`);
23
23
  }
24
24
  for (const hunk of file.hunks) {
25
- const header =
26
- hunk.header && hunk.header.startsWith("@@")
27
- ? hunk.header
28
- : `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`;
25
+ // Only trust a pre-parsed header if it carries the full unified range form
26
+ // `@@ -<o>[,<n>] +<o>[,<n>] @@`. A synthesized create_file hunk (parsers.ts)
27
+ // can carry a degenerate `@@ +1 @@` with no `-`/`+` ranges; a generic patch
28
+ // parser (Pierre) renders zero lines from it, so the expanded diff comes up
29
+ // empty while the collapsed chip still shows the (correct) addition count.
30
+ // In that case regenerate a valid header from the hunk's range fields.
31
+ const headerIsValid = /^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/.test(hunk.header ?? "");
32
+ const header = headerIsValid
33
+ ? hunk.header
34
+ : `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`;
29
35
  lines.push(header);
30
36
  for (const line of hunk.lines) {
31
37
  if (line.type === "meta") continue;
@@ -0,0 +1,116 @@
1
+ // Minimal HAND-MIRROR of the relay stream wire messages the desktop frame client
2
+ // needs (`StreamOpen` encode; `StreamOpenAck` + `StreamFrame` decode). It exists
3
+ // because the publish-closure guard (scripts/publish-closure-guard.ts) forbids
4
+ // `@opengeni/react` from depending on `@opengeni/agent-proto` — the published SDK
5
+ // closure may only reach `@opengeni/sdk` among `@opengeni/*` packages. So, exactly
6
+ // as the guard prescribes ("hand-mirror it"), we re-encode just these three
7
+ // messages against the third-party `@bufbuild/protobuf/wire` runtime (the same
8
+ // runtime `@opengeni/agent-proto` generates against), byte-compatible with the
9
+ // generated code. Field numbers copied verbatim from
10
+ // `packages/agent-proto/src/gen/opengeni_agent.ts`:
11
+ // StreamChannel: channelId=1, workspaceId=2, agentId=3, kind=4(int32), port=5(uint32)
12
+ // StreamOpen: channel=1(msg), token=2, role=3(int32), resumeFromSeq=4(uint64, omitted when "0")
13
+ // StreamOpenAck: accepted=1(bool), error=2(AgentError msg), resumeFromSeq=3
14
+ // AgentError: code=1, message=2, retryable=3, detail=4 (we read only message)
15
+ // StreamFrame: channelId=1, seq=2, data=3(bytes), producedAtMs=4 (we read only data)
16
+ import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
17
+
18
+ /** `StreamKind.STREAM_KIND_DESKTOP` (agent-proto enum value). */
19
+ export const STREAM_KIND_DESKTOP = 2;
20
+ /** `StreamRole.STREAM_ROLE_CLIENT` (agent-proto enum value). */
21
+ export const STREAM_ROLE_CLIENT = 2;
22
+
23
+ export interface RelayChannel {
24
+ channelId: string;
25
+ workspaceId: string;
26
+ agentId: string;
27
+ kind: number;
28
+ port: number;
29
+ }
30
+
31
+ export interface RelayStreamOpen {
32
+ channel: RelayChannel;
33
+ token: string;
34
+ role: number;
35
+ resumeFromSeq: string;
36
+ }
37
+
38
+ function encodeChannel(message: RelayChannel, writer: BinaryWriter): BinaryWriter {
39
+ if (message.channelId !== "") writer.uint32(10).string(message.channelId);
40
+ if (message.workspaceId !== "") writer.uint32(18).string(message.workspaceId);
41
+ if (message.agentId !== "") writer.uint32(26).string(message.agentId);
42
+ if (message.kind !== 0) writer.uint32(32).int32(message.kind);
43
+ if (message.port !== 0) writer.uint32(40).uint32(message.port);
44
+ return writer;
45
+ }
46
+
47
+ /** Encode a `StreamOpen`, byte-compatible with `StreamOpen.encode(...).finish()`. */
48
+ export function encodeStreamOpen(message: RelayStreamOpen): Uint8Array {
49
+ const writer = new BinaryWriter();
50
+ encodeChannel(message.channel, writer.uint32(10).fork()).join();
51
+ if (message.token !== "") writer.uint32(18).string(message.token);
52
+ if (message.role !== 0) writer.uint32(24).int32(message.role);
53
+ if (message.resumeFromSeq !== "0") writer.uint32(32).uint64(message.resumeFromSeq);
54
+ return writer.finish();
55
+ }
56
+
57
+ /** The only `AgentError` field we surface from a rejected ack. */
58
+ function decodeAgentErrorMessage(reader: BinaryReader, length: number): { message?: string } {
59
+ const end = reader.pos + length;
60
+ let message: string | undefined;
61
+ while (reader.pos < end) {
62
+ const tag = reader.uint32();
63
+ if (tag >>> 3 === 2 && tag === 18) {
64
+ message = reader.string();
65
+ continue;
66
+ }
67
+ if ((tag & 7) === 4 || tag === 0) break;
68
+ reader.skip(tag & 7);
69
+ }
70
+ return message !== undefined ? { message } : {};
71
+ }
72
+
73
+ /** Decode a `StreamOpenAck` (we need `accepted` + the error message). */
74
+ export function decodeStreamOpenAck(bytes: Uint8Array): {
75
+ accepted: boolean;
76
+ error?: { message?: string };
77
+ } {
78
+ const reader = new BinaryReader(bytes);
79
+ const end = reader.len;
80
+ let accepted = false;
81
+ let error: { message?: string } | undefined;
82
+ while (reader.pos < end) {
83
+ const tag = reader.uint32();
84
+ if (tag >>> 3 === 1 && tag === 8) {
85
+ accepted = reader.bool();
86
+ continue;
87
+ }
88
+ if (tag >>> 3 === 2 && tag === 18) {
89
+ error = decodeAgentErrorMessage(reader, reader.uint32());
90
+ continue;
91
+ }
92
+ if ((tag & 7) === 4 || tag === 0) break;
93
+ reader.skip(tag & 7);
94
+ }
95
+ return error !== undefined ? { accepted, error } : { accepted };
96
+ }
97
+
98
+ /** Decode a `StreamFrame`, extracting only the `data` (framebuffer PNG) bytes. */
99
+ export function decodeStreamFrame(bytes: Uint8Array): { data: Uint8Array } {
100
+ const reader = new BinaryReader(bytes);
101
+ const end = reader.len;
102
+ let data = new Uint8Array(0);
103
+ while (reader.pos < end) {
104
+ const tag = reader.uint32();
105
+ if (tag >>> 3 === 3 && tag === 26) {
106
+ // Copy into a fresh ArrayBuffer-backed view: BinaryReader.bytes() returns
107
+ // `Uint8Array<ArrayBufferLike>` (a view into the message buffer), which both
108
+ // narrows the type to `Uint8Array<ArrayBuffer>` and isolates just this frame.
109
+ data = new Uint8Array(reader.bytes());
110
+ continue;
111
+ }
112
+ if ((tag & 7) === 4 || tag === 0) break;
113
+ reader.skip(tag & 7);
114
+ }
115
+ return { data };
116
+ }
@@ -0,0 +1,50 @@
1
+ // @opengeni/react/machines — Bring-your-own-compute: Machines dashboard +
2
+ // enrollment flow + status surfacing (M9). View-model types mirror the M10
3
+ // contract shape (MachineView / MetricSample / MachinesResponse) — see
4
+ // types/machines.ts.
5
+ //
6
+ // This is a self-contained island carved out of the package root so consumers
7
+ // that don't surface connected machines never pull it in. The root still
8
+ // re-exports everything here for backwards compatibility (deprecated).
9
+ export type {
10
+ ConnectionStatus,
11
+ MachineKind,
12
+ MachineMetricsSeriesResponse,
13
+ MachineState,
14
+ MachineView,
15
+ MachinesResponse,
16
+ MetricSample,
17
+ } from "./types/machines";
18
+ export { connectionStatusForState } from "./types/machines";
19
+ export {
20
+ ConnectionDot,
21
+ ConnectionStatusPill,
22
+ MachineStatusPill,
23
+ CONNECTION_STATUS_META,
24
+ MACHINE_STATE_BADGE_META,
25
+ } from "./components/machine-status-pill";
26
+ export type {
27
+ ConnectionDotProps,
28
+ ConnectionStatusMeta,
29
+ ConnectionStatusPillProps,
30
+ MachineStateBadgeMeta,
31
+ MachineStatusPillProps,
32
+ } from "./components/machine-status-pill";
33
+ export { MachineMetrics } from "./components/machine-metrics";
34
+ export type { MachineMetricsProps } from "./components/machine-metrics";
35
+ export { MachineCard } from "./components/machine-card";
36
+ export type { MachineCardProps } from "./components/machine-card";
37
+ export { MachinesDashboard } from "./components/machines-dashboard";
38
+ export type { MachinesDashboardProps } from "./components/machines-dashboard";
39
+ export { MachineDockBar, SharedMachineDisclosure } from "./components/machine-dock-bar";
40
+ export type { MachineDockBarProps, SharedMachineDisclosureProps } from "./components/machine-dock-bar";
41
+ export { EnrollmentDeviceFlow } from "./components/enrollment-device-flow";
42
+ export type { DeviceFlowPhase, EnrollmentDeviceFlowProps } from "./components/enrollment-device-flow";
43
+ export { EnrollmentConsent } from "./components/enrollment-consent";
44
+ export type {
45
+ EnrollmentConsentMachine,
46
+ EnrollmentConsentPhase,
47
+ EnrollmentConsentProps,
48
+ } from "./components/enrollment-consent";
49
+ export { useMachines } from "./hooks/use-machines";
50
+ export type { MachinesClientLike, UseMachinesOptions, UseMachinesResult } from "./hooks/use-machines";