@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.
Files changed (70) hide show
  1. package/README.md +2 -2
  2. package/dist/api-renders-routes.d.ts +46 -5
  3. package/dist/api-renders-routes.d.ts.map +1 -1
  4. package/dist/api-renders-routes.js +98 -39
  5. package/dist/api-renders-stream-route.d.ts +2 -1
  6. package/dist/api-renders-stream-route.d.ts.map +1 -1
  7. package/dist/api-renders-stream-route.js +14 -2
  8. package/dist/build-mcp.d.ts +16 -0
  9. package/dist/build-mcp.d.ts.map +1 -1
  10. package/dist/build-mcp.js +82 -12
  11. package/dist/console-auth.d.ts +10 -10
  12. package/dist/console-auth.d.ts.map +1 -1
  13. package/dist/control-service.d.ts.map +1 -1
  14. package/dist/control-service.js +0 -3
  15. package/dist/first-seen.d.ts +14 -0
  16. package/dist/first-seen.d.ts.map +1 -0
  17. package/dist/first-seen.js +19 -0
  18. package/dist/ggui-session-channel/action-ingress.d.ts.map +1 -1
  19. package/dist/ggui-session-channel/action-ingress.js +96 -8
  20. package/dist/ggui-session-channel/channel-subscriptions.d.ts +0 -1
  21. package/dist/ggui-session-channel/channel-subscriptions.d.ts.map +1 -1
  22. package/dist/ggui-session-channel/channel-subscriptions.js +4 -1
  23. package/dist/ggui-session-channel/internal-types.d.ts +20 -12
  24. package/dist/ggui-session-channel/internal-types.d.ts.map +1 -1
  25. package/dist/ggui-session-channel/outbound.d.ts.map +1 -1
  26. package/dist/ggui-session-channel/outbound.js +18 -6
  27. package/dist/ggui-session-channel/stream-epoch.d.ts +61 -0
  28. package/dist/ggui-session-channel/stream-epoch.d.ts.map +1 -0
  29. package/dist/ggui-session-channel/stream-epoch.js +47 -0
  30. package/dist/ggui-session-channel/subscribe.d.ts +40 -53
  31. package/dist/ggui-session-channel/subscribe.d.ts.map +1 -1
  32. package/dist/ggui-session-channel/subscribe.js +288 -124
  33. package/dist/ggui-session-channel/subscriber-lifecycle.d.ts.map +1 -1
  34. package/dist/ggui-session-channel/subscriber-lifecycle.js +17 -7
  35. package/dist/ggui-session-channel.d.ts +43 -7
  36. package/dist/ggui-session-channel.d.ts.map +1 -1
  37. package/dist/ggui-session-channel.js +7 -1
  38. package/dist/index.d.ts +1 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +1 -1
  41. package/dist/instructions-presets.d.ts +22 -21
  42. package/dist/instructions-presets.d.ts.map +1 -1
  43. package/dist/instructions-presets.js +38 -38
  44. package/dist/llm-backed-negotiator.d.ts +4 -37
  45. package/dist/llm-backed-negotiator.d.ts.map +1 -1
  46. package/dist/llm-backed-negotiator.js +28 -210
  47. package/dist/mcp-apps-outbound.d.ts +40 -91
  48. package/dist/mcp-apps-outbound.d.ts.map +1 -1
  49. package/dist/mcp-apps-outbound.js +100 -15
  50. package/dist/mcp-endpoint-routes.d.ts +1 -0
  51. package/dist/mcp-endpoint-routes.d.ts.map +1 -1
  52. package/dist/mcp-endpoint-routes.js +38 -4
  53. package/dist/runtime-bundle-hash.d.ts +7 -0
  54. package/dist/runtime-bundle-hash.d.ts.map +1 -1
  55. package/dist/runtime-bundle-hash.js +8 -2
  56. package/dist/runtime-bundle-route.d.ts.map +1 -1
  57. package/dist/runtime-bundle-route.js +15 -0
  58. package/dist/server.d.ts +143 -85
  59. package/dist/server.d.ts.map +1 -1
  60. package/dist/server.js +224 -110
  61. package/dist/storage.d.ts +9 -1
  62. package/dist/storage.d.ts.map +1 -1
  63. package/dist/storage.js +4 -3
  64. package/dist/view-proof-gate.d.ts +96 -0
  65. package/dist/view-proof-gate.d.ts.map +1 -0
  66. package/dist/view-proof-gate.js +167 -0
  67. package/package.json +14 -15
  68. package/dist/render-read-gate.d.ts +0 -50
  69. package/dist/render-read-gate.d.ts.map +0 -1
  70. 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
