@econ-v1/rpc 7.2.10 → 7.3.1

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.
@@ -12,19 +12,42 @@
12
12
  */
13
13
  const DOMAIN_COPY = {
14
14
  "access-code-invalid": { message: "That access code wasn't accepted.", showRetry: false },
15
+ approval_denied: { message: "That action wasn't approved.", showRetry: false },
15
16
  capability_not_declared: { message: "This app asked for something it isn't allowed to use.", showRetry: false },
16
17
  capability_unavailable: { message: "This app isn't running on your node.", showRetry: true },
17
18
  child_unavailable: { message: "This part of the app isn't available right now.", showRetry: false },
18
19
  "code-already-bound": { message: "That access code has already been used.", showRetry: false },
19
20
  conflict: { message: "Someone else changed this first.", showRetry: true },
21
+ forbidden: { message: "This app isn't allowed to do that.", showRetry: false },
22
+ invalid_payload: { message: "Some of those details weren't accepted.", showRetry: false },
20
23
  invalid_request: { message: "Your node couldn't understand that request.", showRetry: false },
21
24
  not_found: { message: "That isn't here any more.", showRetry: false },
25
+ scope_unavailable: { message: "There's nothing to look at there right now.", showRetry: true },
26
+ script_timeout: { message: "That script took too long and was stopped.", showRetry: false },
27
+ script_unavailable: { message: "Scripts can't run in this tab right now.", showRetry: true },
22
28
  stage_composition_not_enabled: { message: "This screen can't open other apps here.", showRetry: false },
29
+ timeout: { message: "That took too long. Please try again.", showRetry: true },
23
30
  unauthorized: { message: "This device isn't allowed to do that.", showRetry: false },
31
+ unknown_command: { message: "Your node doesn't support that action yet.", showRetry: false },
24
32
  unsupported_query: { message: "Your node doesn't support this yet.", showRetry: false },
25
33
  validation_failed: { message: "Some of those details weren't accepted.", showRetry: false },
26
34
  };
27
35
 
36
+ /**
37
+ * A `forbidden` error names why in `details.reason`; each reason gets its own copy so the user
38
+ * learns what to do, not just that they were refused. An unrecognised reason falls back to the
39
+ * generic `forbidden` copy.
40
+ *
41
+ * @type {Readonly<Record<string, { readonly message: string, readonly showRetry: boolean }>>}
42
+ */
43
+ const FORBIDDEN_REASON_COPY = {
44
+ grant_request_invalid: { message: "That permission request has expired. Ask again to continue.", showRetry: false },
45
+ not_exposed: { message: "That isn't available to apps.", showRetry: false },
46
+ not_granted: { message: "This app hasn't been given permission for that yet.", showRetry: false },
47
+ owner_only: { message: "Only the owner's paired device can do that.", showRetry: false },
48
+ reserved_key: { message: "That setting is managed by your node and can't be changed here.", showRetry: false },
49
+ };
50
+
28
51
  /**
29
52
  * Maps any thrown value to what the UI should do with it.
30
53
  *
@@ -51,7 +74,11 @@ export function describeClientError(error) {
51
74
  }
52
75
 
53
76
  if (kind === "domain" && typeof code === "string") {
54
- const copy = DOMAIN_COPY[code];
77
+ const details = /** @type {{ details?: unknown }} */ (error)?.details;
78
+ const reason = code === "forbidden" && typeof details === "object" && details !== null
79
+ ? /** @type {{ reason?: unknown }} */ (details).reason
80
+ : undefined;
81
+ const copy = (typeof reason === "string" ? FORBIDDEN_REASON_COPY[reason] : undefined) ?? DOMAIN_COPY[code];
55
82
  if (copy !== undefined) {
56
83
  return Object.freeze({ ...copy, surface: /** @type {const} */ ("inline") });
57
84
  }
