@ggui-ai/iframe-runtime 0.27.0 → 0.28.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.
@@ -36,11 +36,21 @@ export interface CredentialControllerOptions {
36
36
  readonly sleep?: (ms: number) => Promise<void>;
37
37
  /** Test seam: defaults to `Math.random`. */
38
38
  readonly random?: () => number;
39
+ /** Test seam: the view's clock, in ms; defaults to `Date.now`. */
40
+ readonly now?: () => number;
39
41
  }
40
42
  export interface CredentialController {
41
43
  current(): HeldCredential;
42
44
  /** The credential was accepted on a token rung (a WS or SSE ack, or a polling `ok`). */
43
45
  markAccepted(credential: HeldCredential): void;
46
+ /**
47
+ * Whether a bridge pull should report `credential` expired now (ggui#1734):
48
+ * it is the current credential, its `expiresAt` has passed by the view's
49
+ * clock, and {@link BRIDGE_REFRESH_INTERVAL_MS} has passed since the last
50
+ * refresh attempt on any rung (or there was none). A credential with no
51
+ * `expiresAt` is never due.
52
+ */
53
+ dueOnBridge(credential: HeldCredential): boolean;
44
54
  /**
45
55
  * `credential` is the one the reporting ladder was built with. `retry`
46
56
  * re-sends the SAME request after a relay error, inside the one budgeted
@@ -60,12 +70,23 @@ export interface RefreshRetry {
60
70
  export declare const REFRESH_JITTER_MAX_MS = 5000;
61
71
  /**
62
72
  * The boot refresh of a live-only slice is the view's only way to a live
63
- * channel, so a relay error there (the host's relay failed, or the
64
- * server's store read threw) is re-sent this many times before the boot
65
- * reports `EXPIRED_BOOTSTRAP`, each after a uniform random wait between
73
+ * channel, and so is a bridge-rung refresh (the ladder has left every token
74
+ * rung), so a relay error on either (the host's relay failed, or the
75
+ * server's store read threw) is re-sent this many times inside the one
76
+ * attempt — before the boot reports `EXPIRED_BOOTSTRAP`, or before the bridge
77
+ * waits out its next interval — each after a uniform random wait between
66
78
  * these bounds.
67
79
  */
68
- export declare const BOOT_REFRESH_RETRIES = 2;
80
+ export declare const REFRESH_RELAY_ERROR_RETRIES = 2;
69
81
  export declare const REFRESH_RETRY_DELAY_MIN_MS = 1000;
70
82
  export declare const REFRESH_RETRY_DELAY_MAX_MS = 3000;
83
+ /**
84
+ * The bridge rung's attempt clock (ggui#1734): from the bridge, a view asks
85
+ * for a refresh at most once per this span, counted from its last attempt on
86
+ * any rung and whatever that attempt answered. It is the server's default
87
+ * credential lifetime, the one number both readers share; a server that
88
+ * mints longer-lived credentials slows the heartbeat through `expiresAt`, a
89
+ * shorter-lived one does not speed it past this floor.
90
+ */
91
+ export declare const BRIDGE_REFRESH_INTERVAL_MS: number;
71
92
  export declare function createCredentialController(opts: CredentialControllerOptions): CredentialController;
@@ -10,20 +10,30 @@
10
10
  * the credential and the budget; the runtime owns the ladders and tells it
11
11
  * when a credential expired or was accepted.
12
12
  *
13
- * The budget is one predicate:
14
- * - a credential that came back from a refresh and has NOT been accepted
15
- * on a token rung is never refreshed again, whether it was refused or
16
- * left the token rungs (the loop guard);
17
- * - any other expired credential gets exactly one refresh.
18
- * A credential is marked spent when its refresh is REQUESTED, so triggers
19
- * that fire together (a WS refusal, the polling 410, entering the bridge)
20
- * produce one request.
13
+ * The budget is one predicate per rung class:
14
+ * - On a token rung (a WS refusal, the polling 410, the boot): a credential
15
+ * that came back from a refresh and has NOT been accepted on a token rung
16
+ * is never refreshed again, whether it was refused or left the token
17
+ * rungs (the loop guard); any other expired credential gets exactly one
18
+ * refresh. A credential is marked spent when its refresh is REQUESTED, so
19
+ * triggers that fire together produce one request.
20
+ * - On the bridge rung (ggui#1734): no rung carries the credential there, so
21
+ * only the view's own clock can say it expired, and a relaying host's pull
22
+ * circuit half-opens only on a refresh the session grants. So the view
23
+ * asks again from the bridge whenever the current credential's `expiresAt`
24
+ * has passed and at least {@link BRIDGE_REFRESH_INTERVAL_MS} has passed
25
+ * since its last attempt on ANY rung — whatever that attempt answered,
26
+ * because the attempt that fails is the one inside the outage that opened
27
+ * the circuit. The interval is the storm bound, and it is the floor that
28
+ * keeps a view whose clock runs ahead of the server's from reading every
29
+ * fresh credential as expired.
21
30
  *
22
31
  * Expiry is always reported by the caller, from the slice's `expiresAt` or
23
32
  * from a refusal signal, never derived from a token's issue time: a
24
33
  * chained token can legitimately live less than a full TTL.
25
34
  */
26
35
  import { withWsToken } from "@ggui-ai/protocol/integrations/mcp-apps";
36
+ import { DEFAULT_WS_TOKEN_TTL_SEC } from "@ggui-ai/protocol/transport/websocket";
27
37
  import { unwrapCallToolResult } from "./call-tool-unwrap.js";
28
38
  import { domainErrorCodeOf, isErrorToolResult, toolResultText } from "./tool-result-error.js";
29
39
  /**
@@ -34,23 +44,41 @@ import { domainErrorCodeOf, isErrorToolResult, toolResultText } from "./tool-res
34
44
  export const REFRESH_JITTER_MAX_MS = 5_000;
35
45
  /**
36
46
  * The boot refresh of a live-only slice is the view's only way to a live
37
- * channel, so a relay error there (the host's relay failed, or the
38
- * server's store read threw) is re-sent this many times before the boot
39
- * reports `EXPIRED_BOOTSTRAP`, each after a uniform random wait between
47
+ * channel, and so is a bridge-rung refresh (the ladder has left every token
48
+ * rung), so a relay error on either (the host's relay failed, or the
49
+ * server's store read threw) is re-sent this many times inside the one
50
+ * attempt — before the boot reports `EXPIRED_BOOTSTRAP`, or before the bridge
51
+ * waits out its next interval — each after a uniform random wait between
40
52
  * these bounds.
41
53
  */
42
- export const BOOT_REFRESH_RETRIES = 2;
54
+ export const REFRESH_RELAY_ERROR_RETRIES = 2;
43
55
  export const REFRESH_RETRY_DELAY_MIN_MS = 1_000;
44
56
  export const REFRESH_RETRY_DELAY_MAX_MS = 3_000;
57
+ /**
58
+ * The bridge rung's attempt clock (ggui#1734): from the bridge, a view asks
59
+ * for a refresh at most once per this span, counted from its last attempt on
60
+ * any rung and whatever that attempt answered. It is the server's default
61
+ * credential lifetime, the one number both readers share; a server that
62
+ * mints longer-lived credentials slows the heartbeat through `expiresAt`, a
63
+ * shorter-lived one does not speed it past this floor.
64
+ */
65
+ export const BRIDGE_REFRESH_INTERVAL_MS = DEFAULT_WS_TOKEN_TTL_SEC * 1_000;
45
66
  const REFRESH_TOOL = "ggui_runtime_refresh_ws_token";
46
67
  export function createCredentialController(opts) {
47
68
  const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
48
69
  const random = opts.random ?? Math.random;
70
+ const now = opts.now ?? Date.now;
49
71
  let current = opts.initial;
50
72
  const spent = new Set();
51
73
  const accepted = new Set();
52
- const mayRefresh = (credential) => !spent.has(credential.wsToken) &&
53
- !(credential.origin === "refreshed" && !accepted.has(credential.wsToken));
74
+ /** The view's clock at the last refresh request, on any rung. */
75
+ let lastAttemptAt;
76
+ const expired = (credential) => credential.expiresAt !== undefined && Date.parse(credential.expiresAt) <= now();
77
+ const intervalPassed = () => lastAttemptAt === undefined || now() - lastAttemptAt >= BRIDGE_REFRESH_INTERVAL_MS;
78
+ const mayRefresh = (credential, source) => source === "bridge"
79
+ ? expired(credential) && intervalPassed()
80
+ : !spent.has(credential.wsToken) &&
81
+ !(credential.origin === "refreshed" && !accepted.has(credential.wsToken));
54
82
  const requestRefresh = async (credential) => {
55
83
  let result;
56
84
  try {
@@ -66,12 +94,14 @@ export function createCredentialController(opts) {
66
94
  markAccepted(credential) {
67
95
  accepted.add(credential.wsToken);
68
96
  },
97
+ dueOnBridge: (credential) => credential === current && mayRefresh(credential, "bridge"),
69
98
  async onExpired(credential, source, retry) {
70
99
  if (credential !== current)
71
100
  return { kind: "skipped", reason: "stale-ladder" };
72
- if (!mayRefresh(credential))
101
+ if (!mayRefresh(credential, source))
73
102
  return { kind: "skipped", reason: "budget" };
74
103
  spent.add(credential.wsToken);
104
+ lastAttemptAt = now();
75
105
  if (source !== "boot")
76
106
  await sleep(Math.floor(random() * REFRESH_JITTER_MAX_MS));
77
107
  let outcome = await requestRefresh(credential);
@@ -231,8 +231,9 @@ export function buildBridgePolling(opts) {
231
231
  // holds the card is presumed quiet; drop to sparse un-held
232
232
  // pulls every `idleIntervalMs` so a dormant card doesn't pin
233
233
  // the host's relay open forever.
234
- // - A FAILED pull (the relay's own `tools/call` timeout, `-32001`,
235
- // a transport error) is not a live session either (ggui#1029):
234
+ // - A FAILED pull (the view's own request timeout — the MCP SDK's
235
+ // 60 s default on `callServerTool`, answered as `-32001` — or a
236
+ // transport error) is not a live session either (ggui#1029):
236
237
  // it counts toward demotion exactly like an empty hold, and
237
238
  // repeated failures back off geometrically from `intervalMs`
238
239
  // up to `idleIntervalMs` — a relay whose call timeout is shorter