- * reconnect-credential mint, identity-default appId resolution,
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 mintedSessionToken;
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
- // Mint the reconnect credential now — before create/observe
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
- ...(mintedSessionToken !== undefined ? { sessionToken: mintedSessionToken } : {}),
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 in single-threaded JS: the next few lines run
305
- // synchronously up to `register(sub)`, and fan-out's per-subscriber
306
- // `seq <= replayCompletedSeq` guard takes care of the window.
307
- const snapshotSeq = await deps.streamBuffer.currentSeq(stored.id);
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
- replayCompletedSeq: snapshotSeq,
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
- sink,
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
- deps.logger.info("render_channel_subscribed", {
351
- sessionId: stored.id,
352
- appId: stored.appId,
353
- identityKind: args.identity.identity.kind,
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
- // Pagination: a hasMore-loop over 100-event pages (the HTTP
393
- // route's default page size). Strict superset of the previous
394
- // single-page-100 WS behavior.
395
- //
396
- // Horizon gate: a cursor below the server's replay horizon OR
397
- // above `lastSequence` (stale from a different deployment) emits
398
- // an error frame with `code: 'REPLAY_HORIZON_PASSED'` and skips
399
- // the replay. The frame carries `resumeId` = lastSequence — a
400
- // dispatched id-bearing frame advances the browser's Last-Event-ID
401
- // to the fresh high-water mark, and the ack snapshot already
402
- // re-mounted state. Client recovery: re-mount from a fresh /state
403
- // read (WS) / the already-delivered ack (SSE).
404
- if (args.sinceSequence !== undefined) {
405
- const sinceSeq = args.sinceSequence;
406
- if (sinceSeq < 0 || !Number.isInteger(sinceSeq)) {
407
- sink.write({
408
- type: "error",
409
- payload: {
410
- code: "INVALID_SINCE_SEQUENCE",
411
- message: "sinceSequence must be a non-negative integer",
412
- },
413
- ...(args.requestId ? { requestId: args.requestId } : {}),
414
- });
415
- }
416
- else {
417
- let cursor = sinceSeq;
418
- let firstPage = true;
419
- for (;;) {
420
- const ledger = await deps.renderStore.listEventsSince(stored.id, cursor, 100);
421
- if (ledger === null) {
422
- // GguiSession disappeared between resolve and ledger read —
423
- // already handled by the broader error envelope path;
424
- // nothing to do here.
425
- break;
426
- }
427
- if (firstPage && (sinceSeq > ledger.lastSequence || sinceSeq < ledger.horizonSeq)) {
428
- sink.write({
429
- type: "error",
430
- payload: {
431
- code: "REPLAY_HORIZON_PASSED",
432
- message: `cursor ${sinceSeq} is outside replayable range [${ledger.horizonSeq}, ${ledger.lastSequence}]`,
433
- details: { currentSequence: ledger.lastSequence },
434
- },
435
- ...(args.requestId ? { requestId: args.requestId } : {}),
436
- }, { resumeId: String(ledger.lastSequence) });
437
- break;
438
- }
439
- firstPage = false;
440
- for (const event of ledger.events) {
441
- // GguiSessionEvent is now the wire-shape ledger primitive
442
- // (Wave 7 of flatten-render-identity, 2026-05-28); no
443
- // projection — emit the store's row directly.
444
- sink.write({ type: "render_event", payload: event }, { resumeId: String(event.seq) });
445
- cursor = event.seq;
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
- // Send replay frames AFTER the ack. Ordering by `seq` ASC — the
456
- // buffer returns them pre-sorted. Client sees ack(streamSeq=N) →
457
- // up to N replay `data` frames → live tail (seq > N). No explicit
458
- // "replay end" marker is needed; the client uses envelope.seq as
459
- // the single source of truth for ordering.
460
- if (replay) {
461
- for (const env of replay.envelopes) {
462
- sink.write({ type: "data", payload: env });
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;AAEH,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,CAiH5F"}
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 pump's first action is `await iter.next()`, which yields
39
- * control back to the event loop. This is what preserves the
40
- * subscribe-handler ordering invariant: ack → replay frames →
41
- * live frames. The replay-frame send loop completes synchronously
42
- * before the pump can ever send anything, regardless of fanout
43
- * timing.
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
- if (value.seq <= sub.replayCompletedSeq)
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?.();