@opengeni/react 0.13.0 → 0.15.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.
- package/README.md +19 -13
- package/dist/chunk-TOJR776I.js +2280 -0
- package/dist/chunk-TOJR776I.js.map +1 -0
- package/dist/index.d.ts +314 -255
- package/dist/index.js +5862 -4404
- package/dist/index.js.map +1 -1
- package/dist/{machines-CnlMb7E-.d.ts → machines-BpdwuQcD.d.ts} +130 -10
- package/dist/machines.d.ts +1 -1
- package/dist/machines.js +23 -1
- package/package.json +5 -2
- package/src/client.ts +10 -1
- package/src/components/chat-composer.tsx +309 -57
- package/src/components/machine-card.tsx +81 -15
- package/src/components/machine-health-pill.tsx +68 -0
- package/src/components/machine-metrics.tsx +10 -24
- package/src/components/machines/health.ts +146 -0
- package/src/components/machines/machine-detail.tsx +220 -0
- package/src/components/machines/metric-history-chart.tsx +298 -0
- package/src/components/machines/metric-sparkline.tsx +76 -0
- package/src/components/machines/series.ts +113 -0
- package/src/components/machines-dashboard.tsx +13 -1
- package/src/components/queue-surface.tsx +578 -0
- package/src/components/sandbox-files.tsx +94 -9
- package/src/components/sandbox-workspace.tsx +186 -52
- package/src/components/session-status.tsx +0 -6
- package/src/components/workbench-changes.tsx +64 -20
- package/src/components/workspace-dock.tsx +146 -55
- package/src/hooks/use-composer.ts +369 -39
- package/src/hooks/use-session-control.ts +6 -7
- package/src/hooks/use-session-events.ts +3 -2
- package/src/hooks/use-session-lineage.ts +15 -6
- package/src/hooks/use-session.ts +10 -2
- package/src/hooks/use-turn-queue.ts +175 -47
- package/src/index.ts +13 -7
- package/src/machines.ts +16 -0
- package/src/provider.tsx +192 -5
- package/src/timeline/parsers.ts +43 -6
- package/src/timeline/projection.ts +24 -2
- package/styles/index.css +22 -0
- package/dist/chunk-NFYVQWIB.js +0 -1377
- package/dist/chunk-NFYVQWIB.js.map +0 -1
package/src/hooks/use-session.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Session, SessionEvent } from "@opengeni/sdk";
|
|
2
|
-
import { useCallback, useState } from "react";
|
|
2
|
+
import { useCallback, useEffect, useState } from "react";
|
|
3
3
|
import { useOpenGeni, type ClientOverride } from "../provider";
|
|
4
4
|
import {
|
|
5
5
|
useMutationRunner,
|
|
@@ -37,7 +37,8 @@ export function useSession(
|
|
|
37
37
|
sessionId: string | null | undefined,
|
|
38
38
|
options: UseSessionOptions = {},
|
|
39
39
|
): UseSessionResult {
|
|
40
|
-
const { client, workspaceId } =
|
|
40
|
+
const { client, workspaceId, workspaceControlEvent, registerSessionReconciler } =
|
|
41
|
+
useOpenGeni(options);
|
|
41
42
|
const enabled = (options.enabled ?? true) && Boolean(sessionId);
|
|
42
43
|
const [override, setOverride] = useState<Session | null>(null);
|
|
43
44
|
const { run, mutating, mutationError, clearMutationError } = useMutationRunner();
|
|
@@ -54,6 +55,13 @@ export function useSession(
|
|
|
54
55
|
pollIntervalMs: options.pollIntervalMs,
|
|
55
56
|
enabled,
|
|
56
57
|
});
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (enabled && workspaceControlEvent) void refresh();
|
|
60
|
+
}, [enabled, refresh, workspaceControlEvent]);
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!sessionId || !enabled) return;
|
|
63
|
+
return registerSessionReconciler(sessionId, "session", refresh);
|
|
64
|
+
}, [enabled, refresh, registerSessionReconciler, sessionId]);
|
|
57
65
|
|
|
58
66
|
const base = data ?? null;
|
|
59
67
|
// The override only ever carries title/titleSource patches; it is reset on
|
|
@@ -1,14 +1,20 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
1
|
+
import type {
|
|
2
|
+
ComposerDraft,
|
|
3
|
+
EffectiveSessionControl,
|
|
4
|
+
SessionEvent,
|
|
5
|
+
SessionQueueMutationResponse,
|
|
6
|
+
SessionQueueSnapshot,
|
|
7
|
+
SessionTurn,
|
|
8
|
+
} from "@opengeni/sdk";
|
|
9
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
10
|
import { useOpenGeni, type ClientOverride } from "../provider";
|
|
4
11
|
import {
|
|
5
12
|
useDebouncedCallback,
|
|
6
|
-
useMutationRunner,
|
|
7
13
|
useSessionEventTrigger,
|
|
8
14
|
type SessionEventFeedOptions,
|
|
9
15
|
} from "./internal";
|
|
10
16
|
|
|
11
|
-
/** Events that can change the authoritative prompt queue or
|
|
17
|
+
/** Events that can change the authoritative prompt queue or effective control. */
|
|
12
18
|
export function isTurnQueueEvent(event: Pick<SessionEvent, "type">): boolean {
|
|
13
19
|
return (
|
|
14
20
|
event.type.startsWith("turn.") ||
|
|
@@ -18,6 +24,8 @@ export function isTurnQueueEvent(event: Pick<SessionEvent, "type">): boolean {
|
|
|
18
24
|
);
|
|
19
25
|
}
|
|
20
26
|
|
|
27
|
+
export type QueueMutationKind = "move" | "edit" | "steer" | "delete";
|
|
28
|
+
|
|
21
29
|
export type UseTurnQueueOptions = ClientOverride &
|
|
22
30
|
SessionEventFeedOptions & {
|
|
23
31
|
pollIntervalMs?: number | undefined;
|
|
@@ -27,68 +35,91 @@ export type UseTurnQueueResult = {
|
|
|
27
35
|
snapshot: SessionQueueSnapshot | null;
|
|
28
36
|
/** Human/API prompts exactly in server execution order. Never client-sorted. */
|
|
29
37
|
queue: SessionTurn[];
|
|
30
|
-
|
|
31
|
-
controlGeneration: number | null;
|
|
32
|
-
workspaceInferenceState: SessionQueueSnapshot["workspaceInferenceState"] | null;
|
|
33
|
-
workspaceInferenceGeneration: number | null;
|
|
34
|
-
workspaceRunExceptionGeneration: number | null;
|
|
38
|
+
effectiveControl: EffectiveSessionControl | null;
|
|
35
39
|
loading: boolean;
|
|
36
40
|
error: Error | null;
|
|
37
41
|
refresh: () => Promise<void>;
|
|
38
|
-
|
|
42
|
+
moveTurn: (turnId: string, beforeTurnId: string | null) => Promise<boolean>;
|
|
43
|
+
/** Atomically withdraw a waiting prompt into the private durable composer draft. */
|
|
44
|
+
editTurn: (
|
|
45
|
+
turnId: string,
|
|
46
|
+
options: { expectedDraftRevision: number; replaceDraft: boolean },
|
|
47
|
+
) => Promise<ComposerDraft | null>;
|
|
48
|
+
/** Advance the same durable waiting prompt; no duplicate prompt is created. */
|
|
49
|
+
steerTurn: (turnId: string) => Promise<boolean>;
|
|
39
50
|
removeTurn: (turnId: string) => Promise<boolean>;
|
|
51
|
+
pendingByTurn: Readonly<Record<string, QueueMutationKind>>;
|
|
52
|
+
mutationFor: (turnId: string) => QueueMutationKind | null;
|
|
40
53
|
mutating: boolean;
|
|
41
54
|
mutationError: Error | null;
|
|
42
55
|
clearMutationError: () => void;
|
|
43
56
|
};
|
|
44
57
|
|
|
45
58
|
/**
|
|
46
|
-
* The
|
|
47
|
-
*
|
|
59
|
+
* The one authoritative human prompt queue. Every mutation carries the exact
|
|
60
|
+
* server versions the operator saw and accepts only monotonic snapshots. A
|
|
61
|
+
* conflict immediately reloads server truth; the client never invents order.
|
|
48
62
|
*/
|
|
49
63
|
export function useTurnQueue(
|
|
50
64
|
sessionId: string | null | undefined,
|
|
51
65
|
options: UseTurnQueueOptions = {},
|
|
52
66
|
): UseTurnQueueResult {
|
|
53
|
-
const { client, workspaceId } =
|
|
67
|
+
const { client, workspaceId, workspaceControlEvent, registerSessionReconciler } =
|
|
68
|
+
useOpenGeni(options);
|
|
54
69
|
const enabled = (options.enabled ?? true) && Boolean(sessionId);
|
|
55
70
|
const [snapshot, setSnapshot] = useState<SessionQueueSnapshot | null>(null);
|
|
56
71
|
const [loading, setLoading] = useState(enabled);
|
|
57
72
|
const [error, setError] = useState<Error | null>(null);
|
|
58
|
-
const
|
|
59
|
-
const
|
|
73
|
+
const [mutationError, setMutationError] = useState<Error | null>(null);
|
|
74
|
+
const [pendingByTurn, setPendingByTurn] = useState<Record<string, QueueMutationKind>>({});
|
|
75
|
+
const pendingRef = useRef<Record<string, QueueMutationKind>>({});
|
|
76
|
+
const readGeneration = useRef(0);
|
|
60
77
|
const targetKeyRef = useRef<string | null>(null);
|
|
61
78
|
const snapshotRef = useRef<SessionQueueSnapshot | null>(null);
|
|
62
79
|
|
|
63
|
-
const
|
|
80
|
+
const acceptSnapshot = useCallback((next: SessionQueueSnapshot | null): boolean => {
|
|
81
|
+
const current = snapshotRef.current;
|
|
82
|
+
if (
|
|
83
|
+
next &&
|
|
84
|
+
current &&
|
|
85
|
+
(next.version < current.version ||
|
|
86
|
+
next.effectiveControl.controlVersion < current.effectiveControl.controlVersion)
|
|
87
|
+
) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
64
90
|
snapshotRef.current = next;
|
|
65
91
|
setSnapshot(next);
|
|
92
|
+
return true;
|
|
66
93
|
}, []);
|
|
67
94
|
|
|
68
95
|
const load = useCallback(async (): Promise<void> => {
|
|
69
96
|
if (!sessionId) return;
|
|
70
|
-
const ticket = ++
|
|
97
|
+
const ticket = ++readGeneration.current;
|
|
71
98
|
try {
|
|
72
99
|
const fetched = await client.getQueue(workspaceId, sessionId);
|
|
73
|
-
if (ticket ===
|
|
74
|
-
|
|
100
|
+
if (ticket === readGeneration.current) {
|
|
101
|
+
acceptSnapshot(fetched);
|
|
75
102
|
setError(null);
|
|
76
103
|
setLoading(false);
|
|
77
104
|
}
|
|
78
105
|
} catch (cause) {
|
|
79
|
-
if (ticket ===
|
|
80
|
-
setError(
|
|
106
|
+
if (ticket === readGeneration.current) {
|
|
107
|
+
setError(asError(cause));
|
|
81
108
|
setLoading(false);
|
|
82
109
|
}
|
|
83
110
|
}
|
|
84
|
-
}, [client, workspaceId, sessionId,
|
|
111
|
+
}, [client, workspaceId, sessionId, acceptSnapshot]);
|
|
85
112
|
|
|
86
113
|
useEffect(() => {
|
|
87
114
|
const targetKey = `${workspaceId}\u0000${sessionId ?? ""}`;
|
|
88
115
|
if (targetKeyRef.current !== targetKey) {
|
|
89
116
|
targetKeyRef.current = targetKey;
|
|
90
|
-
|
|
117
|
+
readGeneration.current += 1;
|
|
118
|
+
acceptSnapshot(null);
|
|
91
119
|
setError(null);
|
|
120
|
+
setMutationError(null);
|
|
121
|
+
setPendingByTurn({});
|
|
122
|
+
pendingRef.current = {};
|
|
92
123
|
}
|
|
93
124
|
if (!enabled) {
|
|
94
125
|
setLoading(false);
|
|
@@ -99,15 +130,23 @@ export function useTurnQueue(
|
|
|
99
130
|
const pollIntervalMs = options.pollIntervalMs;
|
|
100
131
|
if (pollIntervalMs === undefined || pollIntervalMs <= 0) {
|
|
101
132
|
return () => {
|
|
102
|
-
|
|
133
|
+
readGeneration.current += 1;
|
|
103
134
|
};
|
|
104
135
|
}
|
|
105
136
|
const timer = setInterval(() => void load(), pollIntervalMs);
|
|
106
137
|
return () => {
|
|
107
138
|
clearInterval(timer);
|
|
108
|
-
|
|
139
|
+
readGeneration.current += 1;
|
|
109
140
|
};
|
|
110
|
-
}, [load, enabled, workspaceId, sessionId, options.pollIntervalMs,
|
|
141
|
+
}, [load, enabled, workspaceId, sessionId, options.pollIntervalMs, acceptSnapshot]);
|
|
142
|
+
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
if (enabled && workspaceControlEvent) void load();
|
|
145
|
+
}, [enabled, load, workspaceControlEvent]);
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
if (!sessionId || !enabled) return;
|
|
148
|
+
return registerSessionReconciler(sessionId, "queue", load);
|
|
149
|
+
}, [enabled, load, registerSessionReconciler, sessionId]);
|
|
111
150
|
|
|
112
151
|
const scheduleRefresh = useDebouncedCallback(() => void load());
|
|
113
152
|
useSessionEventTrigger(client, workspaceId, sessionId, isTurnQueueEvent, scheduleRefresh, {
|
|
@@ -115,42 +154,131 @@ export function useTurnQueue(
|
|
|
115
154
|
...(options.events !== undefined ? { events: options.events } : {}),
|
|
116
155
|
});
|
|
117
156
|
|
|
118
|
-
const
|
|
119
|
-
async (
|
|
120
|
-
|
|
157
|
+
const mutate = useCallback(
|
|
158
|
+
async (
|
|
159
|
+
turnId: string,
|
|
160
|
+
kind: QueueMutationKind,
|
|
161
|
+
command: (
|
|
162
|
+
current: SessionQueueSnapshot,
|
|
163
|
+
turn: SessionTurn,
|
|
164
|
+
) => Promise<SessionQueueMutationResponse>,
|
|
165
|
+
): Promise<SessionQueueMutationResponse | null> => {
|
|
166
|
+
if (!sessionId || pendingRef.current[turnId]) return null;
|
|
121
167
|
const current = snapshotRef.current;
|
|
122
|
-
const
|
|
123
|
-
if (!current || !
|
|
124
|
-
|
|
125
|
-
|
|
168
|
+
const turn = current?.items.find((candidate) => candidate.id === turnId);
|
|
169
|
+
if (!current || !turn) return null;
|
|
170
|
+
pendingRef.current = { ...pendingRef.current, [turnId]: kind };
|
|
171
|
+
setPendingByTurn(pendingRef.current);
|
|
172
|
+
setMutationError(null);
|
|
173
|
+
try {
|
|
174
|
+
const result = await command(current, turn);
|
|
175
|
+
acceptSnapshot(result.snapshot);
|
|
176
|
+
return result;
|
|
177
|
+
} catch (cause) {
|
|
178
|
+
setMutationError(asError(cause));
|
|
179
|
+
await load();
|
|
180
|
+
return null;
|
|
181
|
+
} finally {
|
|
182
|
+
if (turnId in pendingRef.current) {
|
|
183
|
+
const next = { ...pendingRef.current };
|
|
184
|
+
delete next[turnId];
|
|
185
|
+
pendingRef.current = next;
|
|
186
|
+
setPendingByTurn(next);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
[acceptSnapshot, load, sessionId],
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const moveTurn = useCallback(
|
|
194
|
+
async (turnId: string, beforeTurnId: string | null): Promise<boolean> => {
|
|
195
|
+
const result = await mutate(turnId, "move", (current) =>
|
|
196
|
+
client.moveQueueItem(workspaceId, sessionId!, turnId, {
|
|
197
|
+
clientEventId: operationKey(),
|
|
126
198
|
expectedQueueVersion: current.version,
|
|
127
|
-
|
|
199
|
+
beforeTurnId,
|
|
128
200
|
}),
|
|
129
201
|
);
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
202
|
+
return result !== null;
|
|
203
|
+
},
|
|
204
|
+
[client, mutate, sessionId, workspaceId],
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const editTurn = useCallback(
|
|
208
|
+
async (
|
|
209
|
+
turnId: string,
|
|
210
|
+
edit: { expectedDraftRevision: number; replaceDraft: boolean },
|
|
211
|
+
): Promise<ComposerDraft | null> => {
|
|
212
|
+
const result = await mutate(turnId, "edit", (_current, turn) =>
|
|
213
|
+
client.editQueueItem(workspaceId, sessionId!, turnId, {
|
|
214
|
+
clientEventId: operationKey(),
|
|
215
|
+
expectedTurnVersion: turn.version,
|
|
216
|
+
expectedDraftRevision: edit.expectedDraftRevision,
|
|
217
|
+
replaceDraft: edit.replaceDraft,
|
|
218
|
+
}),
|
|
219
|
+
);
|
|
220
|
+
return result?.draft ?? null;
|
|
221
|
+
},
|
|
222
|
+
[client, mutate, sessionId, workspaceId],
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
const steerTurn = useCallback(
|
|
226
|
+
async (turnId: string): Promise<boolean> => {
|
|
227
|
+
const result = await mutate(turnId, "steer", (current, turn) =>
|
|
228
|
+
client.steerQueueItem(workspaceId, sessionId!, turnId, {
|
|
229
|
+
clientEventId: operationKey(),
|
|
230
|
+
expectedTurnVersion: turn.version,
|
|
231
|
+
controlEtag: current.effectiveControl.controlEtag,
|
|
232
|
+
}),
|
|
233
|
+
);
|
|
234
|
+
return result !== null;
|
|
235
|
+
},
|
|
236
|
+
[client, mutate, sessionId, workspaceId],
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
const removeTurn = useCallback(
|
|
240
|
+
async (turnId: string): Promise<boolean> => {
|
|
241
|
+
const result = await mutate(turnId, "delete", (_current, turn) =>
|
|
242
|
+
client.deleteQueueItem(workspaceId, sessionId!, turnId, {
|
|
243
|
+
clientEventId: operationKey(),
|
|
244
|
+
expectedTurnVersion: turn.version,
|
|
245
|
+
reason: "Deleted from the prompt queue",
|
|
246
|
+
}),
|
|
247
|
+
);
|
|
248
|
+
return result !== null;
|
|
136
249
|
},
|
|
137
|
-
[client,
|
|
250
|
+
[client, mutate, sessionId, workspaceId],
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
const mutationFor = useCallback(
|
|
254
|
+
(turnId: string): QueueMutationKind | null => pendingByTurn[turnId] ?? null,
|
|
255
|
+
[pendingByTurn],
|
|
138
256
|
);
|
|
257
|
+
const mutating = useMemo(() => Object.keys(pendingByTurn).length > 0, [pendingByTurn]);
|
|
139
258
|
|
|
140
259
|
return {
|
|
141
260
|
snapshot,
|
|
142
261
|
queue: snapshot?.items ?? [],
|
|
143
|
-
|
|
144
|
-
controlGeneration: snapshot?.controlGeneration ?? null,
|
|
145
|
-
workspaceInferenceState: snapshot?.workspaceInferenceState ?? null,
|
|
146
|
-
workspaceInferenceGeneration: snapshot?.workspaceInferenceGeneration ?? null,
|
|
147
|
-
workspaceRunExceptionGeneration: snapshot?.workspaceRunExceptionGeneration ?? null,
|
|
262
|
+
effectiveControl: snapshot?.effectiveControl ?? null,
|
|
148
263
|
loading,
|
|
149
264
|
error,
|
|
150
265
|
refresh: load,
|
|
266
|
+
moveTurn,
|
|
267
|
+
editTurn,
|
|
268
|
+
steerTurn,
|
|
151
269
|
removeTurn,
|
|
270
|
+
pendingByTurn,
|
|
271
|
+
mutationFor,
|
|
152
272
|
mutating,
|
|
153
273
|
mutationError,
|
|
154
|
-
clearMutationError,
|
|
274
|
+
clearMutationError: useCallback(() => setMutationError(null), []),
|
|
155
275
|
};
|
|
156
276
|
}
|
|
277
|
+
|
|
278
|
+
function operationKey(): string {
|
|
279
|
+
return globalThis.crypto.randomUUID();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function asError(cause: unknown): Error {
|
|
283
|
+
return cause instanceof Error ? cause : new Error(String(cause));
|
|
284
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// oxlint-disable-next-line typescript/triple-slash-reference -- package consumers must load the ambient type for this optional untyped peer without emitting a runtime import.
|
|
2
|
+
/// <reference path="./types/external.d.ts" />
|
|
3
|
+
|
|
1
4
|
// @opengeni/react — hooks + styled components on @opengeni/sdk.
|
|
2
5
|
//
|
|
3
6
|
// Import the styles once in your Tailwind entry CSS:
|
|
@@ -20,15 +23,11 @@ export type {
|
|
|
20
23
|
export {
|
|
21
24
|
useComposer,
|
|
22
25
|
composeSendInput,
|
|
26
|
+
shouldSteerOnKey,
|
|
23
27
|
shouldSubmitOnKey,
|
|
24
28
|
FILE_ONLY_MESSAGE_TEXT,
|
|
25
29
|
} from "./hooks/use-composer";
|
|
26
|
-
export type {
|
|
27
|
-
ComposerMode,
|
|
28
|
-
ComposerSendExtras,
|
|
29
|
-
ComposerState,
|
|
30
|
-
UseComposerOptions,
|
|
31
|
-
} from "./hooks/use-composer";
|
|
30
|
+
export type { ComposerSendExtras, ComposerState, UseComposerOptions } from "./hooks/use-composer";
|
|
32
31
|
export { useFileAttachments } from "./hooks/use-file-attachments";
|
|
33
32
|
export type {
|
|
34
33
|
FileAttachment,
|
|
@@ -36,7 +35,14 @@ export type {
|
|
|
36
35
|
UseFileAttachmentsResult,
|
|
37
36
|
} from "./hooks/use-file-attachments";
|
|
38
37
|
export { useTurnQueue, isTurnQueueEvent } from "./hooks/use-turn-queue";
|
|
39
|
-
export type {
|
|
38
|
+
export type {
|
|
39
|
+
QueueMutationKind,
|
|
40
|
+
UseTurnQueueOptions,
|
|
41
|
+
UseTurnQueueResult,
|
|
42
|
+
} from "./hooks/use-turn-queue";
|
|
43
|
+
export { QueueSurface } from "./components/queue-surface";
|
|
44
|
+
export type { QueueSurfaceProps } from "./components/queue-surface";
|
|
45
|
+
export { OPEN_WORKSTREAM_CONTROL_EVENT } from "./components/chat-composer";
|
|
40
46
|
export { useGoal, isGoalEvent } from "./hooks/use-goal";
|
|
41
47
|
export type { UseGoalOptions, UseGoalResult } from "./hooks/use-goal";
|
|
42
48
|
export { useSessionControl } from "./hooks/use-session-control";
|
package/src/machines.ts
CHANGED
|
@@ -34,6 +34,22 @@ export { MachineMetrics } from "./components/machine-metrics";
|
|
|
34
34
|
export type { MachineMetricsProps } from "./components/machine-metrics";
|
|
35
35
|
export { MachineCard } from "./components/machine-card";
|
|
36
36
|
export type { MachineCardProps } from "./components/machine-card";
|
|
37
|
+
export { MachineHealthPill } from "./components/machine-health-pill";
|
|
38
|
+
export type { MachineHealthPillProps } from "./components/machine-health-pill";
|
|
39
|
+
// Telemetry: fused health signal + history charts + the per-machine detail view.
|
|
40
|
+
export { deriveHealth, HEALTH_TOKEN, healthPulses } from "./components/machines/health";
|
|
41
|
+
export type { HealthLevel, HealthVerdict } from "./components/machines/health";
|
|
42
|
+
export { MetricSparkline } from "./components/machines/metric-sparkline";
|
|
43
|
+
export type { MetricSparklineProps } from "./components/machines/metric-sparkline";
|
|
44
|
+
export { MetricHistoryChart } from "./components/machines/metric-history-chart";
|
|
45
|
+
export type {
|
|
46
|
+
MetricHistoryChartProps,
|
|
47
|
+
SeriesPoint,
|
|
48
|
+
} from "./components/machines/metric-history-chart";
|
|
49
|
+
export { MachineDetail } from "./components/machines/machine-detail";
|
|
50
|
+
export type { MachineDetailProps } from "./components/machines/machine-detail";
|
|
51
|
+
export { METRICS, METRIC_WINDOWS, WINDOW_LABEL, pointsFor } from "./components/machines/series";
|
|
52
|
+
export type { MetricDef, MetricKey, MetricWindow } from "./components/machines/series";
|
|
37
53
|
export { MachinesDashboard } from "./components/machines-dashboard";
|
|
38
54
|
export type { MachinesDashboardProps } from "./components/machines-dashboard";
|
|
39
55
|
export { MachineDockBar, SharedMachineDisclosure } from "./components/machine-dock-bar";
|
package/src/provider.tsx
CHANGED
|
@@ -1,16 +1,44 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
OpenGeniApiContractMismatchError,
|
|
3
|
+
OPENGENI_API_CONTRACT_REVISION,
|
|
4
|
+
type StreamConnectionState,
|
|
5
|
+
type WorkspaceControlEvent,
|
|
6
|
+
} from "@opengeni/sdk";
|
|
7
|
+
import {
|
|
8
|
+
createContext,
|
|
9
|
+
useCallback,
|
|
10
|
+
useContext,
|
|
11
|
+
useEffect,
|
|
12
|
+
useMemo,
|
|
13
|
+
useRef,
|
|
14
|
+
useState,
|
|
15
|
+
type ReactNode,
|
|
16
|
+
} from "react";
|
|
2
17
|
import type { SessionClientLike } from "./client";
|
|
3
18
|
|
|
4
19
|
export type OpenGeniContextValue = {
|
|
5
20
|
client: SessionClientLike;
|
|
6
21
|
workspaceId: string;
|
|
22
|
+
workspaceControlEvent: WorkspaceControlEvent | null;
|
|
23
|
+
workspaceControlConnectionState: StreamConnectionState | "idle" | "error";
|
|
24
|
+
registerSessionReconciler: (
|
|
25
|
+
sessionId: string,
|
|
26
|
+
key: string,
|
|
27
|
+
reconcile: () => Promise<void>,
|
|
28
|
+
) => () => void;
|
|
29
|
+
reconcileSession: (sessionId: string) => Promise<void>;
|
|
7
30
|
};
|
|
8
31
|
|
|
9
32
|
const OpenGeniContext = createContext<OpenGeniContextValue | null>(null);
|
|
33
|
+
const NOOP_REGISTER_RECONCILER: OpenGeniContextValue["registerSessionReconciler"] = () => () =>
|
|
34
|
+
undefined;
|
|
35
|
+
const NOOP_RECONCILE_SESSION: OpenGeniContextValue["reconcileSession"] = async () => undefined;
|
|
36
|
+
const CONTRACT_RELOAD_STORAGE_PREFIX = "opengeni.reloadForApiContract:";
|
|
10
37
|
|
|
11
38
|
export type OpenGeniProviderProps = {
|
|
12
39
|
client: SessionClientLike;
|
|
13
40
|
workspaceId: string;
|
|
41
|
+
onWorkspaceControlEvent?: ((event: WorkspaceControlEvent) => void) | undefined;
|
|
14
42
|
children?: ReactNode;
|
|
15
43
|
};
|
|
16
44
|
|
|
@@ -18,9 +46,161 @@ export type OpenGeniProviderProps = {
|
|
|
18
46
|
* Supplies the OpenGeni client + workspace to all hooks below it. Hooks also
|
|
19
47
|
* accept `{ client, workspaceId }` overrides per call for multi-workspace UIs.
|
|
20
48
|
*/
|
|
21
|
-
export function OpenGeniProvider({
|
|
22
|
-
|
|
23
|
-
|
|
49
|
+
export function OpenGeniProvider({
|
|
50
|
+
client,
|
|
51
|
+
workspaceId,
|
|
52
|
+
onWorkspaceControlEvent,
|
|
53
|
+
children,
|
|
54
|
+
}: OpenGeniProviderProps) {
|
|
55
|
+
const [workspaceControlEvent, setWorkspaceControlEvent] = useState<WorkspaceControlEvent | null>(
|
|
56
|
+
null,
|
|
57
|
+
);
|
|
58
|
+
const [workspaceControlConnectionState, setWorkspaceControlConnectionState] = useState<
|
|
59
|
+
StreamConnectionState | "idle" | "error"
|
|
60
|
+
>("idle");
|
|
61
|
+
const [contractMismatch, setContractMismatch] = useState<OpenGeniApiContractMismatchError | null>(
|
|
62
|
+
null,
|
|
63
|
+
);
|
|
64
|
+
const callbackRef = useRef(onWorkspaceControlEvent);
|
|
65
|
+
const reconcilersRef = useRef(new Map<string, Map<string, () => Promise<void>>>());
|
|
66
|
+
callbackRef.current = onWorkspaceControlEvent;
|
|
67
|
+
|
|
68
|
+
const verifyApiContract = useCallback(async (): Promise<void> => {
|
|
69
|
+
try {
|
|
70
|
+
const config = await client.getClientConfig();
|
|
71
|
+
if (config.apiContractRevision !== OPENGENI_API_CONTRACT_REVISION) {
|
|
72
|
+
throw new OpenGeniApiContractMismatchError(
|
|
73
|
+
OPENGENI_API_CONTRACT_REVISION,
|
|
74
|
+
String(config.apiContractRevision || "(missing)"),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (error instanceof OpenGeniApiContractMismatchError) {
|
|
79
|
+
setContractMismatch(error);
|
|
80
|
+
reloadForContractMismatchOnce(error);
|
|
81
|
+
}
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}, [client]);
|
|
85
|
+
|
|
86
|
+
const registerSessionReconciler = useMemo(
|
|
87
|
+
() =>
|
|
88
|
+
(sessionId: string, key: string, reconcile: () => Promise<void>): (() => void) => {
|
|
89
|
+
const sessionReconcilers = reconcilersRef.current.get(sessionId) ?? new Map();
|
|
90
|
+
sessionReconcilers.set(key, reconcile);
|
|
91
|
+
reconcilersRef.current.set(sessionId, sessionReconcilers);
|
|
92
|
+
return () => {
|
|
93
|
+
const current = reconcilersRef.current.get(sessionId);
|
|
94
|
+
current?.delete(key);
|
|
95
|
+
if (current?.size === 0) reconcilersRef.current.delete(sessionId);
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
[],
|
|
99
|
+
);
|
|
100
|
+
const reconcileSession = useMemo(
|
|
101
|
+
() =>
|
|
102
|
+
async (sessionId: string): Promise<void> => {
|
|
103
|
+
// This read also crosses the exact API-contract handshake before stale
|
|
104
|
+
// state can be presented as live after a deployment.
|
|
105
|
+
await verifyApiContract();
|
|
106
|
+
const callbacks = [...(reconcilersRef.current.get(sessionId)?.values() ?? [])];
|
|
107
|
+
await Promise.all(callbacks.map((reconcile) => reconcile()));
|
|
108
|
+
},
|
|
109
|
+
[verifyApiContract],
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
const controller = new AbortController();
|
|
114
|
+
setWorkspaceControlEvent(null);
|
|
115
|
+
setWorkspaceControlConnectionState("connecting");
|
|
116
|
+
void (async () => {
|
|
117
|
+
try {
|
|
118
|
+
await verifyApiContract();
|
|
119
|
+
const workspace = await client.getWorkspace(workspaceId);
|
|
120
|
+
const stream = client.streamWorkspaceControlEvents(workspaceId, {
|
|
121
|
+
after: workspace.inferenceControl.revision,
|
|
122
|
+
signal: controller.signal,
|
|
123
|
+
onStateChange: setWorkspaceControlConnectionState,
|
|
124
|
+
});
|
|
125
|
+
for await (const event of stream) {
|
|
126
|
+
if (controller.signal.aborted) return;
|
|
127
|
+
setWorkspaceControlEvent((current) =>
|
|
128
|
+
!current || event.sequence > current.sequence ? event : current,
|
|
129
|
+
);
|
|
130
|
+
callbackRef.current?.(event);
|
|
131
|
+
}
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (error instanceof OpenGeniApiContractMismatchError) {
|
|
134
|
+
setContractMismatch(error);
|
|
135
|
+
reloadForContractMismatchOnce(error);
|
|
136
|
+
}
|
|
137
|
+
if (!controller.signal.aborted) setWorkspaceControlConnectionState("error");
|
|
138
|
+
}
|
|
139
|
+
})();
|
|
140
|
+
return () => controller.abort();
|
|
141
|
+
}, [client, verifyApiContract, workspaceId]);
|
|
142
|
+
|
|
143
|
+
const value = useMemo(
|
|
144
|
+
() => ({
|
|
145
|
+
client,
|
|
146
|
+
workspaceId,
|
|
147
|
+
workspaceControlEvent,
|
|
148
|
+
workspaceControlConnectionState,
|
|
149
|
+
registerSessionReconciler,
|
|
150
|
+
reconcileSession,
|
|
151
|
+
}),
|
|
152
|
+
[
|
|
153
|
+
client,
|
|
154
|
+
workspaceId,
|
|
155
|
+
workspaceControlEvent,
|
|
156
|
+
workspaceControlConnectionState,
|
|
157
|
+
registerSessionReconciler,
|
|
158
|
+
reconcileSession,
|
|
159
|
+
],
|
|
160
|
+
);
|
|
161
|
+
return (
|
|
162
|
+
<OpenGeniContext.Provider value={value}>
|
|
163
|
+
{children}
|
|
164
|
+
{contractMismatch ? <ApiContractMismatchScreen mismatch={contractMismatch} /> : null}
|
|
165
|
+
</OpenGeniContext.Provider>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function ApiContractMismatchScreen({ mismatch }: { mismatch: OpenGeniApiContractMismatchError }) {
|
|
170
|
+
return (
|
|
171
|
+
<div
|
|
172
|
+
className="og-root fixed inset-0 z-[2147483647] grid place-items-center bg-og-bg/95 p-6 backdrop-blur-sm"
|
|
173
|
+
role="alert"
|
|
174
|
+
aria-live="assertive"
|
|
175
|
+
data-opengeni-api-contract-mismatch
|
|
176
|
+
>
|
|
177
|
+
<div className="w-full max-w-md rounded-xl border border-og-border bg-og-surface p-6 shadow-2xl">
|
|
178
|
+
<p className="text-sm font-semibold text-og-fg">OpenGeni updated</p>
|
|
179
|
+
<p className="mt-2 text-sm leading-6 text-og-muted">
|
|
180
|
+
This tab cannot safely continue with the new server version. Reload it before sending or
|
|
181
|
+
controlling work.
|
|
182
|
+
</p>
|
|
183
|
+
<p className="mt-3 font-mono text-xs text-og-subtle">
|
|
184
|
+
Client {mismatch.expected} · API {mismatch.actual}
|
|
185
|
+
</p>
|
|
186
|
+
<button
|
|
187
|
+
type="button"
|
|
188
|
+
className="mt-5 inline-flex h-9 items-center rounded-md bg-og-fg px-3 text-sm font-medium text-og-bg"
|
|
189
|
+
onClick={() => window.location.reload()}
|
|
190
|
+
>
|
|
191
|
+
Reload now
|
|
192
|
+
</button>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function reloadForContractMismatchOnce(mismatch: OpenGeniApiContractMismatchError): void {
|
|
199
|
+
if (typeof window === "undefined" || typeof sessionStorage === "undefined") return;
|
|
200
|
+
const key = `${CONTRACT_RELOAD_STORAGE_PREFIX}${mismatch.actual}`;
|
|
201
|
+
if (sessionStorage.getItem(key) === OPENGENI_API_CONTRACT_REVISION) return;
|
|
202
|
+
sessionStorage.setItem(key, OPENGENI_API_CONTRACT_REVISION);
|
|
203
|
+
window.setTimeout(() => window.location.reload(), 150);
|
|
24
204
|
}
|
|
25
205
|
|
|
26
206
|
export type ClientOverride = {
|
|
@@ -38,7 +218,14 @@ export function useOpenGeni(override: ClientOverride = {}): OpenGeniContextValue
|
|
|
38
218
|
"@opengeni/react: no OpenGeni client/workspace available. Wrap the tree in <OpenGeniProvider> or pass { client, workspaceId } to the hook.",
|
|
39
219
|
);
|
|
40
220
|
}
|
|
41
|
-
return {
|
|
221
|
+
return {
|
|
222
|
+
client,
|
|
223
|
+
workspaceId,
|
|
224
|
+
workspaceControlEvent: context?.workspaceControlEvent ?? null,
|
|
225
|
+
workspaceControlConnectionState: context?.workspaceControlConnectionState ?? "idle",
|
|
226
|
+
registerSessionReconciler: context?.registerSessionReconciler ?? NOOP_REGISTER_RECONCILER,
|
|
227
|
+
reconcileSession: context?.reconcileSession ?? NOOP_RECONCILE_SESSION,
|
|
228
|
+
};
|
|
42
229
|
}
|
|
43
230
|
|
|
44
231
|
/**
|