@ggui-ai/iframe-runtime 0.8.0 → 0.9.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/dist/call-tool-unwrap.d.ts +29 -0
- package/dist/call-tool-unwrap.js +56 -0
- package/dist/channels/props-update.d.ts +19 -0
- package/dist/channels/props-update.js +14 -1
- package/dist/events-polling.d.ts +123 -1
- package/dist/events-polling.js +181 -53
- package/dist/iframe-runtime.js +63 -63
- package/dist/iframe-runtime.meta.json +1 -1
- package/dist/mcp-app-iframe-host.d.ts +1 -1
- package/dist/meta-parse.js +13 -0
- package/dist/observability.d.ts +1 -1
- package/dist/provisional-renderer.d.ts +1 -1
- package/dist/provisional-renderer.js +1 -1
- package/dist/react-renderer.d.ts +1 -1
- package/dist/react-renderer.js +40 -12
- package/dist/registry-subscribe.d.ts +42 -3
- package/dist/registry-subscribe.js +33 -10
- package/dist/render-item.js +1 -1
- package/dist/runtime-telemetry.d.ts +53 -0
- package/dist/runtime-telemetry.js +74 -0
- package/dist/runtime.js +325 -70
- package/dist/system-cards/UiFeedbackCard.d.ts +1 -1
- package/dist/system-cards/UiFeedbackCard.js +1 -1
- package/dist/ui-feedback-chrome.js +7 -4
- package/package.json +7 -7
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared 3-tier `CallToolResult` payload unwrap.
|
|
3
|
+
*
|
|
4
|
+
* MCP hosts relay `tools/call` results to the iframe in three observed
|
|
5
|
+
* envelope shapes. The tiers, in the order probed:
|
|
6
|
+
*
|
|
7
|
+
* 1. `structuredContent` — spec-canonical hosts.
|
|
8
|
+
* 2. `content[0].text` parsed as JSON — relay hosts that NORMALIZE
|
|
9
|
+
* the CallToolResult down to its text block (claude.ai's live
|
|
10
|
+
* behavior on the #471 retest: the enqueue succeeded server-side,
|
|
11
|
+
* but the response arrived text-only and classified as a failure,
|
|
12
|
+
* so no doorbell rang).
|
|
13
|
+
* 3. Fields directly on the bare result — looser hosts.
|
|
14
|
+
*
|
|
15
|
+
* Shared by the submit-action response classifier (`runtime.ts`'s
|
|
16
|
+
* `submitActionPayload`) and the bridge-pull rung's `fetchBody`
|
|
17
|
+
* carrier (`events-polling.ts`'s `buildBridgePolling`), so both reads
|
|
18
|
+
* agree on which envelope tier carries the payload for a given host
|
|
19
|
+
* shape.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Unwrap the payload record of a relayed `tools/call` result. `result`
|
|
23
|
+
* is the CallToolResult-level value (i.e. the JSON-RPC envelope's
|
|
24
|
+
* `result` field, already extracted by the caller).
|
|
25
|
+
*
|
|
26
|
+
* Returns `null` when no tier yields an object — the caller treats
|
|
27
|
+
* that as fallback/unknown.
|
|
28
|
+
*/
|
|
29
|
+
export declare function unwrapCallToolResult(result: unknown): Record<string, unknown> | null;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared 3-tier `CallToolResult` payload unwrap.
|
|
3
|
+
*
|
|
4
|
+
* MCP hosts relay `tools/call` results to the iframe in three observed
|
|
5
|
+
* envelope shapes. The tiers, in the order probed:
|
|
6
|
+
*
|
|
7
|
+
* 1. `structuredContent` — spec-canonical hosts.
|
|
8
|
+
* 2. `content[0].text` parsed as JSON — relay hosts that NORMALIZE
|
|
9
|
+
* the CallToolResult down to its text block (claude.ai's live
|
|
10
|
+
* behavior on the #471 retest: the enqueue succeeded server-side,
|
|
11
|
+
* but the response arrived text-only and classified as a failure,
|
|
12
|
+
* so no doorbell rang).
|
|
13
|
+
* 3. Fields directly on the bare result — looser hosts.
|
|
14
|
+
*
|
|
15
|
+
* Shared by the submit-action response classifier (`runtime.ts`'s
|
|
16
|
+
* `submitActionPayload`) and the bridge-pull rung's `fetchBody`
|
|
17
|
+
* carrier (`events-polling.ts`'s `buildBridgePolling`), so both reads
|
|
18
|
+
* agree on which envelope tier carries the payload for a given host
|
|
19
|
+
* shape.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Unwrap the payload record of a relayed `tools/call` result. `result`
|
|
23
|
+
* is the CallToolResult-level value (i.e. the JSON-RPC envelope's
|
|
24
|
+
* `result` field, already extracted by the caller).
|
|
25
|
+
*
|
|
26
|
+
* Returns `null` when no tier yields an object — the caller treats
|
|
27
|
+
* that as fallback/unknown.
|
|
28
|
+
*/
|
|
29
|
+
export function unwrapCallToolResult(result) {
|
|
30
|
+
if (result === null || typeof result !== 'object')
|
|
31
|
+
return null;
|
|
32
|
+
const r = result;
|
|
33
|
+
if (r.structuredContent && typeof r.structuredContent === 'object') {
|
|
34
|
+
return r.structuredContent;
|
|
35
|
+
}
|
|
36
|
+
// Text-block tier BEFORE the bare-result tier when content exists:
|
|
37
|
+
// a normalized result's own top level carries only `content`, so
|
|
38
|
+
// probing it for payload fields would always miss anyway — but a
|
|
39
|
+
// parseable text block is decisive evidence of the real payload.
|
|
40
|
+
const content = r.content;
|
|
41
|
+
if (Array.isArray(content) && content.length > 0) {
|
|
42
|
+
const first = content[0];
|
|
43
|
+
if (first && first.type === 'text' && typeof first.text === 'string') {
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(first.text);
|
|
46
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
47
|
+
return parsed;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// Not JSON — fall through to the bare-result tier.
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return r;
|
|
56
|
+
}
|
|
@@ -45,5 +45,24 @@ export interface PropsUpdateHandlerDeps {
|
|
|
45
45
|
* the render-frame handler so React updates flow through one path.
|
|
46
46
|
*/
|
|
47
47
|
readonly applyRender: (render: GguiSession | GguiSessionSeedInput) => Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* This mount's OWN history epoch (#483 freeze latch). A
|
|
50
|
+
* `props_update` frame carrying a HIGHER epoch means an
|
|
51
|
+
* `ggui_update` minted a newer card and this one is now history —
|
|
52
|
+
* the handler hands off to {@link onSuperseded} instead of applying.
|
|
53
|
+
* Absent ⇒ 0 (a fresh render).
|
|
54
|
+
*/
|
|
55
|
+
readonly getSelfEpoch?: () => number;
|
|
56
|
+
/**
|
|
57
|
+
* Invoked (once) when a higher-epoch update supersedes this mount.
|
|
58
|
+
* The runtime freezes the frame: stop applying, tear down
|
|
59
|
+
* subscriptions, disable dispatch, show the superseded cue.
|
|
60
|
+
*/
|
|
61
|
+
readonly onSuperseded?: () => void;
|
|
62
|
+
/**
|
|
63
|
+
* True after this mount has frozen. When true the handler drops
|
|
64
|
+
* every frame — a superseded mount never repaints again.
|
|
65
|
+
*/
|
|
66
|
+
readonly isSuperseded?: () => boolean;
|
|
48
67
|
}
|
|
49
68
|
export declare function createPropsUpdateHandler(deps: PropsUpdateHandlerDeps): ChannelHandler<PropsUpdatePayload>;
|
|
@@ -33,9 +33,22 @@ export function createPropsUpdateHandler(deps) {
|
|
|
33
33
|
return {
|
|
34
34
|
type: 'props_update',
|
|
35
35
|
onMessage: async (payload) => {
|
|
36
|
-
|
|
36
|
+
// Already frozen (#483): a superseded mount never repaints again.
|
|
37
|
+
if (deps.isSuperseded?.() === true)
|
|
38
|
+
return;
|
|
39
|
+
const { sessionId, props, epoch } = payload;
|
|
37
40
|
if (typeof sessionId !== 'string' || sessionId.length === 0)
|
|
38
41
|
return;
|
|
42
|
+
// Freeze latch (#483): a frame stamped with a HIGHER epoch than
|
|
43
|
+
// this mount's own means a `ggui_update` minted a newer card —
|
|
44
|
+
// this one is history. Do NOT apply; hand off to the freeze
|
|
45
|
+
// routine. (`ggui_amend` carries the unchanged head epoch, so
|
|
46
|
+
// amends to the live head still apply.)
|
|
47
|
+
const selfEpoch = deps.getSelfEpoch?.() ?? 0;
|
|
48
|
+
if (typeof epoch === 'number' && epoch > selfEpoch) {
|
|
49
|
+
deps.onSuperseded?.();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
39
52
|
if (props === null || typeof props !== 'object')
|
|
40
53
|
return;
|
|
41
54
|
const current = deps.getCurrentGguiSession();
|
package/dist/events-polling.d.ts
CHANGED
|
@@ -22,6 +22,15 @@
|
|
|
22
22
|
* only sees the body, so we emit a synthetic `error` frame for the
|
|
23
23
|
* registry to surface upward.
|
|
24
24
|
*
|
|
25
|
+
* # Bridge-pull carrier (terminal rung)
|
|
26
|
+
*
|
|
27
|
+
* `buildBridgePolling` composes the SAME algorithm on the `fetchBody`
|
|
28
|
+
* carrier: a CSP-jailed MCP-Apps iframe with no network path pulls the
|
|
29
|
+
* same ledger by issuing `ggui_runtime_pull` `tools/call`s over the
|
|
30
|
+
* host's postMessage bridge. One parse core
|
|
31
|
+
* (`createEventsSnapshotParser`), two carriers — the tool's output is
|
|
32
|
+
* byte-parity with the `/events` route by contract.
|
|
33
|
+
*
|
|
25
34
|
* # Anthropic first-mount race fix
|
|
26
35
|
*
|
|
27
36
|
* Iframes mounted inside the Anthropic SDK lose `__GGUI_META__` because
|
|
@@ -39,6 +48,39 @@
|
|
|
39
48
|
* fallback, and live updates.
|
|
40
49
|
*/
|
|
41
50
|
import type { RegistryPollingOptions } from '@ggui-ai/live-channel';
|
|
51
|
+
import type { GguiRuntimePullInput } from '@ggui-ai/protocol';
|
|
52
|
+
/**
|
|
53
|
+
* Shared, monotonic event-ledger cursor for the failover ladder.
|
|
54
|
+
*
|
|
55
|
+
* The SSE rung advances it via `RegistrySseOptions.onSequence` (the
|
|
56
|
+
* SSE `id:` field IS the ledger sequence); the polling descriptor
|
|
57
|
+
* reads it per tick — so an SSE→polling demotion resumes from the
|
|
58
|
+
* last streamed event instead of re-replaying from the boot snapshot.
|
|
59
|
+
*/
|
|
60
|
+
export interface SequenceCursor {
|
|
61
|
+
get(): number;
|
|
62
|
+
/**
|
|
63
|
+
* Monotonic max — no-op when `seq <= current`, guaranteeing a stale
|
|
64
|
+
* rung can never rewind progress another rung already made. Normal
|
|
65
|
+
* delivery path (SSE `id:`, polling `lastSequence`).
|
|
66
|
+
*/
|
|
67
|
+
advance(seq: number): void;
|
|
68
|
+
/**
|
|
69
|
+
* Unconditional server-truth override — the REPLAY_HORIZON_PASSED
|
|
70
|
+
* escape hatch, and ONLY that. When the server declares the cursor
|
|
71
|
+
* outside the replayable window it reports its actual high-water
|
|
72
|
+
* mark, which can be BELOW the client cursor (a re-minted or reset
|
|
73
|
+
* session whose ledger restarted). A monotonic `advance` would no-op
|
|
74
|
+
* there and every subsequent tick would re-ask ahead of the horizon —
|
|
75
|
+
* a permanent error loop. `reset` adopts the server's number so the
|
|
76
|
+
* next tick lands inside the window and the ladder self-heals.
|
|
77
|
+
*/
|
|
78
|
+
reset(seq: number): void;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Create a {@link SequenceCursor} seeded at `seed` (default `0`).
|
|
82
|
+
*/
|
|
83
|
+
export declare function createSequenceCursor(seed?: number): SequenceCursor;
|
|
42
84
|
export interface BuildEventsPollingOptions {
|
|
43
85
|
/**
|
|
44
86
|
* Base URL the polling tick reads from. The composer appends
|
|
@@ -53,7 +95,9 @@ export interface BuildEventsPollingOptions {
|
|
|
53
95
|
* Optional cursor seed — initial value of `sinceSequence` on the
|
|
54
96
|
* first tick. Typically threaded from `render.lastSequence` so the
|
|
55
97
|
* cold-mount-after-WS-fail path picks up where the snapshot left
|
|
56
|
-
* off. Defaults to `0` (replay everything still retained).
|
|
98
|
+
* off. Defaults to `0` (replay everything still retained). Ignored
|
|
99
|
+
* when {@link cursor} is supplied — the shared cursor carries its
|
|
100
|
+
* own seed.
|
|
57
101
|
*/
|
|
58
102
|
readonly initialSinceSequence?: number;
|
|
59
103
|
/**
|
|
@@ -67,6 +111,14 @@ export interface BuildEventsPollingOptions {
|
|
|
67
111
|
* single tick to keep latency bounded.
|
|
68
112
|
*/
|
|
69
113
|
readonly limit?: number;
|
|
114
|
+
/**
|
|
115
|
+
* Shared ladder cursor (WS → SSE → polling). When supplied, the
|
|
116
|
+
* descriptor reads/advances it instead of a private closure cursor,
|
|
117
|
+
* so SSE deliveries observed via `RegistrySseOptions.onSequence`
|
|
118
|
+
* move the polling replay point forward. Absent → private internal
|
|
119
|
+
* cursor seeded from {@link initialSinceSequence} (today's behavior).
|
|
120
|
+
*/
|
|
121
|
+
readonly cursor?: SequenceCursor;
|
|
70
122
|
}
|
|
71
123
|
/**
|
|
72
124
|
* Build a {@link RegistryPollingOptions} descriptor that reads
|
|
@@ -93,3 +145,73 @@ export interface BuildEventsPollingOptions {
|
|
|
93
145
|
* pulls the live cursor and composes the URL anew.
|
|
94
146
|
*/
|
|
95
147
|
export declare function buildEventsPolling(opts: BuildEventsPollingOptions): RegistryPollingOptions;
|
|
148
|
+
export interface BuildBridgePollingOptions {
|
|
149
|
+
/**
|
|
150
|
+
* Host `tools/call` dispatcher the tick pulls through. The runtime
|
|
151
|
+
* binds this to `app.callServerTool` on the connected MCP-Apps App
|
|
152
|
+
* handle — in a CSP-jailed host the postMessage bridge is the only
|
|
153
|
+
* carrier the iframe has, which is why this rung exists. The
|
|
154
|
+
* resolved value is the raw `CallToolResult`; the tick unwraps it
|
|
155
|
+
* via the shared 3-tier {@link unwrapCallToolResult} (the same
|
|
156
|
+
* unwrap submit-action responses use, so host-normalization
|
|
157
|
+
* quirks — claude.ai's text-only collapse included — are handled
|
|
158
|
+
* identically on both paths).
|
|
159
|
+
*/
|
|
160
|
+
readonly callTool: (name: 'ggui_runtime_pull', args: GguiRuntimePullInput) => Promise<unknown>;
|
|
161
|
+
/** Active render id — threaded verbatim as the tool's `sessionId`. */
|
|
162
|
+
readonly sessionId: string;
|
|
163
|
+
/**
|
|
164
|
+
* Shared ladder cursor (WS → SSE → polling → bridge). REQUIRED —
|
|
165
|
+
* unlike {@link BuildEventsPollingOptions.cursor} there is no
|
|
166
|
+
* private-cursor fallback, because the bridge rung never exists
|
|
167
|
+
* alone: the runtime always creates the ladder cursor when it
|
|
168
|
+
* composes this rung, and a demotion into the bridge must resume
|
|
169
|
+
* from whatever the rungs above already delivered.
|
|
170
|
+
*/
|
|
171
|
+
readonly cursor: SequenceCursor;
|
|
172
|
+
/**
|
|
173
|
+
* Failure-pacing interval. Defaults to 3000ms. In subscription mode
|
|
174
|
+
* the chain paces via `nextDelayMs`; this value only paces retries
|
|
175
|
+
* after FAILED ticks (transport contract).
|
|
176
|
+
*/
|
|
177
|
+
readonly intervalMs?: number;
|
|
178
|
+
/**
|
|
179
|
+
* Server-side hold per subscription-mode pull, in seconds. Defaults
|
|
180
|
+
* to 20 (under ggui_consume's proven 25s host tolerance). `0`
|
|
181
|
+
* disables holding entirely — every pull returns immediately and
|
|
182
|
+
* pacing degrades to {@link idleIntervalMs}.
|
|
183
|
+
*/
|
|
184
|
+
readonly holdSeconds?: number;
|
|
185
|
+
/**
|
|
186
|
+
* Consecutive empty holds before demoting subscription → idle
|
|
187
|
+
* polling. Defaults to 3. Any delivered event re-promotes.
|
|
188
|
+
*/
|
|
189
|
+
readonly demoteAfterEmpties?: number;
|
|
190
|
+
/**
|
|
191
|
+
* Idle-mode pull cadence. Defaults to 15000ms.
|
|
192
|
+
*/
|
|
193
|
+
readonly idleIntervalMs?: number;
|
|
194
|
+
/**
|
|
195
|
+
* Optional per-page event cap, forwarded as the tool's `limit`
|
|
196
|
+
* argument only when set. Absent → the key is omitted and the
|
|
197
|
+
* server default applies.
|
|
198
|
+
*/
|
|
199
|
+
readonly limit?: number;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Build the bridge-pull rung of the failover ladder — the SAME
|
|
203
|
+
* polling algorithm as {@link buildEventsPolling} on a different
|
|
204
|
+
* carrier: instead of an HTTP `url`, the descriptor supplies a
|
|
205
|
+
* `fetchBody` that pulls the event ledger through the host's
|
|
206
|
+
* `tools/call` postMessage bridge (`ggui_runtime_pull`). Terminal
|
|
207
|
+
* rung: the registry never arms a failure budget on it.
|
|
208
|
+
*
|
|
209
|
+
* Per tick: `callTool('ggui_runtime_pull', {sessionId, sinceSequence:
|
|
210
|
+
* <cursor>, limit?})` → unwrap the CallToolResult via the shared
|
|
211
|
+
* 3-tier {@link unwrapCallToolResult} → the unwrapped body feeds the
|
|
212
|
+
* SAME parse core as the HTTP rung (`EventsResponse` dispatch,
|
|
213
|
+
* `lastSequence` advance, REPLAY_HORIZON_PASSED reset — the tool's
|
|
214
|
+
* output is byte-parity with `GET /api/sessions/:sessionId/events` by
|
|
215
|
+
* contract).
|
|
216
|
+
*/
|
|
217
|
+
export declare function buildBridgePolling(opts: BuildBridgePollingOptions): RegistryPollingOptions;
|
package/dist/events-polling.js
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
|
+
import { unwrapCallToolResult } from './call-tool-unwrap.js';
|
|
1
2
|
const DEFAULT_EVENTS_POLL_INTERVAL_MS = 2000;
|
|
2
3
|
const DEFAULT_EVENTS_PAGE_LIMIT = 100;
|
|
4
|
+
const DEFAULT_BRIDGE_PULL_INTERVAL_MS = 3000;
|
|
5
|
+
// Subscription-mode defaults (transport-ladder ruling 20). The hold
|
|
6
|
+
// stays under ggui_consume's proven 25s host tolerance; K=3 empty
|
|
7
|
+
// holds (~1 min of silence) demotes to sparse idle pulls.
|
|
8
|
+
const DEFAULT_BRIDGE_HOLD_SECONDS = 20;
|
|
9
|
+
const DEFAULT_BRIDGE_DEMOTE_AFTER_EMPTIES = 3;
|
|
10
|
+
const DEFAULT_BRIDGE_IDLE_INTERVAL_MS = 15_000;
|
|
11
|
+
/**
|
|
12
|
+
* Create a {@link SequenceCursor} seeded at `seed` (default `0`).
|
|
13
|
+
*/
|
|
14
|
+
export function createSequenceCursor(seed = 0) {
|
|
15
|
+
let current = seed;
|
|
16
|
+
return {
|
|
17
|
+
get: () => current,
|
|
18
|
+
advance: (seq) => {
|
|
19
|
+
if (seq > current)
|
|
20
|
+
current = seq;
|
|
21
|
+
},
|
|
22
|
+
reset: (seq) => {
|
|
23
|
+
current = seq;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
3
27
|
/**
|
|
4
28
|
* Compose the per-tick URL with `&sinceSequence=N&limit=M` appended.
|
|
5
29
|
* The base URL may or may not already carry a query string; we pick
|
|
@@ -68,7 +92,10 @@ function isGguiSessionEvent(value) {
|
|
|
68
92
|
* pulls the live cursor and composes the URL anew.
|
|
69
93
|
*/
|
|
70
94
|
export function buildEventsPolling(opts) {
|
|
71
|
-
|
|
95
|
+
// Shared ladder cursor when supplied; else a private cursor seeded
|
|
96
|
+
// from `initialSinceSequence` — behaviorally identical to the old
|
|
97
|
+
// closure `let cursor`, modulo monotonicity (advance() never rewinds).
|
|
98
|
+
const cur = opts.cursor ?? createSequenceCursor(opts.initialSinceSequence ?? 0);
|
|
72
99
|
const limit = opts.limit ?? DEFAULT_EVENTS_PAGE_LIMIT;
|
|
73
100
|
const intervalMs = opts.intervalMs ?? DEFAULT_EVENTS_POLL_INTERVAL_MS;
|
|
74
101
|
// Each `url` access recomputes from the current cursor. The
|
|
@@ -76,7 +103,7 @@ export function buildEventsPolling(opts) {
|
|
|
76
103
|
const descriptor = Object.create(null);
|
|
77
104
|
Object.defineProperty(descriptor, 'url', {
|
|
78
105
|
enumerable: true,
|
|
79
|
-
get: () => composeTickUrl(opts.baseUrl,
|
|
106
|
+
get: () => composeTickUrl(opts.baseUrl, cur.get(), limit),
|
|
80
107
|
});
|
|
81
108
|
Object.defineProperty(descriptor, 'intervalMs', {
|
|
82
109
|
enumerable: true,
|
|
@@ -84,60 +111,161 @@ export function buildEventsPolling(opts) {
|
|
|
84
111
|
});
|
|
85
112
|
Object.defineProperty(descriptor, 'parseSnapshot', {
|
|
86
113
|
enumerable: true,
|
|
87
|
-
value: (
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
114
|
+
value: createEventsSnapshotParser(cur),
|
|
115
|
+
});
|
|
116
|
+
return descriptor;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The ONE per-tick `EventsResponse` parse core behind BOTH ledger-pull
|
|
120
|
+
* carriers — HTTP polling ({@link buildEventsPolling}) and bridge-pull
|
|
121
|
+
* ({@link buildBridgePolling}). Byte-parity between `GET
|
|
122
|
+
* /api/sessions/:sessionId/events` and `ggui_runtime_pull` output is
|
|
123
|
+
* contractual precisely so this single parser handles both.
|
|
124
|
+
*
|
|
125
|
+
* Reads the `EventsResponse` envelope, dispatches each event by
|
|
126
|
+
* `event.type` to the registered channel handler, advances the shared
|
|
127
|
+
* cursor to `lastSequence`, and folds the REPLAY_HORIZON_PASSED
|
|
128
|
+
* escape hatch through `cursor.reset()` + a synthetic `error` frame.
|
|
129
|
+
*/
|
|
130
|
+
function createEventsSnapshotParser(cur) {
|
|
131
|
+
return (body) => {
|
|
132
|
+
// 410 case manifests as a body that doesn't match the
|
|
133
|
+
// EventsResponse shape (`{reason: 'REPLAY_HORIZON_PASSED',
|
|
134
|
+
// currentSequence}`). Surface a synthetic `error` frame so the
|
|
135
|
+
// registry's error handler can fire the re-mount signal.
|
|
136
|
+
if (body !== null &&
|
|
137
|
+
typeof body === 'object' &&
|
|
138
|
+
body.reason === 'REPLAY_HORIZON_PASSED') {
|
|
139
|
+
const cs = body.currentSequence;
|
|
140
|
+
const currentSequence = typeof cs === 'number' ? cs : 0;
|
|
141
|
+
// Advance to the server's high-water mark; next tick starts
|
|
142
|
+
// fresh from there (monotonic — inside the replayable-range
|
|
143
|
+
// contract the high-water mark is >= the cursor, and a shared
|
|
144
|
+
// ladder cursor must never rewind another rung's progress).
|
|
145
|
+
// Consumers handle the re-mount via the error channel.
|
|
146
|
+
// Server-truth override, NOT advance(): the horizon error can
|
|
147
|
+
// report a high-water mark BELOW this cursor (re-minted/reset
|
|
148
|
+
// session ledger) — monotonic advance would no-op and every
|
|
149
|
+
// subsequent tick would re-ask ahead of the horizon forever.
|
|
150
|
+
cur.reset(currentSequence);
|
|
151
|
+
const errorFrame = {
|
|
152
|
+
type: 'error',
|
|
153
|
+
payload: {
|
|
154
|
+
code: 'REPLAY_HORIZON_PASSED',
|
|
155
|
+
message: `events polling cursor outside replayable range; reset to ${currentSequence}`,
|
|
156
|
+
details: { currentSequence },
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
return { error: errorFrame };
|
|
160
|
+
}
|
|
161
|
+
if (!isEventsResponse(body))
|
|
162
|
+
return null;
|
|
163
|
+
// Advance cursor even on empty pages — the server's high-water
|
|
164
|
+
// mark moves with /state reads too.
|
|
165
|
+
cur.advance(body.lastSequence);
|
|
166
|
+
if (body.events.length === 0) {
|
|
167
|
+
// Nothing to dispatch; the empty object signals "snapshot
|
|
168
|
+
// parsed but no handlers matched today's keys" (distinct from
|
|
169
|
+
// `null` = no change).
|
|
170
|
+
return {};
|
|
171
|
+
}
|
|
172
|
+
// Group events by type. The registry-level polling transport
|
|
173
|
+
// calls one handler per type; if multiple events share a type
|
|
174
|
+
// we'd lose deliveries. Today's wire frame types
|
|
175
|
+
// (render/props_update) are typically distinct per tick at the
|
|
176
|
+
// expected 2s cadence, but the protocol allows multiple of the
|
|
177
|
+
// same type. Honest workaround: dispatch the LAST event of each
|
|
178
|
+
// type and rely on the consumer's idempotency. Future R8: extend
|
|
179
|
+
// the dispatch shape to a list per type.
|
|
180
|
+
const frames = {};
|
|
181
|
+
for (const event of body.events) {
|
|
182
|
+
if (!isGguiSessionEvent(event))
|
|
183
|
+
continue;
|
|
184
|
+
// Two namespaces meet here, and THIS is the single translation
|
|
185
|
+
// point: the ledger speaks the canonical event taxonomy
|
|
186
|
+
// ('ui.updated', 'user.submitted', …) while the registry's
|
|
187
|
+
// handler map speaks live-channel FRAME types ('props_update',
|
|
188
|
+
// …). `ggui_update` appends 'ui.updated' with the exact
|
|
189
|
+
// `{sessionId, props}` payload the props_update handler
|
|
190
|
+
// expects — map the name so pull rungs repaint through the
|
|
191
|
+
// same handler the WS/SSE push planes use.
|
|
192
|
+
const frameType = event.type === 'ui.updated' ? 'props_update' : event.type;
|
|
193
|
+
frames[frameType] = {
|
|
194
|
+
type: frameType,
|
|
195
|
+
payload: event.data,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
return frames;
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Build the bridge-pull rung of the failover ladder — the SAME
|
|
203
|
+
* polling algorithm as {@link buildEventsPolling} on a different
|
|
204
|
+
* carrier: instead of an HTTP `url`, the descriptor supplies a
|
|
205
|
+
* `fetchBody` that pulls the event ledger through the host's
|
|
206
|
+
* `tools/call` postMessage bridge (`ggui_runtime_pull`). Terminal
|
|
207
|
+
* rung: the registry never arms a failure budget on it.
|
|
208
|
+
*
|
|
209
|
+
* Per tick: `callTool('ggui_runtime_pull', {sessionId, sinceSequence:
|
|
210
|
+
* <cursor>, limit?})` → unwrap the CallToolResult via the shared
|
|
211
|
+
* 3-tier {@link unwrapCallToolResult} → the unwrapped body feeds the
|
|
212
|
+
* SAME parse core as the HTTP rung (`EventsResponse` dispatch,
|
|
213
|
+
* `lastSequence` advance, REPLAY_HORIZON_PASSED reset — the tool's
|
|
214
|
+
* output is byte-parity with `GET /api/sessions/:sessionId/events` by
|
|
215
|
+
* contract).
|
|
216
|
+
*/
|
|
217
|
+
export function buildBridgePolling(opts) {
|
|
218
|
+
const { callTool, sessionId, cursor } = opts;
|
|
219
|
+
const limit = opts.limit;
|
|
220
|
+
const intervalMs = opts.intervalMs ?? DEFAULT_BRIDGE_PULL_INTERVAL_MS;
|
|
221
|
+
const holdSeconds = opts.holdSeconds ?? DEFAULT_BRIDGE_HOLD_SECONDS;
|
|
222
|
+
const demoteAfterEmpties = opts.demoteAfterEmpties ?? DEFAULT_BRIDGE_DEMOTE_AFTER_EMPTIES;
|
|
223
|
+
const idleIntervalMs = opts.idleIntervalMs ?? DEFAULT_BRIDGE_IDLE_INTERVAL_MS;
|
|
224
|
+
// Subscription-mode state (transport-ladder ruling 20). Two modes,
|
|
225
|
+
// one counter:
|
|
226
|
+
// - SUBSCRIPTION (hot): each pull carries `wait` — the server
|
|
227
|
+
// holds the call until an event lands or the hold times out —
|
|
228
|
+
// and the next pull fires IMMEDIATELY on return. Back-to-back
|
|
229
|
+
// held single-shots emulate push: event→screen ≈ one relay RTT.
|
|
230
|
+
// - POLLING (idle): after `demoteAfterEmpties` consecutive empty
|
|
231
|
+
// holds the card is presumed quiet; drop to sparse un-held
|
|
232
|
+
// pulls every `idleIntervalMs` so a dormant card doesn't pin
|
|
233
|
+
// the host's relay open forever.
|
|
234
|
+
// Any delivered event promotes straight back to subscription mode.
|
|
235
|
+
let consecutiveEmpties = 0;
|
|
236
|
+
const hot = () => consecutiveEmpties < demoteAfterEmpties;
|
|
237
|
+
return {
|
|
238
|
+
intervalMs,
|
|
239
|
+
fetchBody: async () => {
|
|
240
|
+
const result = await callTool('ggui_runtime_pull', {
|
|
241
|
+
sessionId,
|
|
242
|
+
sinceSequence: cursor.get(),
|
|
243
|
+
...(limit !== undefined ? { limit } : {}),
|
|
244
|
+
...(hot() ? { wait: holdSeconds } : {}),
|
|
245
|
+
});
|
|
246
|
+
return unwrapCallToolResult(result);
|
|
247
|
+
},
|
|
248
|
+
parseSnapshot: createEventsSnapshotParser(cursor),
|
|
249
|
+
nextDelayMs: (body) => {
|
|
250
|
+
if (isEventsResponse(body)) {
|
|
251
|
+
if (body.events.length > 0) {
|
|
252
|
+
consecutiveEmpties = 0;
|
|
253
|
+
return 0;
|
|
254
|
+
}
|
|
255
|
+
consecutiveEmpties += 1;
|
|
256
|
+
return hot() ? 0 : idleIntervalMs;
|
|
257
|
+
}
|
|
92
258
|
if (body !== null &&
|
|
93
259
|
typeof body === 'object' &&
|
|
94
260
|
body.reason === 'REPLAY_HORIZON_PASSED') {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
// the error channel.
|
|
100
|
-
cursor = currentSequence;
|
|
101
|
-
const errorFrame = {
|
|
102
|
-
type: 'error',
|
|
103
|
-
payload: {
|
|
104
|
-
code: 'REPLAY_HORIZON_PASSED',
|
|
105
|
-
message: `events polling cursor outside replayable range; reset to ${currentSequence}`,
|
|
106
|
-
details: { currentSequence },
|
|
107
|
-
},
|
|
108
|
-
};
|
|
109
|
-
return { error: errorFrame };
|
|
110
|
-
}
|
|
111
|
-
if (!isEventsResponse(body))
|
|
112
|
-
return null;
|
|
113
|
-
// Advance cursor even on empty pages — the server's high-water
|
|
114
|
-
// mark moves with /state reads too.
|
|
115
|
-
cursor = body.lastSequence;
|
|
116
|
-
if (body.events.length === 0) {
|
|
117
|
-
// Nothing to dispatch; the empty object signals "snapshot
|
|
118
|
-
// parsed but no handlers matched today's keys" (distinct from
|
|
119
|
-
// `null` = no change).
|
|
120
|
-
return {};
|
|
121
|
-
}
|
|
122
|
-
// Group events by type. The registry-level polling transport
|
|
123
|
-
// calls one handler per type; if multiple events share a type
|
|
124
|
-
// we'd lose deliveries. Today's wire frame types
|
|
125
|
-
// (render/props_update) are typically distinct per tick at the
|
|
126
|
-
// expected 2s cadence, but the protocol allows multiple of the
|
|
127
|
-
// same type. Honest workaround: dispatch the LAST event of each
|
|
128
|
-
// type and rely on the consumer's idempotency. Future R8: extend
|
|
129
|
-
// the dispatch shape to a list per type.
|
|
130
|
-
const frames = {};
|
|
131
|
-
for (const event of body.events) {
|
|
132
|
-
if (!isGguiSessionEvent(event))
|
|
133
|
-
continue;
|
|
134
|
-
frames[event.type] = {
|
|
135
|
-
type: event.type,
|
|
136
|
-
payload: event.data,
|
|
137
|
-
};
|
|
261
|
+
// Cursor just self-healed to the server's high-water mark
|
|
262
|
+
// (parseSnapshot reset it) — one immediate re-pull lands
|
|
263
|
+
// inside the window.
|
|
264
|
+
return 0;
|
|
138
265
|
}
|
|
139
|
-
|
|
266
|
+
// Unrecognized body — pace like idle rather than hot-looping on
|
|
267
|
+
// garbage.
|
|
268
|
+
return idleIntervalMs;
|
|
140
269
|
},
|
|
141
|
-
}
|
|
142
|
-
return descriptor;
|
|
270
|
+
};
|
|
143
271
|
}
|