@ggui-ai/iframe-runtime 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 (41) hide show
  1. package/README.md +2 -2
  2. package/dist/channels/data.d.ts +8 -0
  3. package/dist/channels/data.js +2 -0
  4. package/dist/credential-controller.d.ts +71 -0
  5. package/dist/credential-controller.js +135 -0
  6. package/dist/iframe-runtime.js +97 -93
  7. package/dist/iframe-runtime.meta.json +1 -1
  8. package/dist/index.d.ts +2 -2
  9. package/dist/ladders.d.ts +77 -0
  10. package/dist/ladders.js +383 -0
  11. package/dist/meta-parse.js +38 -8
  12. package/dist/observability.d.ts +38 -11
  13. package/dist/react-renderer.d.ts +22 -0
  14. package/dist/react-renderer.js +118 -3
  15. package/dist/registry-subscribe.d.ts +53 -2
  16. package/dist/registry-subscribe.js +63 -8
  17. package/dist/render-failure-report.d.ts +15 -0
  18. package/dist/render-failure-report.js +61 -0
  19. package/dist/render-item.d.ts +6 -1
  20. package/dist/render-item.js +7 -2
  21. package/dist/runtime-telemetry.d.ts +28 -18
  22. package/dist/runtime-telemetry.js +68 -29
  23. package/dist/runtime.d.ts +157 -39
  24. package/dist/runtime.js +724 -368
  25. package/dist/shims/wire.js +1 -1
  26. package/dist/stream-seq.d.ts +56 -0
  27. package/dist/stream-seq.js +46 -0
  28. package/dist/system-cards/ProtocolProbeCard.d.ts +16 -0
  29. package/dist/system-cards/ProtocolProbeCard.js +17 -14
  30. package/dist/tool-result-error.d.ts +6 -0
  31. package/dist/tool-result-error.js +32 -0
  32. package/dist/types.d.ts +49 -1
  33. package/dist/view-origin.d.ts +29 -0
  34. package/dist/view-origin.js +33 -0
  35. package/dist/view-proof-signer.d.ts +31 -0
  36. package/dist/view-proof-signer.js +99 -0
  37. package/dist/view-root.d.ts +51 -0
  38. package/dist/view-root.js +56 -0
  39. package/dist/wire-config.d.ts +15 -1
  40. package/dist/wire-config.js +42 -1
  41. package/package.json +8 -7
@@ -31,6 +31,21 @@
31
31
  */
32
32
  import React, { type ReactNode } from 'react';
33
33
  import type { AppTheme } from '@ggui-ai/protocol/wire';
