@ggui-ai/mcp-server 0.24.0 → 0.26.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 +2 -2
- package/dist/api-renders-routes.d.ts +46 -5
- package/dist/api-renders-routes.d.ts.map +1 -1
- package/dist/api-renders-routes.js +98 -39
- package/dist/api-renders-stream-route.d.ts +2 -1
- package/dist/api-renders-stream-route.d.ts.map +1 -1
- package/dist/api-renders-stream-route.js +14 -2
- package/dist/build-mcp.d.ts +16 -0
- package/dist/build-mcp.d.ts.map +1 -1
- package/dist/build-mcp.js +82 -12
- package/dist/console-auth.d.ts +10 -10
- package/dist/console-auth.d.ts.map +1 -1
- package/dist/control-service.d.ts.map +1 -1
- package/dist/control-service.js +0 -3
- package/dist/first-seen.d.ts +14 -0
- package/dist/first-seen.d.ts.map +1 -0
- package/dist/first-seen.js +19 -0
- package/dist/ggui-session-channel/action-ingress.d.ts.map +1 -1
- package/dist/ggui-session-channel/action-ingress.js +96 -8
- package/dist/ggui-session-channel/channel-subscriptions.d.ts +0 -1
- package/dist/ggui-session-channel/channel-subscriptions.d.ts.map +1 -1
- package/dist/ggui-session-channel/channel-subscriptions.js +4 -1
- package/dist/ggui-session-channel/internal-types.d.ts +20 -12
- package/dist/ggui-session-channel/internal-types.d.ts.map +1 -1
- package/dist/ggui-session-channel/outbound.d.ts.map +1 -1
- package/dist/ggui-session-channel/outbound.js +18 -6
- package/dist/ggui-session-channel/stream-epoch.d.ts +61 -0
- package/dist/ggui-session-channel/stream-epoch.d.ts.map +1 -0
- package/dist/ggui-session-channel/stream-epoch.js +47 -0
- package/dist/ggui-session-channel/subscribe.d.ts +40 -53
- package/dist/ggui-session-channel/subscribe.d.ts.map +1 -1
- package/dist/ggui-session-channel/subscribe.js +288 -124
- package/dist/ggui-session-channel/subscriber-lifecycle.d.ts.map +1 -1
- package/dist/ggui-session-channel/subscriber-lifecycle.js +17 -7
- package/dist/ggui-session-channel.d.ts +43 -7
- package/dist/ggui-session-channel.d.ts.map +1 -1
- package/dist/ggui-session-channel.js +7 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/instructions-presets.d.ts +22 -21
- package/dist/instructions-presets.d.ts.map +1 -1
- package/dist/instructions-presets.js +38 -38
- package/dist/llm-backed-negotiator.d.ts +4 -37
- package/dist/llm-backed-negotiator.d.ts.map +1 -1
- package/dist/llm-backed-negotiator.js +28 -210
- package/dist/mcp-apps-outbound.d.ts +40 -91
- package/dist/mcp-apps-outbound.d.ts.map +1 -1
- package/dist/mcp-apps-outbound.js +100 -15
- package/dist/mcp-endpoint-routes.d.ts +1 -0
- package/dist/mcp-endpoint-routes.d.ts.map +1 -1
- package/dist/mcp-endpoint-routes.js +38 -4
- package/dist/runtime-bundle-hash.d.ts +7 -0
- package/dist/runtime-bundle-hash.d.ts.map +1 -1
- package/dist/runtime-bundle-hash.js +8 -2
- package/dist/runtime-bundle-route.d.ts.map +1 -1
- package/dist/runtime-bundle-route.js +15 -0
- package/dist/server.d.ts +143 -85
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +224 -110
- package/dist/storage.d.ts +9 -1
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +4 -3
- package/dist/view-proof-gate.d.ts +96 -0
- package/dist/view-proof-gate.d.ts.map +1 -0
- package/dist/view-proof-gate.js +167 -0
- package/package.json +14 -15
- package/dist/render-read-gate.d.ts +0 -50
- package/dist/render-read-gate.d.ts.map +0 -1
- package/dist/render-read-gate.js +0 -36
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Subscribe / credential-path family for the live channel — upgrade-
|
|
3
3
|
* time identity resolution across the three auth planes (bearer via
|
|
4
4
|
* the AuthAdapter, ws-token bootstrap, console cookie) and the
|
|
5
|
-
* `subscribe` handler itself: version handshake, bootstrap verify
|
|
6
|
-
*
|
|
5
|
+
* `subscribe` handler itself: version handshake, bootstrap verify,
|
|
6
|
+
* identity-default appId resolution,
|
|
7
7
|
* dev-mode render provisioning, replay, registration, and the ack.
|
|
8
8
|
*/
|
|
9
9
|
import { PROTOCOL_SCHEMA_VERSION, UPGRADE_REQUIRED } from "@ggui-ai/protocol";
|
|
@@ -23,6 +23,56 @@ const BOOTSTRAP_PENDING_MARKER = "__bootstrap_pending__";
|
|
|
23
23
|
function isBootstrapPendingIdentity(identity) {
|
|
24
24
|
return identity.identity.kind === "user" && identity.identity.userId === BOOTSTRAP_PENDING_MARKER;
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* A subscriber's sink as every writer other than the subscribe tail sees it
|
|
28
|
+
* (ggui#1525). Until {@link release}, frames are held in arrival order;
|
|
29
|
+
* `release` flushes them and passes every later write straight through.
|
|
30
|
+
*
|
|
31
|
+
* The tail writes the ack and the replay frames through the raw sink, and
|
|
32
|
+
* awaits the ledger read in between when `sinceSequence` is set. Meanwhile
|
|
33
|
+
* the subscriber is registered, so the live pump, the direct walks (a
|
|
34
|
+
* `render` or `props_update`, a `drain_ack`, a cross-replica
|
|
35
|
+
* `externalBroadcast`) and a source-fed `channel_payload` poll can already
|
|
36
|
+
* write to it. Held here, their frames reach the wire after the replay,
|
|
37
|
+
* which is the order the ack promises: ack → replay → live.
|
|
38
|
+
*
|
|
39
|
+
* One writer stays outside, by design: `channel_error` goes straight to
|
|
40
|
+
* the socket, both when it answers the client's own `channel_subscribe`
|
|
41
|
+
* and when a source poll reports `POLL_FAILED`. It carries no stream
|
|
42
|
+
* state, and neither can precede the release: a poll exists only after a
|
|
43
|
+
* processed `channel_subscribe`, which the per-socket inbound chain
|
|
44
|
+
* handles after the subscribe resolves.
|
|
45
|
+
*
|
|
46
|
+
* A read that stalls holds these frames for as long as it stalls; the
|
|
47
|
+
* store's own read timeout is what bounds it.
|
|
48
|
+
*/
|
|
49
|
+
function holdUntilReplayed(sink) {
|
|
50
|
+
let held = [];
|
|
51
|
+
return {
|
|
52
|
+
sink: {
|
|
53
|
+
isOpen: () => sink.isOpen(),
|
|
54
|
+
end: (reason) => sink.end(reason),
|
|
55
|
+
write: (frame, opts) => {
|
|
56
|
+
if (held !== undefined) {
|
|
57
|
+
held.push({ frame, opts });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
sink.write(frame, opts);
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
release: () => {
|
|
64
|
+
const queue = held;
|
|
65
|
+
held = undefined;
|
|
66
|
+
if (queue === undefined)
|
|
67
|
+
return;
|
|
68
|
+
for (const { frame, opts } of queue) {
|
|
69
|
+
if (!sink.isOpen())
|
|
70
|
+
return;
|
|
71
|
+
sink.write(frame, opts);
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
26
76
|
export function createSubscribeHandlers(deps) {
|
|
27
77
|
async function resolveIdentityFromUpgrade(req) {
|
|
28
78
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
@@ -166,7 +216,7 @@ export function createSubscribeHandlers(deps) {
|
|
|
166
216
|
// AuthAdapter identity — iframes don't carry bearer tokens.
|
|
167
217
|
// Mutually-exclusive on purpose.
|
|
168
218
|
let effectiveIdentity = identity;
|
|
169
|
-
let
|
|
219
|
+
let viaWsToken = false;
|
|
170
220
|
let tokenBoundAppId;
|
|
171
221
|
if (typeof payload.wsToken === "string" && payload.wsToken.length > 0) {
|
|
172
222
|
if (!deps.bootstrap) {
|
|
@@ -221,15 +271,53 @@ export function createSubscribeHandlers(deps) {
|
|
|
221
271
|
},
|
|
222
272
|
source: "apikey",
|
|
223
273
|
};
|
|
224
|
-
|
|
225
|
-
// work — so a downstream failure doesn't leave the client with
|
|
226
|
-
// no way to resume.
|
|
227
|
-
mintedSessionToken = deps.bootstrap.issueSessionToken(bound.sessionId, bound.appId);
|
|
274
|
+
viaWsToken = true;
|
|
228
275
|
deps.logger.info("render_channel_bootstrap_accepted", {
|
|
229
276
|
sessionId: bound.sessionId,
|
|
230
277
|
appId: bound.appId,
|
|
231
278
|
});
|
|
232
279
|
}
|
|
280
|
+
// A DECLARED app on the bearer path is a claim, not a binding
|
|
281
|
+
// (ggui#1480, SPEC §12.2). The wsToken and console-cookie paths
|
|
282
|
+
// above already refuse a declared appId their credential does not
|
|
283
|
+
// bind; the bearer path proves only an identity. When the declared
|
|
284
|
+
// app is not that identity's own, it MUST pass the deployment's
|
|
285
|
+
// per-app authorization — the same check the `/mcp` endpoint runs
|
|
286
|
+
// on a URL-addressed app — before it decides the app-scope gate or
|
|
287
|
+
// names a provisioned row's app below. Refused before any store
|
|
288
|
+
// read, with one fixed answer whatever the reason, so the refusal
|
|
289
|
+
// says nothing about whether the app or the session exists. A
|
|
290
|
+
// deployment that wires no authorization keeps today's posture,
|
|
291
|
+
// as the `/mcp` endpoint does for per-app routing without one.
|
|
292
|
+
//
|
|
293
|
+
// A credential whose source the deployment lists as per-app-only
|
|
294
|
+
// (ggui#1482) passes the same authorization on EVERY subscribe, its
|
|
295
|
+
// identity-default app included — omitting `appId`, or declaring
|
|
296
|
+
// the identity's own, must not skip the checks that authorization
|
|
297
|
+
// runs. The channel refuses to construct with such a list and no
|
|
298
|
+
// authorization, so this arm can never fail open.
|
|
299
|
+
if (tokenBoundAppId === undefined && cookieBound === undefined && deps.authorizeApp) {
|
|
300
|
+
const identityAppId = (deps.appIdFromIdentity ?? defaultAppIdFromIdentity)(effectiveIdentity);
|
|
301
|
+
const appToAuthorize = payload.appId ?? identityAppId;
|
|
302
|
+
const perAppOnly = deps.perAppOnlySources?.includes(effectiveIdentity.source) === true;
|
|
303
|
+
if (perAppOnly || appToAuthorize !== identityAppId) {
|
|
304
|
+
try {
|
|
305
|
+
await deps.authorizeApp(appToAuthorize, effectiveIdentity);
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
deps.logger.warn("render_channel_app_refused", {
|
|
309
|
+
sessionId: payload.sessionId,
|
|
310
|
+
appId: appToAuthorize,
|
|
311
|
+
declared: payload.appId !== undefined,
|
|
312
|
+
identityAppId,
|
|
313
|
+
source: effectiveIdentity.source,
|
|
314
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
315
|
+
});
|
|
316
|
+
deps.sendError(ws, "APP_MISMATCH", "This credential may not act on the app this subscribe resolves", message.requestId);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
233
321
|
// Identity-default appId resolution (SPEC §12.2): `payload.appId`
|
|
234
322
|
// is optional on the wire. Absent ⇒ resolve it from the
|
|
235
323
|
// connection's authenticated identity BEFORE any store work —
|
|
@@ -290,8 +378,13 @@ export function createSubscribeHandlers(deps) {
|
|
|
290
378
|
ws,
|
|
291
379
|
...(payload.sinceSequence !== undefined ? { sinceSequence: payload.sinceSequence } : {}),
|
|
292
380
|
...(payload.fromSeq !== undefined ? { fromSeq: payload.fromSeq } : {}),
|
|
381
|
+
...(typeof payload.fromEpoch === "string" && payload.fromEpoch !== "" ? { fromEpoch: payload.fromEpoch } : {}),
|
|
293
382
|
...(message.requestId ? { requestId: message.requestId } : {}),
|
|
294
|
-
...(
|
|
383
|
+
...(viaWsToken
|
|
384
|
+
? { credential: "ws_token" }
|
|
385
|
+
: cookieBound !== undefined
|
|
386
|
+
? { credential: "console_cookie" }
|
|
387
|
+
: {}),
|
|
295
388
|
});
|
|
296
389
|
}
|
|
297
390
|
async function completeSubscribe(args) {
|
|
@@ -301,10 +394,18 @@ export function createSubscribeHandlers(deps) {
|
|
|
301
394
|
// between here and registration gets seq > snapshotSeq, so the
|
|
302
395
|
// subscriber will receive it via live fan-out (not via replay).
|
|
303
396
|
//
|
|
304
|
-
// This is race-safe
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
|
|
397
|
+
// This is race-safe: a delivery landing between this read and
|
|
398
|
+
// `register(sub)` (the replay read below is awaited) is either in the
|
|
399
|
+
// replay or published to the fan-out iterator subscribed below, and
|
|
400
|
+
// the per-subscriber `seq <= replayCompletedSeq` guard drops the
|
|
401
|
+
// copies the replay already carries.
|
|
402
|
+
//
|
|
403
|
+
// The seq and the epoch it counts in are read at once when the buffer
|
|
404
|
+
// can (ggui#1531); a buffer that predates epochs gives the seq alone.
|
|
405
|
+
const snapshot = deps.streamBuffer.currentCursor !== undefined
|
|
406
|
+
? await deps.streamBuffer.currentCursor(stored.id)
|
|
407
|
+
: { seq: await deps.streamBuffer.currentSeq(stored.id) };
|
|
408
|
+
const snapshotSeq = snapshot.seq;
|
|
308
409
|
// Phase B: a render IS the addressable unit, so the active item
|
|
309
410
|
// is the resolved render's visible-bits surface itself.
|
|
310
411
|
const activeItem = stored.render;
|
|
@@ -319,8 +420,16 @@ export function createSubscribeHandlers(deps) {
|
|
|
319
420
|
const activeStreamSpec = activeItem.type !== "mcpApps" && activeItem.type !== "system"
|
|
320
421
|
? activeItem.streamSpec
|
|
321
422
|
: undefined;
|
|
423
|
+
//
|
|
424
|
+
// A resume whose cursor belongs to a counter that has since restarted
|
|
425
|
+
// (ggui#1531) replays everything retained, from 0, and reports the gap:
|
|
426
|
+
// its epoch differs from the session's, or, from a client that sent no
|
|
427
|
+
// epoch, its `fromSeq` is above anything the counter has assigned.
|
|
428
|
+
const counterRestarted = args.fromSeq !== undefined &&
|
|
429
|
+
((args.fromEpoch !== undefined && snapshot.epoch !== undefined && args.fromEpoch !== snapshot.epoch) ||
|
|
430
|
+
args.fromSeq > snapshotSeq);
|
|
322
431
|
const replay = args.fromSeq !== undefined
|
|
323
|
-
? await deps.streamBuffer.replay(stored.id, args.fromSeq, activeStreamSpec)
|
|
432
|
+
? await deps.streamBuffer.replay(stored.id, counterRestarted ? 0 : args.fromSeq, activeStreamSpec)
|
|
324
433
|
: await deps.streamBuffer.replay(stored.id, 0, undefined);
|
|
325
434
|
// Subscribe to the StreamFanout BEFORE constructing the Subscriber:
|
|
326
435
|
// the seam returns an AsyncIterable whose iterator we hand off; the
|
|
@@ -329,139 +438,194 @@ export function createSubscribeHandlers(deps) {
|
|
|
329
438
|
// point onward queues into our iterator — paired with the
|
|
330
439
|
// replayCompletedSeq cursor below, that's race-free.
|
|
331
440
|
const fanoutIter = deps.streamFanout.subscribe(stored.id)[Symbol.asyncIterator]();
|
|
441
|
+
const gate = holdUntilReplayed(sink);
|
|
332
442
|
const base = {
|
|
333
443
|
sessionId: stored.id,
|
|
334
444
|
appId: stored.appId,
|
|
335
445
|
identity: args.identity,
|
|
336
446
|
connectedAt: Date.now(),
|
|
337
|
-
|
|
447
|
+
replayCursor: { seq: snapshotSeq, epoch: snapshot.epoch },
|
|
338
448
|
iter: fanoutIter,
|
|
339
449
|
// Per-subscriber channel-subscribe tracker. Populated
|
|
340
450
|
// lazily by the `channel_subscribe` handler when the operator
|
|
341
451
|
// wired `streamWebSocketLocalTools`; stays empty otherwise
|
|
342
452
|
// (structurally so for SSE — no inbound channel).
|
|
343
453
|
channelSubs: new Map(),
|
|
344
|
-
|
|
454
|
+
// Every writer but this tail goes through the gate until the
|
|
455
|
+
// replay is on the wire (ggui#1525); the tail writes to `sink`.
|
|
456
|
+
sink: gate.sink,
|
|
345
457
|
};
|
|
346
458
|
const sub = args.transport === "ws"
|
|
347
459
|
? { ...base, transport: "ws", ws: args.ws }
|
|
348
460
|
: { ...base, transport: "sse" };
|
|
349
461
|
deps.register(sub);
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
transport: args.transport,
|
|
355
|
-
fromSeq: args.fromSeq,
|
|
356
|
-
snapshotSeq,
|
|
357
|
-
replayCount: replay?.envelopes.length ?? 0,
|
|
358
|
-
replayTruncated: replay?.truncated ?? false,
|
|
359
|
-
bootstrap: args.sessionToken !== undefined,
|
|
360
|
-
});
|
|
361
|
-
const ackPayload = {
|
|
362
|
-
sequence: stored.eventSequence,
|
|
363
|
-
timestamp: Date.now(),
|
|
364
|
-
session: stored.render,
|
|
365
|
-
streamSeq: snapshotSeq,
|
|
366
|
-
// Advertise the server's protocol version on every successful
|
|
367
|
-
// subscribe ack (SPEC §11.2.2). Clients whose
|
|
368
|
-
// CLIENT_SUPPORTED_VERSIONS doesn't contain this string surface
|
|
369
|
-
// UpgradeRequiredError to their caller; clients that don't wire
|
|
370
|
-
// the handshake ignore the field (legacy-pass-through).
|
|
371
|
-
serverVersion: PROTOCOL_SCHEMA_VERSION,
|
|
372
|
-
...(replay?.truncated ? { replayTruncated: true } : {}),
|
|
373
|
-
...(args.sessionToken !== undefined ? { sessionToken: args.sessionToken } : {}),
|
|
374
|
-
};
|
|
375
|
-
sink.write({
|
|
376
|
-
type: "ack",
|
|
377
|
-
payload: ackPayload,
|
|
378
|
-
...(args.requestId ? { requestId: args.requestId } : {}),
|
|
379
|
-
});
|
|
380
|
-
// R7 — GguiSessionEvent ledger replay. When `args.sinceSequence` is
|
|
381
|
-
// present, fetch events with `seq > sinceSequence` from the per-
|
|
382
|
-
// render ledger and emit each as a `render_event` wire frame
|
|
383
|
-
// BEFORE the per-channel stream-buffer replay. Consumers dispatch
|
|
384
|
-
// by `event.type` to fold the wire-frame-equivalent handler
|
|
385
|
-
// (render/props_update/etc.) — same cursor model as the HTTP
|
|
386
|
-
// `/api/sessions/:id/events?sinceSequence=N` endpoint.
|
|
387
|
-
//
|
|
388
|
-
// Each replay frame carries `resumeId` = the ledger seq — the SSE
|
|
389
|
-
// sink stamps it as the `id:` field so the browser's Last-Event-ID
|
|
390
|
-
// lands on the exact same cursor space; the WS sink ignores it.
|
|
462
|
+
// From here the subscriber is registered, so the live pump and the
|
|
463
|
+
// direct walks can write to it; the gate holds their frames until the
|
|
464
|
+
// ack and every replay frame below are written (ggui#1525), and the
|
|
465
|
+
// `finally` always releases them.
|
|
391
466
|
//
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
467
|
+
// A subscribe that throws past this point (a ledger read that fails)
|
|
468
|
+
// is not left registered (ggui#1528). The client may already hold the
|
|
469
|
+
// ack and would run on a partial replay with no signal, and on SSE
|
|
470
|
+
// nothing else would ever unregister it. So the subscriber is
|
|
471
|
+
// unregistered and its transport ended with `internal_error` (WS
|
|
472
|
+
// close 1011; the SSE stream ends), the client re-subscribes with a
|
|
473
|
+
// fresh replay, and the error still reaches the caller. Not
|
|
474
|
+
// `service_restart`: its 1012 invites an immediate reconnect, and a
|
|
475
|
+
// failure that repeats for one render (a row the store cannot read)
|
|
476
|
+
// would become a tight reconnect loop per open view.
|
|
477
|
+
try {
|
|
478
|
+
// The first ledger page is read BEFORE the ack (ggui#1528). It is the
|
|
479
|
+
// read that can fail for a row the store cannot read, and failing
|
|
480
|
+
// before the ack means the client gets no ack for a subscribe that
|
|
481
|
+
// could not complete: a client whose retry budget resets on an
|
|
482
|
+
// accepted ack then counts the attempt as failed and backs off to
|
|
483
|
+
// its cap, instead of reconnecting once a second forever. Only the
|
|
484
|
+
// first page: every SSE open carries `sinceSequence`, often 0, so
|
|
485
|
+
// reading every page first would hold a render's whole ledger in
|
|
486
|
+
// memory per open. Later pages stream after the ack as before.
|
|
487
|
+
const sinceSeqValid = args.sinceSequence !== undefined && Number.isInteger(args.sinceSequence) && args.sinceSequence >= 0
|
|
488
|
+
? args.sinceSequence
|
|
489
|
+
: undefined;
|
|
490
|
+
const firstLedgerPage = sinceSeqValid !== undefined ? await deps.renderStore.listEventsSince(stored.id, sinceSeqValid, 100) : undefined;
|
|
491
|
+
const resumeTruncated = args.fromSeq !== undefined && (replay?.truncated === true || counterRestarted);
|
|
492
|
+
deps.logger.info("render_channel_subscribed", {
|
|
493
|
+
sessionId: stored.id,
|
|
494
|
+
appId: stored.appId,
|
|
495
|
+
identityKind: args.identity.identity.kind,
|
|
496
|
+
transport: args.transport,
|
|
497
|
+
fromSeq: args.fromSeq,
|
|
498
|
+
snapshotSeq,
|
|
499
|
+
replayCount: replay?.envelopes.length ?? 0,
|
|
500
|
+
replayTruncated: resumeTruncated,
|
|
501
|
+
...(counterRestarted ? { counterRestarted: true } : {}),
|
|
502
|
+
// The credential the subscribe came on (ggui#1488): a wsToken, the
|
|
503
|
+
// console cookie, or the adapter's bearer source. The first two ride
|
|
504
|
+
// a synthetic identity whose own `source` would misreport them.
|
|
505
|
+
bootstrap: args.credential === "ws_token",
|
|
506
|
+
source: args.credential ?? args.identity.source,
|
|
507
|
+
});
|
|
508
|
+
const ackPayload = {
|
|
509
|
+
sequence: stored.eventSequence,
|
|
510
|
+
timestamp: Date.now(),
|
|
511
|
+
session: stored.render,
|
|
512
|
+
streamSeq: snapshotSeq,
|
|
513
|
+
// The epoch `streamSeq` counts in (ggui#1531), when the buffer has one.
|
|
514
|
+
...(snapshot.epoch !== undefined ? { streamEpoch: snapshot.epoch } : {}),
|
|
515
|
+
// Advertise the server's protocol version on every successful
|
|
516
|
+
// subscribe ack (SPEC §11.2.2). Clients whose
|
|
517
|
+
// CLIENT_SUPPORTED_VERSIONS doesn't contain this string surface
|
|
518
|
+
// UpgradeRequiredError to their caller; clients that don't wire
|
|
519
|
+
// the handshake ignore the field (legacy-pass-through).
|
|
520
|
+
serverVersion: PROTOCOL_SCHEMA_VERSION,
|
|
521
|
+
// Only a resume can have a gap: `replayTruncated` is "absent on
|
|
522
|
+
// fresh subscribes" (`AckPayload`). A fresh subscribe still walks the
|
|
523
|
+
// reserved channels from 0, and a buffer whose retention expired
|
|
524
|
+
// reports that walk truncated, which says nothing to this client.
|
|
525
|
+
...(resumeTruncated ? { replayTruncated: true } : {}),
|
|
526
|
+
};
|
|
527
|
+
sink.write({
|
|
528
|
+
type: "ack",
|
|
529
|
+
payload: ackPayload,
|
|
530
|
+
...(args.requestId ? { requestId: args.requestId } : {}),
|
|
531
|
+
});
|
|
532
|
+
// R7 — GguiSessionEvent ledger replay. When `args.sinceSequence` is
|
|
533
|
+
// present, fetch events with `seq > sinceSequence` from the per-
|
|
534
|
+
// render ledger and emit each as a `render_event` wire frame
|
|
535
|
+
// BEFORE the per-channel stream-buffer replay. Consumers dispatch
|
|
536
|
+
// by `event.type` to fold the wire-frame-equivalent handler
|
|
537
|
+
// (render/props_update/etc.) — same cursor model as the HTTP
|
|
538
|
+
// `/api/sessions/:id/events?sinceSequence=N` endpoint.
|
|
539
|
+
//
|
|
540
|
+
// Each replay frame carries `resumeId` = the ledger seq — the SSE
|
|
541
|
+
// sink stamps it as the `id:` field so the browser's Last-Event-ID
|
|
542
|
+
// lands on the exact same cursor space; the WS sink ignores it.
|
|
543
|
+
//
|
|
544
|
+
// Pagination: a hasMore-loop over 100-event pages (the HTTP
|
|
545
|
+
// route's default page size). Strict superset of the previous
|
|
546
|
+
// single-page-100 WS behavior.
|
|
547
|
+
//
|
|
548
|
+
// Horizon gate: a cursor below the server's replay horizon OR
|
|
549
|
+
// above `lastSequence` (stale from a different deployment) emits
|
|
550
|
+
// an error frame with `code: 'REPLAY_HORIZON_PASSED'` and skips
|
|
551
|
+
// the replay. The frame carries `resumeId` = lastSequence — a
|
|
552
|
+
// dispatched id-bearing frame advances the browser's Last-Event-ID
|
|
553
|
+
// to the fresh high-water mark, and the ack snapshot already
|
|
554
|
+
// re-mounted state. Client recovery: re-mount from a fresh /state
|
|
555
|
+
// read (WS) / the already-delivered ack (SSE).
|
|
556
|
+
if (args.sinceSequence !== undefined) {
|
|
557
|
+
if (sinceSeqValid === undefined) {
|
|
558
|
+
sink.write({
|
|
559
|
+
type: "error",
|
|
560
|
+
payload: {
|
|
561
|
+
code: "INVALID_SINCE_SEQUENCE",
|
|
562
|
+
message: "sinceSequence must be a non-negative integer",
|
|
563
|
+
},
|
|
564
|
+
...(args.requestId ? { requestId: args.requestId } : {}),
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
else {
|
|
568
|
+
const sinceSeq = sinceSeqValid;
|
|
569
|
+
let cursor = sinceSeq;
|
|
570
|
+
let firstPage = true;
|
|
571
|
+
for (;;) {
|
|
572
|
+
// The first page was read before the ack; later pages are read here.
|
|
573
|
+
const ledger = firstPage
|
|
574
|
+
? firstLedgerPage
|
|
575
|
+
: await deps.renderStore.listEventsSince(stored.id, cursor, 100);
|
|
576
|
+
if (ledger === null || ledger === undefined) {
|
|
577
|
+
// GguiSession disappeared between resolve and ledger read —
|
|
578
|
+
// already handled by the broader error envelope path;
|
|
579
|
+
// nothing to do here.
|
|
580
|
+
break;
|
|
581
|
+
}
|
|
582
|
+
if (firstPage && (sinceSeq > ledger.lastSequence || sinceSeq < ledger.horizonSeq)) {
|
|
583
|
+
sink.write({
|
|
584
|
+
type: "error",
|
|
585
|
+
payload: {
|
|
586
|
+
code: "REPLAY_HORIZON_PASSED",
|
|
587
|
+
message: `cursor ${sinceSeq} is outside replayable range [${ledger.horizonSeq}, ${ledger.lastSequence}]`,
|
|
588
|
+
details: { currentSequence: ledger.lastSequence },
|
|
589
|
+
},
|
|
590
|
+
...(args.requestId ? { requestId: args.requestId } : {}),
|
|
591
|
+
}, { resumeId: String(ledger.lastSequence) });
|
|
592
|
+
break;
|
|
593
|
+
}
|
|
594
|
+
firstPage = false;
|
|
595
|
+
for (const event of ledger.events) {
|
|
596
|
+
// GguiSessionEvent is now the wire-shape ledger primitive
|
|
597
|
+
// (Wave 7 of flatten-render-identity, 2026-05-28); no
|
|
598
|
+
// projection — emit the store's row directly.
|
|
599
|
+
sink.write({ type: "render_event", payload: event }, { resumeId: String(event.seq) });
|
|
600
|
+
cursor = event.seq;
|
|
601
|
+
}
|
|
602
|
+
// Defensive: an empty page with hasMore would loop forever;
|
|
603
|
+
// the store contract never produces it, but a broken adapter
|
|
604
|
+
// must not spin the server.
|
|
605
|
+
if (!ledger.hasMore || ledger.events.length === 0)
|
|
606
|
+
break;
|
|
446
607
|
}
|
|
447
|
-
// Defensive: an empty page with hasMore would loop forever;
|
|
448
|
-
// the store contract never produces it, but a broken adapter
|
|
449
|
-
// must not spin the server.
|
|
450
|
-
if (!ledger.hasMore || ledger.events.length === 0)
|
|
451
|
-
break;
|
|
452
608
|
}
|
|
453
609
|
}
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
610
|
+
// Send replay frames AFTER the ack. Ordering by `seq` ASC — the
|
|
611
|
+
// buffer returns them pre-sorted. Client sees ack(streamSeq=N) →
|
|
612
|
+
// up to N replay `data` frames → live tail (seq > N). No explicit
|
|
613
|
+
// "replay end" marker is needed; the client uses envelope.seq as
|
|
614
|
+
// the single source of truth for ordering.
|
|
615
|
+
if (replay) {
|
|
616
|
+
for (const env of replay.envelopes) {
|
|
617
|
+
sink.write({ type: "data", payload: env });
|
|
618
|
+
}
|
|
463
619
|
}
|
|
464
620
|
}
|
|
621
|
+
catch (err) {
|
|
622
|
+
deps.unregister(sub);
|
|
623
|
+
sink.end("internal_error");
|
|
624
|
+
throw err;
|
|
625
|
+
}
|
|
626
|
+
finally {
|
|
627
|
+
gate.release();
|
|
628
|
+
}
|
|
465
629
|
return {
|
|
466
630
|
detach: () => {
|
|
467
631
|
deps.unregister(sub);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"subscriber-lifecycle.d.ts","sourceRoot":"","sources":["../../src/ggui-session-channel/subscriber-lifecycle.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;
|
|
1
|
+
{"version":3,"file":"subscriber-lifecycle.d.ts","sourceRoot":"","sources":["../../src/ggui-session-channel/subscriber-lifecycle.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAGH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,IAAI,CAAC;AACpC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAC3C,OAAO,KAAK,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAEpE,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,2EAA2E;IAC3E,QAAQ,CAAC,aAAa,EAAE,GAAG,CAAC,UAAU,CAAC,CAAC;IACxC;;;;OAIG;IACH,QAAQ,CAAC,eAAe,EAAE,OAAO,CAAC,SAAS,EAAE,YAAY,CAAC,CAAC;IAC3D,+EAA+E;IAC/E,QAAQ,CAAC,iBAAiB,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;IACzD,kFAAkF;IAClF,QAAQ,CAAC,oBAAoB,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;CAC7D;AAED,MAAM,WAAW,mBAAmB;IAClC,gEAAgE;IAChE,QAAQ,CAAC,GAAG,EAAE,UAAU,GAAG,IAAI,CAAC;IAChC;;;;;OAKG;IACH,UAAU,CAAC,GAAG,EAAE,UAAU,GAAG,IAAI,CAAC;CACnC;AAED,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,uBAAuB,GAAG,mBAAmB,CA0H5F"}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* hosted cross-replica pubsub scoping keys on "does this replica hold any
|
|
18
18
|
* subscriber for this render", regardless of transport.
|
|
19
19
|
*/
|
|
20
|
+
import { admitLiveEnvelope } from './stream-epoch.js';
|
|
20
21
|
export function createSubscriberLifecycle(deps) {
|
|
21
22
|
/**
|
|
22
23
|
* Per-render local subscriber count. Drives the
|
|
@@ -35,12 +36,12 @@ export function createSubscriberLifecycle(deps) {
|
|
|
35
36
|
* frames with `seq <= replayCompletedSeq` were (or will be)
|
|
36
37
|
* delivered via the replay path on subscribe.
|
|
37
38
|
*
|
|
38
|
-
* The
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
39
|
+
* The ordering invariant ack → replay frames → live frames is kept by
|
|
40
|
+
* the subscribe tail, not by timing: `sub.sink` is gated until the
|
|
41
|
+
* tail has written the ack and every replay frame, so whatever the
|
|
42
|
+
* pump writes meanwhile is held and flushed after them. The tail is
|
|
43
|
+
* NOT synchronous when `sinceSequence` is set (it awaits the ledger
|
|
44
|
+
* read), which is why the gate exists (ggui#1525).
|
|
44
45
|
*/
|
|
45
46
|
async function pumpSubscriber(sub) {
|
|
46
47
|
try {
|
|
@@ -48,7 +49,16 @@ export function createSubscriberLifecycle(deps) {
|
|
|
48
49
|
const { value, done } = await sub.iter.next();
|
|
49
50
|
if (done)
|
|
50
51
|
return;
|
|
51
|
-
|
|
52
|
+
const admission = admitLiveEnvelope(sub.replayCursor, value);
|
|
53
|
+
if (admission.epochChanged !== undefined) {
|
|
54
|
+
deps.logger.info("stream_epoch_changed", {
|
|
55
|
+
sessionId: sub.sessionId,
|
|
56
|
+
from: admission.epochChanged.from,
|
|
57
|
+
to: admission.epochChanged.to,
|
|
58
|
+
transport: sub.transport,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
if (!admission.deliver)
|
|
52
62
|
continue;
|
|
53
63
|
if (!sub.sink.isOpen()) {
|
|
54
64
|
await sub.iter.return?.();
|