@ggui-ai/iframe-runtime 0.25.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 (40) 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 -94
  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 +22 -1
  13. package/dist/react-renderer.d.ts +20 -0
  14. package/dist/react-renderer.js +22 -0
  15. package/dist/registry-subscribe.d.ts +37 -1
  16. package/dist/registry-subscribe.js +34 -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 +3 -0
  20. package/dist/render-item.js +2 -0
  21. package/dist/runtime-telemetry.d.ts +28 -18
  22. package/dist/runtime-telemetry.js +68 -29
  23. package/dist/runtime.d.ts +114 -35
  24. package/dist/runtime.js +556 -309
  25. package/dist/stream-seq.d.ts +56 -0
  26. package/dist/stream-seq.js +46 -0
  27. package/dist/system-cards/ProtocolProbeCard.d.ts +16 -0
  28. package/dist/system-cards/ProtocolProbeCard.js +17 -14
  29. package/dist/tool-result-error.d.ts +6 -0
  30. package/dist/tool-result-error.js +32 -0
  31. package/dist/types.d.ts +49 -1
  32. package/dist/view-origin.d.ts +29 -0
  33. package/dist/view-origin.js +33 -0
  34. package/dist/view-proof-signer.d.ts +31 -0
  35. package/dist/view-proof-signer.js +99 -0
  36. package/dist/view-root.d.ts +51 -0
  37. package/dist/view-root.js +56 -0
  38. package/dist/wire-config.d.ts +13 -0
  39. package/dist/wire-config.js +39 -1
  40. package/package.json +8 -7
@@ -113,9 +113,23 @@ class RcrErrorBoundary extends Component {
113
113
  autoRetrying: false,
114
114
  };
115
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;
116
122
  static getDerivedStateFromError(error) {
117
123
  return { error };
118
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
+ }
119
133
  componentDidCatch(error, _info) {
120
134
  const nextCount = this.state.catchCount + 1;
121
135
  this.setState({ catchCount: nextCount });
@@ -126,6 +140,13 @@ class RcrErrorBoundary extends Component {
126
140
  }, AUTO_RETRY_DELAY);
127
141
  return;
128
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
+ });
129
150
  this.props.onError?.(error);
130
151
  }