34
+ import type { RenderFailurePhase } from '@ggui-ai/protocol/render-failure';
35
+ /** Test hook: shorten the no-commit deadline; `null` restores the default. */
36
+ export declare function __setNoCommitDeadlineForTest(ms: number | null): void;
37
+ /**
38
+ * The boundary's give-up, named (ggui#1679): what the component threw on
39
+ * its terminal catch, the phase — `mount` when the component had never
40
+ * committed, `update` when a painted card's re-render threw — and the
41
+ * boundary's catch count at that moment (the retry included: 2 today). The
42
+ * runtime reports it to the server in exactly these terms.
43
+ */
44
+ export interface RenderFailure {
45
+ readonly error: Error;
46
+ readonly phase: RenderFailurePhase;
47
+ readonly catches: number;
48
+ }
34
49
  export interface ReactRootMountOptions {
35
50
  /**
36
51
  * GguiSession whose componentCode + props drive the mount. Only
@@ -98,6 +113,13 @@ export interface ReactRootMountOptions {
98
113
  /** `fill` — the root is the whole canvas: no silhouette, fills the page (ggui#1041; from the host's `displayMode`). */
99
114
  readonly fit?: 'fill';
100
115
  readonly onError?: (error: Error) => void;
116
+ /**
117
+ * The error boundary's give-up (ggui#1679): fires once per terminal catch,
118
+ * beside `onError`, with the phase and the catch count the runtime reports
119
+ * to the server. Not fired for an eval failure — a module that does not
120
+ * load is "no component" (ggui#1103), not a component that threw.
121
+ */
122
+ readonly onRenderFailure?: (failure: RenderFailure) => void;
101
123
  /**
102
124
  * Children injected BETWEEN the mount DOM (scope + CSS) and the
103
125
  * evaluated component element. The caller's render dispatcher
@@ -29,7 +29,7 @@
29
29
  * `React.createElement` + `React.Fragment` directly so the renderer's
30
30
  * TypeScript config doesn't need a JSX runtime plumbing.
31
31
  */
32
- import React, { Component, createElement, Fragment, } from 'react';
32
+ import React, { Component, createElement, Fragment, useEffect, } from 'react';
33
33
  import { createRoot } from 'react-dom/client';
34
34
  import { postObservabilityToParent } from './observability.js';
35
35
  import { trackThemeFontFaces } from './host-fonts.js';
@@ -69,6 +69,39 @@ function makeScopeClass() {
69
69
  * re-sanitize here.
70
70
  */
71
71
  // =============================================================================
72
+ // Paint probe (ggui#1104) — the blanks a HELD component can still produce.
73
+ // =============================================================================
74
+ /**
75
+ * How long a render may go without committing before it is named a
76
+ * `no-commit` blank. A healthy commit lands within a frame or two of
77
+ * `root.render`; a component that suspends with no Suspense boundary never
78
+ * commits at all, and until now that blank said nothing on any channel.
79
+ */
80
+ const NO_COMMIT_DEADLINE_MS = 5_000;
81
+ let noCommitDeadlineMs = NO_COMMIT_DEADLINE_MS;
82
+ /** Test hook: shorten the no-commit deadline; `null` restores the default. */
83
+ export function __setNoCommitDeadlineForTest(ms) {
84
+ noCommitDeadlineMs = ms ?? NO_COMMIT_DEADLINE_MS;
85
+ }
86
+ /**
87
+ * Rendered inside the scope; its PASSIVE effect runs once the whole commit is
88
+ * done, refs included. (A layout effect would not do: layout effects run
89
+ * child-first, before the parent scope `<div>`'s ref is attached, so the
90
+ * probe would read no scope and misname a healthy mount `detached`.) Renders
91
+ * nothing itself.
92
+ */
93
+ function CommitProbe(props) {
94
+ useEffect(() => {
95
+ props.onCommit();
96
+ });
97
+ return null;
98
+ }
99
+ const HELD_BLANK_DETAIL = {
100
+ 'rendered-nothing': 'the component rendered no element',
101
+ detached: 'the tree committed into a scope that is no longer in the document (another mount took the container)',
102
+ 'no-commit': 'the tree did not commit (a component that suspends with no Suspense boundary never commits)',
103
+ };
104
+ // =============================================================================
72
105
  // Error boundary — port of RCR's internal ErrorBoundary (L68–203).
73
106
  // =============================================================================
74
107
  const AUTO_RETRY_LIMIT = 1;
@@ -80,9 +113,23 @@ class RcrErrorBoundary extends Component {
80
113
  autoRetrying: false,
81
114
  };
82
115
  retryTimer = null;
116
+ // Whether the children have ever committed (ggui#1679): a commit with no
117
+ // error held is the component painting. A boundary whose children never
118
+ // committed fails in `mount`; one whose painted children throw on a later
119
+ // render fails in `update`. The boundary is re-keyed on new code, so a
120
+ // replaced component starts over at `mount`.
121
+ childrenCommitted = false;
83
122
  static getDerivedStateFromError(error) {
84
123
  return { error };
85
124
  }
125
+ componentDidMount() {
126
+ if (this.state.error === null)
127
+ this.childrenCommitted = true;
128
+ }
129
+ componentDidUpdate() {
130
+ if (this.state.error === null)
131
+ this.childrenCommitted = true;
132
+ }
86
133
  componentDidCatch(error, _info) {
87
134
  const nextCount = this.state.catchCount + 1;
88
135
  this.setState({ catchCount: nextCount });
@@ -93,6 +140,13 @@ class RcrErrorBoundary extends Component {
93
140
  }, AUTO_RETRY_DELAY);
94
141
  return;
95
142
  }
143
+ // The give-up, named first (ggui#1679) — the phase and the count are the
144
+ // boundary's facts — then the host's own hook, unchanged.
145
+ this.props.onRenderFailure?.({
146
+ error,
147
+ phase: this.childrenCommitted ? 'update' : 'mount',
148
+ catches: nextCount,
149
+ });
96
150
  this.props.onError?.(error);
97
151
  }