@@ -0,0 +1,8 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * The one grant the shell can record today: page inspection (console, network, page structure
5
+ * and owner-approved scripts). The kernel's grant policy and the shell's consent dialog and
6
+ * settings page all name it through this constant so the id cannot drift between them.
7
+ */
8
+ export const DEVTOOLS_GRANT = "devtools.inspect";
@@ -0,0 +1,60 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * @typedef {{ type: string, id: string, label?: string }} HostContextRef
5
+ * @typedef {{ screen: string, entity?: HostContextRef, selection?: HostContextRef[], summary?: string, facts?: Record<string, string | number | boolean | null> }} HostContextView
6
+ */
7
+
8
+ /** Serialized size limit of one `ctx.host.context.set` view (spec §4.5.2). */
9
+ export const HOST_CONTEXT_MAX_BYTES = 4096;
10
+ const VIEW_KEYS = new Set(["screen", "entity", "selection", "summary", "facts"]);
11
+ const REF_KEYS = new Set(["type", "id", "label"]);
12
+
13
+ /** UTF-8 length without `TextEncoder`, which this platform-free package cannot assume.
14
+ * @param {string} text */
15
+ function utf8Length(text) {
16
+ let bytes = 0;
17
+ for (const char of text) {
18
+ const code = /** @type {number} */ (char.codePointAt(0));
19
+ bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4;
20
+ }
21
+ return bytes;
22
+ }
23
+ /** @param {unknown} value @returns {value is HostContextRef} */
24
+ function isRef(value) {
25
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
26
+ const ref = /** @type {Record<string, unknown>} */ (value);
27
+ return Object.keys(ref).every((key) => REF_KEYS.has(key)) && typeof ref.type === "string" && typeof ref.id === "string"
28
+ && (ref.label === undefined || typeof ref.label === "string");
29
+ }
30
+
31
+ /**
32
+ * The one rule set for a stage-reported view. The shell checks it before reporting and the
33
+ * kernel checks it again on receipt, so neither trusts the other's copy. Returns a detached
34
+ * JSON copy on success (caller getters are read once) or the reason it was rejected.
35
+ * @param {unknown} value @returns {string | HostContextView}
36
+ */
37
+ export function validateHostContextView(value) {
38
+ try {
39
+ const json = JSON.stringify(value);
40
+ if (utf8Length(json) > HOST_CONTEXT_MAX_BYTES) return `view exceeds ${HOST_CONTEXT_MAX_BYTES} bytes`;
41
+ value = JSON.parse(json);
42
+ } catch { return "view must be JSON"; }
43
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return "view must be an object";
44
+ const view = /** @type {Record<string, unknown>} */ (value);
45
+ const unknown = Object.keys(view).find((key) => !VIEW_KEYS.has(key));
46
+ if (unknown !== undefined) return `view has unknown key ${unknown}`;
47
+ if (typeof view.screen !== "string" || view.screen.length === 0 || view.screen.length > 64) return "screen must be 1–64 chars";
48
+ if (view.summary !== undefined && (typeof view.summary !== "string" || view.summary.length > 280)) return "summary must be ≤ 280 chars";
49
+ if (view.entity !== undefined && !isRef(view.entity)) return "entity must be { type, id, label? } strings";
50
+ if (view.selection !== undefined) {
51
+ if (!Array.isArray(view.selection) || view.selection.length > 20) return "selection must have ≤ 20 items";
52
+ if (!view.selection.every(isRef)) return "selection items must be { type, id, label? } strings";
53
+ }
54
+ if (view.facts !== undefined) {
55
+ if (view.facts === null || typeof view.facts !== "object" || Array.isArray(view.facts)) return "facts must be an object";
56
+ const entries = Object.entries(view.facts);
57
+ if (entries.length > 32 || entries.some(([, v]) => v !== null && !["string", "number", "boolean"].includes(typeof v))) return "facts must have ≤ 32 primitive values";
58
+ }
59
+ return /** @type {HostContextView} */ (view);
60
+ }
package/src/index.js CHANGED
@@ -8,6 +8,9 @@
8
8
  /** @typedef {import("./client.js").PreparedStage} PreparedStage */
9
9
  /** @typedef {import("./client.js").StageRpcSession} StageRpcSession */
10
10
  /** @typedef {import("./client.js").Unsubscribe} Unsubscribe */
11
+ /** @typedef {import("./client.js").EventSubscribeOptions} EventSubscribeOptions */
12
+ /** @typedef {import("./client.js").EventSubscription} EventSubscription */
13
+ /** @typedef {import("./client.js").SubscriptionState} SubscriptionState */
11
14
  /** @typedef {import("@econ-v1/domain").ClientError} ClientError */
12
15
  /** @typedef {import("@econ-v1/domain").DomainEvent} DomainEvent */
13
16
  /** @typedef {import("@econ-v1/domain").DomainErrorCode} DomainErrorCode */
@@ -201,9 +204,12 @@ export {
201
204
  serializeClientError,
202
205
  } from "./errors.js";
203
206
  export { DEFAULT_REQUEST_TIMEOUT_MS, KernelRpcTimeoutError, MessagePortKernelRpcClient } from "./message-port-client.js";
207
+ export { SubscriptionStateCell } from "./subscription-state.js";
204
208
  export { NODE_APP_LIFECYCLE_COMMAND, NODE_APP_MEMORY_CAPTURE_COMMAND, NODE_APP_MEMORY_DETAIL_QUERY, NODE_APP_MEMORY_DOWNLOAD_COMMAND, NODE_APP_MEMORY_LIST_QUERY, NODE_APP_MEMORY_OVERVIEW_QUERY, NODE_APP_MEMORY_STATUS_COMMAND } from "./node-app-memory-contract.js";
205
209
  export { APPS_LIFECYCLE_PIN_COMMAND, APPS_LIFECYCLE_QUERY } from "./apps-lifecycle-contract.js";
206
210
  export { CLIENT_SERVICES_RETRY_COMMAND } from "./client-services-contract.js";
211
+ export { DEVTOOLS_GRANT } from "./grants-contract.js";
212
+ export { HOST_CONTEXT_MAX_BYTES, validateHostContextView } from "./host-context-contract.js";
207
213
  export { L402_ENFORCEMENT_QUERY, L402_ENFORCEMENT_ROW_ID, L402_ENFORCEMENT_SET_COMMAND } from "./l402-enforcement-contract.js";
208
214
  export { isRouteMode, MODEL_PICKER_CATALOG_QUERY, MODEL_PICKER_CATALOG_ROW_ID, MODEL_PICKER_RECENTS_LIMIT, MODEL_PICKER_RECENTS_SETTING, MODEL_PICKER_STAGE_ID, ROUTE_MODES, WEB_SEARCH_AUTO } from "./model-picker-contract.js";