131
152
  componentWillUnmount() {
@@ -464,6 +485,7 @@ export async function mountReactRoot(container, opts) {
464
485
  root.render(createElement('div', { className: scopeClass, ref: setScopeEl }, createElement('style', null, themeCss), createElement(RcrErrorBoundary, {
465
486
  key: currentCode?.length ?? 0,
466
487
  ...(opts.onError ? { onError: opts.onError } : {}),
488
+ ...(opts.onRenderFailure ? { onRenderFailure: opts.onRenderFailure } : {}),
467
489
  }, createElement(Fragment, null, wrapped)), createElement(CommitProbe, { onCommit })));
468
490
  // ggui#1104 — a held component must commit. Armed only when this render
469
491
  // carries one; the probe's commit disarms it.
@@ -2,7 +2,7 @@ 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
4
  import type { AnyTransportHandle, ChannelLogger, ChannelRegistry, FrameClassifier, RegistryPollingOptions, RegistrySseOptions, WsTransportHandle } from '@ggui-ai/live-channel';
5
- import { type ProtocolErrorEmitter } from './protocol-error.js';
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,6 +158,13 @@ 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
  }
133
169
  /**
134
170
  * The iframe's verdict on channel frames for the WebSocket retry budget
@@ -246,15 +246,15 @@ export function connectViaRegistry(opts) {
246
246
  opts.registry.register({
247
247
  type: 'error',
248
248
  onMessage: (payload) => {
249
- // Post-ack error frames are NOT handled here — the runtime
250
- // doesn't currently route post-ack `error` frames through a
251
- // registered handler (they used to surface via the legacy
252
- // `onMessage` callback; B3b drops that channel since
253
- // post-ack errors are rare + the host inspector logs them
254
- // through the typed ProtocolError emitter).
255
- if (settled)
256
- return;
257
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
+ }
258
258
  const upgrade = liftUpgradeRequiredFromError(errPayload);
259
259
  if (upgrade !== null) {
260
260
  settled = true;
@@ -270,6 +270,17 @@ export function connectViaRegistry(opts) {
270
270
  }
271
271
  settled = true;
272
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
+ }
273
284
  reject(new Error(errPayload.message ?? errPayload.code));
274
285
  },
275
286
  });
@@ -277,6 +288,8 @@ export function connectViaRegistry(opts) {
277
288
  // payload to the bind continuation without sharing a mutable
278
289
  // local with the outer Promise body.
279
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 };
280
293
  const mappedStatusCallback = (status) => {
281
294
  // `connecting` is the initial state from WSTransport — first
282
295
  // surfaced concurrent with `start()`. Subsequent `connecting`
@@ -341,6 +354,7 @@ export function connectViaRegistry(opts) {
341
354
  ...(opts.bridge !== undefined ? { bridge: opts.bridge } : {}),
342
355
  })
343
356
  .then((bound) => {
357
+ opts.onBound?.(bound);
344
358
  if (!hasTrio) {
345
359
  // Bridge-only bind — no subscribe handshake, no ack. The
346
360
  // polling facade reports 'open' immediately (mapped to
@@ -363,6 +377,18 @@ export function connectViaRegistry(opts) {
363
377
  return;
364
378
  }
365
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
+ };
366
392
  // If we already received the ack while bind was resolving,
367
393
  // settle the outer Promise now.
368
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. */
@@ -74,6 +75,8 @@ export interface RenderItemOptions {
74
75
  readonly hostPalette?: Readonly<Record<string, string>>;
75
76
  readonly cssOverrides?: string;
76
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;
77
80
  /**
78
81
  * Optional outer wrapper applied AROUND the GguiWireProvider — used
79
82
  * by `bootProduction` to install the `<ContextStateHost>` provider
@@ -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';
@@ -268,6 +269,7 @@ export async function mountRender(container, opts) {
268
269
  ...(next.cssOverrides !== undefined ? { cssOverrides: next.cssOverrides } : {}),
269
270
  ...(next.fit !== undefined ? { fit: next.fit } : {}),
270
271
  ...(next.onError !== undefined ? { onError: next.onError } : {}),
272
+ ...(next.onRenderFailure !== undefined ? { onRenderFailure: next.onRenderFailure } : {}),
271
273
  renderWrapper: wrapInScopedProvider,
272
274
  });
273
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. */
@@ -1,24 +1,58 @@
1
+ import { RUNTIME_TELEMETRY_KINDS, } from '@ggui-ai/protocol/runtime-telemetry';
1
2
  import { RUNTIME_TELEMETRY_MAX_EVENTS } from '@ggui-ai/protocol/wire';
2
3
  const FIRST_FLUSH_DELAY_MS = 4_000;
3
4
  const FLUSH_THROTTLE_MS = 8_000;
4
- const MAX_FLUSHES_PER_SESSION = 12;
5
+ const MAX_HEALTH_FLUSHES_PER_SESSION = 8;
6
+ const MAX_DIAGNOSTIC_FLUSHES_PER_SESSION = 4;
5
7
  const MAX_DETAIL_CHARS = 500;
8
+ /** The kind vocabulary (`@ggui-ai/protocol/runtime-telemetry`, ggui#1381), indexed by kind. */
9
+ const SPEC_BY_KIND = new Map(RUNTIME_TELEMETRY_KINDS.map((s) => [s.kind, s]));
10
+ const ID_DETAIL = /^[A-Za-z0-9_.:-]{1,128}$/;
11
+ function isBooleansObject(detail) {
12
+ let parsed;
13
+ try {
14
+ parsed = JSON.parse(detail);
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
20
+ return false;
21
+ const values = Object.values(parsed);
22
+ return values.length > 0 && values.every((v) => typeof v === 'boolean');
23
+ }
24
+ /** The detail a kind may carry, or `undefined` when its shape does not admit this one. */
25
+ function admittedDetail(spec, detail) {
26
+ if (detail === undefined || spec === undefined)
27
+ return undefined;
28
+ switch (spec.detail) {
29
+ case 'none':
30
+ return undefined;
31
+ case 'id':
32
+ return ID_DETAIL.test(detail) ? detail : undefined;
33
+ case 'booleans':
34
+ return detail.length <= MAX_DETAIL_CHARS && isBooleansObject(detail) ? detail : undefined;
35
+ case 'fields':
36
+ return detail.slice(0, MAX_DETAIL_CHARS);
37
+ }
38
+ }
6
39
  export function createTelemetrySink(opts) {
7
40
  const bootAt = Date.now();
8
- const buffer = [];
9
- let flushes = 0;
41
+ const batches = {
42
+ health: { events: [], flushes: 0, maxFlushes: MAX_HEALTH_FLUSHES_PER_SESSION },
43
+ diagnostic: { events: [], flushes: 0, maxFlushes: MAX_DIAGNOSTIC_FLUSHES_PER_SESSION },
44
+ };
45
+ const order = ['health', 'diagnostic'];
10
46
  let timer = null;
11
47
  let disposed = false;
12
48
  let firstFlushDone = false;
13
- const flush = () => {
14
- timer = null;
15
- if (disposed || buffer.length === 0)
16
- return;
17
- if (flushes >= MAX_FLUSHES_PER_SESSION)
49
+ const canFlush = (b) => batches[b].events.length > 0 && batches[b].flushes < batches[b].maxFlushes;
50
+ const send = (b) => {
51
+ if (!canFlush(b))
18
52
  return;
19
- flushes += 1;
20
- firstFlushDone = true;
21
- const events = buffer.splice(0, buffer.length);
53
+ const batch = batches[b];
54
+ batch.flushes += 1;
55
+ const events = batch.events.splice(0, batch.events.length);
22
56
  void opts
23
57
  .callTool({
24
58
  name: 'ggui_runtime_telemetry',
@@ -29,39 +63,44 @@ export function createTelemetrySink(opts) {
29
63
  // cause the failures it exists to report.
30
64
  });
31
65
  };
66
+ const flush = () => {
67
+ timer = null;
68
+ if (disposed)
69
+ return;
70
+ firstFlushDone = true;
71
+ for (const b of order)
72
+ send(b);
73
+ };
32
74
  const scheduleFlush = () => {
33
75
  if (disposed || timer !== null)
34
76
  return;
35
- if (flushes >= MAX_FLUSHES_PER_SESSION)
77
+ if (!order.some(canFlush))
36
78
  return;
37
79
  timer = setTimeout(flush, firstFlushDone ? FLUSH_THROTTLE_MS : FIRST_FLUSH_DELAY_MS);
38
80
  };
39
- const record = (kind, detail) => {
81
+ const append = (kind, detail) => {
40
82
  if (disposed)
41
83
  return;
42
- if (buffer.length >= RUNTIME_TELEMETRY_MAX_EVENTS)
43
- buffer.shift();
44
- buffer.push({
84
+ const spec = SPEC_BY_KIND.get(kind);
85
+ // An unlisted kind (a caller past the types) travels as diagnostic, and without its detail.
86
+ const batch = batches[spec?.batch ?? 'diagnostic'];
87
+ if (batch.events.length >= RUNTIME_TELEMETRY_MAX_EVENTS)
88
+ batch.events.shift();
89
+ const kept = admittedDetail(spec, detail);
90
+ batch.events.push({
45
91
  at: Math.max(0, Date.now() - bootAt),
46
92
  kind: kind.slice(0, 64),
47
- ...(detail !== undefined ? { detail: detail.slice(0, MAX_DETAIL_CHARS) } : {}),
93
+ ...(kept !== undefined ? { detail: kept } : {}),
48
94
  });
49
95
  scheduleFlush();
50
96
  };
51
- const fieldsToDetail = (fields) => {
52
- try {
53
- return JSON.stringify(fields).slice(0, MAX_DETAIL_CHARS);
54
- }
55
- catch {
56
- return '[unserializable]';
57
- }
58
- };
97
+ const onChannelEvent = (event) => append(event, undefined);
59
98
  return {
60
- record,
99
+ record: (kind, detail) => append(kind, detail),
61
100
  channelLogger: {
62
- info: (event, fields) => record(event, fieldsToDetail(fields)),
63
- warn: (event, fields) => record(event, fieldsToDetail(fields)),
64
- debug: (event, fields) => record(event, fieldsToDetail(fields)),
101
+ info: onChannelEvent,
102
+ warn: onChannelEvent,
103
+ debug: onChannelEvent,
65
104
  },
66
105
  dispose() {
67
106
  disposed = true;