98
152
  componentWillUnmount() {
@@ -212,6 +266,52 @@ export async function mountReactRoot(container, opts) {
212
266
  let currentOpts = opts;
213
267
  let currentCode = null;
214
268
  let currentComponent = null;
269
+ // ggui#1104 — the paint probe's state. `heldAtRender` is whether the render
270
+ // being committed carried a component (when it did not, ggui#1103's
271
+ // `reportNoComponent` has already named the blank). A blank is reported
272
+ // once per episode: `blankReported` resets when a commit shows paint.
273
+ let scopeEl = null;
274
+ let committedOnce = false;
275
+ let heldAtRender = false;
276
+ let blankReported = false;
277
+ let commitTimer = null;
278
+ function reportHeldBlank(where, reason) {
279
+ if (blankReported)
280
+ return;
281
+ blankReported = true;
282
+ // eslint-disable-next-line no-console -- operator-visible failure signal
283
+ console.warn(`[ggui] mountReactRoot: the ${where} painted nothing (${reason}) — ${HELD_BLANK_DETAIL[reason]}`);
284
+ try {
285
+ postObservabilityToParent({ kind: 'component-empty', where, reason });
286
+ }
287
+ catch {
288
+ // Best-effort seam, as in reportNoComponent: never turn a blank into a throw.
289
+ }
290
+ }
291
+ const setScopeEl = (el) => {
292
+ scopeEl = el;
293
+ };
294
+ const onCommit = () => {
295
+ if (commitTimer !== null) {
296
+ clearTimeout(commitTimer);
297
+ commitTimer = null;
298
+ }
299
+ const where = committedOnce ? 'update' : 'mount';
300
+ committedOnce = true;
301
+ if (!heldAtRender)
302
+ return;
303
+ const scope = scopeEl;
304
+ if (scope === null || !scope.isConnected) {
305
+ reportHeldBlank(where, 'detached');
306
+ return;
307
+ }
308
+ const painted = Array.from(scope.children).some((child) => child.tagName !== 'STYLE');
309
+ if (painted) {
310
+ blankReported = false;
311
+ return;
312
+ }
313
+ reportHeldBlank(where, 'rendered-nothing');
314
+ };
215
315
  async function evaluate(code) {
216
316
  // 0. If code is a URL (S3 presigned), fetch the bytes first.
217
317
  // Matches RCR's first-step URL-fetch guard verbatim.
@@ -382,10 +482,22 @@ export async function mountReactRoot(container, opts) {
382
482
  // The scope `<div>` + inline `<style>` + error-boundary-wrapped
383
483
  // children match RCR L376–383. `key={currentCode?.length}` mirrors
384
484
  // RCR's "reset boundary when code changes" heuristic.
385
- root.render(createElement('div', { className: scopeClass }, createElement('style', null, themeCss), createElement(RcrErrorBoundary, {
485
+ root.render(createElement('div', { className: scopeClass, ref: setScopeEl }, createElement('style', null, themeCss), createElement(RcrErrorBoundary, {
386
486
  key: currentCode?.length ?? 0,
387
487
  ...(opts.onError ? { onError: opts.onError } : {}),
388
- }, createElement(Fragment, null, wrapped))));
488
+ ...(opts.onRenderFailure ? { onRenderFailure: opts.onRenderFailure } : {}),
489
+ }, createElement(Fragment, null, wrapped)), createElement(CommitProbe, { onCommit })));
490
+ // ggui#1104 — a held component must commit. Armed only when this render
491
+ // carries one; the probe's commit disarms it.
492
+ heldAtRender = currentComponent !== null;
493
+ if (commitTimer !== null)
494
+ clearTimeout(commitTimer);
495
+ commitTimer = heldAtRender
496
+ ? setTimeout(() => {
497
+ commitTimer = null;
498
+ reportHeldBlank(committedOnce ? 'update' : 'mount', 'no-commit');
499
+ }, noCommitDeadlineMs)
500
+ : null;
389
501
  }
390
502
  /**
391
503
  * The mount painted NOTHING (ggui#1103). `wrapped` is `null` whenever the
@@ -484,6 +596,9 @@ export async function mountReactRoot(container, opts) {
484
596
  }
485
597
  },
486
598
  unmount() {
599
+ if (commitTimer !== null)
600
+ clearTimeout(commitTimer);
601
+ commitTimer = null;
487
602
  root.unmount();
488
603
  container.replaceChildren();
489
604
  },
@@ -1,8 +1,8 @@
1
1
  import type { AckPayload } from '@ggui-ai/protocol/wire';
2
2
  import type { ConnectionStatus } from '@ggui-ai/protocol/transport/websocket';
3
3
  import type { McpAppAiGguiRenderMeta } from '@ggui-ai/protocol/integrations/mcp-apps';
4
- import type { AnyTransportHandle, ChannelLogger, ChannelRegistry, RegistryPollingOptions, RegistrySseOptions, WsTransportHandle } from '@ggui-ai/live-channel';
5
- import { type ProtocolErrorEmitter } from './protocol-error.js';
4
+ import type { AnyTransportHandle, ChannelLogger, ChannelRegistry, FrameClassifier, RegistryPollingOptions, RegistrySseOptions, WsTransportHandle } from '@ggui-ai/live-channel';
5
+ import { type AuthFailureCode, type ProtocolErrorEmitter } from './protocol-error.js';
6
6
  import type { ObservabilityEmitter } from './observability.js';
7
7
  /**
8
8
  * Result of a successful handshake — the live typed transport handle
@@ -25,11 +25,31 @@ export interface RegistrySubscribeHandle {
25
25
  * absent entirely (exact-optional).
26
26
  */
27
27
  readonly ack?: AckPayload;
28
+ /**
29
+ * The auth-class code the server refused the subscribe with before any
30
+ * ack (ggui#1496). The refusal failed the WS rung, and the ladder in
31
+ * `handle` goes on demoting; the caller keeps it, so that a view which
32
+ * refreshes its credential can dispose it when the new ladder takes
33
+ * over. Absent on an accepted bind. The typed auth `ProtocolError` has
34
+ * already been emitted.
35
+ */
36
+ readonly refused?: SubscribeRefusal;
37
+ }
38
+ /** A pre-ack auth-class refusal, with the server's message when it sent one. */
39
+ export interface SubscribeRefusal {
40
+ readonly code: AuthFailureCode;
41
+ readonly message?: string;
28
42
  }
29
43
  /**
30
44
  * Caller-provided callbacks. The runtime's `bootSequence` owns the
31
45
  * concrete implementations; tests pass mocks via the `connectFn` seam.
32
46
  */
47
+ /** A post-ack `error` frame's payload, as the server sent it (ggui#1424). */
48
+ export interface PostAckErrorPayload {
49
+ readonly code: string;
50
+ readonly message?: string;
51
+ readonly details?: unknown;
52
+ }
33
53
  export interface ConnectViaRegistryOptions {
34
54
  /**
35
55
  * GguiSession slice (`McpAppAiGguiRenderMeta`) — the live-channel
@@ -90,6 +110,15 @@ export interface ConnectViaRegistryOptions {
90
110
  * coherent by the time the resubscribe-ack arrives.
91
111
  */
92
112
  readonly onResubscribeAck?: (ack: AckPayload) => void;
113
+ /**
114
+ * ggui#1424 — every `error` frame that arrives AFTER the handshake
115
+ * settled, as sent. Pre-ack errors keep their classification path (the
116
+ * handshake's refusal / upgrade handling) and never reach this. The
117
+ * runtime reads a server refusal of a spent one-shot here (the channel
118
+ * door's CONTRACT_VIOLATION frame with a violation at
119
+ * `actionSpec.<name>.oneShot`) and shows the action as done.
120
+ */
121
+ readonly onPostAckError?: (payload: PostAckErrorPayload) => void;
93
122
  /**
94
123
  * Registry-level polling descriptor (R6). When supplied, the live-
95
124
  * channel's `FailoverHandle` uses this as its polling fallback when
@@ -129,7 +158,29 @@ export interface ConnectViaRegistryOptions {
129
158
  * behavior on console-less sandboxed hosts reaches the server.
130
159
  */
131
160
  readonly logger?: ChannelLogger;
161
+ /**
162
+ * The bound transport, as soon as `bind()` resolves (ggui#1496), before
163
+ * any ack or refusal settles the handshake. A caller that may need to
164
+ * dispose a ladder whose handshake never settles (a socket that never
165
+ * opens, demoting to its bridge) takes the handle here.
166
+ */
167
+ readonly onBound?: (handle: AnyTransportHandle) => void;
132
168
  }
169
+ /**
170
+ * The iframe's verdict on channel frames for the WebSocket retry budget
171
+ * (ggui#1496), handed to every `ChannelRegistry` the runtime builds.
172
+ *
173
+ * - An `ack` is an accepted subscription, so the budget resets. It no
174
+ * longer resets when a socket merely opens: a socket the server opens
175
+ * and then refuses at subscribe used to reset it on every attempt, so
176
+ * the transport never reached `'failed'` and the failover never moved.
177
+ * - An `error` whose code is a {@link PRE_ACK_AUTH_CODES} member is a
178
+ * refusal no retry of the same credential can fix (the subscribe is
179
+ * rebuilt from the same `wsToken` each attempt), so the transport
180
+ * fails at once and the ladder moves to its next rung. The same list
181
+ * classifies the handshake's typed error, so the two cannot drift.
182
+ */
183
+ export declare const classifyChannelFrame: FrameClassifier;
133
184
  /**
134
185
  * Open the WS via the registry, send subscribe (composed by the
135
186
  * registry's `subscribeFrameBuilder`), await the first `ack`. Resolves
@@ -110,6 +110,35 @@ const PRE_ACK_AUTH_CODES = new Set([
110
110
  function isAuthFailureCode(code) {
111
111
  return PRE_ACK_AUTH_CODES.has(code);
112
112
  }
113
+ /**
114
+ * The iframe's verdict on channel frames for the WebSocket retry budget
115
+ * (ggui#1496), handed to every `ChannelRegistry` the runtime builds.
116
+ *
117
+ * - An `ack` is an accepted subscription, so the budget resets. It no
118
+ * longer resets when a socket merely opens: a socket the server opens
119
+ * and then refuses at subscribe used to reset it on every attempt, so
120
+ * the transport never reached `'failed'` and the failover never moved.
121
+ * - An `error` whose code is a {@link PRE_ACK_AUTH_CODES} member is a
122
+ * refusal no retry of the same credential can fix (the subscribe is
123
+ * rebuilt from the same `wsToken` each attempt), so the transport
124
+ * fails at once and the ladder moves to its next rung. The same list
125
+ * classifies the handshake's typed error, so the two cannot drift.
126
+ */
127
+ export const classifyChannelFrame = (frame) => {
128
+ if (frame.type === 'ack')
129
+ return 'accepted';
130
+ if (frame.type !== 'error')
131
+ return undefined;
132
+ const payload = frame.payload;
133
+ if (typeof payload === 'object' &&
134
+ payload !== null &&
135
+ 'code' in payload &&
136
+ typeof payload.code === 'string' &&
137
+ isAuthFailureCode(payload.code)) {
138
+ return 'refused-terminal';
139
+ }
140
+ return undefined;
141
+ };
113
142
  /**
114
143
  * Map a pre-ack `error` frame onto a non-UPGRADE_REQUIRED ProtocolError.
115
144
  * {@link PRE_ACK_AUTH_CODES} → `'auth'`; every other code → `'protocol'`
@@ -217,15 +246,15 @@ export function connectViaRegistry(opts) {
217
246
  opts.registry.register({
218
247
  type: 'error',
219
248
  onMessage: (payload) => {
220
- // Post-ack error frames are NOT handled here — the runtime
221
- // doesn't currently route post-ack `error` frames through a
222
- // registered handler (they used to surface via the legacy
223
- // `onMessage` callback; B3b drops that channel since
224
- // post-ack errors are rare + the host inspector logs them
225
- // through the typed ProtocolError emitter).
226
- if (settled)
227
- return;
228
249
  const errPayload = payload;
250
+ // Post-ack error frames go to the caller as sent (ggui#1424): the
251
+ // runtime reads the channel door's refusal of a spent one-shot
252
+ // there. Before that they reached nothing (B3b had dropped the
253
+ // legacy `onMessage` channel as rare).
254
+ if (settled) {
255
+ opts.onPostAckError?.(errPayload);
256
+ return;
257
+ }
229
258
  const upgrade = liftUpgradeRequiredFromError(errPayload);
230
259
  if (upgrade !== null) {
231
260
  settled = true;
@@ -241,6 +270,17 @@ export function connectViaRegistry(opts) {
241
270
  }
242
271
  settled = true;
243
272
  emitProtocolError(classifyPreAckError(errPayload));
273
+ if (isAuthFailureCode(errPayload.code)) {
274
+ // An auth-class refusal fails the WS rung (the classifier's
275
+ // verdict) and the ladder demotes; resolve with the handle so
276
+ // the caller keeps it (ggui#1496).
277
+ refusalState.refusal = {
278
+ code: errPayload.code,
279
+ ...(errPayload.message !== undefined ? { message: errPayload.message } : {}),
280
+ };
281
+ refusalState.onRefused?.();
282
+ return;
283
+ }
244
284
  reject(new Error(errPayload.message ?? errPayload.code));
245
285
  },
246
286
  });
@@ -248,6 +288,8 @@ export function connectViaRegistry(opts) {
248
288
  // payload to the bind continuation without sharing a mutable
249
289
  // local with the outer Promise body.
250
290
  const ackResolverState = { payload: null, onAck: null };
291
+ // Same hand-off for a pre-ack auth-class refusal (ggui#1496).
292
+ const refusalState = { refusal: null, onRefused: null };
251
293
  const mappedStatusCallback = (status) => {
252
294
  // `connecting` is the initial state from WSTransport — first
253
295
  // surfaced concurrent with `start()`. Subsequent `connecting`
@@ -312,6 +354,7 @@ export function connectViaRegistry(opts) {
312
354
  ...(opts.bridge !== undefined ? { bridge: opts.bridge } : {}),
313
355
  })
314
356
  .then((bound) => {
357
+ opts.onBound?.(bound);
315
358
  if (!hasTrio) {
316
359
  // Bridge-only bind — no subscribe handshake, no ack. The
317
360
  // polling facade reports 'open' immediately (mapped to
@@ -334,6 +377,18 @@ export function connectViaRegistry(opts) {
334
377
  return;
335
378
  }
336
379
  const wsHandle = bound;
380
+ // A refusal that landed while bind was resolving settles now;
381
+ // one that lands later settles through its continuation.
382
+ const refusal = refusalState.refusal;
383
+ if (refusal !== null) {
384
+ resolve({ handle: wsHandle, refused: refusal });
385
+ return;
386
+ }
387
+ refusalState.onRefused = () => {
388
+ const late = refusalState.refusal;
389
+ if (late !== null)
390
+ resolve({ handle: wsHandle, refused: late });
391
+ };
337
392
  // If we already received the ack while bind was resolving,
338
393
  // settle the outer Promise now.
339
394
  if (ackResolverState.payload !== null) {
@@ -0,0 +1,15 @@
1
+ import type { GguiReportRenderFailureInput } from '@ggui-ai/protocol';
2
+ import type { RenderFailure } from './react-renderer.js';
3
+ /** The slice of a tool caller the reporter needs: the bridge's `(name, arguments)`. */
4
+ export type RenderFailureCallTool = (name: string, args: GguiReportRenderFailureInput) => Promise<unknown>;
5
+ export interface RenderFailureReporter {
6
+ /** Report the boundary's give-up. Idempotent per session: the second call is a no-op. */
7
+ readonly report: (failure: RenderFailure) => void;
8
+ /** Whether this session's one report has been sent (or attempted). */
9
+ readonly reported: () => boolean;
10
+ }
11
+ export declare function createRenderFailureReporter(opts: {
12
+ readonly sessionId: string;
13
+ readonly appId: string;
14
+ readonly callTool: RenderFailureCallTool;
15
+ }): RenderFailureReporter;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The runtime's render-failure report (ggui#1679) — the emit half of
3
+ * `ggui_runtime_report_render_failure` (ggui#1609; SPEC §4.9).
4
+ *
5
+ * When the card's error boundary gives up — its retry exhausted, on the first
6
+ * paint or on a later re-render — the runtime tells the server once, for the
7
+ * session it renders. The report carries ids and counts only: the session and
8
+ * app ids the boot envelope named, the phase, the thrown value's class name
9
+ * under the tool's own pattern ({@link renderFailureErrorName}: never a
10
+ * message, a stack, props or component source) and the boundary's catch
11
+ * count, clamped to the tool's bound.
12
+ *
13
+ * Obligations (SPEC §4.9, runtime side):
14
+ *
15
+ * - **At most one report per session.** The first terminal failure is the
16
+ * one reported; a card that fails again, in the other phase or after a
17
+ * re-mount, sends nothing more. The flag is spent when the call is MADE,
18
+ * not when it is answered, so a report that fails is never resent: a
19
+ * relay carries no per-session limiter, and one relayed call per session
20
+ * is the bound.
21
+ * - **Nothing the visitor sees changes.** The call is fire-and-forget; a
22
+ * refusal, a tool-not-found from a server that predates the tool, or a
23
+ * network loss is dropped in silence. No toast, no doorbell, no retry.
24
+ *
25
+ * The call site is {@link bridgeCallToolVia}'s `tools/call` over the host
26
+ * relay, so the request carries the view proof the server records against
27
+ * (`_meta["ai.ggui/view"]`, ggui#1415); an unproven report is answered `ok`
28
+ * and recorded nowhere, by the server's own rule.
29
+ */
30
+ // The pure-const subpath, never the package root: the root would pull the
31
+ // whole zod schema module into the card runtime's bundle (ggui#1679, +34 KB).
32
+ import { RENDER_FAILURE_MAX_CATCHES, renderFailureErrorName } from '@ggui-ai/protocol/render-failure';
33
+ export function createRenderFailureReporter(opts) {
34
+ let spent = false;
35
+ const report = (failure) => {
36
+ if (spent)
37
+ return;
38
+ spent = true;
39
+ const args = {
40
+ sessionId: opts.sessionId,
41
+ appId: opts.appId,
42
+ phase: failure.phase,
43
+ errorName: renderFailureErrorName(failure.error),
44
+ catches: Math.min(Math.max(1, Math.trunc(failure.catches)), RENDER_FAILURE_MAX_CATCHES),
45
+ };
46
+ let call;
47
+ try {
48
+ call = opts.callTool('ggui_runtime_report_render_failure', args);
49
+ }
50
+ catch {
51
+ // A caller that throws synchronously (no App bridge bound) is the
52
+ // same outcome as a refused call: not reported, nothing shown.
53
+ return;
54
+ }
55
+ void call.catch(() => {
56
+ // Dropped by design (SPEC §4.9): a report never changes what the
57
+ // visitor sees, and a failed one is never retried.
58
+ });
59
+ };
60
+ return { report, reported: () => spent };
61
+ }
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react';
2
2
  import type { AppTheme, GguiSession } from '@ggui-ai/protocol/wire';
3
3
  import type { GguiSessionSeedInput } from './types.js';
4
4
  import { type BuiltWireConfig } from '@ggui-ai/wire';
5
+ import { type RenderFailure } from './react-renderer.js';
5
6
  import type { StreamBus } from './wire-config.js';
6
7
  export interface RenderItemOptions {
7
8
  /** The render to mount. */
@@ -12,7 +13,9 @@ export interface RenderItemOptions {
12
13
  * today's DynamicComponent fallback when no GguiRender parent is
13
14
  * present). The config carries the card's `actionSpent` source
14
15
  * (ggui#1223), and the component mounts inside `ActionSpentContext` too,
15
- * so `useActionSpent` reads the one-shot guard's own spent set. */
16
+ * so `useActionSpent` reads the one-shot guard's own spent set; likewise
17
+ * `actionPending` inside `ActionPendingContext` (ggui#1398), so
18
+ * `useActionPending` reads the card's in-flight dispatches. */
16
19
  readonly scopedWireConfig: BuiltWireConfig | null;
17
20
  /** Shared stream bus — provisional renderer subscribes to
18
21
  * `_ggui:preview` for this render. */
@@ -72,6 +75,8 @@ export interface RenderItemOptions {
72
75
  readonly hostPalette?: Readonly<Record<string, string>>;
73
76
  readonly cssOverrides?: string;
74
77
  readonly onError?: (err: Error) => void;
78
+ /** The boundary's give-up, forwarded to the React mount (ggui#1679; see {@link ReactRootMountOptions.onRenderFailure}). */
79
+ readonly onRenderFailure?: (failure: RenderFailure) => void;
75
80
  /**
76
81
  * Optional outer wrapper applied AROUND the GguiWireProvider — used
77
82
  * by `bootProduction` to install the `<ContextStateHost>` provider
@@ -39,7 +39,7 @@
39
39
  */
40
40
  import { createElement } from 'react';
41
41
  import { GguiWireProvider } from '@ggui-ai/wire';
42
- import { ActionSpentContext } from '@ggui-ai/wire/internal';
42
+ import { ActionPendingContext, ActionSpentContext } from '@ggui-ai/wire/internal';
43
43
  import { mountReactRoot } from './react-renderer.js';
44
44
  import { mountProvisional, } from './provisional-renderer.js';
45
45
  import { mountMcpAppIframe, } from './mcp-app-iframe-host.js';
@@ -189,6 +189,7 @@ export async function mountRender(container, opts) {
189
189
  // without it, and every served hello composed as an inline card.
190
190
  ...(currentOpts.fit !== undefined ? { fit: currentOpts.fit } : {}),
191
191
  ...(currentOpts.onError !== undefined ? { onError: currentOpts.onError } : {}),
192
+ ...(currentOpts.onRenderFailure !== undefined ? { onRenderFailure: currentOpts.onRenderFailure } : {}),
192
193
  renderWrapper: wrapInScopedProvider,
193
194
  });
194
195
  currentKind = 'react';
@@ -214,7 +215,10 @@ export async function mountRender(container, opts) {
214
215
  ? mountedComponent
215
216
  : createElement(GguiWireProvider, {
216
217
  config,
217
- children: createElement(ActionSpentContext.Provider, { value: config.actionSpent, children: mountedComponent }),
218
+ children: createElement(ActionSpentContext.Provider, {
219
+ value: config.actionSpent,
220
+ children: createElement(ActionPendingContext.Provider, { value: config.actionPending, children: mountedComponent }),
221
+ }),
218
222
  });
219
223
  return currentOpts.wrapOuter !== undefined
220
224
  ? currentOpts.wrapOuter(wireWrapped)
@@ -265,6 +269,7 @@ export async function mountRender(container, opts) {
265
269
  ...(next.cssOverrides !== undefined ? { cssOverrides: next.cssOverrides } : {}),
266
270
  ...(next.fit !== undefined ? { fit: next.fit } : {}),
267
271
  ...(next.onError !== undefined ? { onError: next.onError } : {}),
272
+ ...(next.onRenderFailure !== undefined ? { onRenderFailure: next.onRenderFailure } : {}),
268
273
  renderWrapper: wrapInScopedProvider,
269
274
  });
270
275
  return;
@@ -10,38 +10,48 @@
10
10
  * them over the host's `tools/call` postMessage bridge, the one
11
11
  * carrier a CSP jail cannot block.
12
12
  *
13
- * What flows through it:
14
- * - `record()` calls from the boot sequence (path decisions, status
15
- * transitions, doorbell rings);
16
- * - the live-channel's OWN diagnostics via {@link channelLogger} —
17
- * a `ChannelLogger` facade that forwards every
18
- * `channel_failover_swap` / `channel_polling_*` / SSE event the
19
- * transports already emit. Tapping the logger means ZERO new
20
- * instrumentation inside `@ggui-ai/live-channel`.
13
+ * Two batches, never mixed in one call (#1383):
14
+ * - HEALTH — which rung the card booted on, its connection status,
15
+ * whether its doorbell rang, which transport failed. Event names and
16
+ * the card's own ids only: each health kind pins the one `detail`
17
+ * shape it admits (none | id | booleans), and a detail outside that
18
+ * shape is left off the event, never sent free. A host may admit this
19
+ * vocabulary and nothing else, judging a batch whole; keeping the
20
+ * other events out of it means they can never cost it the doorbell
21
+ * ring.
22
+ * - DIAGNOSTIC — the gesture trail and the ladder's finer story (the
23
+ * credential renewals, stream restarts, a live channel's end). Its
24
+ * details are codes, enums, numbers and ids, never free text: no
25
+ * intent strings, no error messages, no URLs.
26
+ * {@link RUNTIME_TELEMETRY_KINDS} is the one table of kinds, and
27
+ * `record` takes only its kinds, so a new emit site is a new table row.
21
28
  *
22
29
  * Posture: bounded, fire-and-forget, diagnostics-only.
23
- * - Buffer caps at the protocol's per-batch limit; overflow drops
24
- * the OLDEST entries (the tail of a failure story beats its
30
+ * - Each batch buffers up to the protocol's per-batch limit; overflow
31
+ * drops the OLDEST entries (the tail of a failure story beats its
25
32
  * preamble).
26
33
  * - First flush is delayed (~4s) so one batch carries the whole
27
- * boot+ladder story; later flushes throttle.
28
- * - A hard per-session flush cap bounds host load; flush failures
29
- * are swallowed (a diagnostics channel must never cause the
30
- * symptoms it reports).
34
+ * boot+ladder story; later flushes throttle. The health batch
35
+ * flushes first.
36
+ * - Each batch has its own per-session flush cap (health 8, diagnostic
37
+ * 4), so refused diagnostic flushes never starve the health batch;
38
+ * flush failures are swallowed (a diagnostics channel must never
39
+ * cause the symptoms it reports).
31
40
  */
32
41
  import type { ChannelLogger } from '@ggui-ai/live-channel';
42
+ import { type RuntimeTelemetryKind } from '@ggui-ai/protocol/runtime-telemetry';
33
43
  /** Narrow slice of `App.callServerTool` the sink needs. */
34
44
  export type TelemetryCallTool = (args: {
35
45
  name: string;
36
46
  arguments: Record<string, unknown>;
37
47
  }) => Promise<unknown>;
38
48
  export interface TelemetrySink {
39
- /** Append one event; schedules a (throttled) flush. */
40
- record(kind: string, detail?: string): void;
49
+ /** Append one event to its kind's batch; schedules a (throttled) flush. */
50
+ record(kind: RuntimeTelemetryKind, detail?: string): void;
41
51
  /**
42
52
  * `ChannelLogger` facade for the live-channel bind — every event the
43
- * transports emit lands in the buffer verbatim (event name = kind,
44
- * fields JSON = detail).
53
+ * transports emit lands in the health batch by name, with no detail
54
+ * (the transports' fields carry URLs and error messages).
45
55
  */
46
56
  readonly channelLogger: ChannelLogger;
47
57
  /** Cancel timers; buffered-but-unflushed events are dropped. */