@opengeni/sdk 0.36.1 → 0.37.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 +6 -1
- package/dist/chunk-DXDL7EEW.js +573 -0
- package/dist/chunk-DXDL7EEW.js.map +1 -0
- package/dist/chunk-OB5PGV6N.js +1988 -0
- package/dist/chunk-OB5PGV6N.js.map +1 -0
- package/dist/chunk-OINSI33U.js +97 -0
- package/dist/chunk-OINSI33U.js.map +1 -0
- package/dist/{chunk-B7E4FPNZ.js → chunk-VOQ235WN.js} +136 -106
- package/dist/chunk-VOQ235WN.js.map +1 -0
- package/dist/client.d.ts +35 -2
- package/dist/codex-realtime-controller.d.ts +109 -0
- package/dist/codex-realtime-controller.js +12 -0
- package/dist/codex-realtime-controller.js.map +1 -0
- package/dist/codex-realtime-lifecycle.d.ts +18 -0
- package/dist/codex-realtime-v3-wire.d.ts +86 -0
- package/dist/codex-realtime-v3.d.ts +52 -0
- package/dist/codex-realtime.d.ts +68 -0
- package/dist/core.js +6 -4
- package/dist/gateway-realtime-transport.d.ts +2 -0
- package/dist/gateway-realtime-transport.js +7 -0
- package/dist/gateway-realtime-transport.js.map +1 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +38 -6
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +193 -3
- package/package.json +10 -1
- package/src/client.ts +211 -17
- package/src/codex-realtime-controller.ts +1427 -0
- package/src/codex-realtime-lifecycle.ts +97 -0
- package/src/codex-realtime-v3-wire.ts +349 -0
- package/src/codex-realtime-v3.ts +575 -0
- package/src/codex-realtime.ts +411 -0
- package/src/gateway-realtime-transport.ts +650 -0
- package/src/index.ts +71 -0
- package/src/types.ts +238 -3
- package/dist/chunk-B7E4FPNZ.js.map +0 -1
|
@@ -0,0 +1,1427 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CODEX_REALTIME_V3_MAX_EVENT_BYTES,
|
|
3
|
+
CODEX_REALTIME_V3_PENDING_MAX_BYTES,
|
|
4
|
+
CODEX_REALTIME_V3_PENDING_MAX_ENTRIES,
|
|
5
|
+
createCodexRealtimeV3Bridge,
|
|
6
|
+
parseCodexRealtimeV3Event,
|
|
7
|
+
} from "./codex-realtime-v3";
|
|
8
|
+
import type {
|
|
9
|
+
CodexRealtimeV3Bridge,
|
|
10
|
+
CodexRealtimeV3BridgeFatal,
|
|
11
|
+
CodexRealtimeV3BridgeSnapshot,
|
|
12
|
+
} from "./codex-realtime-v3";
|
|
13
|
+
import type { SessionRealtimeLifecycleProjection } from "./codex-realtime-lifecycle";
|
|
14
|
+
import {
|
|
15
|
+
acquireCodexRealtimeMicrophone,
|
|
16
|
+
codexRealtimeMicrophoneHealthy,
|
|
17
|
+
CodexRealtimeMicrophoneError,
|
|
18
|
+
startCodexRealtimeWebrtc,
|
|
19
|
+
} from "./codex-realtime";
|
|
20
|
+
import type {
|
|
21
|
+
CodexRealtimeAudibleOutputState,
|
|
22
|
+
CodexRealtimeConnectionHealth,
|
|
23
|
+
CodexRealtimeMicrophoneErrorCode,
|
|
24
|
+
CodexRealtimeWebrtcSession,
|
|
25
|
+
} from "./codex-realtime";
|
|
26
|
+
import { OpenGeniApiError } from "./errors";
|
|
27
|
+
import type {
|
|
28
|
+
ActivateCodexRealtimeConnectionRequest,
|
|
29
|
+
BeginSessionRealtimeRequest,
|
|
30
|
+
CodexRealtimeWebrtcRequest,
|
|
31
|
+
CodexRealtimeWebrtcResponse,
|
|
32
|
+
GatewayRealtimeConnectRequest,
|
|
33
|
+
GatewayRealtimeConnectResponse,
|
|
34
|
+
EndSessionRealtimeRequest,
|
|
35
|
+
RenewSessionRealtimeRequest,
|
|
36
|
+
SessionRealtimeMode,
|
|
37
|
+
SessionRealtimeModel,
|
|
38
|
+
SessionRealtimeMutationResponse,
|
|
39
|
+
SyncSessionRealtimeLedgerRequest,
|
|
40
|
+
SyncSessionRealtimeLedgerResponse,
|
|
41
|
+
} from "./types";
|
|
42
|
+
|
|
43
|
+
export { projectSessionRealtimeLifecycle } from "./codex-realtime-lifecycle";
|
|
44
|
+
export type { SessionRealtimeLifecycleProjection } from "./codex-realtime-lifecycle";
|
|
45
|
+
|
|
46
|
+
const HEARTBEAT_INTERVAL_MS = 10_000;
|
|
47
|
+
const OUTBOUND_SYNC_INTERVAL_MS = 1_000;
|
|
48
|
+
export const CODEX_REALTIME_NEGOTIATION_TIMEOUT_MS = 20_000;
|
|
49
|
+
// OpenGeni policy: rotate conservatively without asserting an upstream lifetime.
|
|
50
|
+
const DEFAULT_CONNECTION_ROTATION_INTERVAL_MS = 15 * 60_000;
|
|
51
|
+
const DEFAULT_RECONNECT_BACKOFF_MS = [250, 1_000, 2_000, 5_000] as const;
|
|
52
|
+
const OWNER_RECORD_VERSION = 1;
|
|
53
|
+
|
|
54
|
+
export type CodexRealtimeControllerStatus =
|
|
55
|
+
| "idle"
|
|
56
|
+
| "starting"
|
|
57
|
+
| "active"
|
|
58
|
+
| "stopping"
|
|
59
|
+
| "recovering"
|
|
60
|
+
| "lost_owner"
|
|
61
|
+
| "error";
|
|
62
|
+
|
|
63
|
+
export type CodexRealtimeMicrophoneState =
|
|
64
|
+
| "inactive"
|
|
65
|
+
| "acquiring"
|
|
66
|
+
| "active"
|
|
67
|
+
| CodexRealtimeMicrophoneErrorCode;
|
|
68
|
+
|
|
69
|
+
export type CodexRealtimeDiagnosticKind =
|
|
70
|
+
| "permission_failure"
|
|
71
|
+
| "device_failure"
|
|
72
|
+
| "autoplay_blocked"
|
|
73
|
+
| "negotiation_failure"
|
|
74
|
+
| "rotation"
|
|
75
|
+
| "reconnect"
|
|
76
|
+
| "lost_owner"
|
|
77
|
+
| "terminal_stop";
|
|
78
|
+
|
|
79
|
+
export type CodexRealtimeDiagnostic = {
|
|
80
|
+
kind: CodexRealtimeDiagnosticKind;
|
|
81
|
+
message: string;
|
|
82
|
+
recoverable: boolean;
|
|
83
|
+
connectionGeneration: number;
|
|
84
|
+
attempt: number;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export type CodexRealtimeControllerSnapshot = {
|
|
88
|
+
status: CodexRealtimeControllerStatus;
|
|
89
|
+
realtimeId: string | null;
|
|
90
|
+
mode: SessionRealtimeMode | null;
|
|
91
|
+
bridge: CodexRealtimeV3BridgeSnapshot | null;
|
|
92
|
+
microphone: CodexRealtimeMicrophoneState;
|
|
93
|
+
inputMuted: boolean;
|
|
94
|
+
audibleOutput: CodexRealtimeAudibleOutputState;
|
|
95
|
+
outputMuted: boolean;
|
|
96
|
+
connectionGeneration: number;
|
|
97
|
+
reconnectAttempt: number;
|
|
98
|
+
diagnostic: CodexRealtimeDiagnostic | null;
|
|
99
|
+
error: string | null;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export type CodexRealtimeControllerClient = {
|
|
103
|
+
beginSessionRealtime(
|
|
104
|
+
workspaceId: string,
|
|
105
|
+
sessionId: string,
|
|
106
|
+
request: BeginSessionRealtimeRequest,
|
|
107
|
+
): Promise<SessionRealtimeMutationResponse>;
|
|
108
|
+
heartbeatSessionRealtime(
|
|
109
|
+
workspaceId: string,
|
|
110
|
+
sessionId: string,
|
|
111
|
+
realtimeId: string,
|
|
112
|
+
request: RenewSessionRealtimeRequest,
|
|
113
|
+
): Promise<SessionRealtimeMutationResponse>;
|
|
114
|
+
negotiateCodexRealtimeWebrtc(
|
|
115
|
+
workspaceId: string,
|
|
116
|
+
sessionId: string,
|
|
117
|
+
request: CodexRealtimeWebrtcRequest,
|
|
118
|
+
options?: { signal?: AbortSignal | undefined },
|
|
119
|
+
): Promise<CodexRealtimeWebrtcResponse>;
|
|
120
|
+
negotiateGatewayRealtime?(
|
|
121
|
+
workspaceId: string,
|
|
122
|
+
sessionId: string,
|
|
123
|
+
request: GatewayRealtimeConnectRequest,
|
|
124
|
+
options?: { signal?: AbortSignal | undefined },
|
|
125
|
+
): Promise<GatewayRealtimeConnectResponse>;
|
|
126
|
+
activateCodexRealtimeConnection(
|
|
127
|
+
workspaceId: string,
|
|
128
|
+
sessionId: string,
|
|
129
|
+
realtimeId: string,
|
|
130
|
+
connectionId: string,
|
|
131
|
+
request: ActivateCodexRealtimeConnectionRequest,
|
|
132
|
+
options?: { signal?: AbortSignal | undefined },
|
|
133
|
+
): Promise<SessionRealtimeMutationResponse>;
|
|
134
|
+
syncSessionRealtimeLedger(
|
|
135
|
+
workspaceId: string,
|
|
136
|
+
sessionId: string,
|
|
137
|
+
realtimeId: string,
|
|
138
|
+
request: SyncSessionRealtimeLedgerRequest,
|
|
139
|
+
): Promise<SyncSessionRealtimeLedgerResponse>;
|
|
140
|
+
endSessionRealtime(
|
|
141
|
+
workspaceId: string,
|
|
142
|
+
sessionId: string,
|
|
143
|
+
realtimeId: string,
|
|
144
|
+
request: EndSessionRealtimeRequest,
|
|
145
|
+
): Promise<SessionRealtimeMutationResponse>;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export type CodexRealtimeOwnerStorage = Pick<Storage, "getItem" | "setItem" | "removeItem">;
|
|
149
|
+
|
|
150
|
+
export type CreateCodexRealtimeControllerOptions = {
|
|
151
|
+
client: CodexRealtimeControllerClient;
|
|
152
|
+
workspaceId: string;
|
|
153
|
+
sessionId: string;
|
|
154
|
+
storage?: CodexRealtimeOwnerStorage | undefined;
|
|
155
|
+
remoteAudio?: HTMLAudioElement | undefined;
|
|
156
|
+
createPeerConnection?: (() => RTCPeerConnection) | undefined;
|
|
157
|
+
getUserMedia?: ((constraints: MediaStreamConstraints) => Promise<MediaStream>) | undefined;
|
|
158
|
+
randomUUID?: (() => string) | undefined;
|
|
159
|
+
setInterval?: ((callback: () => void, delayMs: number) => unknown) | undefined;
|
|
160
|
+
clearInterval?: ((handle: unknown) => void) | undefined;
|
|
161
|
+
setTimeout?: ((callback: () => void, delayMs: number) => unknown) | undefined;
|
|
162
|
+
clearTimeout?: ((handle: unknown) => void) | undefined;
|
|
163
|
+
negotiationTimeoutMs?: number | undefined;
|
|
164
|
+
connectionRotationIntervalMs?: number | undefined;
|
|
165
|
+
reconnectBackoffMs?: readonly number[] | undefined;
|
|
166
|
+
/** Defaults to connected Codex; alternate transports opt in explicitly. */
|
|
167
|
+
model?: SessionRealtimeModel | undefined;
|
|
168
|
+
ownerStorageNamespace?: string | undefined;
|
|
169
|
+
startTransport?: RealtimeControllerTransportStarter | undefined;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export type RealtimeControllerTransportStarter = (input: {
|
|
173
|
+
client: CodexRealtimeControllerClient;
|
|
174
|
+
workspaceId: string;
|
|
175
|
+
sessionId: string;
|
|
176
|
+
realtimeId: string;
|
|
177
|
+
operationId: string;
|
|
178
|
+
browserInstanceId: string;
|
|
179
|
+
ownerKey: string;
|
|
180
|
+
expectedVersion: number;
|
|
181
|
+
expectedConnectionEpoch: number;
|
|
182
|
+
rotate: boolean;
|
|
183
|
+
signal: AbortSignal;
|
|
184
|
+
media: MediaStream;
|
|
185
|
+
onEventsCreated(events: RTCDataChannel): void;
|
|
186
|
+
onAudibleOutputState(state: CodexRealtimeAudibleOutputState): void;
|
|
187
|
+
onMicrophoneEnded(): void;
|
|
188
|
+
onConnectionHealth(health: CodexRealtimeConnectionHealth): void;
|
|
189
|
+
}) => Promise<CodexRealtimeWebrtcSession>;
|
|
190
|
+
|
|
191
|
+
export type CodexRealtimeController = {
|
|
192
|
+
snapshot(): CodexRealtimeControllerSnapshot;
|
|
193
|
+
subscribe(listener: (snapshot: CodexRealtimeControllerSnapshot) => void): () => void;
|
|
194
|
+
start(): Promise<void>;
|
|
195
|
+
observeLifecycle(lifecycle: SessionRealtimeLifecycleProjection | null): Promise<void>;
|
|
196
|
+
heartbeat(): Promise<void>;
|
|
197
|
+
flush(): Promise<void>;
|
|
198
|
+
ingestProviderEvent(payload: string): Promise<void>;
|
|
199
|
+
retry(): Promise<void>;
|
|
200
|
+
retryAudibleOutput(): Promise<boolean>;
|
|
201
|
+
setInputMuted(muted: boolean): void;
|
|
202
|
+
setOutputMuted(muted: boolean): void;
|
|
203
|
+
stop(): Promise<void>;
|
|
204
|
+
/** Close browser resources but retain owner proof for truthful reload recovery. */
|
|
205
|
+
close(): void;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
type OwnerRecord = {
|
|
209
|
+
version: typeof OWNER_RECORD_VERSION;
|
|
210
|
+
workspaceId: string;
|
|
211
|
+
sessionId: string;
|
|
212
|
+
operationId: string;
|
|
213
|
+
browserInstanceId: string;
|
|
214
|
+
ownerKey: string;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
type ConnectionRuntime = {
|
|
218
|
+
generation: number;
|
|
219
|
+
transport: CodexRealtimeWebrtcSession;
|
|
220
|
+
bridge: CodexRealtimeV3Bridge;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
type RecoveryCause = "rotation" | "reconnect" | "microphone" | "reload" | "manual";
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Compose the existing lifecycle API, WebRTC transport, and durable V3 bridge
|
|
227
|
+
* into one indefinitely rotating browser owner. Provider protocol semantics stay
|
|
228
|
+
* below this seam; this controller owns only browser resources and connection
|
|
229
|
+
* generations.
|
|
230
|
+
*/
|
|
231
|
+
export function createCodexRealtimeController(
|
|
232
|
+
options: CreateCodexRealtimeControllerOptions,
|
|
233
|
+
): CodexRealtimeController {
|
|
234
|
+
const storage = options.storage ?? defaultStorage();
|
|
235
|
+
const storageKey = ownerStorageKey(
|
|
236
|
+
options.workspaceId,
|
|
237
|
+
options.sessionId,
|
|
238
|
+
options.ownerStorageNamespace,
|
|
239
|
+
);
|
|
240
|
+
const model = options.model ?? "gpt-live-1-boulder-alpha";
|
|
241
|
+
const randomUUID = options.randomUUID ?? defaultRandomUUID;
|
|
242
|
+
const scheduleInterval =
|
|
243
|
+
options.setInterval ?? ((callback, delay) => globalThis.setInterval(callback, delay));
|
|
244
|
+
const unscheduleInterval =
|
|
245
|
+
options.clearInterval ??
|
|
246
|
+
((handle) => globalThis.clearInterval(handle as ReturnType<typeof setInterval>));
|
|
247
|
+
const scheduleTimeout =
|
|
248
|
+
options.setTimeout ?? ((callback, delay) => globalThis.setTimeout(callback, delay));
|
|
249
|
+
const unscheduleTimeout =
|
|
250
|
+
options.clearTimeout ??
|
|
251
|
+
((handle) => globalThis.clearTimeout(handle as ReturnType<typeof setTimeout>));
|
|
252
|
+
const rotationInterval = positiveDuration(
|
|
253
|
+
options.connectionRotationIntervalMs ?? DEFAULT_CONNECTION_ROTATION_INTERVAL_MS,
|
|
254
|
+
"connection rotation interval",
|
|
255
|
+
);
|
|
256
|
+
const negotiationTimeout = positiveDuration(
|
|
257
|
+
options.negotiationTimeoutMs ?? CODEX_REALTIME_NEGOTIATION_TIMEOUT_MS,
|
|
258
|
+
"negotiation timeout",
|
|
259
|
+
);
|
|
260
|
+
const reconnectBackoff = validateReconnectBackoff(
|
|
261
|
+
options.reconnectBackoffMs ?? DEFAULT_RECONNECT_BACKOFF_MS,
|
|
262
|
+
);
|
|
263
|
+
const listeners = new Set<(snapshot: CodexRealtimeControllerSnapshot) => void>();
|
|
264
|
+
let owner: OwnerRecord | null = readOwnerRecord(storage, storageKey, options);
|
|
265
|
+
let state: CodexRealtimeControllerSnapshot = {
|
|
266
|
+
status: owner ? "recovering" : "idle",
|
|
267
|
+
realtimeId: null,
|
|
268
|
+
mode: null,
|
|
269
|
+
bridge: null,
|
|
270
|
+
microphone: "inactive",
|
|
271
|
+
inputMuted: false,
|
|
272
|
+
audibleOutput: "inactive",
|
|
273
|
+
outputMuted: false,
|
|
274
|
+
connectionGeneration: 0,
|
|
275
|
+
reconnectAttempt: 0,
|
|
276
|
+
diagnostic: null,
|
|
277
|
+
error: null,
|
|
278
|
+
};
|
|
279
|
+
let active: ConnectionRuntime | null = null;
|
|
280
|
+
let pendingAbort: AbortController | null = null;
|
|
281
|
+
let pendingGeneration: number | null = null;
|
|
282
|
+
let microphone: MediaStream | null = null;
|
|
283
|
+
let heartbeatTimer: unknown = null;
|
|
284
|
+
let syncTimer: unknown = null;
|
|
285
|
+
let rotationTimer: unknown = null;
|
|
286
|
+
let reconnectTimer: unknown = null;
|
|
287
|
+
let negotiationTimer: unknown = null;
|
|
288
|
+
let closed = false;
|
|
289
|
+
let stopping = false;
|
|
290
|
+
let generation = 0;
|
|
291
|
+
let reconnectAttempt = 0;
|
|
292
|
+
let recoveryTerminal = false;
|
|
293
|
+
let mutationTail = Promise.resolve();
|
|
294
|
+
let connectionTask: Promise<void> | null = null;
|
|
295
|
+
|
|
296
|
+
const publish = (patch: Partial<CodexRealtimeControllerSnapshot>): void => {
|
|
297
|
+
state = { ...state, ...patch };
|
|
298
|
+
for (const listener of listeners) listener({ ...state });
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const diagnostic = (
|
|
302
|
+
kind: CodexRealtimeDiagnosticKind,
|
|
303
|
+
message: string,
|
|
304
|
+
recoverable: boolean,
|
|
305
|
+
targetGeneration = state.connectionGeneration,
|
|
306
|
+
): CodexRealtimeDiagnostic => ({
|
|
307
|
+
kind,
|
|
308
|
+
message,
|
|
309
|
+
recoverable,
|
|
310
|
+
connectionGeneration: targetGeneration,
|
|
311
|
+
attempt: reconnectAttempt,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const exclusive = async <T>(operation: () => Promise<T>): Promise<T> => {
|
|
315
|
+
const pending = mutationTail.then(operation, operation);
|
|
316
|
+
mutationTail = pending.then(
|
|
317
|
+
() => undefined,
|
|
318
|
+
() => undefined,
|
|
319
|
+
);
|
|
320
|
+
return await pending;
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const stopTimers = (): void => {
|
|
324
|
+
if (heartbeatTimer !== null) unscheduleInterval(heartbeatTimer);
|
|
325
|
+
if (syncTimer !== null) unscheduleInterval(syncTimer);
|
|
326
|
+
if (rotationTimer !== null) unscheduleTimeout(rotationTimer);
|
|
327
|
+
if (reconnectTimer !== null) unscheduleTimeout(reconnectTimer);
|
|
328
|
+
if (negotiationTimer !== null) unscheduleTimeout(negotiationTimer);
|
|
329
|
+
heartbeatTimer = null;
|
|
330
|
+
syncTimer = null;
|
|
331
|
+
rotationTimer = null;
|
|
332
|
+
reconnectTimer = null;
|
|
333
|
+
negotiationTimer = null;
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const stopNegotiationTimers = (): void => {
|
|
337
|
+
if (heartbeatTimer !== null) unscheduleInterval(heartbeatTimer);
|
|
338
|
+
if (rotationTimer !== null) unscheduleTimeout(rotationTimer);
|
|
339
|
+
if (reconnectTimer !== null) unscheduleTimeout(reconnectTimer);
|
|
340
|
+
if (negotiationTimer !== null) unscheduleTimeout(negotiationTimer);
|
|
341
|
+
heartbeatTimer = null;
|
|
342
|
+
rotationTimer = null;
|
|
343
|
+
reconnectTimer = null;
|
|
344
|
+
negotiationTimer = null;
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
const clearNegotiationTimer = (): void => {
|
|
348
|
+
if (negotiationTimer !== null) unscheduleTimeout(negotiationTimer);
|
|
349
|
+
negotiationTimer = null;
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
const releaseMicrophone = (): void => {
|
|
353
|
+
const current = microphone;
|
|
354
|
+
microphone = null;
|
|
355
|
+
current?.getTracks().forEach((track) => track.stop());
|
|
356
|
+
publish({ microphone: "inactive" });
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
const closeActive = (): void => {
|
|
360
|
+
const current = active;
|
|
361
|
+
active = null;
|
|
362
|
+
current?.bridge.close();
|
|
363
|
+
current?.transport.stop();
|
|
364
|
+
publish({ bridge: null, audibleOutput: "inactive" });
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const closeBrowserResources = (releaseMedia = true): void => {
|
|
368
|
+
stopTimers();
|
|
369
|
+
pendingAbort?.abort(new DOMException("Codex realtime browser owner closed", "AbortError"));
|
|
370
|
+
pendingAbort = null;
|
|
371
|
+
pendingGeneration = null;
|
|
372
|
+
closeActive();
|
|
373
|
+
if (releaseMedia) releaseMicrophone();
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
const clearOwner = (): void => {
|
|
377
|
+
owner = null;
|
|
378
|
+
storage?.removeItem(storageKey);
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const transitionEnded = (message = "Realtime mode ended"): void => {
|
|
382
|
+
stopping = false;
|
|
383
|
+
connectionTask = null;
|
|
384
|
+
closeBrowserResources();
|
|
385
|
+
clearOwner();
|
|
386
|
+
reconnectAttempt = 0;
|
|
387
|
+
recoveryTerminal = false;
|
|
388
|
+
publish({
|
|
389
|
+
status: "idle",
|
|
390
|
+
realtimeId: null,
|
|
391
|
+
mode: null,
|
|
392
|
+
bridge: null,
|
|
393
|
+
inputMuted: false,
|
|
394
|
+
outputMuted: false,
|
|
395
|
+
reconnectAttempt: 0,
|
|
396
|
+
diagnostic: diagnostic("terminal_stop", message, false),
|
|
397
|
+
error: null,
|
|
398
|
+
});
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const ensureMicrophone = async (replace: boolean, signal: AbortSignal): Promise<MediaStream> => {
|
|
402
|
+
if (!replace && codexRealtimeMicrophoneHealthy(microphone)) return microphone!;
|
|
403
|
+
releaseMicrophone();
|
|
404
|
+
publish({ microphone: "acquiring" });
|
|
405
|
+
try {
|
|
406
|
+
const acquired = await acquireCodexRealtimeMicrophone({
|
|
407
|
+
signal,
|
|
408
|
+
getUserMedia: options.getUserMedia,
|
|
409
|
+
});
|
|
410
|
+
if (closed || stopping || signal.aborted) {
|
|
411
|
+
acquired.getTracks().forEach((track) => track.stop());
|
|
412
|
+
throw signal.reason ?? new DOMException("Aborted", "AbortError");
|
|
413
|
+
}
|
|
414
|
+
microphone = acquired;
|
|
415
|
+
for (const track of acquired.getAudioTracks()) track.enabled = !state.inputMuted;
|
|
416
|
+
publish({ microphone: "active" });
|
|
417
|
+
return acquired;
|
|
418
|
+
} catch (error) {
|
|
419
|
+
if (error instanceof CodexRealtimeMicrophoneError) {
|
|
420
|
+
const kind = error.code === "permission_denied" ? "permission_failure" : "device_failure";
|
|
421
|
+
publish({
|
|
422
|
+
microphone: error.code,
|
|
423
|
+
status: state.mode?.state === "active" ? "recovering" : "error",
|
|
424
|
+
diagnostic: diagnostic(kind, error.message, true),
|
|
425
|
+
error: error.message,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
throw error;
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
const syncForGeneration = async (
|
|
433
|
+
targetGeneration: number,
|
|
434
|
+
realtimeId: string,
|
|
435
|
+
request: SyncSessionRealtimeLedgerRequest,
|
|
436
|
+
): Promise<SyncSessionRealtimeLedgerResponse> =>
|
|
437
|
+
await exclusive(async () => {
|
|
438
|
+
const current = state.mode;
|
|
439
|
+
if (
|
|
440
|
+
!owner ||
|
|
441
|
+
!current ||
|
|
442
|
+
current.id !== realtimeId ||
|
|
443
|
+
current.state !== "active" ||
|
|
444
|
+
active?.generation !== targetGeneration
|
|
445
|
+
) {
|
|
446
|
+
throw new Error("Codex realtime connection generation is no longer active");
|
|
447
|
+
}
|
|
448
|
+
return await options.client.syncSessionRealtimeLedger(
|
|
449
|
+
options.workspaceId,
|
|
450
|
+
options.sessionId,
|
|
451
|
+
realtimeId,
|
|
452
|
+
{ ...request, expectedVersion: current.version },
|
|
453
|
+
);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
const onAudibleOutput = (
|
|
457
|
+
targetGeneration: number,
|
|
458
|
+
next: CodexRealtimeAudibleOutputState,
|
|
459
|
+
): void => {
|
|
460
|
+
if (active?.generation !== targetGeneration || closed || stopping) return;
|
|
461
|
+
if (next === "blocked") {
|
|
462
|
+
const message =
|
|
463
|
+
"Browser blocked audible realtime output. Use Resume audio to continue listening.";
|
|
464
|
+
publish({
|
|
465
|
+
audibleOutput: next,
|
|
466
|
+
diagnostic: diagnostic("autoplay_blocked", message, true),
|
|
467
|
+
error: message,
|
|
468
|
+
});
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
publish({ audibleOutput: next, ...(next === "audible" ? { error: null } : {}) });
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
const startActiveIntervals = (): void => {
|
|
475
|
+
if (heartbeatTimer === null) {
|
|
476
|
+
heartbeatTimer = scheduleInterval(() => {
|
|
477
|
+
void heartbeat().catch((error) => {
|
|
478
|
+
if (!closed && !stopping) {
|
|
479
|
+
publish({ error: safeError(error) });
|
|
480
|
+
scheduleRecovery("reconnect", false);
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
484
|
+
}
|
|
485
|
+
if (syncTimer === null) {
|
|
486
|
+
syncTimer = scheduleInterval(() => {
|
|
487
|
+
void flush().catch((error) => {
|
|
488
|
+
if (!closed && !stopping) publish({ error: safeError(error) });
|
|
489
|
+
});
|
|
490
|
+
}, OUTBOUND_SYNC_INTERVAL_MS);
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
const scheduleRecovery = (
|
|
495
|
+
cause: RecoveryCause,
|
|
496
|
+
replaceMicrophone: boolean,
|
|
497
|
+
immediate = false,
|
|
498
|
+
): void => {
|
|
499
|
+
if (closed || stopping || recoveryTerminal || !owner || state.mode?.state !== "active") return;
|
|
500
|
+
if (connectionTask || reconnectTimer !== null) return;
|
|
501
|
+
if (rotationTimer !== null) unscheduleTimeout(rotationTimer);
|
|
502
|
+
rotationTimer = null;
|
|
503
|
+
startActiveIntervals();
|
|
504
|
+
const kind =
|
|
505
|
+
cause === "rotation" ? "rotation" : cause === "microphone" ? "device_failure" : "reconnect";
|
|
506
|
+
const message =
|
|
507
|
+
cause === "rotation"
|
|
508
|
+
? "Rotating the finite provider connection"
|
|
509
|
+
: cause === "microphone"
|
|
510
|
+
? "Recovering microphone and realtime connection"
|
|
511
|
+
: "Recovering the realtime provider connection";
|
|
512
|
+
publish({
|
|
513
|
+
status: "recovering",
|
|
514
|
+
reconnectAttempt,
|
|
515
|
+
diagnostic: diagnostic(kind, message, true),
|
|
516
|
+
error: cause === "rotation" ? null : message,
|
|
517
|
+
});
|
|
518
|
+
const delay = immediate
|
|
519
|
+
? 0
|
|
520
|
+
: reconnectBackoff[Math.min(reconnectAttempt, reconnectBackoff.length - 1)]!;
|
|
521
|
+
reconnectTimer = scheduleTimeout(() => {
|
|
522
|
+
reconnectTimer = null;
|
|
523
|
+
const record = owner;
|
|
524
|
+
const mode = state.mode;
|
|
525
|
+
if (connectionTask || !record || !mode || closed || stopping || mode.state !== "active") {
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
connectionTask = establish(record, mode, cause, true, replaceMicrophone)
|
|
529
|
+
.then(() => {
|
|
530
|
+
reconnectAttempt = 0;
|
|
531
|
+
publish({ reconnectAttempt: 0 });
|
|
532
|
+
})
|
|
533
|
+
.catch(async (error: unknown) => {
|
|
534
|
+
connectionTask = null;
|
|
535
|
+
await handleConnectionFailure(error, cause, replaceMicrophone);
|
|
536
|
+
})
|
|
537
|
+
.finally(() => {
|
|
538
|
+
connectionTask = null;
|
|
539
|
+
});
|
|
540
|
+
}, delay);
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
const handleConnectionFailure = async (
|
|
544
|
+
error: unknown,
|
|
545
|
+
cause: RecoveryCause,
|
|
546
|
+
replaceMicrophone: boolean,
|
|
547
|
+
): Promise<void> => {
|
|
548
|
+
if (closed || stopping || isAbortError(error)) return;
|
|
549
|
+
const message = safeError(error);
|
|
550
|
+
if (error instanceof CodexRealtimeMicrophoneError) {
|
|
551
|
+
if (error.code === "track_ended" && state.mode?.state === "active") {
|
|
552
|
+
reconnectAttempt += 1;
|
|
553
|
+
publish({
|
|
554
|
+
status: "recovering",
|
|
555
|
+
microphone: error.code,
|
|
556
|
+
reconnectAttempt,
|
|
557
|
+
diagnostic: diagnostic("device_failure", error.message, true),
|
|
558
|
+
error: error.message,
|
|
559
|
+
});
|
|
560
|
+
scheduleRecovery("microphone", true);
|
|
561
|
+
} else if (state.mode?.state === "active") {
|
|
562
|
+
// Keep the owned mode alive while an explicit permission/device retry
|
|
563
|
+
// waits for a user gesture; no provider connection is claimed healthy.
|
|
564
|
+
startActiveIntervals();
|
|
565
|
+
}
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (error instanceof OpenGeniApiError && error.status === 404) {
|
|
569
|
+
transitionEnded("Realtime owner no longer exists");
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
if (error instanceof OpenGeniApiError && error.status === 409 && error.retryable && owner) {
|
|
573
|
+
try {
|
|
574
|
+
const reconciled = await begin(owner, true, false);
|
|
575
|
+
if (!reconciled || reconciled.state === "ended") return;
|
|
576
|
+
} catch (reconcileError) {
|
|
577
|
+
if (reconcileError instanceof OpenGeniApiError && !reconcileError.retryable) {
|
|
578
|
+
stopTimers();
|
|
579
|
+
if (!active) releaseMicrophone();
|
|
580
|
+
recoveryTerminal = true;
|
|
581
|
+
publish({
|
|
582
|
+
status: "error",
|
|
583
|
+
diagnostic: diagnostic("negotiation_failure", safeError(reconcileError), false),
|
|
584
|
+
error: safeError(reconcileError),
|
|
585
|
+
});
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
} else if (error instanceof OpenGeniApiError && !error.retryable) {
|
|
590
|
+
stopTimers();
|
|
591
|
+
if (!active) releaseMicrophone();
|
|
592
|
+
recoveryTerminal = true;
|
|
593
|
+
publish({
|
|
594
|
+
status: "error",
|
|
595
|
+
diagnostic: diagnostic("negotiation_failure", message, false),
|
|
596
|
+
error: message,
|
|
597
|
+
});
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
reconnectAttempt += 1;
|
|
601
|
+
publish({
|
|
602
|
+
status: "recovering",
|
|
603
|
+
reconnectAttempt,
|
|
604
|
+
diagnostic: diagnostic("negotiation_failure", message, true),
|
|
605
|
+
error: message,
|
|
606
|
+
});
|
|
607
|
+
scheduleRecovery(cause === "rotation" ? "reconnect" : cause, replaceMicrophone);
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
const onBridgeFatal = (
|
|
611
|
+
targetGeneration: number,
|
|
612
|
+
failedBridge: CodexRealtimeV3Bridge,
|
|
613
|
+
fatal: CodexRealtimeV3BridgeFatal,
|
|
614
|
+
): void => {
|
|
615
|
+
const current = active;
|
|
616
|
+
if (closed || stopping || !current || current.generation !== targetGeneration) return;
|
|
617
|
+
active = null;
|
|
618
|
+
failedBridge.close();
|
|
619
|
+
current.transport.stop();
|
|
620
|
+
publish({
|
|
621
|
+
status: "recovering",
|
|
622
|
+
bridge: failedBridge.snapshot(),
|
|
623
|
+
audibleOutput: "inactive",
|
|
624
|
+
diagnostic: diagnostic("negotiation_failure", fatal.message, true, targetGeneration),
|
|
625
|
+
error: fatal.message,
|
|
626
|
+
});
|
|
627
|
+
scheduleRecovery("reconnect", false, true);
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
const onConnectionHealth = (
|
|
631
|
+
targetGeneration: number,
|
|
632
|
+
health: CodexRealtimeConnectionHealth,
|
|
633
|
+
): void => {
|
|
634
|
+
if (closed || stopping) return;
|
|
635
|
+
if (pendingGeneration === targetGeneration && health !== "connected") {
|
|
636
|
+
pendingAbort?.abort(new Error(`Codex realtime replacement ${health} before activation`));
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
if (active?.generation !== targetGeneration || health === "connected") return;
|
|
640
|
+
scheduleRecovery("reconnect", false);
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
const onMicrophoneEnded = (targetGeneration: number): void => {
|
|
644
|
+
if (closed || stopping) return;
|
|
645
|
+
const error = new CodexRealtimeMicrophoneError(
|
|
646
|
+
"track_ended",
|
|
647
|
+
"The microphone device was disconnected",
|
|
648
|
+
);
|
|
649
|
+
publish({
|
|
650
|
+
microphone: "track_ended",
|
|
651
|
+
diagnostic: diagnostic("device_failure", error.message, true),
|
|
652
|
+
error: error.message,
|
|
653
|
+
});
|
|
654
|
+
if (pendingGeneration === targetGeneration) {
|
|
655
|
+
pendingAbort?.abort(error);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
if (active?.generation !== targetGeneration) return;
|
|
659
|
+
scheduleRecovery("microphone", true, true);
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
const startTimers = (): void => {
|
|
663
|
+
stopTimers();
|
|
664
|
+
startActiveIntervals();
|
|
665
|
+
rotationTimer = scheduleTimeout(() => {
|
|
666
|
+
rotationTimer = null;
|
|
667
|
+
scheduleRecovery("rotation", false, true);
|
|
668
|
+
}, rotationInterval);
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
const establish = async (
|
|
672
|
+
record: OwnerRecord,
|
|
673
|
+
currentMode: SessionRealtimeMode,
|
|
674
|
+
cause: RecoveryCause,
|
|
675
|
+
rotate: boolean,
|
|
676
|
+
replaceMicrophone: boolean,
|
|
677
|
+
): Promise<void> => {
|
|
678
|
+
if (currentMode.state !== "active") {
|
|
679
|
+
transitionEnded();
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
if (rotate) {
|
|
683
|
+
// Refresh the 30-second lease immediately before freezing mode-version
|
|
684
|
+
// changes for a negotiation that is itself bounded to 20 seconds.
|
|
685
|
+
await heartbeat();
|
|
686
|
+
const renewedMode = state.mode;
|
|
687
|
+
if (!renewedMode || renewedMode.state !== "active") return;
|
|
688
|
+
if (closed || stopping || owner !== record) {
|
|
689
|
+
throw new DOMException("Realtime connection generation was retired", "AbortError");
|
|
690
|
+
}
|
|
691
|
+
currentMode = renewedMode;
|
|
692
|
+
}
|
|
693
|
+
// Freeze lease-version changes while this exact negotiation/activation
|
|
694
|
+
// proof is in flight, but keep flushing the old active bridge so durable
|
|
695
|
+
// updates are not suppressed while its replacement is prepared.
|
|
696
|
+
stopNegotiationTimers();
|
|
697
|
+
const targetGeneration = ++generation;
|
|
698
|
+
pendingGeneration = targetGeneration;
|
|
699
|
+
const abort = new AbortController();
|
|
700
|
+
pendingAbort = abort;
|
|
701
|
+
const earlyEvents = createCodexRealtimeEarlyEventBuffer({
|
|
702
|
+
onFatal: (error) => {
|
|
703
|
+
if (pendingGeneration === targetGeneration && !abort.signal.aborted) abort.abort(error);
|
|
704
|
+
},
|
|
705
|
+
});
|
|
706
|
+
negotiationTimer = scheduleTimeout(() => {
|
|
707
|
+
if (pendingGeneration === targetGeneration && !abort.signal.aborted) {
|
|
708
|
+
abort.abort(
|
|
709
|
+
new CodexRealtimeGenerationError(
|
|
710
|
+
"Codex realtime negotiation did not open within 20 seconds",
|
|
711
|
+
),
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
}, negotiationTimeout);
|
|
715
|
+
publish({
|
|
716
|
+
status: rotate ? "recovering" : "starting",
|
|
717
|
+
realtimeId: currentMode.id,
|
|
718
|
+
mode: currentMode,
|
|
719
|
+
connectionGeneration: targetGeneration,
|
|
720
|
+
diagnostic:
|
|
721
|
+
cause === "rotation"
|
|
722
|
+
? diagnostic(
|
|
723
|
+
"rotation",
|
|
724
|
+
"Rotating the finite provider connection",
|
|
725
|
+
true,
|
|
726
|
+
targetGeneration,
|
|
727
|
+
)
|
|
728
|
+
: cause === "reload" || cause === "reconnect"
|
|
729
|
+
? diagnostic("reconnect", "Reconnecting the same realtime mode", true, targetGeneration)
|
|
730
|
+
: state.diagnostic,
|
|
731
|
+
error: null,
|
|
732
|
+
});
|
|
733
|
+
let connected: CodexRealtimeWebrtcSession;
|
|
734
|
+
try {
|
|
735
|
+
const media = await ensureMicrophone(replaceMicrophone, abort.signal);
|
|
736
|
+
const operationId = randomUUID();
|
|
737
|
+
const commonTransportInput = {
|
|
738
|
+
client: options.client,
|
|
739
|
+
workspaceId: options.workspaceId,
|
|
740
|
+
sessionId: options.sessionId,
|
|
741
|
+
realtimeId: currentMode.id,
|
|
742
|
+
operationId,
|
|
743
|
+
browserInstanceId: record.browserInstanceId,
|
|
744
|
+
ownerKey: record.ownerKey,
|
|
745
|
+
expectedVersion: currentMode.version,
|
|
746
|
+
expectedConnectionEpoch: currentMode.connectionEpoch,
|
|
747
|
+
rotate,
|
|
748
|
+
signal: abort.signal,
|
|
749
|
+
media,
|
|
750
|
+
onEventsCreated: earlyEvents.attach,
|
|
751
|
+
onAudibleOutputState: (next: CodexRealtimeAudibleOutputState) =>
|
|
752
|
+
onAudibleOutput(targetGeneration, next),
|
|
753
|
+
onMicrophoneEnded: () => onMicrophoneEnded(targetGeneration),
|
|
754
|
+
onConnectionHealth: (health: CodexRealtimeConnectionHealth) =>
|
|
755
|
+
onConnectionHealth(targetGeneration, health),
|
|
756
|
+
};
|
|
757
|
+
connected = options.startTransport
|
|
758
|
+
? await options.startTransport(commonTransportInput)
|
|
759
|
+
: await startCodexRealtimeWebrtc({
|
|
760
|
+
...commonTransportInput,
|
|
761
|
+
remoteAudio: options.remoteAudio,
|
|
762
|
+
activateRemoteAudio: false,
|
|
763
|
+
createPeerConnection: options.createPeerConnection,
|
|
764
|
+
getUserMedia: options.getUserMedia,
|
|
765
|
+
negotiate: async (request, requestOptions) =>
|
|
766
|
+
await options.client.negotiateCodexRealtimeWebrtc(
|
|
767
|
+
options.workspaceId,
|
|
768
|
+
options.sessionId,
|
|
769
|
+
request,
|
|
770
|
+
requestOptions,
|
|
771
|
+
),
|
|
772
|
+
});
|
|
773
|
+
} catch (error) {
|
|
774
|
+
clearNegotiationTimer();
|
|
775
|
+
earlyEvents.close();
|
|
776
|
+
if (pendingGeneration === targetGeneration) {
|
|
777
|
+
pendingGeneration = null;
|
|
778
|
+
pendingAbort = null;
|
|
779
|
+
}
|
|
780
|
+
throw error;
|
|
781
|
+
}
|
|
782
|
+
try {
|
|
783
|
+
await waitForDataChannelOpen(connected.events, abort.signal);
|
|
784
|
+
if (
|
|
785
|
+
closed ||
|
|
786
|
+
stopping ||
|
|
787
|
+
abort.signal.aborted ||
|
|
788
|
+
pendingGeneration !== targetGeneration ||
|
|
789
|
+
!connected.microphoneHealthy()
|
|
790
|
+
) {
|
|
791
|
+
if (!connected.microphoneHealthy() && !abort.signal.aborted) {
|
|
792
|
+
abort.abort(
|
|
793
|
+
new CodexRealtimeMicrophoneError(
|
|
794
|
+
"track_ended",
|
|
795
|
+
"The microphone audio track ended before realtime activation",
|
|
796
|
+
),
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
throw abort.signal.reason ?? new DOMException("Aborted", "AbortError");
|
|
800
|
+
}
|
|
801
|
+
const activated = await options.client.activateCodexRealtimeConnection(
|
|
802
|
+
options.workspaceId,
|
|
803
|
+
options.sessionId,
|
|
804
|
+
currentMode.id,
|
|
805
|
+
connected.connectionId,
|
|
806
|
+
{
|
|
807
|
+
operationId: connected.operationId,
|
|
808
|
+
browserInstanceId: record.browserInstanceId,
|
|
809
|
+
ownerKey: record.ownerKey,
|
|
810
|
+
connectionEpoch: connected.connectionEpoch,
|
|
811
|
+
expectedVersion: currentMode.version,
|
|
812
|
+
expectedConnectionEpoch: currentMode.connectionEpoch,
|
|
813
|
+
},
|
|
814
|
+
{ signal: abort.signal },
|
|
815
|
+
);
|
|
816
|
+
if (activated.mode.state !== "active") {
|
|
817
|
+
clearNegotiationTimer();
|
|
818
|
+
earlyEvents.close();
|
|
819
|
+
connected.stop();
|
|
820
|
+
transitionEnded();
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
if (
|
|
824
|
+
closed ||
|
|
825
|
+
stopping ||
|
|
826
|
+
abort.signal.aborted ||
|
|
827
|
+
pendingGeneration !== targetGeneration ||
|
|
828
|
+
!connected.microphoneHealthy()
|
|
829
|
+
) {
|
|
830
|
+
if (!connected.microphoneHealthy() && !abort.signal.aborted) {
|
|
831
|
+
abort.abort(
|
|
832
|
+
new CodexRealtimeMicrophoneError(
|
|
833
|
+
"track_ended",
|
|
834
|
+
"The microphone audio track ended during realtime activation",
|
|
835
|
+
),
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
throw abort.signal.reason ?? new DOMException("Aborted", "AbortError");
|
|
839
|
+
}
|
|
840
|
+
clearNegotiationTimer();
|
|
841
|
+
const previous = active;
|
|
842
|
+
let bridge!: CodexRealtimeV3Bridge;
|
|
843
|
+
bridge = createCodexRealtimeV3Bridge({
|
|
844
|
+
events: connected.events,
|
|
845
|
+
connectionId: connected.connectionId,
|
|
846
|
+
connectionEpoch: connected.connectionEpoch,
|
|
847
|
+
startupFenceSequence: connected.startupFenceSequence,
|
|
848
|
+
modeVersion: activated.mode.version,
|
|
849
|
+
owner: {
|
|
850
|
+
browserInstanceId: record.browserInstanceId,
|
|
851
|
+
ownerKey: record.ownerKey,
|
|
852
|
+
expectedVersion: activated.mode.version,
|
|
853
|
+
},
|
|
854
|
+
listen: false,
|
|
855
|
+
sync: async (request) =>
|
|
856
|
+
await syncForGeneration(targetGeneration, activated.mode.id, request),
|
|
857
|
+
randomUUID,
|
|
858
|
+
onSnapshot: (nextBridge) => {
|
|
859
|
+
if (active?.generation === targetGeneration) publish({ bridge: nextBridge });
|
|
860
|
+
},
|
|
861
|
+
onFatal: (fatal) => onBridgeFatal(targetGeneration, bridge, fatal),
|
|
862
|
+
});
|
|
863
|
+
active = { generation: targetGeneration, transport: connected, bridge };
|
|
864
|
+
connected.setOutputMuted(state.outputMuted);
|
|
865
|
+
recoveryTerminal = false;
|
|
866
|
+
pendingAbort = null;
|
|
867
|
+
pendingGeneration = null;
|
|
868
|
+
publish({
|
|
869
|
+
status: "active",
|
|
870
|
+
realtimeId: activated.mode.id,
|
|
871
|
+
mode: activated.mode,
|
|
872
|
+
microphone: "active",
|
|
873
|
+
audibleOutput: connected.audibleOutputState(),
|
|
874
|
+
bridge: bridge.snapshot(),
|
|
875
|
+
connectionGeneration: targetGeneration,
|
|
876
|
+
reconnectAttempt: 0,
|
|
877
|
+
diagnostic:
|
|
878
|
+
cause === "rotation"
|
|
879
|
+
? diagnostic(
|
|
880
|
+
"rotation",
|
|
881
|
+
"Provider connection rotation completed",
|
|
882
|
+
true,
|
|
883
|
+
targetGeneration,
|
|
884
|
+
)
|
|
885
|
+
: cause === "reload" || cause === "reconnect" || cause === "microphone"
|
|
886
|
+
? diagnostic("reconnect", "Realtime connection recovered", true, targetGeneration)
|
|
887
|
+
: null,
|
|
888
|
+
error: null,
|
|
889
|
+
});
|
|
890
|
+
connected.activateRemoteAudio();
|
|
891
|
+
previous?.bridge.close();
|
|
892
|
+
previous?.transport.stop();
|
|
893
|
+
earlyEvents.handoff(bridge);
|
|
894
|
+
if (active?.generation !== targetGeneration) {
|
|
895
|
+
throw new CodexRealtimeGenerationError(
|
|
896
|
+
bridge.snapshot().fatal?.message ??
|
|
897
|
+
"Codex realtime connection generation was retired during activation",
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
startTimers();
|
|
901
|
+
} catch (error) {
|
|
902
|
+
clearNegotiationTimer();
|
|
903
|
+
earlyEvents.close();
|
|
904
|
+
connected.stop();
|
|
905
|
+
if (pendingGeneration === targetGeneration) {
|
|
906
|
+
pendingGeneration = null;
|
|
907
|
+
pendingAbort = null;
|
|
908
|
+
}
|
|
909
|
+
throw error;
|
|
910
|
+
}
|
|
911
|
+
};
|
|
912
|
+
|
|
913
|
+
const begin = async (
|
|
914
|
+
record: OwnerRecord,
|
|
915
|
+
recover: boolean,
|
|
916
|
+
connectAfterBegin = true,
|
|
917
|
+
): Promise<SessionRealtimeMode | null> => {
|
|
918
|
+
const response = await options.client.beginSessionRealtime(
|
|
919
|
+
options.workspaceId,
|
|
920
|
+
options.sessionId,
|
|
921
|
+
{
|
|
922
|
+
operationId: record.operationId,
|
|
923
|
+
browserInstanceId: record.browserInstanceId,
|
|
924
|
+
ownerKey: record.ownerKey,
|
|
925
|
+
model,
|
|
926
|
+
},
|
|
927
|
+
);
|
|
928
|
+
if (response.mode.state !== "active") {
|
|
929
|
+
transitionEnded();
|
|
930
|
+
return null;
|
|
931
|
+
}
|
|
932
|
+
publish({ mode: response.mode, realtimeId: response.mode.id });
|
|
933
|
+
if (connectAfterBegin) {
|
|
934
|
+
await establish(
|
|
935
|
+
record,
|
|
936
|
+
response.mode,
|
|
937
|
+
recover ? "reload" : "manual",
|
|
938
|
+
recover || response.replay,
|
|
939
|
+
false,
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
return response.mode;
|
|
943
|
+
};
|
|
944
|
+
|
|
945
|
+
const heartbeat = async (): Promise<void> => {
|
|
946
|
+
await exclusive(async () => {
|
|
947
|
+
const current = state.mode;
|
|
948
|
+
if (!owner || !current || current.state !== "active") {
|
|
949
|
+
throw new Error("Codex realtime owner is not active");
|
|
950
|
+
}
|
|
951
|
+
const result = await options.client.heartbeatSessionRealtime(
|
|
952
|
+
options.workspaceId,
|
|
953
|
+
options.sessionId,
|
|
954
|
+
current.id,
|
|
955
|
+
{
|
|
956
|
+
browserInstanceId: owner.browserInstanceId,
|
|
957
|
+
ownerKey: owner.ownerKey,
|
|
958
|
+
expectedVersion: current.version,
|
|
959
|
+
},
|
|
960
|
+
);
|
|
961
|
+
// Publish the renewed version before releasing the mutation FIFO. A
|
|
962
|
+
// queued ledger sync must never observe the pre-heartbeat version after
|
|
963
|
+
// the server has already committed its successor.
|
|
964
|
+
if (result.mode.state === "ended") {
|
|
965
|
+
transitionEnded("Realtime lease ended");
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
publish({ mode: result.mode, realtimeId: result.mode.id, error: null });
|
|
969
|
+
});
|
|
970
|
+
};
|
|
971
|
+
|
|
972
|
+
const flush = async (): Promise<void> => {
|
|
973
|
+
await active?.bridge.flush();
|
|
974
|
+
};
|
|
975
|
+
|
|
976
|
+
const retry = async (): Promise<void> => {
|
|
977
|
+
if (!owner || state.mode?.state !== "active") {
|
|
978
|
+
throw new Error("Codex realtime owner is not recoverable");
|
|
979
|
+
}
|
|
980
|
+
if (recoveryTerminal || state.diagnostic?.recoverable === false) {
|
|
981
|
+
throw new Error("Codex realtime recovery is terminal; stop the mode before retrying");
|
|
982
|
+
}
|
|
983
|
+
if (reconnectTimer !== null) unscheduleTimeout(reconnectTimer);
|
|
984
|
+
reconnectTimer = null;
|
|
985
|
+
const replace = !codexRealtimeMicrophoneHealthy(microphone);
|
|
986
|
+
scheduleRecovery(replace ? "microphone" : "manual", replace, true);
|
|
987
|
+
};
|
|
988
|
+
|
|
989
|
+
const retryAudibleOutput = async (): Promise<boolean> => {
|
|
990
|
+
if (!active || state.audibleOutput !== "blocked") return false;
|
|
991
|
+
const target = active;
|
|
992
|
+
const resumed = await target.transport.retryAudibleOutput();
|
|
993
|
+
if (active?.generation !== target.generation) return false;
|
|
994
|
+
return resumed;
|
|
995
|
+
};
|
|
996
|
+
|
|
997
|
+
const setInputMuted = (muted: boolean): void => {
|
|
998
|
+
if (state.inputMuted === muted) return;
|
|
999
|
+
for (const track of microphone?.getAudioTracks() ?? []) track.enabled = !muted;
|
|
1000
|
+
publish({ inputMuted: muted });
|
|
1001
|
+
};
|
|
1002
|
+
|
|
1003
|
+
const setOutputMuted = (muted: boolean): void => {
|
|
1004
|
+
if (state.outputMuted === muted) return;
|
|
1005
|
+
active?.transport.setOutputMuted(muted);
|
|
1006
|
+
publish({ outputMuted: muted });
|
|
1007
|
+
};
|
|
1008
|
+
|
|
1009
|
+
const controller: CodexRealtimeController = {
|
|
1010
|
+
snapshot: () => ({ ...state }),
|
|
1011
|
+
subscribe: (listener) => {
|
|
1012
|
+
listeners.add(listener);
|
|
1013
|
+
listener({ ...state });
|
|
1014
|
+
return () => listeners.delete(listener);
|
|
1015
|
+
},
|
|
1016
|
+
start: async () => {
|
|
1017
|
+
if (state.status === "lost_owner") {
|
|
1018
|
+
throw new Error("Realtime mode belongs to another browser owner");
|
|
1019
|
+
}
|
|
1020
|
+
if (!["idle", "error"].includes(state.status) || state.mode?.state === "active") return;
|
|
1021
|
+
closed = false;
|
|
1022
|
+
stopping = false;
|
|
1023
|
+
recoveryTerminal = false;
|
|
1024
|
+
const record: OwnerRecord = {
|
|
1025
|
+
version: OWNER_RECORD_VERSION,
|
|
1026
|
+
workspaceId: options.workspaceId,
|
|
1027
|
+
sessionId: options.sessionId,
|
|
1028
|
+
browserInstanceId: randomUUID(),
|
|
1029
|
+
ownerKey: `opengeni-realtime-owner:${randomUUID()}`,
|
|
1030
|
+
operationId: randomUUID(),
|
|
1031
|
+
};
|
|
1032
|
+
owner = record;
|
|
1033
|
+
storage?.setItem(storageKey, JSON.stringify(record));
|
|
1034
|
+
publish({ status: "starting", error: null, diagnostic: null });
|
|
1035
|
+
try {
|
|
1036
|
+
await begin(record, false);
|
|
1037
|
+
} catch (error) {
|
|
1038
|
+
closeBrowserResources(false);
|
|
1039
|
+
const failedMode = state.mode as SessionRealtimeMode | null;
|
|
1040
|
+
if (failedMode?.state === "active") {
|
|
1041
|
+
await handleConnectionFailure(error, "reconnect", false);
|
|
1042
|
+
} else {
|
|
1043
|
+
clearOwner();
|
|
1044
|
+
publish({ status: "error", realtimeId: null, mode: null, error: safeError(error) });
|
|
1045
|
+
}
|
|
1046
|
+
throw error;
|
|
1047
|
+
}
|
|
1048
|
+
},
|
|
1049
|
+
observeLifecycle: async (lifecycle) => {
|
|
1050
|
+
if (!lifecycle) {
|
|
1051
|
+
if (recoveryTerminal && state.mode?.state === "active") return;
|
|
1052
|
+
const record = readOwnerRecord(storage, storageKey, options);
|
|
1053
|
+
if (!record) {
|
|
1054
|
+
transitionEnded();
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
if (connectionTask || state.status === "starting" || state.status === "active") return;
|
|
1058
|
+
closed = false;
|
|
1059
|
+
stopping = false;
|
|
1060
|
+
owner = record;
|
|
1061
|
+
publish({ status: "recovering", error: null });
|
|
1062
|
+
connectionTask = begin(record, true)
|
|
1063
|
+
.then(() => undefined)
|
|
1064
|
+
.catch(async (error) => {
|
|
1065
|
+
connectionTask = null;
|
|
1066
|
+
await handleConnectionFailure(error, "reload", false);
|
|
1067
|
+
})
|
|
1068
|
+
.finally(() => {
|
|
1069
|
+
connectionTask = null;
|
|
1070
|
+
});
|
|
1071
|
+
await connectionTask;
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
if (lifecycle.state === "ended") {
|
|
1075
|
+
const record = readOwnerRecord(storage, storageKey, options);
|
|
1076
|
+
if (record && record.operationId !== lifecycle.operationId) {
|
|
1077
|
+
if (connectionTask || state.status === "active") return;
|
|
1078
|
+
closed = false;
|
|
1079
|
+
stopping = false;
|
|
1080
|
+
owner = record;
|
|
1081
|
+
connectionTask = begin(record, true)
|
|
1082
|
+
.then(() => undefined)
|
|
1083
|
+
.catch(async (error) => {
|
|
1084
|
+
connectionTask = null;
|
|
1085
|
+
await handleConnectionFailure(error, "reload", false);
|
|
1086
|
+
})
|
|
1087
|
+
.finally(() => {
|
|
1088
|
+
connectionTask = null;
|
|
1089
|
+
});
|
|
1090
|
+
await connectionTask;
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
if (state.realtimeId === null || lifecycle.realtimeId === state.realtimeId) {
|
|
1094
|
+
transitionEnded(`Realtime ended: ${lifecycle.reason}`);
|
|
1095
|
+
}
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
if (recoveryTerminal && state.realtimeId === lifecycle.realtimeId) return;
|
|
1099
|
+
if (state.status === "active" && state.realtimeId === lifecycle.realtimeId) return;
|
|
1100
|
+
const record = readOwnerRecord(storage, storageKey, options);
|
|
1101
|
+
if (!record || record.operationId !== lifecycle.operationId) {
|
|
1102
|
+
closeBrowserResources();
|
|
1103
|
+
owner = null;
|
|
1104
|
+
const message =
|
|
1105
|
+
"Realtime is active in another browser owner. It can resume after that owner stops or its lease expires.";
|
|
1106
|
+
publish({
|
|
1107
|
+
status: "lost_owner",
|
|
1108
|
+
realtimeId: lifecycle.realtimeId,
|
|
1109
|
+
mode: null,
|
|
1110
|
+
bridge: null,
|
|
1111
|
+
diagnostic: diagnostic("lost_owner", message, false),
|
|
1112
|
+
error: message,
|
|
1113
|
+
});
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
if (connectionTask || state.status === "starting") return;
|
|
1117
|
+
closed = false;
|
|
1118
|
+
stopping = false;
|
|
1119
|
+
owner = record;
|
|
1120
|
+
publish({ status: "recovering", realtimeId: lifecycle.realtimeId, error: null });
|
|
1121
|
+
connectionTask = begin(record, true)
|
|
1122
|
+
.then(() => undefined)
|
|
1123
|
+
.catch(async (error) => {
|
|
1124
|
+
connectionTask = null;
|
|
1125
|
+
await handleConnectionFailure(error, "reload", false);
|
|
1126
|
+
})
|
|
1127
|
+
.finally(() => {
|
|
1128
|
+
connectionTask = null;
|
|
1129
|
+
});
|
|
1130
|
+
await connectionTask;
|
|
1131
|
+
},
|
|
1132
|
+
heartbeat,
|
|
1133
|
+
flush,
|
|
1134
|
+
ingestProviderEvent: async (payload) => {
|
|
1135
|
+
if (!active) throw new Error("Codex realtime provider channel is not connected");
|
|
1136
|
+
await active.bridge.ingest(payload);
|
|
1137
|
+
},
|
|
1138
|
+
retry,
|
|
1139
|
+
retryAudibleOutput,
|
|
1140
|
+
setInputMuted,
|
|
1141
|
+
setOutputMuted,
|
|
1142
|
+
stop: async () => {
|
|
1143
|
+
const currentOwner = owner;
|
|
1144
|
+
if (!currentOwner || !state.mode) {
|
|
1145
|
+
if (state.status !== "lost_owner") transitionEnded();
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
stopping = true;
|
|
1149
|
+
publish({
|
|
1150
|
+
status: "stopping",
|
|
1151
|
+
diagnostic: diagnostic("terminal_stop", "Stopping realtime and releasing media", false),
|
|
1152
|
+
error: null,
|
|
1153
|
+
});
|
|
1154
|
+
stopTimers();
|
|
1155
|
+
pendingAbort?.abort(new DOMException("Realtime stopped", "AbortError"));
|
|
1156
|
+
pendingGeneration = null;
|
|
1157
|
+
const retiredTask = connectionTask;
|
|
1158
|
+
connectionTask = null;
|
|
1159
|
+
void retiredTask?.catch(() => undefined);
|
|
1160
|
+
try {
|
|
1161
|
+
await active?.bridge.sealAndFlush();
|
|
1162
|
+
closeBrowserResources();
|
|
1163
|
+
let current = state.mode;
|
|
1164
|
+
if (!current) throw new Error("Codex realtime mode disappeared while stopping");
|
|
1165
|
+
let response: SessionRealtimeMutationResponse;
|
|
1166
|
+
try {
|
|
1167
|
+
response = await exclusive(
|
|
1168
|
+
async () =>
|
|
1169
|
+
await options.client.endSessionRealtime(
|
|
1170
|
+
options.workspaceId,
|
|
1171
|
+
options.sessionId,
|
|
1172
|
+
current.id,
|
|
1173
|
+
{
|
|
1174
|
+
browserInstanceId: currentOwner.browserInstanceId,
|
|
1175
|
+
ownerKey: currentOwner.ownerKey,
|
|
1176
|
+
expectedVersion: current.version,
|
|
1177
|
+
reason: "user_stop",
|
|
1178
|
+
},
|
|
1179
|
+
),
|
|
1180
|
+
);
|
|
1181
|
+
} catch (error) {
|
|
1182
|
+
if (!(error instanceof OpenGeniApiError) || error.status !== 409) throw error;
|
|
1183
|
+
const reconciled = await begin(currentOwner, true, false);
|
|
1184
|
+
if (!reconciled || reconciled.state === "ended") return;
|
|
1185
|
+
current = reconciled;
|
|
1186
|
+
response = await exclusive(
|
|
1187
|
+
async () =>
|
|
1188
|
+
await options.client.endSessionRealtime(
|
|
1189
|
+
options.workspaceId,
|
|
1190
|
+
options.sessionId,
|
|
1191
|
+
current.id,
|
|
1192
|
+
{
|
|
1193
|
+
browserInstanceId: currentOwner.browserInstanceId,
|
|
1194
|
+
ownerKey: currentOwner.ownerKey,
|
|
1195
|
+
expectedVersion: current.version,
|
|
1196
|
+
reason: "user_stop",
|
|
1197
|
+
},
|
|
1198
|
+
),
|
|
1199
|
+
);
|
|
1200
|
+
}
|
|
1201
|
+
if (response.mode.state === "ended") transitionEnded("Realtime stopped by this browser");
|
|
1202
|
+
} catch (error) {
|
|
1203
|
+
stopping = false;
|
|
1204
|
+
owner = currentOwner;
|
|
1205
|
+
startActiveIntervals();
|
|
1206
|
+
publish({
|
|
1207
|
+
status: "recovering",
|
|
1208
|
+
diagnostic: diagnostic("terminal_stop", safeError(error), true),
|
|
1209
|
+
error: safeError(error),
|
|
1210
|
+
});
|
|
1211
|
+
throw error;
|
|
1212
|
+
}
|
|
1213
|
+
},
|
|
1214
|
+
close: () => {
|
|
1215
|
+
closed = true;
|
|
1216
|
+
stopping = false;
|
|
1217
|
+
closeBrowserResources();
|
|
1218
|
+
listeners.clear();
|
|
1219
|
+
},
|
|
1220
|
+
};
|
|
1221
|
+
return controller;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
type CodexRealtimeEarlyEventBuffer = {
|
|
1225
|
+
attach(events: RTCDataChannel): void;
|
|
1226
|
+
handoff(bridge: CodexRealtimeV3Bridge): void;
|
|
1227
|
+
close(): void;
|
|
1228
|
+
};
|
|
1229
|
+
|
|
1230
|
+
function createCodexRealtimeEarlyEventBuffer(input: {
|
|
1231
|
+
onFatal(error: Error): void;
|
|
1232
|
+
}): CodexRealtimeEarlyEventBuffer {
|
|
1233
|
+
let events: RTCDataChannel | null = null;
|
|
1234
|
+
let buffered: string[] = [];
|
|
1235
|
+
let bufferedBytes = 0;
|
|
1236
|
+
let fatal = false;
|
|
1237
|
+
|
|
1238
|
+
const detach = (): void => {
|
|
1239
|
+
events?.removeEventListener("message", onMessage);
|
|
1240
|
+
events = null;
|
|
1241
|
+
};
|
|
1242
|
+
const fail = (): void => {
|
|
1243
|
+
if (fatal) return;
|
|
1244
|
+
fatal = true;
|
|
1245
|
+
detach();
|
|
1246
|
+
buffered = [];
|
|
1247
|
+
bufferedBytes = 0;
|
|
1248
|
+
input.onFatal(
|
|
1249
|
+
new CodexRealtimeGenerationError(
|
|
1250
|
+
"Codex realtime activation event buffer exceeded its hard limit",
|
|
1251
|
+
),
|
|
1252
|
+
);
|
|
1253
|
+
};
|
|
1254
|
+
const onMessage = (message: MessageEvent): void => {
|
|
1255
|
+
if (fatal || typeof message.data !== "string") return;
|
|
1256
|
+
const parsed = parseCodexRealtimeV3Event(message.data);
|
|
1257
|
+
// Invalid and oversized provider input is quarantined before it can occupy
|
|
1258
|
+
// activation memory. Audio deltas are ephemeral browser playback only.
|
|
1259
|
+
if (!parsed.ok || parsed.event.type === "output_audio.delta") return;
|
|
1260
|
+
const bytes = new TextEncoder().encode(message.data).byteLength;
|
|
1261
|
+
if (
|
|
1262
|
+
bytes > CODEX_REALTIME_V3_MAX_EVENT_BYTES ||
|
|
1263
|
+
buffered.length + 1 > CODEX_REALTIME_V3_PENDING_MAX_ENTRIES ||
|
|
1264
|
+
bufferedBytes + bytes > CODEX_REALTIME_V3_PENDING_MAX_BYTES
|
|
1265
|
+
) {
|
|
1266
|
+
fail();
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
buffered.push(message.data);
|
|
1270
|
+
bufferedBytes += bytes;
|
|
1271
|
+
};
|
|
1272
|
+
|
|
1273
|
+
return {
|
|
1274
|
+
attach: (channel) => {
|
|
1275
|
+
if (fatal || events === channel) return;
|
|
1276
|
+
if (events) throw new Error("Codex realtime activation buffer is already attached");
|
|
1277
|
+
events = channel;
|
|
1278
|
+
events.addEventListener("message", onMessage);
|
|
1279
|
+
},
|
|
1280
|
+
handoff: (bridge) => {
|
|
1281
|
+
if (fatal) {
|
|
1282
|
+
bridge.close();
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
// JavaScript event dispatch cannot interleave between these synchronous
|
|
1286
|
+
// operations. Reentrant arrivals append to the same array and are picked
|
|
1287
|
+
// up by the loop before the early listener is removed; the direct bridge
|
|
1288
|
+
// listener is enabled only after that removal, so there is no duplicate.
|
|
1289
|
+
for (let index = 0; index < buffered.length; index += 1) {
|
|
1290
|
+
void bridge.ingest(buffered[index]!).catch(() => undefined);
|
|
1291
|
+
}
|
|
1292
|
+
buffered = [];
|
|
1293
|
+
bufferedBytes = 0;
|
|
1294
|
+
detach();
|
|
1295
|
+
bridge.listen();
|
|
1296
|
+
},
|
|
1297
|
+
close: () => {
|
|
1298
|
+
detach();
|
|
1299
|
+
buffered = [];
|
|
1300
|
+
bufferedBytes = 0;
|
|
1301
|
+
},
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
class CodexRealtimeGenerationError extends Error {
|
|
1306
|
+
readonly name = "CodexRealtimeGenerationError";
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
function ownerStorageKey(
|
|
1310
|
+
workspaceId: string,
|
|
1311
|
+
sessionId: string,
|
|
1312
|
+
namespace = "codex-realtime-owner",
|
|
1313
|
+
): string {
|
|
1314
|
+
return `opengeni:${namespace}:${workspaceId}:${sessionId}`;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
function readOwnerRecord(
|
|
1318
|
+
storage: CodexRealtimeOwnerStorage | undefined,
|
|
1319
|
+
key: string,
|
|
1320
|
+
scope: Pick<CreateCodexRealtimeControllerOptions, "workspaceId" | "sessionId">,
|
|
1321
|
+
): OwnerRecord | null {
|
|
1322
|
+
const raw = storage?.getItem(key);
|
|
1323
|
+
if (!raw) return null;
|
|
1324
|
+
try {
|
|
1325
|
+
const parsed = recordValue(JSON.parse(raw));
|
|
1326
|
+
if (
|
|
1327
|
+
parsed?.version !== OWNER_RECORD_VERSION ||
|
|
1328
|
+
parsed.workspaceId !== scope.workspaceId ||
|
|
1329
|
+
parsed.sessionId !== scope.sessionId ||
|
|
1330
|
+
!stringValue(parsed.operationId) ||
|
|
1331
|
+
!stringValue(parsed.browserInstanceId) ||
|
|
1332
|
+
!stringValue(parsed.ownerKey) ||
|
|
1333
|
+
String(parsed.ownerKey).length < 32
|
|
1334
|
+
) {
|
|
1335
|
+
storage?.removeItem(key);
|
|
1336
|
+
return null;
|
|
1337
|
+
}
|
|
1338
|
+
return parsed as OwnerRecord;
|
|
1339
|
+
} catch {
|
|
1340
|
+
storage?.removeItem(key);
|
|
1341
|
+
return null;
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
function defaultStorage(): CodexRealtimeOwnerStorage | undefined {
|
|
1346
|
+
return typeof sessionStorage === "undefined" ? undefined : sessionStorage;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
function defaultRandomUUID(): string {
|
|
1350
|
+
if (!globalThis.crypto?.randomUUID) throw new Error("crypto.randomUUID is unavailable");
|
|
1351
|
+
return globalThis.crypto.randomUUID();
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
function waitForDataChannelOpen(events: RTCDataChannel, signal: AbortSignal): Promise<void> {
|
|
1355
|
+
if (signal.aborted) {
|
|
1356
|
+
return Promise.reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
|
|
1357
|
+
}
|
|
1358
|
+
if (events.readyState === "open") return Promise.resolve();
|
|
1359
|
+
if (events.readyState === "closing" || events.readyState === "closed") {
|
|
1360
|
+
return Promise.reject(new Error("Codex realtime data channel closed before opening"));
|
|
1361
|
+
}
|
|
1362
|
+
return new Promise<void>((resolve, reject) => {
|
|
1363
|
+
const cleanup = (): void => {
|
|
1364
|
+
events.removeEventListener("open", onOpen);
|
|
1365
|
+
events.removeEventListener("close", onClose);
|
|
1366
|
+
events.removeEventListener("error", onError);
|
|
1367
|
+
signal.removeEventListener("abort", onAbort);
|
|
1368
|
+
};
|
|
1369
|
+
const onOpen = (): void => {
|
|
1370
|
+
cleanup();
|
|
1371
|
+
resolve();
|
|
1372
|
+
};
|
|
1373
|
+
const onClose = (): void => {
|
|
1374
|
+
cleanup();
|
|
1375
|
+
reject(new Error("Codex realtime data channel closed before opening"));
|
|
1376
|
+
};
|
|
1377
|
+
const onError = (): void => {
|
|
1378
|
+
cleanup();
|
|
1379
|
+
reject(new Error("Codex realtime data channel failed before opening"));
|
|
1380
|
+
};
|
|
1381
|
+
const onAbort = (): void => {
|
|
1382
|
+
cleanup();
|
|
1383
|
+
reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
|
|
1384
|
+
};
|
|
1385
|
+
events.addEventListener("open", onOpen, { once: true });
|
|
1386
|
+
events.addEventListener("close", onClose, { once: true });
|
|
1387
|
+
events.addEventListener("error", onError, { once: true });
|
|
1388
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1389
|
+
if (signal.aborted) onAbort();
|
|
1390
|
+
else if (events.readyState === "open") onOpen();
|
|
1391
|
+
else if (events.readyState === "closing" || events.readyState === "closed") onClose();
|
|
1392
|
+
});
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
function positiveDuration(value: number, name: string): number {
|
|
1396
|
+
if (!Number.isSafeInteger(value) || value <= 0)
|
|
1397
|
+
throw new Error(`Codex realtime ${name} is invalid`);
|
|
1398
|
+
return value;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
function validateReconnectBackoff(values: readonly number[]): readonly number[] {
|
|
1402
|
+
if (
|
|
1403
|
+
values.length === 0 ||
|
|
1404
|
+
values.some((value) => !Number.isSafeInteger(value) || value < 0 || value > 60_000)
|
|
1405
|
+
) {
|
|
1406
|
+
throw new Error("Codex realtime reconnect backoff is invalid");
|
|
1407
|
+
}
|
|
1408
|
+
return [...values];
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
function recordValue(value: unknown): Record<string, unknown> | null {
|
|
1412
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
1413
|
+
? (value as Record<string, unknown>)
|
|
1414
|
+
: null;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
function stringValue(value: unknown): string | null {
|
|
1418
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
function isAbortError(error: unknown): boolean {
|
|
1422
|
+
return error instanceof Error && error.name === "AbortError";
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
function safeError(error: unknown): string {
|
|
1426
|
+
return error instanceof Error ? error.message : "Codex realtime browser controller failed";
|
|
1427
|
+
}
|