@opengeni/api-router 0.5.6 → 0.7.3
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/dist/app.d.ts +21 -3
- package/dist/app.js +3 -1
- package/dist/{chunk-HBEJMWD3.js → chunk-EYYTFA7N.js} +2396 -676
- package/dist/chunk-EYYTFA7N.js.map +1 -0
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/package.json +11 -11
- package/src/app.ts +47 -4
- package/src/github-access.ts +46 -0
- package/src/github-browser-flow.ts +83 -0
- package/src/http/auth.ts +12 -4
- package/src/http/sse.ts +526 -92
- package/src/index.ts +2 -1
- package/src/mcp/server.ts +774 -146
- package/src/mcp/session-view.ts +622 -203
- package/src/mcp/toolspace.ts +110 -25
- package/src/routes/codex.ts +17 -14
- package/src/routes/enrollments.ts +2 -2
- package/src/routes/github.ts +63 -202
- package/src/routes/install.ts +1 -1
- package/src/routes/machines.ts +2 -2
- package/src/routes/sessions.ts +639 -76
- package/src/routes/workspace-capture.ts +56 -38
- package/src/routes/workspaces.ts +30 -11
- package/src/sandbox/access.ts +1 -1
- package/src/sandbox/auth-callout.ts +1 -1
- package/src/sandbox/channel-a.ts +14 -1
- package/src/sandbox/enrollment.ts +4 -4
- package/src/sandbox/machines.ts +1 -1
- package/src/sandbox/metrics-ingestion.ts +1 -1
- package/src/sandbox/viewer.ts +1 -1
- package/dist/chunk-HBEJMWD3.js.map +0 -1
package/src/http/sse.ts
CHANGED
|
@@ -1,6 +1,246 @@
|
|
|
1
1
|
import type { SessionEvent, WorkspaceControlEvent } from "@opengeni/contracts";
|
|
2
2
|
import { listSessionEvents, listWorkspaceControlEvents, type Database } from "@opengeni/db";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
formatSessionEventSse,
|
|
5
|
+
formatWorkspaceControlEventSse,
|
|
6
|
+
SESSION_EVENT_SSE_FRAME_MAX_BYTES,
|
|
7
|
+
type EventBus,
|
|
8
|
+
} from "@opengeni/events";
|
|
9
|
+
import type { Observability } from "@opengeni/observability";
|
|
10
|
+
|
|
11
|
+
const SESSION_REPLAY_PAGE_SIZE = 100;
|
|
12
|
+
const WORKSPACE_CONTROL_REPLAY_PAGE_SIZE = 100;
|
|
13
|
+
export const SSE_QUEUED_FRAME_MAX_COUNT = 1;
|
|
14
|
+
export const SSE_WRITE_STALL_TIMEOUT_MS = 30_000;
|
|
15
|
+
|
|
16
|
+
export type SseDeliveryBoundObservation = {
|
|
17
|
+
reason: "desired_size_non_positive" | "stall_timeout" | "frame_too_large";
|
|
18
|
+
desiredSize: number | null;
|
|
19
|
+
queuedFrames: number;
|
|
20
|
+
queuedBytes: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type ByteBoundedSseStreamOptions = {
|
|
24
|
+
maxQueuedBytes?: number;
|
|
25
|
+
stallTimeoutMs?: number;
|
|
26
|
+
onStop?: () => void;
|
|
27
|
+
onObservation?: (observation: SseDeliveryBoundObservation) => void;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type ByteBoundedSseStream = {
|
|
31
|
+
stream: ReadableStream<Uint8Array>;
|
|
32
|
+
write: (frame: string) => Promise<boolean>;
|
|
33
|
+
close: () => void;
|
|
34
|
+
fail: (error: unknown) => void;
|
|
35
|
+
stopped: () => boolean;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A byte-counted SSE body. `ReadableStreamDefaultController.enqueue()` does not
|
|
40
|
+
* itself wait for a slow HTTP consumer, so replaying bounded frames without
|
|
41
|
+
* checking `desiredSize` can still accumulate an unbounded server-side queue.
|
|
42
|
+
*
|
|
43
|
+
* One writer is expected per stream. The Web Streams queue holds at most one
|
|
44
|
+
* complete frame, and that frame must fit inside the byte cap. A second write
|
|
45
|
+
* waits for consumer pull only for a bounded interval; cancellation or a stalled
|
|
46
|
+
* reader wakes it and terminates upstream delivery before another durable page is
|
|
47
|
+
* read. One frame is deliberate: it makes both queued-frame count and queued
|
|
48
|
+
* bytes independently bounded instead of relying on byte accounting alone.
|
|
49
|
+
*/
|
|
50
|
+
export function createByteBoundedSseStream(
|
|
51
|
+
options: ByteBoundedSseStreamOptions = {},
|
|
52
|
+
): ByteBoundedSseStream {
|
|
53
|
+
const maxQueuedBytes = options.maxQueuedBytes ?? SESSION_EVENT_SSE_FRAME_MAX_BYTES;
|
|
54
|
+
const stallTimeoutMs = options.stallTimeoutMs ?? SSE_WRITE_STALL_TIMEOUT_MS;
|
|
55
|
+
if (!Number.isSafeInteger(maxQueuedBytes) || maxQueuedBytes <= 0) {
|
|
56
|
+
throw new RangeError("SSE byte high-water mark must be a positive safe integer");
|
|
57
|
+
}
|
|
58
|
+
if (!Number.isSafeInteger(stallTimeoutMs) || stallTimeoutMs <= 0) {
|
|
59
|
+
throw new RangeError("SSE write stall timeout must be a positive safe integer");
|
|
60
|
+
}
|
|
61
|
+
const encoder = new TextEncoder();
|
|
62
|
+
let controller!: ReadableStreamDefaultController<Uint8Array>;
|
|
63
|
+
let stopped = false;
|
|
64
|
+
let capacityWake: (() => void) | null = null;
|
|
65
|
+
let queuedFrames = 0;
|
|
66
|
+
let queuedBytes = 0;
|
|
67
|
+
|
|
68
|
+
const wakeWriter = () => {
|
|
69
|
+
const wake = capacityWake;
|
|
70
|
+
capacityWake = null;
|
|
71
|
+
wake?.();
|
|
72
|
+
};
|
|
73
|
+
const stop = (settle: () => void) => {
|
|
74
|
+
if (stopped) return;
|
|
75
|
+
stopped = true;
|
|
76
|
+
wakeWriter();
|
|
77
|
+
options.onStop?.();
|
|
78
|
+
try {
|
|
79
|
+
settle();
|
|
80
|
+
} catch {
|
|
81
|
+
// A concurrent consumer cancellation may already have settled the body.
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const stream = new ReadableStream<Uint8Array>(
|
|
86
|
+
{
|
|
87
|
+
start: (rawController) => {
|
|
88
|
+
controller = rawController;
|
|
89
|
+
},
|
|
90
|
+
pull: () => {
|
|
91
|
+
// With a one-frame high-water mark, pull after an enqueue means that
|
|
92
|
+
// frame has left the controller queue (either delivered to a pending
|
|
93
|
+
// read or consumed by the HTTP adapter). There is no hidden second frame.
|
|
94
|
+
queuedFrames = 0;
|
|
95
|
+
queuedBytes = 0;
|
|
96
|
+
wakeWriter();
|
|
97
|
+
},
|
|
98
|
+
cancel: () => {
|
|
99
|
+
if (stopped) return;
|
|
100
|
+
stopped = true;
|
|
101
|
+
wakeWriter();
|
|
102
|
+
options.onStop?.();
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
highWaterMark: SSE_QUEUED_FRAME_MAX_COUNT,
|
|
107
|
+
size: () => 1,
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
stream,
|
|
113
|
+
write: async (frame) => {
|
|
114
|
+
const chunk = encoder.encode(frame);
|
|
115
|
+
if (chunk.byteLength > maxQueuedBytes) {
|
|
116
|
+
const error = new RangeError(
|
|
117
|
+
`SSE frame cannot fit in the configured queue (${chunk.byteLength} > ${maxQueuedBytes} bytes)`,
|
|
118
|
+
);
|
|
119
|
+
options.onObservation?.({
|
|
120
|
+
reason: "frame_too_large",
|
|
121
|
+
desiredSize: controller.desiredSize,
|
|
122
|
+
queuedFrames,
|
|
123
|
+
queuedBytes,
|
|
124
|
+
});
|
|
125
|
+
stop(() => controller.error(error));
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
for (;;) {
|
|
129
|
+
if (stopped) return false;
|
|
130
|
+
const desired = controller.desiredSize;
|
|
131
|
+
if (desired === null) return false;
|
|
132
|
+
if (desired >= 1 && queuedFrames === 0) {
|
|
133
|
+
controller.enqueue(chunk);
|
|
134
|
+
queuedFrames = 1;
|
|
135
|
+
queuedBytes = chunk.byteLength;
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
options.onObservation?.({
|
|
139
|
+
reason: "desired_size_non_positive",
|
|
140
|
+
desiredSize: desired,
|
|
141
|
+
queuedFrames,
|
|
142
|
+
queuedBytes,
|
|
143
|
+
});
|
|
144
|
+
const outcome = await new Promise<"capacity" | "timeout">((resolve) => {
|
|
145
|
+
let settled = false;
|
|
146
|
+
const finish = (result: "capacity" | "timeout") => {
|
|
147
|
+
if (settled) return;
|
|
148
|
+
settled = true;
|
|
149
|
+
clearTimeout(timer);
|
|
150
|
+
if (capacityWake === wake) capacityWake = null;
|
|
151
|
+
resolve(result);
|
|
152
|
+
};
|
|
153
|
+
const wake = () => finish("capacity");
|
|
154
|
+
const timer = setTimeout(() => finish("timeout"), stallTimeoutMs);
|
|
155
|
+
capacityWake = wake;
|
|
156
|
+
});
|
|
157
|
+
if (outcome === "timeout" && !stopped) {
|
|
158
|
+
const error = new TypeError(
|
|
159
|
+
`SSE consumer did not drain the single-frame queue within ${stallTimeoutMs}ms`,
|
|
160
|
+
);
|
|
161
|
+
options.onObservation?.({
|
|
162
|
+
reason: "stall_timeout",
|
|
163
|
+
desiredSize: controller.desiredSize,
|
|
164
|
+
queuedFrames,
|
|
165
|
+
queuedBytes,
|
|
166
|
+
});
|
|
167
|
+
stop(() => controller.error(error));
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
close: () => stop(() => controller.close()),
|
|
173
|
+
fail: (error) => stop(() => controller.error(error)),
|
|
174
|
+
stopped: () => stopped,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export type LatestWinsDelivery<T extends { sequence: number }> = {
|
|
179
|
+
publish: (events: readonly T[]) => void;
|
|
180
|
+
stop: () => void;
|
|
181
|
+
whenIdle: () => Promise<void>;
|
|
182
|
+
pendingSequence: () => number | null;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Keep at most one live notification while an earlier notification is being
|
|
187
|
+
* delivered. The notification is only a cursor target: `send` gap-fills every
|
|
188
|
+
* missing durable event from Postgres, so replacing N intermediate notices with
|
|
189
|
+
* their newest sequence loses no event and prevents backpressure from migrating
|
|
190
|
+
* into the NATS subscription queue.
|
|
191
|
+
*/
|
|
192
|
+
export function createLatestWinsDelivery<T extends { sequence: number }>(
|
|
193
|
+
send: (event: T) => Promise<void>,
|
|
194
|
+
onError: (error: unknown) => void,
|
|
195
|
+
): LatestWinsDelivery<T> {
|
|
196
|
+
let newest: T | null = null;
|
|
197
|
+
let running: Promise<void> | null = null;
|
|
198
|
+
let stopped = false;
|
|
199
|
+
|
|
200
|
+
const start = () => {
|
|
201
|
+
if (stopped || running || !newest) return;
|
|
202
|
+
const run = async () => {
|
|
203
|
+
for (;;) {
|
|
204
|
+
if (stopped || !newest) return;
|
|
205
|
+
const target = newest;
|
|
206
|
+
newest = null;
|
|
207
|
+
await send(target);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
running = run()
|
|
211
|
+
.catch((error) => {
|
|
212
|
+
stopped = true;
|
|
213
|
+
newest = null;
|
|
214
|
+
onError(error);
|
|
215
|
+
})
|
|
216
|
+
.finally(() => {
|
|
217
|
+
running = null;
|
|
218
|
+
start();
|
|
219
|
+
});
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
publish: (events) => {
|
|
224
|
+
if (stopped) return;
|
|
225
|
+
for (const event of events) {
|
|
226
|
+
if (!newest || event.sequence > newest.sequence) newest = event;
|
|
227
|
+
}
|
|
228
|
+
start();
|
|
229
|
+
},
|
|
230
|
+
stop: () => {
|
|
231
|
+
stopped = true;
|
|
232
|
+
newest = null;
|
|
233
|
+
},
|
|
234
|
+
whenIdle: async () => {
|
|
235
|
+
for (;;) {
|
|
236
|
+
const pending = running;
|
|
237
|
+
if (!pending) return;
|
|
238
|
+
await pending;
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
pendingSequence: () => newest?.sequence ?? null,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
4
244
|
|
|
5
245
|
export async function sseSessionStream(
|
|
6
246
|
db: Database,
|
|
@@ -9,76 +249,137 @@ export async function sseSessionStream(
|
|
|
9
249
|
sessionId: string,
|
|
10
250
|
after: number,
|
|
11
251
|
signal: AbortSignal,
|
|
252
|
+
options: SessionSseDeliveryOptions = {},
|
|
12
253
|
): Promise<Response> {
|
|
13
|
-
|
|
14
|
-
|
|
254
|
+
if (
|
|
255
|
+
options.reauthorize &&
|
|
256
|
+
options.reauthorizeAfterMs !== undefined &&
|
|
257
|
+
(!Number.isSafeInteger(options.reauthorizeAfterMs) ||
|
|
258
|
+
options.reauthorizeAfterMs < 1_000 ||
|
|
259
|
+
options.reauthorizeAfterMs > 60_000)
|
|
260
|
+
) {
|
|
261
|
+
throw new RangeError("session stream reauthorization must be between 1000 and 60000ms");
|
|
262
|
+
}
|
|
15
263
|
let lastSent = after;
|
|
16
|
-
let
|
|
17
|
-
|
|
264
|
+
let bootstrapping = true;
|
|
265
|
+
let newestBuffered: SessionEvent | null = null;
|
|
18
266
|
let unsubscribe: (() => void) | null = null;
|
|
267
|
+
let delivery: LatestWinsDelivery<SessionEvent> | null = null;
|
|
268
|
+
let reauthorizationTimer: ReturnType<typeof setTimeout> | null = null;
|
|
269
|
+
let detachAbortListener = () => {};
|
|
270
|
+
const stopUpstream = () => {
|
|
271
|
+
detachAbortListener();
|
|
272
|
+
if (reauthorizationTimer) {
|
|
273
|
+
clearTimeout(reauthorizationTimer);
|
|
274
|
+
reauthorizationTimer = null;
|
|
275
|
+
}
|
|
276
|
+
delivery?.stop();
|
|
277
|
+
const release = unsubscribe;
|
|
278
|
+
unsubscribe = null;
|
|
279
|
+
release?.();
|
|
280
|
+
};
|
|
281
|
+
const channel = createByteBoundedSseStream({
|
|
282
|
+
maxQueuedBytes: options.maxQueuedBytes ?? SESSION_EVENT_SSE_FRAME_MAX_BYTES,
|
|
283
|
+
...(options.stallTimeoutMs === undefined ? {} : { stallTimeoutMs: options.stallTimeoutMs }),
|
|
284
|
+
onObservation: sseObservationReporter("session", options),
|
|
285
|
+
onStop: stopUpstream,
|
|
286
|
+
});
|
|
19
287
|
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
288
|
+
const fail = (error: unknown) => {
|
|
289
|
+
channel.fail(retryableSseFailure("session event stream delivery failed", error));
|
|
290
|
+
};
|
|
291
|
+
const scheduleReauthorization = () => {
|
|
292
|
+
if (!options.reauthorize || channel.stopped()) return;
|
|
293
|
+
const interval = options.reauthorizeAfterMs ?? 15_000;
|
|
294
|
+
reauthorizationTimer = setTimeout(() => {
|
|
295
|
+
reauthorizationTimer = null;
|
|
296
|
+
void options.reauthorize!()
|
|
297
|
+
.then(scheduleReauthorization)
|
|
298
|
+
.catch((error) => fail(error));
|
|
299
|
+
}, interval);
|
|
300
|
+
};
|
|
301
|
+
scheduleReauthorization();
|
|
302
|
+
const writeFrame = async (frame: string) => {
|
|
303
|
+
if (!(await channel.write(frame))) {
|
|
304
|
+
throw new SseStreamStoppedError();
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
const send = async (event: SessionEvent) => {
|
|
308
|
+
if (event.sequence <= lastSent) return;
|
|
309
|
+
if (event.sequence > lastSent + 1) {
|
|
310
|
+
while (lastSent + 1 < event.sequence) {
|
|
311
|
+
const previousLastSent = lastSent;
|
|
312
|
+
const missing = await listSessionEvents(db, workspaceId, sessionId, {
|
|
313
|
+
after: lastSent,
|
|
314
|
+
limit: Math.min(SESSION_REPLAY_PAGE_SIZE, event.sequence - lastSent - 1),
|
|
315
|
+
});
|
|
316
|
+
if (missing.length === 0) {
|
|
317
|
+
throw new Error(
|
|
318
|
+
`Session event replay stalled before sequence ${event.sequence}; last sent ${lastSent}`,
|
|
34
319
|
);
|
|
35
|
-
for (const missed of missing) {
|
|
36
|
-
if (missed.sequence > lastSent) {
|
|
37
|
-
controller.enqueue(encoder.encode(formatSse(missed)));
|
|
38
|
-
lastSent = missed.sequence;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
320
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
buffered.push(...events);
|
|
49
|
-
return;
|
|
321
|
+
for (const missed of missing) {
|
|
322
|
+
if (missed.sequence >= event.sequence) break;
|
|
323
|
+
if (missed.sequence > lastSent) {
|
|
324
|
+
await writeFrame(formatSessionEventSse(missed));
|
|
325
|
+
lastSent = missed.sequence;
|
|
326
|
+
}
|
|
50
327
|
}
|
|
51
|
-
|
|
52
|
-
|
|
328
|
+
if (lastSent === previousLastSent) {
|
|
329
|
+
throw new Error(
|
|
330
|
+
`Session event replay made no progress before sequence ${event.sequence}; last sent ${lastSent}`,
|
|
331
|
+
);
|
|
53
332
|
}
|
|
54
|
-
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
await writeFrame(formatSessionEventSse(event));
|
|
336
|
+
lastSent = event.sequence;
|
|
337
|
+
};
|
|
338
|
+
delivery = createLatestWinsDelivery(send, fail);
|
|
55
339
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
340
|
+
void (async () => {
|
|
341
|
+
const release = await bus.subscribe(workspaceId, sessionId, (events) => {
|
|
342
|
+
if (bootstrapping) {
|
|
343
|
+
for (const event of events) {
|
|
344
|
+
if (!newestBuffered || event.sequence > newestBuffered.sequence) {
|
|
345
|
+
newestBuffered = event;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
} else {
|
|
349
|
+
delivery?.publish(events);
|
|
64
350
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
351
|
+
});
|
|
352
|
+
if (channel.stopped()) {
|
|
353
|
+
release();
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
unsubscribe = release;
|
|
357
|
+
|
|
358
|
+
await replaySessionEvents(
|
|
359
|
+
(cursor, limit) => listSessionEvents(db, workspaceId, sessionId, cursor, limit),
|
|
360
|
+
send,
|
|
361
|
+
after,
|
|
362
|
+
SESSION_REPLAY_PAGE_SIZE,
|
|
363
|
+
);
|
|
364
|
+
await writeFrame(": connected\n\n");
|
|
365
|
+
bootstrapping = false;
|
|
366
|
+
const buffered = newestBuffered;
|
|
367
|
+
newestBuffered = null;
|
|
368
|
+
if (buffered) delivery.publish([buffered]);
|
|
369
|
+
})().catch((error) => {
|
|
370
|
+
if (!(error instanceof SseStreamStoppedError)) fail(error);
|
|
71
371
|
});
|
|
72
372
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
{ once: true }
|
|
79
|
-
|
|
373
|
+
const abort = () => {
|
|
374
|
+
channel.close();
|
|
375
|
+
};
|
|
376
|
+
if (signal.aborted) abort();
|
|
377
|
+
else {
|
|
378
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
379
|
+
detachAbortListener = () => signal.removeEventListener("abort", abort);
|
|
380
|
+
}
|
|
80
381
|
|
|
81
|
-
return new Response(stream, {
|
|
382
|
+
return new Response(channel.stream, {
|
|
82
383
|
headers: {
|
|
83
384
|
"Content-Type": "text/event-stream; charset=utf-8",
|
|
84
385
|
"Cache-Control": "no-cache, no-transform",
|
|
@@ -91,21 +392,28 @@ export async function replaySessionEvents(
|
|
|
91
392
|
loadPage: (after: number, limit: number) => Promise<SessionEvent[]>,
|
|
92
393
|
send: (event: SessionEvent) => Promise<void>,
|
|
93
394
|
after: number,
|
|
94
|
-
pageSize =
|
|
395
|
+
pageSize = SESSION_REPLAY_PAGE_SIZE,
|
|
95
396
|
): Promise<void> {
|
|
96
397
|
let cursor = after;
|
|
97
398
|
while (true) {
|
|
399
|
+
const previousCursor = cursor;
|
|
98
400
|
const page = await loadPage(cursor, pageSize);
|
|
99
401
|
if (page.length === 0) {
|
|
100
402
|
return;
|
|
101
403
|
}
|
|
102
404
|
for (const event of page.sort((a, b) => a.sequence - b.sequence)) {
|
|
405
|
+
if (event.sequence <= cursor) continue;
|
|
103
406
|
await send(event);
|
|
104
|
-
cursor =
|
|
407
|
+
cursor = event.sequence;
|
|
105
408
|
}
|
|
106
409
|
if (page.length < pageSize) {
|
|
107
410
|
return;
|
|
108
411
|
}
|
|
412
|
+
if (cursor === previousCursor) {
|
|
413
|
+
throw new Error(
|
|
414
|
+
`Session event replay made no progress after sequence ${cursor}; refusing to repeat a full stale page`,
|
|
415
|
+
);
|
|
416
|
+
}
|
|
109
417
|
}
|
|
110
418
|
}
|
|
111
419
|
|
|
@@ -115,47 +423,106 @@ export async function sseWorkspaceControlStream(
|
|
|
115
423
|
workspaceId: string,
|
|
116
424
|
after: number,
|
|
117
425
|
signal: AbortSignal,
|
|
426
|
+
options: SseDeliveryOptions = {},
|
|
118
427
|
): Promise<Response> {
|
|
119
|
-
const encoder = new TextEncoder();
|
|
120
428
|
let lastSent = after;
|
|
121
|
-
let
|
|
122
|
-
|
|
429
|
+
let bootstrapping = true;
|
|
430
|
+
let newestBuffered: WorkspaceControlEvent | null = null;
|
|
123
431
|
let unsubscribe: (() => void) | null = null;
|
|
432
|
+
let delivery: LatestWinsDelivery<WorkspaceControlEvent> | null = null;
|
|
433
|
+
let detachAbortListener = () => {};
|
|
434
|
+
const stopUpstream = () => {
|
|
435
|
+
detachAbortListener();
|
|
436
|
+
delivery?.stop();
|
|
437
|
+
const release = unsubscribe;
|
|
438
|
+
unsubscribe = null;
|
|
439
|
+
release?.();
|
|
440
|
+
};
|
|
441
|
+
const channel = createByteBoundedSseStream({
|
|
442
|
+
maxQueuedBytes: options.maxQueuedBytes ?? SESSION_EVENT_SSE_FRAME_MAX_BYTES,
|
|
443
|
+
...(options.stallTimeoutMs === undefined ? {} : { stallTimeoutMs: options.stallTimeoutMs }),
|
|
444
|
+
onObservation: sseObservationReporter("workspace_control", options),
|
|
445
|
+
onStop: stopUpstream,
|
|
446
|
+
});
|
|
124
447
|
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
448
|
+
const fail = (error: unknown) => {
|
|
449
|
+
channel.fail(retryableSseFailure("workspace control stream delivery failed", error));
|
|
450
|
+
};
|
|
451
|
+
const writeFrame = async (frame: string) => {
|
|
452
|
+
if (!(await channel.write(frame))) throw new SseStreamStoppedError();
|
|
453
|
+
};
|
|
454
|
+
const send = async (event: WorkspaceControlEvent) => {
|
|
455
|
+
if (event.sequence <= lastSent) return;
|
|
456
|
+
if (event.sequence > lastSent + 1) {
|
|
457
|
+
while (lastSent < event.sequence) {
|
|
458
|
+
const previousLastSent = lastSent;
|
|
459
|
+
const limit = Math.min(
|
|
460
|
+
WORKSPACE_CONTROL_REPLAY_PAGE_SIZE,
|
|
461
|
+
Math.max(1, event.sequence - lastSent),
|
|
462
|
+
);
|
|
463
|
+
const missing = await listWorkspaceControlEvents(db, workspaceId, lastSent, limit);
|
|
464
|
+
let reachedIncoming = false;
|
|
465
|
+
for (const missed of missing.sort((a, b) => a.sequence - b.sequence)) {
|
|
466
|
+
if (missed.sequence >= event.sequence) {
|
|
467
|
+
reachedIncoming = true;
|
|
468
|
+
break;
|
|
469
|
+
}
|
|
470
|
+
if (missed.sequence > lastSent) {
|
|
471
|
+
await writeFrame(formatWorkspaceControlEventSse(missed));
|
|
472
|
+
lastSent = missed.sequence;
|
|
473
|
+
}
|
|
137
474
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
send(event);
|
|
144
|
-
cursor = Math.max(cursor, event.sequence);
|
|
475
|
+
if (reachedIncoming || missing.length < limit) break;
|
|
476
|
+
if (lastSent === previousLastSent) {
|
|
477
|
+
throw new Error(
|
|
478
|
+
`Workspace control gap fill returned a full stale page before sequence ${event.sequence}; last sent ${lastSent}`,
|
|
479
|
+
);
|
|
145
480
|
}
|
|
146
|
-
if (page.length < 1000) break;
|
|
147
481
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
482
|
+
}
|
|
483
|
+
await writeFrame(formatWorkspaceControlEventSse(event));
|
|
484
|
+
lastSent = event.sequence;
|
|
485
|
+
};
|
|
486
|
+
delivery = createLatestWinsDelivery(send, fail);
|
|
487
|
+
|
|
488
|
+
void (async () => {
|
|
489
|
+
const release = await bus.subscribeWorkspaceControl(workspaceId, (event) => {
|
|
490
|
+
if (bootstrapping) {
|
|
491
|
+
if (!newestBuffered || event.sequence > newestBuffered.sequence) newestBuffered = event;
|
|
492
|
+
} else {
|
|
493
|
+
delivery?.publish([event]);
|
|
151
494
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
495
|
+
});
|
|
496
|
+
if (channel.stopped()) {
|
|
497
|
+
release();
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
unsubscribe = release;
|
|
501
|
+
await replayWorkspaceControlEvents(
|
|
502
|
+
(cursor, limit) => listWorkspaceControlEvents(db, workspaceId, cursor, limit),
|
|
503
|
+
send,
|
|
504
|
+
after,
|
|
505
|
+
WORKSPACE_CONTROL_REPLAY_PAGE_SIZE,
|
|
506
|
+
);
|
|
507
|
+
await writeFrame(": connected\n\n");
|
|
508
|
+
bootstrapping = false;
|
|
509
|
+
const buffered = newestBuffered;
|
|
510
|
+
newestBuffered = null;
|
|
511
|
+
if (buffered) delivery.publish([buffered]);
|
|
512
|
+
})().catch((error) => {
|
|
513
|
+
if (!(error instanceof SseStreamStoppedError)) fail(error);
|
|
156
514
|
});
|
|
157
|
-
|
|
158
|
-
|
|
515
|
+
|
|
516
|
+
const abort = () => {
|
|
517
|
+
channel.close();
|
|
518
|
+
};
|
|
519
|
+
if (signal.aborted) abort();
|
|
520
|
+
else {
|
|
521
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
522
|
+
detachAbortListener = () => signal.removeEventListener("abort", abort);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
return new Response(channel.stream, {
|
|
159
526
|
headers: {
|
|
160
527
|
"Content-Type": "text/event-stream; charset=utf-8",
|
|
161
528
|
"Cache-Control": "no-cache, no-transform",
|
|
@@ -163,3 +530,70 @@ export async function sseWorkspaceControlStream(
|
|
|
163
530
|
},
|
|
164
531
|
});
|
|
165
532
|
}
|
|
533
|
+
|
|
534
|
+
async function replayWorkspaceControlEvents(
|
|
535
|
+
loadPage: (after: number, limit: number) => Promise<WorkspaceControlEvent[]>,
|
|
536
|
+
send: (event: WorkspaceControlEvent) => Promise<void>,
|
|
537
|
+
after: number,
|
|
538
|
+
pageSize: number,
|
|
539
|
+
): Promise<void> {
|
|
540
|
+
let cursor = after;
|
|
541
|
+
while (true) {
|
|
542
|
+
const previousCursor = cursor;
|
|
543
|
+
const page = await loadPage(cursor, pageSize);
|
|
544
|
+
if (page.length === 0) return;
|
|
545
|
+
for (const event of page.sort((a, b) => a.sequence - b.sequence)) {
|
|
546
|
+
if (event.sequence <= cursor) continue;
|
|
547
|
+
await send(event);
|
|
548
|
+
cursor = event.sequence;
|
|
549
|
+
}
|
|
550
|
+
if (page.length < pageSize) return;
|
|
551
|
+
if (cursor === previousCursor) {
|
|
552
|
+
throw new Error(
|
|
553
|
+
`Workspace control replay made no progress after sequence ${cursor}; refusing to repeat a full stale page`,
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
class SseStreamStoppedError extends Error {}
|
|
560
|
+
|
|
561
|
+
export type SseDeliveryOptions = {
|
|
562
|
+
maxQueuedBytes?: number;
|
|
563
|
+
stallTimeoutMs?: number;
|
|
564
|
+
observability?: Observability | undefined;
|
|
565
|
+
onObservation?: ((observation: SseDeliveryBoundObservation) => void) | undefined;
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
export type SessionSseDeliveryOptions = SseDeliveryOptions & {
|
|
569
|
+
/** Host ACL re-check, run even while the event stream is otherwise idle. */
|
|
570
|
+
reauthorize?: (() => Promise<void>) | undefined;
|
|
571
|
+
reauthorizeAfterMs?: number | undefined;
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
function sseObservationReporter(
|
|
575
|
+
stream: "session" | "workspace_control",
|
|
576
|
+
options: SseDeliveryOptions,
|
|
577
|
+
): (observation: SseDeliveryBoundObservation) => void {
|
|
578
|
+
return (observation) => {
|
|
579
|
+
options.onObservation?.(observation);
|
|
580
|
+
options.observability?.incrementCounter({
|
|
581
|
+
name: "opengeni_sse_delivery_bound_events_total",
|
|
582
|
+
help: "SSE writes that encountered a configured queue, frame, or stall bound.",
|
|
583
|
+
labels: { stream, reason: observation.reason },
|
|
584
|
+
});
|
|
585
|
+
if (observation.reason !== "desired_size_non_positive") {
|
|
586
|
+
options.observability?.warn("SSE delivery terminated at a bounded stream seam", {
|
|
587
|
+
stream,
|
|
588
|
+
reason: observation.reason,
|
|
589
|
+
desiredSize: observation.desiredSize,
|
|
590
|
+
queuedFrames: observation.queuedFrames,
|
|
591
|
+
queuedBytes: observation.queuedBytes,
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function retryableSseFailure(message: string, error: unknown): TypeError {
|
|
598
|
+
return error instanceof TypeError ? error : new TypeError(message, { cause: error });
|
|
599
|
+
}
|