@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.
- package/README.md +2 -2
- package/dist/channels/data.d.ts +8 -0
- package/dist/channels/data.js +2 -0
- package/dist/credential-controller.d.ts +71 -0
- package/dist/credential-controller.js +135 -0
- package/dist/iframe-runtime.js +97 -94
- package/dist/iframe-runtime.meta.json +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/ladders.d.ts +77 -0
- package/dist/ladders.js +383 -0
- package/dist/meta-parse.js +38 -8
- package/dist/observability.d.ts +22 -1
- package/dist/react-renderer.d.ts +20 -0
- package/dist/react-renderer.js +22 -0
- package/dist/registry-subscribe.d.ts +37 -1
- package/dist/registry-subscribe.js +34 -8
- package/dist/render-failure-report.d.ts +15 -0
- package/dist/render-failure-report.js +61 -0
- package/dist/render-item.d.ts +3 -0
- package/dist/render-item.js +2 -0
- package/dist/runtime-telemetry.d.ts +28 -18
- package/dist/runtime-telemetry.js +68 -29
- package/dist/runtime.d.ts +114 -35
- package/dist/runtime.js +556 -309
- package/dist/stream-seq.d.ts +56 -0
- package/dist/stream-seq.js +46 -0
- package/dist/system-cards/ProtocolProbeCard.d.ts +16 -0
- package/dist/system-cards/ProtocolProbeCard.js +17 -14
- package/dist/tool-result-error.d.ts +6 -0
- package/dist/tool-result-error.js +32 -0
- package/dist/types.d.ts +49 -1
- package/dist/view-origin.d.ts +29 -0
- package/dist/view-origin.js +33 -0
- package/dist/view-proof-signer.d.ts +31 -0
- package/dist/view-proof-signer.js +99 -0
- package/dist/view-root.d.ts +51 -0
- package/dist/view-root.js +56 -0
- package/dist/wire-config.d.ts +13 -0
- package/dist/wire-config.js +39 -1
- package/package.json +8 -7
package/dist/react-renderer.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/render-item.d.ts
CHANGED
|
@@ -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
|
package/dist/render-item.js
CHANGED
|
@@ -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
|
-
*
|
|
14
|
-
* -
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* a
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
* -
|
|
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
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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:
|
|
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
|
|
44
|
-
* fields
|
|
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
|
|
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
|
|
9
|
-
|
|
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
|
|
14
|
-
|
|
15
|
-
if (
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
const events =
|
|
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 (
|
|
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
|
|
81
|
+
const append = (kind, detail) => {
|
|
40
82
|
if (disposed)
|
|
41
83
|
return;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
...(
|
|
93
|
+
...(kept !== undefined ? { detail: kept } : {}),
|
|
48
94
|
});
|
|
49
95
|
scheduleFlush();
|
|
50
96
|
};
|
|
51
|
-
const
|
|
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:
|
|
63
|
-
warn:
|
|
64
|
-
debug:
|
|
101
|
+
info: onChannelEvent,
|
|
102
|
+
warn: onChannelEvent,
|
|
103
|
+
debug: onChannelEvent,
|
|
65
104
|
},
|
|
66
105
|
dispose() {
|
|
67
106
|
disposed = true;
|