209
215
  export {
@@ -276,3 +282,7 @@ export { CHILD_ACTIONS, CHILD_STATE_CODES, describeChildState, isTransientChildS
276
282
  /** @typedef {import("./child-state-contract.js").ChildAction} ChildAction */
277
283
  export { createNodeHealthQuery, NODE_HEALTH_QUERY } from "./node-health-query.js";
278
284
  export { createResponsivenessTracker, EMA_HOLD_MS } from "./node-responsiveness.js";
285
+ /** @typedef {import("@econ-v1/ports").DevtoolsScriptContext} DevtoolsScriptContext */
286
+ /** @typedef {import("@econ-v1/ports").DevtoolsScriptHostPort} DevtoolsScriptHostPort */
287
+ /** @typedef {import("./host-context-contract.js").HostContextRef} HostContextRef */
288
+ /** @typedef {import("./host-context-contract.js").HostContextView} HostContextView */
@@ -1,11 +1,13 @@
1
1
  // @ts-check
2
2
  import {
3
3
  DomainError,
4
+ SyncError,
4
5
  TransportError,
5
6
  } from "@econ-v1/domain";
6
7
 
7
8
  import { isJsonValue } from "./errors.js";
8
9
  import { EMA_REFERENCE_TIMEOUT_MS, slowBarMs } from "./node-responsiveness.js";
10
+ import { SubscriptionStateCell } from "./subscription-state.js";
9
11
  import {
10
12
  deserializeRpcEvent,
11
13
  deserializeRpcResponse,
@@ -20,11 +22,18 @@ import {
20
22
  /** @typedef {import("./client.js").PreparedStage} PreparedStage */
21
23
  /** @typedef {import("./client.js").StageRpcSession} StageRpcSession */
22
24
  /** @typedef {import("./client.js").Unsubscribe} Unsubscribe */
25
+ /** @typedef {import("./client.js").EventSubscribeOptions} EventSubscribeOptions */
26
+ /** @typedef {import("./client.js").EventSubscription} EventSubscription */
27
+ /** @typedef {import("./client.js").SubscriptionState} SubscriptionState */
23
28
  /** @typedef {import("./protocol.js").RpcRequest} RpcRequest */
24
29
  /** @typedef {RpcRequest extends infer TRequest ? TRequest extends RpcRequest ? Omit<TRequest, "id"> : never : never} RpcRequestWithoutId */
25
30
  /** @typedef {import("./transport.js").RpcTransport} RpcTransport */
26
31
  /** @typedef {{ readonly reject: (error: unknown) => void, readonly resolve: (value: JsonValue) => void, readonly sessionId: string | undefined, readonly slowBarId: ReturnType<typeof globalThis.setTimeout>, readonly startedAtMs: number, readonly timeoutId: ReturnType<typeof globalThis.setTimeout>, readonly timeoutMs: number }} PendingRequest */
27
- /** @typedef {{ readonly eventType: KernelEventType, readonly listeners: Set<(event: DomainEvent) => void>, readonly sessionId: string | undefined, readonly subscriptionId: string }} EventGroup */
32
+ /**
33
+ * `cell` counts the group's own generations rather than the kernel's, because a new kernel
34
+ * (a leader-tab failover replays this subscription onto it) starts its count again.
35
+ * @typedef {{ readonly cell: SubscriptionStateCell, readonly eventType: KernelEventType, readonly listeners: Set<(event: DomainEvent) => void>, readonly pendingReady: Set<(error: TransportError) => void>, refusal?: unknown, readonly sessionId: string | undefined, readonly subscriptionId: string }} EventGroup
36
+ */
28
37
  /** @typedef {{ readonly errorListeners: Set<(error: { readonly message: string }) => void>, readonly listeners: Set<(diff: import("@econ-v1/domain").QueryDiff<JsonValue>) => void>, readonly name: QueryName, readonly params: JsonValue, retryTimer: ReturnType<typeof globalThis.setTimeout> | undefined, readonly sessionId: string | undefined, subscriptionId: string }} LiveQueryGroup */
29
38
  /** @typedef {{ markSlow(id: string): void, settle(id: string, elapsedMs: number, answered: boolean, timeoutMs: number): void }} RpcResponsivenessObserver */
30
39
  /** @typedef {{ readonly monotonicNow?: () => number, readonly now?: () => string, readonly observer?: RpcResponsivenessObserver, readonly requestTimeoutMs?: number }} MessagePortKernelRpcClientOptions */
@@ -169,6 +178,8 @@ export class MessagePortKernelRpcClient {
169
178
  /** @type {() => void} */
170
179
  #unsubscribeMessages;
171
180
  #closed = false;
181
+ /** @type {((name: string, payload: JsonValue) => Promise<JsonValue>) | undefined} */
182
+ #uiRequestHandler;
172
183
  #nextRequestId = 1;
173
184
  #nextSubscriptionId = 1;
174
185
 
@@ -288,10 +299,11 @@ export class MessagePortKernelRpcClient {
288
299
  * @template {KernelEventType} T
289
300
  * @param {T} type
290
301
  * @param {(event: import("@econ-v1/domain").KernelEventByType<T>) => void} listener
291
- * @returns {Unsubscribe}
302
+ * @param {EventSubscribeOptions} [options]
303
+ * @returns {EventSubscription}
292
304
  */
293
- subscribe(type, listener) {
294
- return this.#subscribeEvent(undefined, type, listener);
305
+ subscribe(type, listener, options) {
306
+ return this.#subscribeEvent(undefined, type, listener, options);
295
307
  }
296
308
 
297
309
  /** @param {string} sessionId */
@@ -322,6 +334,42 @@ export class MessagePortKernelRpcClient {
322
334
  }));
323
335
  }
324
336
 
337
+ /** @template T @param {string} sessionId @param {string} name @param {unknown} payload @returns {Promise<T>} */
338
+ commandStage(sessionId, name, payload) {
339
+ return /** @type {Promise<T>} */ (this.#request({ name, payload: jsonValue(payload, "command payload"), sessionId, type: "stage.command" }));
340
+ }
341
+
342
+ /** @param {(name: string, payload: JsonValue) => Promise<JsonValue>} handler @returns {Unsubscribe} */
343
+ onUiRequest(handler) {
344
+ this.#uiRequestHandler = handler;
345
+ return () => { if (this.#uiRequestHandler === handler) this.#uiRequestHandler = undefined; };
346
+ }
347
+
348
+ /** @param {{ readonly name: string, readonly payload: JsonValue, readonly requestId: string }} event */
349
+ async #answerUiRequest(event) {
350
+ const handler = this.#uiRequestHandler;
351
+ /** @type {RpcRequestWithoutId} */
352
+ let response;
353
+ try {
354
+ if (handler === undefined) throw new TransportError({ code: "unavailable", message: "No shell is handling UI requests in this tab" });
355
+ const value = jsonValue(await handler(event.name, event.payload), "UI response value");
356
+ response = { ok: true, requestId: event.requestId, type: "ui.response", value };
357
+ } catch (error) {
358
+ const clientError = error instanceof DomainError || error instanceof SyncError || error instanceof TransportError
359
+ ? error
360
+ : new DomainError({ code: "invalid_request", message: "UI request handler failed" });
361
+ response = { error: clientError, ok: false, requestId: event.requestId, type: "ui.response" };
362
+ }
363
+ if (this.#closed || this.#transport.state !== "open") return;
364
+ const id = `request-${this.#nextRequestId}`;
365
+ this.#nextRequestId += 1;
366
+ try {
367
+ this.#transport.send(serializeRpcRequest(/** @type {RpcRequest} */ ({ ...response, id })));
368
+ } catch {
369
+ // UI responses are fire-and-forget; a closed transport cannot receive them.
370
+ }
371
+ }
372
+
325
373
  /**
326
374
  * @template T
327
375
  * @param {string} sessionId
@@ -368,10 +416,11 @@ export class MessagePortKernelRpcClient {
368
416
  * @param {string} sessionId
369
417
  * @param {T} type
370
418
  * @param {(event: import("@econ-v1/domain").KernelEventByType<T>) => void} listener
371
- * @returns {Unsubscribe}
419
+ * @param {EventSubscribeOptions} [options]
420
+ * @returns {EventSubscription}
372
421
  */
373
- subscribeStage(sessionId, type, listener) {
374
- return this.#subscribeEvent(sessionId, type, listener);
422
+ subscribeStage(sessionId, type, listener, options) {
423
+ return this.#subscribeEvent(sessionId, type, listener, options);
375
424
  }
376
425
 
377
426
  #handleClose() {
@@ -381,6 +430,11 @@ export class MessagePortKernelRpcClient {
381
430
  this.#unsubscribeClose();
382
431
  const error = new TransportError({ code: "connection_lost", message: "Kernel RPC port closed" });
383
432
  for (const requestId of [...this.#pending.keys()]) this.#takePending(requestId, false)?.reject(error);
433
+ // Tell each subscription it went quiet, instead of leaving its listeners silent.
434
+ for (const group of this.#eventGroupsByKey.values()) {
435
+ group.cell.report("lost");
436
+ this.#cancelEventGroupReady(group, error);
437
+ }
384
438
  this.#eventGroupsByKey.clear();
385
439
  this.#eventGroupsBySubscription.clear();
386
440
  // A pending re-subscribe timer must not outlive the port it would retry on — otherwise a
@@ -429,6 +483,10 @@ export class MessagePortKernelRpcClient {
429
483
 
430
484
  try {
431
485
  const event = deserializeRpcEvent(message);
486
+ if (event.type === "ui.request") {
487
+ void this.#answerUiRequest(event);
488
+ return;
489
+ }
432
490
  if (event.type === "kernel.event") {
433
491
  const group = this.#eventGroupsBySubscription.get(event.subscriptionId);
434
492
  if (group === undefined || group.eventType !== event.event.type) return;
@@ -448,6 +506,11 @@ export class MessagePortKernelRpcClient {
448
506
  for (const listener of [...group.errorListeners]) this.#notify(() => listener({ message: failureMessage }));
449
507
  return;
450
508
  }
509
+ if (event.type === "subscription.state") {
510
+ const group = this.#eventGroupsBySubscription.get(event.subscriptionId);
511
+ if (group !== undefined) group.cell.report(event.state.status, event.state.confirmed, event.state.reason);
512
+ return;
513
+ }
451
514
  const group = this.#liveGroupsBySubscription.get(event.subscriptionId);
452
515
  if (group === undefined) return;
453
516
  for (const listener of [...group.listeners]) this.#notify(() => listener(event.diff));
@@ -495,6 +558,8 @@ export class MessagePortKernelRpcClient {
495
558
  #removeSessionSubscriptions(sessionId) {
496
559
  for (const [key, group] of [...this.#eventGroupsByKey]) {
497
560
  if (group.sessionId === sessionId) {
561
+ group.cell.report("lost");
562
+ this.#cancelEventGroupReady(group, new TransportError({ code: "connection_lost", message: "Stage RPC session closed" }));
498
563
  this.#eventGroupsByKey.delete(key);
499
564
  this.#eventGroupsBySubscription.delete(group.subscriptionId);
500
565
  }
@@ -559,32 +624,53 @@ export class MessagePortKernelRpcClient {
559
624
  * @param {string | undefined} sessionId
560
625
  * @param {T} eventType
561
626
  * @param {(event: import("@econ-v1/domain").KernelEventByType<T>) => void} listener
562
- * @returns {Unsubscribe}
627
+ * @param {EventSubscribeOptions} [options]
628
+ * @returns {EventSubscription}
563
629
  */
564
- #subscribeEvent(sessionId, eventType, listener) {
565
- const key = `${sessionId ?? "host"}:event:${eventType}`;
566
- let group = this.#eventGroupsByKey.get(key);
567
- if (group === undefined) {
568
- const subscriptionId = this.#newSubscriptionId();
569
- group = { eventType, listeners: new Set(), sessionId, subscriptionId };
570
- this.#eventGroupsByKey.set(key, group);
571
- this.#eventGroupsBySubscription.set(subscriptionId, group);
572
- const request = sessionId === undefined
573
- ? /** @type {const} */ ({ eventType, subscriptionId, type: "event.subscribe" })
574
- : /** @type {const} */ ({ eventType, sessionId, subscriptionId, type: "stage.event.subscribe" });
575
- void this.#request(request).catch((error) => {
576
- this.#dropEventGroup(key, /** @type {EventGroup} */ (group));
577
- warnSubscriptionRejected("event", eventType, sessionId, error);
578
- });
630
+ #subscribeEvent(sessionId, eventType, listener, options) {
631
+ if (this.#closed || this.#transport.state !== "open") {
632
+ if (!this.#closed) this.#handleClose();
633
+ return this.#lostEventSubscription(options);
579
634
  }
635
+ const key = `${sessionId ?? "host"}:event:${eventType}`;
636
+ const group = this.#eventGroupsByKey.get(key) ?? this.#openEventGroup(key, sessionId, eventType);
637
+ const { cell } = group;
580
638
  const storedListener = /** @type {(event: DomainEvent) => void} */ (listener);
581
639
  group.listeners.add(storedListener);
640
+ const unwatch = options?.onState && cell.watch(options.onState);
641
+ // This handle's own `ready`: a group that joined while lost or pending must wait for
642
+ // its next `live`, not reuse one from before the gap.
643
+ /** @type {((error: TransportError) => void) | undefined} */
644
+ let cancelReady;
645
+ /** @type {Promise<SubscriptionState>} */
646
+ const ready = new Promise((resolve, reject) => {
647
+ const off = cell.watch((state) => {
648
+ if (state.status === "live") resolve(state);
649
+ else if (state.status === "refused") reject(group.refusal ?? new TransportError({ code: "unavailable", message: "The home node refused this subscription" }));
650
+ else return;
651
+ off();
652
+ group.pendingReady.delete(cancel);
653
+ cancelReady = undefined;
654
+ });
655
+ const cancel = (/** @type {TransportError} */ error) => {
656
+ off();
657
+ group.pendingReady.delete(cancel);
658
+ cancelReady = undefined;
659
+ reject(error);
660
+ };
661
+ group.pendingReady.add(cancel);
662
+ cancelReady = cancel;
663
+ });
664
+ // A holder that never looks at `ready` must not surface a refusal as unhandled.
665
+ ready.catch(() => undefined);
582
666
  let active = true;
583
- return () => {
667
+ const unsubscribe = () => {
584
668
  if (!active) return;
585
669
  active = false;
586
- group?.listeners.delete(storedListener);
587
- if (group !== undefined && group.listeners.size === 0) {
670
+ unwatch?.();
671
+ cancelReady?.(new TransportError({ code: "connection_lost", message: "Subscription handle closed" }));
672
+ group.listeners.delete(storedListener);
673
+ if (group.listeners.size === 0) {
588
674
  this.#dropEventGroup(key, group);
589
675
  const request = sessionId === undefined
590
676
  ? /** @type {const} */ ({ subscriptionId: group.subscriptionId, type: "event.unsubscribe" })
@@ -592,6 +678,49 @@ export class MessagePortKernelRpcClient {
592
678
  void this.#request(request).catch(() => undefined);
593
679
  }
594
680
  };
681
+ return /** @type {EventSubscription} */ (Object.defineProperty(Object.assign(unsubscribe, { ready }), "state", { get: () => cell.state }));
682
+ }
683
+
684
+ /** @param {EventSubscribeOptions | undefined} options @returns {EventSubscription} */
685
+ #lostEventSubscription(options) {
686
+ const cell = new SubscriptionStateCell();
687
+ cell.report("lost");
688
+ const unwatch = options?.onState && cell.watch(options.onState);
689
+ const ready = Promise.reject(new TransportError({ code: "connection_lost", message: "Kernel RPC port is closed" }));
690
+ ready.catch(() => undefined);
691
+ const unsubscribe = () => unwatch?.();
692
+ return /** @type {EventSubscription} */ (Object.defineProperty(Object.assign(unsubscribe, { ready }), "state", { get: () => cell.state }));
693
+ }
694
+
695
+ /**
696
+ * @param {string} key
697
+ * @param {string | undefined} sessionId
698
+ * @param {KernelEventType} eventType
699
+ * @returns {EventGroup}
700
+ */
701
+ #openEventGroup(key, sessionId, eventType) {
702
+ const subscriptionId = this.#newSubscriptionId();
703
+ /** @type {EventGroup} */
704
+ const group = { cell: new SubscriptionStateCell(), eventType, listeners: new Set(), pendingReady: new Set(), sessionId, subscriptionId };
705
+ this.#eventGroupsByKey.set(key, group);
706
+ this.#eventGroupsBySubscription.set(subscriptionId, group);
707
+ const request = sessionId === undefined
708
+ ? /** @type {const} */ ({ eventType, subscriptionId, type: "event.subscribe" })
709
+ : /** @type {const} */ ({ eventType, sessionId, subscriptionId, type: "stage.event.subscribe" });
710
+ void this.#request(request).then((reply) => {
711
+ if (this.#eventGroupsByKey.get(key) !== group) return;
712
+ // `{ subscriptionState: 1 }` means state events follow, before or after this
713
+ // reply. `null` means none will come for this subscription (an older kernel, or
714
+ // a type a stage session adapter serves): live, but unconfirmed.
715
+ if (reply === null && !group.cell.state) group.cell.report("live");
716
+ }, (error) => {
717
+ if (this.#eventGroupsByKey.get(key) !== group) return;
718
+ group.refusal = error;
719
+ group.cell.report("refused");
720
+ this.#dropEventGroup(key, group);
721
+ warnSubscriptionRejected("event", eventType, sessionId, error);
722
+ });
723
+ return group;
595
724
  }
596
725
 
597
726
  /**
@@ -710,6 +839,11 @@ export class MessagePortKernelRpcClient {
710
839
  this.#eventGroupsBySubscription.delete(group.subscriptionId);
711
840
  }
712
841
 
842
+ /** @param {EventGroup} group @param {TransportError} error */
843
+ #cancelEventGroupReady(group, error) {
844
+ for (const cancel of [...group.pendingReady]) cancel(error);
845
+ }
846
+
713
847
  /**
714
848
  * @param {string} key
715
849
  * @param {LiveQueryGroup} group
@@ -752,6 +886,12 @@ class BoundStageRpcSession {
752
886
  this.#client.closeStageSession(this.#sessionId);
753
887
  }
754
888
 
889
+ /** @template T @param {string} name @param {unknown} payload @returns {Promise<T>} */
890
+ command(name, payload) {
891
+ this.#assertOpen();
892
+ return this.#client.commandStage(this.#sessionId, name, payload);
893
+ }
894
+
755
895
  /**
756
896
  * @template T
757
897
  * @param {string} capability
@@ -802,11 +942,12 @@ class BoundStageRpcSession {
802
942
  * @template {KernelEventType} T
803
943
  * @param {T} type
804
944
  * @param {(event: import("@econ-v1/domain").KernelEventByType<T>) => void} listener
805
- * @returns {Unsubscribe}
945
+ * @param {EventSubscribeOptions} [options]
946
+ * @returns {EventSubscription}
806
947
  */
807
- subscribe(type, listener) {
948
+ subscribe(type, listener, options) {
808
949
  this.#assertOpen();
809
- return this.#client.subscribeStage(this.#sessionId, type, listener);
950
+ return this.#client.subscribeStage(this.#sessionId, type, listener, options);
810
951
  }
811
952
 
812
953
  #assertOpen() {
@@ -48,15 +48,21 @@ export const ROUTE_MODES = /** @type {const} */ (["auto", "public", "private"]);
48
48
  * absent means this node runs it on its own key ("My Store"). */
49
49
  /** @typedef {{ readonly name: string, readonly peerNodeId?: string }} ModelRef */
50
50
 
51
- /** What the stage already knows, so the overlay opens on it instead of a default. */
52
- /** @typedef {{ readonly current?: ModelRef, readonly routeMode?: RouteMode, readonly webSearch?: WebSearchChoice }} ModelPickRequest */
51
+ /** What the stage already knows, so the overlay opens on it instead of a default.
52
+ * `onSelect` hears each Select at once, while the overlay stays open for the chips:
53
+ * the pick carries `model` and the chips as they stand. The promise still resolves
54
+ * when the overlay closes, with the final pick, so a stage that applies both must
55
+ * treat the same model twice as one choice. A host before it ignores `onSelect`. */
56
+ /** @typedef {{ readonly current?: ModelRef, readonly routeMode?: RouteMode, readonly webSearch?: WebSearchChoice, readonly onSelect?: (pick: ModelPick) => void }} ModelPickRequest */
53
57
 
54
58
  /** `model` is present only when the owner pressed Select on a row; absent means
55
59
  * "keep whatever model the stage already had" — the owner changed only the
56
60
  * web-search chip (or nothing but still closed with a chip change). `label` is
57
61
  * the catalog's `picker_label ?? name`, so a pill or chip shows the same words
58
- * the overlay did. */
59
- /** @typedef {{ readonly model?: ModelRef, readonly label?: string, readonly routeMode: RouteMode, readonly webSearch: WebSearchChoice }} ModelPick */
62
+ * the overlay did. `storeName` is the seller's store as the overlay named it
63
+ * (this node's own `mine.name`, or the peer's), so a chip can say whose store
64
+ * sells the model. */
65
+ /** @typedef {{ readonly model?: ModelRef, readonly label?: string, readonly storeName?: string, readonly routeMode: RouteMode, readonly webSearch: WebSearchChoice }} ModelPick */
60
66
 
61
67
  /** Resolves `undefined` when the owner closes without choosing. Rejects with a
62
68
  * `DomainError` `conflict` while another `open()` is still pending — the overlay
package/src/protocol.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  /** @import { AppEventPayload, AppEventType, ClientError, CoreKernelEventType, DomainEvent, JsonObject, JsonValue, KernelEventPayload, QueryDiff, QueryName } from '@econ-v1/domain' */
4
4
  /** @import { NodeReachability } from './transport-contract.js' */
5
+ /** @import { SubscriptionState } from './client.js' */
5
6
  /** @typedef {{ readonly occurredAt: string; readonly payload: { readonly state?: string }; readonly type: "device-cleanup.changed" }} DeviceCleanupDomainEvent */
6
7
  /** @typedef {DomainEvent | DeviceCleanupDomainEvent} ProtocolDomainEvent */
7
8
  /** @typedef {CoreKernelEventType | "device-cleanup.changed"} ProtocolCoreKernelEventType */
@@ -36,6 +37,12 @@ export function serializeRpcRequest(request) {
36
37
  return stringify({ appName: request.appName, id: request.id, type: request.type });
37
38
  case "stage.open-session":
38
39
  return stringify({ appName: request.appName, id: request.id, manifestSha256: request.manifestSha256, ...(request.surfaceId === undefined ? {} : { surfaceId: request.surfaceId }), type: request.type });
40
+ case "stage.command":
41
+ return stringify({ id: request.id, name: request.name, payload: request.payload, sessionId: request.sessionId, type: request.type });
42
+ case "ui.response":
43
+ return request.ok
44
+ ? stringify({ id: request.id, ok: true, requestId: request.requestId, type: request.type, value: request.value })
45
+ : stringify({ error: serializeClientError(request.error), id: request.id, ok: false, requestId: request.requestId, type: request.type });
39
46
  case "stage.query":
40
47
  return stringify({ id: request.id, name: request.name, params: request.params, sessionId: request.sessionId, type: request.type });
41
48
  case "stage.live-query.subscribe":
@@ -74,6 +81,13 @@ export function deserializeRpcRequest(serialized) {
74
81
  const surfaceId = optionalStringField(value, "surfaceId");
75
82
  return { appName: stringField(value, "appName"), id, manifestSha256: stringField(value, "manifestSha256"), ...(surfaceId === undefined ? {} : { surfaceId }), type };
76
83
  }
84
+ case "stage.command": return { id, name: versionedNameField(value, "name"), payload: jsonField(value, "payload"), sessionId: stringField(value, "sessionId"), type };
85
+ case "ui.response": {
86
+ const requestId = stringField(value, "requestId");
87
+ if (value.ok === true) return { id, ok: true, requestId, type, value: jsonField(value, "value") };
88
+ if (value.ok === false && isSerializableClientError(value.error)) return { error: deserializeClientError(value.error), id, ok: false, requestId, type };
89
+ throw new TypeError("Invalid ui.response");
90
+ }
77
91
  case "stage.query": return { id, name: queryNameField(value), params: jsonField(value, "params"), sessionId: stringField(value, "sessionId"), type };
78
92
  case "stage.live-query.subscribe": return { id, name: queryNameField(value), params: jsonField(value, "params"), sessionId: stringField(value, "sessionId"), subscriptionId: stringField(value, "subscriptionId"), type };
79
93
  case "stage.live-query.unsubscribe": return { id, sessionId: stringField(value, "sessionId"), subscriptionId: stringField(value, "subscriptionId"), type };
@@ -123,10 +137,14 @@ export function deserializeRpcResponse(serialized) {
123
137
  * @returns {string}
124
138
  */
125
139
  export function serializeRpcEvent(event) {
140
+ if (event.type === "ui.request")
141
+ return stringify({ name: event.name, payload: event.payload, requestId: event.requestId, type: event.type });
126
142
  if (event.type === "live-query.changed")
127
143
  return stringify({ diff: projectQueryDiff(event.diff), subscriptionId: event.subscriptionId, type: event.type });
128
144
  if (event.type === "live-query.failed")
129
145
  return stringify({ message: event.message, subscriptionId: event.subscriptionId, type: event.type });
146
+ if (event.type === "subscription.state")
147
+ return stringify({ state: event.state, subscriptionId: event.subscriptionId, type: event.type });
130
148
  return stringify({ event: projectDomainEvent(event.event), subscriptionId: event.subscriptionId, type: event.type });
131
149
  }
132
150
  /**
@@ -135,6 +153,8 @@ export function serializeRpcEvent(event) {
135
153
  */
136
154
  export function deserializeRpcEvent(serialized) {
137
155
  const value = parseRecord(serialized);
156
+ if (value.type === "ui.request")
157
+ return { name: versionedNameField(value, "name"), payload: jsonField(value, "payload"), requestId: stringField(value, "requestId"), type: value.type };
138
158
  const subscriptionId = stringField(value, "subscriptionId");
139
159
  if (value.type === "live-query.changed")
140
160
  return { diff: queryDiffField(value), subscriptionId, type: value.type };
@@ -142,8 +162,26 @@ export function deserializeRpcEvent(serialized) {
142
162
  return { message: stringField(value, "message"), subscriptionId, type: value.type };
143
163
  if (value.type === "kernel.event")
144
164
  return { event: domainEventField(value), subscriptionId, type: value.type };
165
+ // Sent to one event subscription only (ADR-039). An older client throws below
166
+ // and drops it, which is the fallback it already had.
167
+ if (value.type === "subscription.state")
168
+ return { state: subscriptionStateField(value), subscriptionId, type: value.type };
145
169
  throw new TypeError("Unknown RPC event type");
146
170
  }
171
+ /**
172
+ * @param {JsonObject} value
173
+ * @returns {SubscriptionState}
174
+ */
175
+ function subscriptionStateField(value) {
176
+ const { confirmed, generation, reason, status } = objectField(value, "state");
177
+ if (typeof confirmed !== "boolean" || !Number.isSafeInteger(generation) || /** @type {number} */ (generation) < 0 ||
178
+ !["live", "lost", "pending", "refused"].includes(/** @type {string} */ (status)) ||
179
+ !(reason === undefined || ["resubscribed", "source_restarted", "source_unavailable", "subscribed"].includes(/** @type {string} */ (reason)))) {
180
+ throw new TypeError("Expected a subscription state");
181
+ }
182
+ // Every field was checked against the closed vocabulary just above; this drops any other.
183
+ return /** @type {SubscriptionState} */ ({ confirmed, generation, ...(reason === undefined ? {} : { reason }), status });
184
+ }
147
185
  /**
148
186
  * @param {unknown} value
149
187
  * @returns {string}
@@ -864,6 +902,9 @@ function entityActionField(value) {
864
902
  }
865
903
  /**
866
904
  * @typedef {| { readonly id: string; readonly name: string; readonly payload: JsonValue; readonly type: "command" }
905
+ * | { readonly id: string; readonly name: string; readonly payload: JsonValue; readonly sessionId: string; readonly type: "stage.command" }
906
+ * | { readonly id: string; readonly ok: true; readonly requestId: string; readonly type: "ui.response"; readonly value: JsonValue }
907
+ * | { readonly error: ClientError; readonly id: string; readonly ok: false; readonly requestId: string; readonly type: "ui.response" }
867
908
  * | { readonly id: string; readonly name: QueryName; readonly params: JsonValue; readonly type: "query" }
868
909
  * | { readonly id: string; readonly name: QueryName; readonly params: JsonValue; readonly subscriptionId: string; readonly type: "live-query.subscribe" }
869
910
  * | { readonly id: string; readonly subscriptionId: string; readonly type: "live-query.unsubscribe" }
@@ -887,6 +928,8 @@ function entityActionField(value) {
887
928
  */
888
929
  /**
889
930
  * @typedef {| { readonly diff: QueryDiff<JsonValue>; readonly subscriptionId: string; readonly type: "live-query.changed" }
931
+ * | { readonly name: string; readonly payload: JsonValue; readonly requestId: string; readonly type: "ui.request" }
890
932
  * | { readonly message: string; readonly subscriptionId: string; readonly type: "live-query.failed" }
891
- * | { readonly event: ProtocolDomainEvent; readonly subscriptionId: string; readonly type: "kernel.event" }} RpcEvent
933
+ * | { readonly event: ProtocolDomainEvent; readonly subscriptionId: string; readonly type: "kernel.event" }
934
+ * | { readonly state: SubscriptionState; readonly subscriptionId: string; readonly type: "subscription.state" }} RpcEvent
892
935
  */
@@ -3,6 +3,8 @@
3
3
  /** @typedef {import("@econ-v1/domain").KernelEventType} KernelEventType */
4
4
  /** @typedef {import("@econ-v1/domain").QueryName} QueryName */
5
5
  /** @typedef {import("./client.js").Unsubscribe} Unsubscribe */
6
+ /** @typedef {import("./client.js").EventSubscribeOptions} EventSubscribeOptions */
7
+ /** @typedef {import("./client.js").EventSubscription} EventSubscription */
6
8
  /** @typedef {import("./dialog-contract.js").DialogCenter} DialogCenter */
7
9
  /** @typedef {import("./model-picker-contract.js").ModelPickerCenter} ModelPickerCenter */
8
10
  /** @typedef {import("./system-notice-contract.js").SystemNoticeCenter} SystemNoticeCenter */
@@ -18,15 +20,17 @@
18
20
  * readonly app: { readonly name: string, readonly version: string },
19
21
  * readonly events: {
20
22
  * publish<T extends KernelEventType>(type: T, payload: import("@econ-v1/domain").KernelEventPayload<T>): Promise<void>,
21
- * subscribe<T extends KernelEventType>(type: T, listener: (event: import("@econ-v1/domain").KernelEventByType<T>) => void): Unsubscribe
23
+ * subscribe<T extends KernelEventType>(type: T, listener: (event: import("@econ-v1/domain").KernelEventByType<T>) => void, options?: EventSubscribeOptions): EventSubscription
22
24
  * },
23
25
  * invokeCapability<T>(capability: string, payload: unknown): Promise<T>,
24
26
  * liveQuery<T>(name: QueryName, params: unknown, listener: (diff: import("@econ-v1/domain").QueryDiff<T>) => void): Unsubscribe,
27
+ * command?: <T>(name: string, payload?: unknown) => Promise<T>,
25
28
  * query<T>(name: QueryName, params: unknown): Promise<T>,
26
29
  * readonly composition?: StageComposition,
27
30
  * readonly stage: StageHost
28
31
  * }} StageContextShared */
29
- /** @typedef {{ navigate(path: string): void, readonly root: Element }} StageHostShared */
32
+ /** @typedef {{ open(): void, close(): void, readonly isOpen: boolean, onVisibilityChange(cb: (open: boolean) => void): Unsubscribe, setStatus(text: string): void, setBadge(count: number): void }} AssistantPanelHost */
33
+ /** @typedef {{ navigate(path: string): void, readonly root: Element, readonly context: { set(view: object): void, clear(): void }, readonly panel?: AssistantPanelHost }} StageHostShared */
30
34
  /** @typedef {StageContextShared & { readonly host: StageHostShared & { readonly uiApi: 1 } }} StageContextV1 */
31
35
  /** @typedef {StageContextShared & { readonly host: StageHostShared & { readonly dialogs: DialogCenter, readonly modelPicker: ModelPickerCenter, readonly systemNotices: SystemNoticeCenter, readonly uiApi: 2 } }} StageContextV2 */
32
36
  /** @typedef {StageContextV1 | StageContextV2} StageContext */