@camstack/system 1.2.313 → 1.2.315

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 (111) hide show
  1. package/dist/addon-runner.js +1 -1
  2. package/dist/addon-runner.mjs +1 -1
  3. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  6. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  7. package/dist/builtins/alerts/alerts.addon.js +1 -1
  8. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  9. package/dist/builtins/autotrack/index.js +1 -1
  10. package/dist/builtins/autotrack/index.mjs +1 -1
  11. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  12. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  13. package/dist/builtins/camera-grid/index.js +1 -1
  14. package/dist/builtins/camera-grid/index.mjs +1 -1
  15. package/dist/builtins/composer/claim-gate.d.ts +32 -0
  16. package/dist/builtins/composer/composed-device-set.d.ts +38 -0
  17. package/dist/builtins/composer/composed-device.d.ts +4 -3
  18. package/dist/builtins/composer/composer-grafts.d.ts +54 -0
  19. package/dist/builtins/composer/composer-plan.d.ts +96 -0
  20. package/dist/builtins/composer/composer-verdict.d.ts +19 -0
  21. package/dist/builtins/composer/composer.addon.js +2717 -477
  22. package/dist/builtins/composer/composer.addon.mjs +2722 -482
  23. package/dist/builtins/composer/composer.d.ts +71 -52
  24. package/dist/builtins/composer/composition-runtime.d.ts +93 -9
  25. package/dist/builtins/composer/confirmed-seed.d.ts +32 -0
  26. package/dist/builtins/composer/existing-target.d.ts +96 -0
  27. package/dist/builtins/composer/field-record.d.ts +50 -0
  28. package/dist/builtins/composer/graft-host.d.ts +47 -0
  29. package/dist/builtins/composer/held-claims.d.ts +12 -0
  30. package/dist/builtins/composer/owned-fields-target.d.ts +55 -0
  31. package/dist/builtins/composer/slice-assembler.d.ts +10 -0
  32. package/dist/builtins/composer/source-readings.d.ts +122 -0
  33. package/dist/builtins/composer/source-tracker.d.ts +35 -42
  34. package/dist/builtins/console-logging/index.js +1 -1
  35. package/dist/builtins/console-logging/index.mjs +1 -1
  36. package/dist/builtins/core-blocks/composition-api.d.ts +7 -4
  37. package/dist/builtins/core-blocks/composition-peers.d.ts +9 -0
  38. package/dist/builtins/core-blocks/composition-sources.d.ts +16 -3
  39. package/dist/builtins/core-blocks/core-block-store.d.ts +8 -1
  40. package/dist/builtins/core-blocks/core-blocks.addon.d.ts +4 -3
  41. package/dist/builtins/core-blocks/core-blocks.addon.js +255 -79
  42. package/dist/builtins/core-blocks/core-blocks.addon.mjs +255 -79
  43. package/dist/builtins/device-manager/claimed-status-overlay.d.ts +12 -0
  44. package/dist/builtins/device-manager/device-manager.addon.d.ts +16 -0
  45. package/dist/builtins/device-manager/device-manager.addon.js +1825 -317
  46. package/dist/builtins/device-manager/device-manager.addon.mjs +1825 -317
  47. package/dist/builtins/device-manager/device-provider-context.d.ts +24 -2
  48. package/dist/builtins/device-manager/device-row-store.d.ts +2 -0
  49. package/dist/builtins/device-manager/device-state-claim-views.d.ts +77 -0
  50. package/dist/builtins/device-manager/device-state-claims.d.ts +43 -0
  51. package/dist/builtins/device-manager/device-state-mirror.d.ts +145 -61
  52. package/dist/builtins/device-manager/field-claims-index.d.ts +69 -0
  53. package/dist/builtins/device-manager/field-ownership.d.ts +53 -0
  54. package/dist/builtins/device-manager/migrate-device.d.ts +27 -0
  55. package/dist/builtins/device-manager/migrate-guard.d.ts +7 -0
  56. package/dist/builtins/device-manager/migrate-hardware-state.d.ts +55 -0
  57. package/dist/builtins/device-manager/migration-refused.d.ts +23 -0
  58. package/dist/builtins/device-manager/mirror-row-writer.d.ts +138 -0
  59. package/dist/builtins/device-manager/runtime-state-persist-gate.d.ts +4 -0
  60. package/dist/builtins/doorbell/binding-mirror.d.ts +1 -0
  61. package/dist/builtins/doorbell/doorbell-composition-migration.d.ts +59 -0
  62. package/dist/builtins/doorbell/virtual-doorbell.addon.d.ts +3 -0
  63. package/dist/builtins/doorbell/virtual-doorbell.addon.js +315 -10
  64. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +315 -10
  65. package/dist/builtins/hub-forwarder/index.js +1 -1
  66. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  67. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  68. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  69. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  70. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  71. package/dist/builtins/local-network/local-network.addon.js +1 -1
  72. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  73. package/dist/builtins/loki-logging/index.js +1 -1
  74. package/dist/builtins/loki-logging/index.mjs +1 -1
  75. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  76. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  77. package/dist/builtins/platform-probe/index.js +1 -1
  78. package/dist/builtins/platform-probe/index.mjs +1 -1
  79. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  80. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  81. package/dist/builtins/snapshot/index.js +1 -1
  82. package/dist/builtins/snapshot/index.mjs +1 -1
  83. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  84. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  85. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  86. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  87. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  88. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  89. package/dist/builtins/system-config/system-config.addon.js +1 -1
  90. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  91. package/dist/builtins/winston-logging/index.js +1 -1
  92. package/dist/builtins/winston-logging/index.mjs +1 -1
  93. package/dist/{child-cap-dispatch-CHTiHbsv.mjs → child-cap-dispatch-AAltfBUH.mjs} +1484 -1417
  94. package/dist/{child-cap-dispatch-Dyn_7ezH.js → child-cap-dispatch-DzcUd_ct.js} +1503 -1424
  95. package/dist/composition-sources-DYqQLRzF.js +122 -0
  96. package/dist/composition-sources-yonsRneP.mjs +111 -0
  97. package/dist/{dist-CkRwnhbo.js → dist-DVKv5i-e.js} +5396 -4331
  98. package/dist/{dist-Cj7pJmXq.mjs → dist-MzYJVCLE.mjs} +5330 -4331
  99. package/dist/index.js +137 -8
  100. package/dist/index.mjs +133 -8
  101. package/dist/kernel/capability-registry.d.ts +43 -1
  102. package/dist/kernel/index.d.ts +3 -1
  103. package/dist/kernel/moleculer/device-cap-proxy.d.ts +0 -10
  104. package/dist/kernel/status-overlay.d.ts +18 -0
  105. package/dist/kernel/transport/claimed-get-status.d.ts +15 -0
  106. package/dist/kernel/transport/index.d.ts +2 -0
  107. package/dist/kernel/transport/local-child-registry.d.ts +23 -0
  108. package/dist/kernel/transport/parent-unowned-call.d.ts +25 -0
  109. package/dist/{retired-settings-keys-C1duPikd.mjs → retired-settings-keys-8yY2sTc-.mjs} +1 -1
  110. package/dist/{retired-settings-keys-CGSuXhP8.js → retired-settings-keys-iY5I0nKM.js} +1 -1
  111. package/package.json +1 -1
@@ -4634,31 +4634,6 @@ function buildNativeCapProxy(broker, addonId, capName, deviceId) {
4634
4634
  };
4635
4635
  } });
4636
4636
  }
4637
- /**
4638
- * Hub-side proxy for a device-scoped native cap whose owning runner is a
4639
- * HUB-LOCAL forked child reachable over UDS. Mirrors {@link buildNativeCapProxy}
4640
- * but dispatches via `LocalChildRegistry.callCapOnChild` (UDS) instead of
4641
- * `broker.call` — the child no longer hosts a Moleculer service after the UDS
4642
- * cutover, so a broker call fails "is not found". The `deviceId` is merged into
4643
- * the method args (provider convention) and carried as the routing field.
4644
- */
4645
- function buildUdsNativeCapProxy(registry, childId, capName, deviceId) {
4646
- return new Proxy({}, { get(_target, property) {
4647
- if (typeof property !== "string") return void 0;
4648
- return (input) => {
4649
- const mergedInput = typeof input === "object" && input !== null ? {
4650
- ...input,
4651
- deviceId
4652
- } : { deviceId };
4653
- return registry.callCapOnChild(childId, {
4654
- capName,
4655
- method: property,
4656
- args: mergedInput,
4657
- deviceId
4658
- });
4659
- };
4660
- } });
4661
- }
4662
4637
  function buildDeviceOpsProvider(device) {
4663
4638
  const assertMatch = (incoming) => {
4664
4639
  if (incoming !== device.id) throw new Error(`device-ops: deviceId mismatch — expected '${device.id}', got '${incoming}'`);
@@ -4844,6 +4819,61 @@ function createBrokerDeviceManagerApi(opts) {
4844
4819
  generation: nativeCapReadinessGeneration,
4845
4820
  sourceNodeId: nodeId
4846
4821
  });
4822
+ },
4823
+ unregisterNativeCap(capName) {
4824
+ const worker = opts.capabilityRegistry === void 0 && (opts.broker !== void 0 || opts.udsMode === true);
4825
+ if (!opts.capabilityRegistry && !worker) throw new Error(`DeviceContext.unregisterNativeCap('${capName}') called on '${stableId}' but none of capabilityRegistry / broker / udsMode was provided.`);
4826
+ const holder = opts.capabilityRegistry ? opts.capabilityRegistry.getNativeAddonId(capName, id) : workerNativeCapAddonIds.get(capName)?.get(id) ?? null;
4827
+ const staleRetract = worker && holder === null;
4828
+ if (holder !== addonId && !staleRetract) {
4829
+ opts.logger.debug("unregisterNativeCap: the cap is not registered by this addon — no-op", {
4830
+ tags: {
4831
+ deviceId: id,
4832
+ addonId
4833
+ },
4834
+ meta: { capName }
4835
+ });
4836
+ return;
4837
+ }
4838
+ if (staleRetract) opts.logger.info("unregisterNativeCap: retracting a binding this process never registered (a previous incarnation of the addon)", {
4839
+ tags: {
4840
+ deviceId: id,
4841
+ addonId
4842
+ },
4843
+ meta: { capName }
4844
+ });
4845
+ else if (opts.capabilityRegistry) opts.capabilityRegistry.unregisterNativeProvider(capName, id);
4846
+ else {
4847
+ workerNativeCaps.get(capName)?.delete(id);
4848
+ workerNativeCapAddonIds.get(capName)?.delete(id);
4849
+ onWorkerNativeCapsChanged?.();
4850
+ }
4851
+ eventBus.emit({
4852
+ id: (0, node_crypto.randomUUID)(),
4853
+ timestamp: /* @__PURE__ */ new Date(),
4854
+ source: {
4855
+ type: "device",
4856
+ id
4857
+ },
4858
+ category: _camstack_types_addon.EventCategory.DeviceBindingsChanged,
4859
+ data: {
4860
+ deviceId: id,
4861
+ capName,
4862
+ reason: "native-unregistered",
4863
+ addonId,
4864
+ nodeId
4865
+ }
4866
+ });
4867
+ (0, _camstack_types_addon.emitReadiness)(eventBus, {
4868
+ capName,
4869
+ scope: {
4870
+ type: "device",
4871
+ deviceId: id
4872
+ },
4873
+ state: "down",
4874
+ generation: nativeCapReadinessGeneration,
4875
+ sourceNodeId: nodeId
4876
+ });
4847
4877
  }
4848
4878
  };
4849
4879
  };
@@ -7984,1536 +8014,1566 @@ function liftRoutingHints(args) {
7984
8014
  ...typeof deviceId === "number" && Number.isFinite(deviceId) ? { deviceId } : {}
7985
8015
  };
7986
8016
  }
7987
- function readFanoutMode() {
7988
- const raw = process.env.CAMSTACK_UDS_EVENT_FANOUT;
7989
- if (raw === "shadow" || raw === "broadcast") return raw;
7990
- return "filter";
8017
+ //#endregion
8018
+ //#region src/kernel/moleculer/resilient-cap-call.ts
8019
+ /** Moleculer error `type` values meaning "the service is not (yet) routable". */
8020
+ var DISCOVERY_ERROR_TYPES = new Set(["SERVICE_NOT_FOUND", "SERVICE_NOT_AVAILABLE"]);
8021
+ /** Default ceiling for the discovery wait — fail-fast past this. */
8022
+ var DEFAULT_DISCOVERY_TIMEOUT_MS$1 = 3e4;
8023
+ function isDiscoveryError(err) {
8024
+ if (typeof err !== "object" || err === null) return false;
8025
+ const type = err.type;
8026
+ return typeof type === "string" && DISCOVERY_ERROR_TYPES.has(type);
7991
8027
  }
7992
8028
  /**
7993
- * Sentinel prefix used in the no-route error thrown when `cap-call-out` has no
7994
- * local sibling and no `onUnownedCall` fallback. `ipcParentLink` detects this
7995
- * prefix to distinguish routing failures (safe to retry via broker) from real
7996
- * provider errors (must NOT retry to avoid double-executing side effects).
7997
- */
7998
- var UDS_NO_ROUTE_PREFIX = "UDS_NO_ROUTE";
7999
- /**
8000
- * Parent-side authority for local addon-runners reachable over a
8001
- * `LocalTransportServer` (UDS). Children register their cap manifest on
8002
- * connect; the registry routes `(capName, deviceId?)` cap calls to the
8003
- * owning child over the channel and drops a child's caps on disconnect.
8029
+ * Call a Moleculer action; on a service-discovery error, wait for the
8030
+ * named service to be discovered and retry the call exactly once.
8004
8031
  */
8005
- var LocalChildRegistry = class {
8006
- children = /* @__PURE__ */ new Map();
8007
- registeredHandler = () => {};
8008
- goneHandler = () => {};
8009
- /** Source of {@link RegisteredChild.incarnation}; one bump per accepted connection. */
8010
- nextIncarnation = 1;
8011
- eventHandler = null;
8012
- logHandler = null;
8013
- readinessHandler = null;
8014
- server;
8015
- onUnownedCall;
8016
- logger;
8017
- getActiveSingletonAddonId;
8018
- resolveChildIdForAddon;
8019
- /** This node's own Moleculer nodeId (see {@link LocalChildRegistryOptions.ownNodeId}). */
8020
- ownNodeId;
8021
- /** See {@link LocalChildRegistryOptions.isAggregatedCollectionMethod}. */
8022
- isAggregatedCollectionMethod;
8023
- isAddonPinnedCall;
8024
- /** See {@link ForwardCensus}. Mutable counters on the hot path, by design. */
8025
- forwardedRaw = 0;
8026
- forwardedDecoded = 0;
8027
- /**
8028
- * The per-ACTION byte census (D456). `ForwardCensus` above is the same
8029
- * question asked of the whole plane; this one asks it per `<cap>.<method>`,
8030
- * beside the bytes that method actually moved. Both are kept: `socketFwd=`
8031
- * says whether the negotiation is working at all, and a `raw=0` on ONE
8032
- * method with `raw>0` on its neighbours is a different fault.
8033
- */
8034
- actionCensus = createCapActionCensus();
8035
- /** See {@link LocalChildRegistryOptions.capTimeoutMs}. */
8036
- capTimeoutMs;
8037
- /** See {@link LocalChildRegistryOptions.capUsageObserver}. */
8038
- capUsageObserver;
8039
- /** Tracks capNames already logged as UDS-routed; one INFO line per capName per process. */
8040
- egressRoutedCaps = /* @__PURE__ */ new Set();
8041
- /** Active event fan-out mode, read once from `CAMSTACK_UDS_EVENT_FANOUT`. */
8042
- fanoutMode = readFanoutMode();
8043
- /** Per-child event fan-out counters (sent / suppressed). */
8044
- childEventStats = /* @__PURE__ */ new Map();
8045
- /** D467: per-category production census (cumulative). See {@link CategoryFanoutSample}. */
8046
- categoryStats = /* @__PURE__ */ new Map();
8047
- /** Broadcasts whose category did not fit in {@link CATEGORY_CENSUS_MAX}. */
8048
- categoryOverflow = 0;
8049
- /** One INFO line the first time the census cap trips, never per event. */
8050
- categoryOverflowLogged = false;
8051
- /** Last pattern-set string logged per child, to dedup the INFO line. */
8052
- loggedPatternSet = /* @__PURE__ */ new Map();
8053
- /**
8054
- * Derived `(cap, device?) → child` index over {@link children}, or `null`
8055
- * when it must be rebuilt. See {@link capIndex} for why it is a cache and
8056
- * never a registry (D3, D457).
8057
- */
8058
- capChildIndex = null;
8059
- /**
8060
- * Accepts either a plain positional `server` argument (backward-compatible)
8061
- * or a full `LocalChildRegistryOptions` object.
8062
- *
8063
- * Positional overloads (existing call sites are unchanged):
8064
- * new LocalChildRegistry(server)
8065
- * new LocalChildRegistry(server, onUnownedCall)
8066
- *
8067
- * Options object (new call sites that pass a logger):
8068
- * new LocalChildRegistry({ server, onUnownedCall, logger })
8069
- */
8070
- constructor(serverOrOptions, onUnownedCallArg) {
8071
- if (serverOrOptions && !("listen" in serverOrOptions)) {
8072
- const opts = serverOrOptions;
8073
- this.server = opts.server;
8074
- this.onUnownedCall = opts.onUnownedCall;
8075
- this.logger = opts.logger;
8076
- this.getActiveSingletonAddonId = opts.getActiveSingletonAddonId;
8077
- this.resolveChildIdForAddon = opts.resolveChildIdForAddon;
8078
- this.ownNodeId = opts.ownNodeId;
8079
- this.isAggregatedCollectionMethod = opts.isAggregatedCollectionMethod;
8080
- this.isAddonPinnedCall = opts.isAddonPinnedCall;
8081
- this.capTimeoutMs = opts.capTimeoutMs;
8082
- this.capUsageObserver = opts.capUsageObserver;
8083
- } else {
8084
- this.server = serverOrOptions;
8085
- this.onUnownedCall = onUnownedCallArg;
8086
- }
8087
- }
8088
- async start() {
8089
- this.logger?.info("UDS event fan-out mode", { mode: this.fanoutMode });
8090
- this.server.onConnection((channel) => this.onConnection(channel));
8091
- await this.server.listen();
8092
- }
8093
- /**
8094
- * Per-child event fan-out counters (`{ sent, suppressed }`) or `null` if the
8095
- * child has no recorded events yet. Exposed for the starvation-verification
8096
- * surface (shadow burn-in + operator debug).
8097
- */
8098
- getChildEventStats(childId) {
8099
- const s = this.childEventStats.get(childId);
8100
- return s === void 0 ? null : {
8101
- sent: s.sent,
8102
- suppressed: s.suppressed
8103
- };
8104
- }
8105
- /**
8106
- * D467: every category this node has broadcast, with its emit count and how
8107
- * many child subscriptions matched. Cumulative for the life of the process —
8108
- * the heartbeat differences it, the same discipline as the per-child
8109
- * counters. Largest `broadcasts` first.
8110
- */
8111
- categoryFanout() {
8112
- const out = [];
8113
- for (const [category, s] of this.categoryStats) out.push({
8114
- category,
8115
- broadcasts: s.broadcasts,
8116
- wanted: s.wanted
8117
- });
8118
- return out.toSorted((a, b) => b.broadcasts - a.broadcasts || a.category.localeCompare(b.category));
8119
- }
8120
- /** Broadcasts the census could not attribute because it was full (cumulative). */
8121
- categoryCensusOverflow() {
8122
- return this.categoryOverflow;
8123
- }
8124
- /**
8125
- * This child's socket traffic — bytes and messages, both directions, split by
8126
- * frame kind — or `null` when the child is gone or its channel keeps no
8127
- * counters (an in-process or test transport).
8128
- *
8129
- * The event counters above cover ONE kind of frame and, once surfaced on
8130
- * 2026-08-29, accounted for 4% of hub-main's ~44 000 socket syscalls/s. This
8131
- * is the same question asked of every frame the channel moves, and it is the
8132
- * only per-peer attribution that exists: `/proc` reports one `rchar`/`wchar`
8133
- * pair for the whole process and has no per-socket breakdown.
8134
- */
8135
- getChildSocketTraffic(childId) {
8136
- const entry = this.children.get(childId);
8137
- if (entry === void 0) return null;
8138
- return entry.channel.readTraffic?.() ?? null;
8032
+ async function callWithServiceDiscovery(broker, serviceName, action, params, opts, discoveryTimeoutMs = DEFAULT_DISCOVERY_TIMEOUT_MS$1) {
8033
+ try {
8034
+ return await broker.call(action, params, opts);
8035
+ } catch (err) {
8036
+ if (!isDiscoveryError(err)) throw err;
8037
+ await broker.waitForServices([serviceName], discoveryTimeoutMs);
8038
+ return await broker.call(action, params, opts);
8139
8039
  }
8040
+ }
8041
+ //#endregion
8042
+ //#region src/kernel/transport/cap-route.ts
8043
+ function buildMessage(capName, method, detail) {
8044
+ const call = method !== void 0 ? `${capName}.${method}` : capName;
8045
+ const target = detail.nodeId !== void 0 ? ` to node ${detail.nodeId}` : "";
8046
+ const rejectedStr = detail.rejected.map((r) => `${r.kind}=${r.why}`).join("; ");
8047
+ const rejectedClause = rejectedStr.length > 0 ? ` (rejected: ${rejectedStr})` : "";
8048
+ return `${call} not routable${target}: ${detail.reason}${rejectedClause}`;
8049
+ }
8050
+ var CapRouteError = class extends Error {
8051
+ reason;
8052
+ nodeId;
8053
+ rejected;
8140
8054
  /**
8141
- * Bytes this process has written to a child that the child has not yet
8142
- * taken, live, or `null` when the channel keeps no such reading. Read on
8143
- * every heartbeat probe (D446) — a child that stops draining is the shape
8144
- * of an outbound-queue episode, and the number lives on the channel.
8055
+ * @param cause Optional original error that triggered this routing failure.
8056
+ * Stored as `Error.cause` (TC39 standard option, Node 16.9+).
8057
+ * Dispatchers wrapping transport errors MUST pass the original.
8145
8058
  */
8146
- getChildQueuedBytes(childId) {
8147
- const entry = this.children.get(childId);
8148
- if (entry === void 0) return null;
8149
- return entry.channel.queuedBytes?.() ?? null;
8059
+ constructor(capName, method, detail, cause) {
8060
+ super(buildMessage(capName, method, detail), cause !== void 0 ? { cause } : void 0);
8061
+ this.name = "CapRouteError";
8062
+ this.reason = detail.reason;
8063
+ this.nodeId = detail.nodeId;
8064
+ this.rejected = detail.rejected;
8150
8065
  }
8151
- /** Cumulative {@link ForwardCensus} of this parent's `cap-call-out` routing. */
8152
- readForwardCensus() {
8153
- return {
8154
- raw: this.forwardedRaw,
8155
- decoded: this.forwardedDecoded
8156
- };
8157
- }
8158
- /**
8159
- * Cumulative per-action byte census: peer → `<cap>.<method>` → bytes, calls
8160
- * and the raw/decoded split. A COPY (`cap-action-census.ts` explains why it
8161
- * must be one); the heartbeat subtracts the previous reading.
8162
- *
8163
- * This is the instrument D454 closed by asking for. `socketTopBytes=` names
8164
- * the peer that moves the bytes and `txkB=` says they are responses; neither
8165
- * can say WHICH method originates them, and after raw-forward the bytes that
8166
- * cross are no longer the proxy for what hub-main pays — 85.7 % of sibling
8167
- * calls cross unread. What costs is what hub-main LOOKS at, and that is the
8168
- * `decoded` half of an entry, next to the bytes it carried.
8169
- */
8170
- readActionCensus() {
8171
- return this.actionCensus.read();
8172
- }
8173
- /**
8174
- * The regime the counters above were produced under, read once from
8175
- * `CAMSTACK_UDS_EVENT_FANOUT` at construction.
8176
- *
8177
- * A suppressed count means "nothing WAS dropped" under `filter` and "nothing
8178
- * WOULD have been dropped" under `shadow`. Those are different claims, and
8179
- * until this getter existed the mode was invisible at runtime — the counters
8180
- * were readable and the regime that produced them was not.
8181
- */
8182
- get eventFanoutMode() {
8183
- return this.fanoutMode;
8184
- }
8185
- /**
8186
- * Connected children that never declared a subscription pattern set.
8187
- *
8188
- * Each one is a fail-open on BOTH planes: `childWantsEvent` returns true for
8189
- * an undeclared child ({@link ChildEntry.eventPatterns} `=== null`), and
8190
- * `aggregateEventInterest` returns `null` — accept everything — if ANY
8191
- * connected child is undeclared, which disables the node's inbound cross-node
8192
- * gate as well. **One legacy runner silently turns off the filtering for the
8193
- * whole node**, and nothing reported it, so a zero here is as load-bearing as
8194
- * a non-zero one.
8195
- */
8196
- undeclaredChildCount() {
8197
- let count = 0;
8198
- for (const entry of this.children.values()) if (entry.eventPatterns === null) count += 1;
8199
- return count;
8200
- }
8201
- /**
8202
- * Does `entry` want to receive an event in `category`? Honours the fan-out
8203
- * mode: `broadcast` → always; undeclared patterns (`null`) → always
8204
- * (fail-open); else the child's declared set must match via `matchesPattern`
8205
- * — the SAME function the child's own `deliverShared` map-key gate uses,
8206
- * giving provable delivery parity. `shadow` is handled by the caller (it
8207
- * counts the would-suppress but still sends).
8208
- *
8209
- * NOTE: this gate must NOT consult `RING_BUFFER_DENY_PATTERNS` /
8210
- * `isHighFrequencyCategory` — those are ring-buffer STORAGE policy; a child
8211
- * that explicitly subscribes to a ring-denied category must still RECEIVE it.
8212
- */
8213
- childWantsEvent(entry, category) {
8214
- if (this.fanoutMode === "broadcast") return true;
8215
- return this.childSubscribesTo(entry, category);
8216
- }
8217
- /**
8218
- * The SUBSCRIPTION answer alone, with no fan-out-mode override.
8219
- *
8220
- * {@link childWantsEvent} answers "will this child be sent the event", which
8221
- * under `broadcast` is `true` for everything and therefore cannot say whether
8222
- * anyone actually asked. The D467 census needs the second question, so the
8223
- * two are separate functions and `broadcast` mode overrides only the first.
8224
- */
8225
- childSubscribesTo(entry, category) {
8226
- if (entry.eventPatterns === null) return true;
8227
- return entry.eventPatterns.some((p) => matchesPattern(p, category));
8228
- }
8229
- /**
8230
- * D2: this node's aggregate cross-node event interest — the UNION of every
8231
- * connected child's declared category patterns. Consumed by the parent's
8232
- * `$event-bus` inbound gate (via `setNodeEventInterest`) so the node only
8233
- * accepts cross-node (Moleculer) categories at least one local child wants.
8234
- *
8235
- * Returns `null` (fail-open — accept everything) when:
8236
- * - NO child is currently connected (boot window before the first forked
8237
- * child completes its UDS handshake, or every child has disconnected): a
8238
- * node MUST NOT drop 100% of inbound cross-node events during that window,
8239
- * and a child may connect imminently. This is distinct from the
8240
- * declared-empty case below.
8241
- * - ANY connected child is UNDECLARED (`eventPatterns === null`, a legacy
8242
- * runner): a node cannot safely narrow its inbound set while a child's
8243
- * real interest is unknown. Mirrors the per-child fail-open in
8244
- * {@link childWantsEvent}, aggregated conservatively.
8245
- *
8246
- * Returns an EMPTY array only when ≥1 child is connected and EVERY connected
8247
- * child declared an explicit empty set — a genuine "wants nothing". Reads the
8248
- * live `children` map, so a child registering / disconnecting / sending
8249
- * `event-sub` is reflected on the next call with no extra bookkeeping.
8250
- */
8251
- aggregateEventInterest() {
8252
- if (this.children.size === 0) return null;
8253
- const union = /* @__PURE__ */ new Set();
8254
- for (const entry of this.children.values()) {
8255
- if (entry.eventPatterns === null) return null;
8256
- for (const p of entry.eventPatterns) union.add(p);
8066
+ };
8067
+ /**
8068
+ * Classifies a (capName, opts) pair into a typed CapRoute dispatch descriptor.
8069
+ *
8070
+ * Precedence (explicit nodeId path):
8071
+ * 1. hub-in-process — hub node + hubInProcessProvides
8072
+ * 2. hub-local-uds — hub node + hubLocalChildProvides
8073
+ * 3. node-offline → CapRouteError{reason:'node-offline'}
8074
+ * 4. agent-child-forward — node is an agent AND nodeKnowsCap
8075
+ * 5. remote-moleculer — any other online non-agent node
8076
+ *
8077
+ * Singleton path (no nodeId):
8078
+ * 1. hub-in-process (hub provides in-process)
8079
+ * 2. hub-local-uds (a hub-local UDS child provides it)
8080
+ * 3. remote-moleculer (any online, non-hub node that knows it)
8081
+ * 4. → CapRouteError{reason:'no-provider', rejected: all considered routes}
8082
+ *
8083
+ * PURE: no side effects, no async, no broker/registry imports.
8084
+ */
8085
+ function classifyCapRoute(capName, opts, snapshot) {
8086
+ const rejected = [];
8087
+ if (opts.nodeId !== void 0) return classifyExplicitNode(capName, opts.nodeId, opts.deviceId, snapshot, rejected);
8088
+ return classifySingleton(capName, opts.deviceId, snapshot, rejected);
8089
+ }
8090
+ function classifyExplicitNode(capName, nodeId, deviceId, snap, rejected) {
8091
+ if (nodeId === snap.hubNodeId) {
8092
+ const residentProvides = snap.hubResidentProvides;
8093
+ const providesOnHub = residentProvides ?? snap.hubInProcessProvides;
8094
+ const getRef = residentProvides !== void 0 ? snap.getHubResidentProviderRef : snap.getInProcessProviderRef;
8095
+ if (providesOnHub(capName)) {
8096
+ const ref = getRef?.(capName) ?? null;
8097
+ if (ref !== null) return {
8098
+ kind: "hub-in-process",
8099
+ capName,
8100
+ ref
8101
+ };
8102
+ rejected.push({
8103
+ kind: "hub-in-process",
8104
+ why: "provider ref not available in snapshot"
8105
+ });
8106
+ throw new CapRouteError(capName, void 0, {
8107
+ reason: "no-provider",
8108
+ nodeId,
8109
+ rejected
8110
+ });
8257
8111
  }
8258
- return [...union];
8259
- }
8260
- statsFor(childId) {
8261
- let s = this.childEventStats.get(childId);
8262
- if (s === void 0) {
8263
- s = {
8264
- sent: 0,
8265
- suppressed: 0
8112
+ if (snap.hubLocalChildProvides(capName, deviceId)) {
8113
+ const childId = snap.getHubLocalChildId?.(capName, deviceId) ?? null;
8114
+ if (childId !== null) return {
8115
+ kind: "hub-local-uds",
8116
+ capName,
8117
+ childId
8266
8118
  };
8267
- this.childEventStats.set(childId, s);
8119
+ rejected.push({
8120
+ kind: "hub-local-uds",
8121
+ why: "child id not resolvable from snapshot"
8122
+ });
8123
+ throw new CapRouteError(capName, void 0, {
8124
+ reason: "no-provider",
8125
+ nodeId,
8126
+ rejected
8127
+ });
8268
8128
  }
8269
- return s;
8129
+ rejected.push({
8130
+ kind: "hub-in-process",
8131
+ why: "hub does not provide this cap in-process"
8132
+ });
8133
+ rejected.push({
8134
+ kind: "hub-local-uds",
8135
+ why: "no hub-local child provides this cap"
8136
+ });
8137
+ throw new CapRouteError(capName, void 0, {
8138
+ reason: "no-provider",
8139
+ nodeId,
8140
+ rejected
8141
+ });
8270
8142
  }
8271
- /** Deduped INFO line whenever a child's declared pattern set changes. */
8272
- logPatternSet(childId, patterns) {
8273
- const key = patterns === null ? "<undeclared>" : JSON.stringify([...patterns].toSorted());
8274
- if (this.loggedPatternSet.get(childId) === key) return;
8275
- this.loggedPatternSet.set(childId, key);
8276
- this.logger?.info("child event patterns", {
8277
- childId,
8278
- patterns: patterns ?? null
8143
+ if (!snap.nodeOnline(nodeId)) {
8144
+ rejected.push({
8145
+ kind: "remote-moleculer",
8146
+ why: `node ${nodeId} is offline`
8147
+ });
8148
+ throw new CapRouteError(capName, void 0, {
8149
+ reason: "node-offline",
8150
+ nodeId,
8151
+ rejected
8279
8152
  });
8280
8153
  }
8281
- /**
8282
- * Child id that can service a call to `capName` (optionally addressing
8283
- * `deviceId`), or null.
8284
- *
8285
- * `deviceId` is a routing HINT, not a hard filter. A singleton cap
8286
- * (`pipeline-runner`, `stream-broker`, …) addresses devices through its
8287
- * METHOD ARGUMENTS — `attachCamera({ deviceId })` is one provider serving
8288
- * many cameras — so its descriptor carries no `deviceId`. Resolving on
8289
- * `deviceId` alone would never match it and would force the call onto the
8290
- * broker fallback (which then hangs in service discovery). Hence:
8291
- * 1. exact device-scoped owner (native per-device caps where each child
8292
- * owns a disjoint device subset) is preferred, then
8293
- * 2. a singleton owner (deviceId-less descriptor) is the fallback.
8294
- * A cap is globally singleton XOR device-scoped, so the two tiers never
8295
- * compete for the same capName.
8296
- */
8297
- resolveChildId(capName, deviceId) {
8298
- const index = this.capIndex();
8299
- if (deviceId !== void 0) {
8300
- const deviceOwner = index.deviceOwner(capName, deviceId);
8301
- if (deviceOwner !== null) return deviceOwner;
8302
- }
8303
- const candidates = index.singletonCandidates(capName);
8304
- if (candidates.length === 0) return null;
8305
- if (candidates.length === 1) return candidates[0];
8306
- const preferredAddonId = this.getActiveSingletonAddonId?.(capName) ?? null;
8307
- if (preferredAddonId !== null) {
8308
- const preferredChildId = this.resolveChildIdForAddon?.(preferredAddonId) ?? preferredAddonId;
8309
- if (candidates.includes(preferredChildId)) return preferredChildId;
8310
- }
8311
- return candidates[0];
8154
+ if (snap.nodeIsAgent(nodeId)) {
8155
+ if (snap.nodeKnowsCap(nodeId, capName)) return {
8156
+ kind: "agent-child-forward",
8157
+ capName,
8158
+ agentNodeId: nodeId,
8159
+ childId: snap.getAgentChildId?.(nodeId, capName) ?? void 0
8160
+ };
8161
+ rejected.push({
8162
+ kind: "agent-child-forward",
8163
+ why: `agent ${nodeId} does not know cap ${capName}`
8164
+ });
8165
+ throw new CapRouteError(capName, void 0, {
8166
+ reason: "no-provider",
8167
+ nodeId,
8168
+ rejected
8169
+ });
8312
8170
  }
8313
- /**
8314
- * Publish one cap-usage observation, if a sink is wired.
8315
- *
8316
- * Cost discipline — this is on the hot path (D181: hub-main's event loop is
8317
- * the cluster's only queue): no sink means one undefined check; with a sink
8318
- * it is one object literal and one `Date.now()`, and the sink itself is O(1).
8319
- * Wrapped so a broken observer can never fail the call it observes — the
8320
- * registry swallows too, and BOTH matter: this catch also covers a sink that
8321
- * is not the registry.
8322
- */
8323
- recordCapUsage(callerChildId, providerChildId, capName, methodName) {
8324
- const sink = this.capUsageObserver;
8325
- if (sink === void 0 || callerChildId === null) return;
8326
- try {
8327
- sink({
8328
- callerAddonId: callerChildId,
8329
- providerAddonId: providerChildId ?? "(unresolved: parent-routed)",
8330
- capName,
8331
- methodName,
8332
- atMs: Date.now()
8333
- });
8334
- } catch {}
8171
+ return {
8172
+ kind: "remote-moleculer",
8173
+ capName,
8174
+ nodeId
8175
+ };
8176
+ }
8177
+ function classifySingleton(capName, deviceId, snap, rejected) {
8178
+ if (snap.hubInProcessProvides(capName)) {
8179
+ const ref = snap.getInProcessProviderRef?.(capName) ?? null;
8180
+ if (ref !== null) return {
8181
+ kind: "hub-in-process",
8182
+ capName,
8183
+ ref
8184
+ };
8185
+ rejected.push({
8186
+ kind: "hub-in-process",
8187
+ why: "provider ref not available in snapshot"
8188
+ });
8189
+ } else rejected.push({
8190
+ kind: "hub-in-process",
8191
+ why: "hub does not provide this cap in-process"
8192
+ });
8193
+ if (snap.hubLocalChildProvides(capName, deviceId)) {
8194
+ const childId = snap.getHubLocalChildId?.(capName, deviceId) ?? null;
8195
+ if (childId !== null) return {
8196
+ kind: "hub-local-uds",
8197
+ capName,
8198
+ childId
8199
+ };
8200
+ rejected.push({
8201
+ kind: "hub-local-uds",
8202
+ why: "child id not resolvable from snapshot"
8203
+ });
8204
+ } else rejected.push({
8205
+ kind: "hub-local-uds",
8206
+ why: "no hub-local child provides this cap"
8207
+ });
8208
+ const knownNodes = snap.listKnownNodeIds?.() ?? [];
8209
+ for (const nodeId of knownNodes) if (nodeId !== snap.hubNodeId && snap.nodeOnline(nodeId) && snap.nodeKnowsCap(nodeId, capName)) return {
8210
+ kind: "remote-moleculer",
8211
+ capName,
8212
+ nodeId
8213
+ };
8214
+ rejected.push({
8215
+ kind: "remote-moleculer",
8216
+ why: "no online remote node knows this cap"
8217
+ });
8218
+ throw new CapRouteError(capName, void 0, {
8219
+ reason: "no-provider",
8220
+ rejected
8221
+ });
8222
+ }
8223
+ //#endregion
8224
+ //#region src/kernel/transport/cap-route-resolver.ts
8225
+ /** The Moleculer service name that Task 6's agent registers. */
8226
+ var AGENT_CAP_FWD_SERVICE = "$agent-cap-fwd";
8227
+ /** The Moleculer action (service.action) for agent cap forwarding. */
8228
+ var AGENT_CAP_FWD_ACTION = `${AGENT_CAP_FWD_SERVICE}.forward`;
8229
+ /** Default timeout for remote Moleculer cap calls (ms). */
8230
+ var REMOTE_CALL_TIMEOUT_MS = 6e4;
8231
+ function extractDeviceId(args) {
8232
+ if (args === null || typeof args !== "object") return void 0;
8233
+ const raw = Reflect.get(args, "deviceId");
8234
+ return typeof raw === "number" ? raw : void 0;
8235
+ }
8236
+ /**
8237
+ * Extract an inline `nodeId` string from a cap call's args — the per-call node
8238
+ * pin a forked addon expresses by carrying `nodeId` in the input (the SAME way
8239
+ * device-scoped caps carry `deviceId`, and the way the generated cap-router on
8240
+ * the hub already honours an inline pin). Mirrors {@link extractDeviceId}.
8241
+ *
8242
+ * This is the routing source for hub→remote-node EXECUTION pinning (e.g. the
8243
+ * benchmark addon running a synthetic/decoder workload ON a chosen agent). The
8244
+ * out-of-band `nodePin(op.context)` path does NOT survive the forked addon →
8245
+ * hub UDS link chain, so `onUnownedCall` reads the inline pin instead. Provider
8246
+ * methods that don't declare `nodeId` simply ignore the extra field (they
8247
+ * destructure the fields they need); methods that DO declare it (e.g.
8248
+ * `getEngineProvisioning({nodeId})`) still receive it unchanged.
8249
+ */
8250
+ function extractNodeId(args) {
8251
+ if (args === null || typeof args !== "object") return void 0;
8252
+ const raw = Reflect.get(args, "nodeId");
8253
+ return typeof raw === "string" && raw.length > 0 ? raw : void 0;
8254
+ }
8255
+ /**
8256
+ * Merge `callerAddonId` into `args` as an extra field — the SAME "provider
8257
+ * destructures what it needs and ignores the rest" convention
8258
+ * {@link extractNodeId} already relies on for the inline `nodeId` pin.
8259
+ * `undefined` (the overwhelmingly common case: no caller hint, or the census
8260
+ * consuming it is disarmed) returns `args` unchanged — no allocation. A
8261
+ * non-object `args` (or `null`/array) is returned unchanged too: there is
8262
+ * nowhere to attach an extra key without changing the payload's shape, and a
8263
+ * provider taking a bare value never reads `callerAddonId` off it anyway.
8264
+ */
8265
+ function withCallerAddonId(args, callerAddonId) {
8266
+ if (callerAddonId === void 0) return args;
8267
+ if (args === null || typeof args !== "object" || Array.isArray(args)) return args;
8268
+ return {
8269
+ ...args,
8270
+ callerAddonId
8271
+ };
8272
+ }
8273
+ var CapRouteResolver = class {
8274
+ hubNodeId;
8275
+ broker;
8276
+ hubLocalRegistry;
8277
+ nodeAuthority;
8278
+ inProcessProviders;
8279
+ hubResidentProviders;
8280
+ capTimeoutMs;
8281
+ snapshot;
8282
+ constructor(deps) {
8283
+ this.hubNodeId = deps.hubNodeId;
8284
+ this.broker = deps.broker;
8285
+ this.hubLocalRegistry = deps.hubLocalRegistry;
8286
+ this.nodeAuthority = deps.nodeAuthority;
8287
+ this.inProcessProviders = deps.inProcessProviders;
8288
+ this.hubResidentProviders = deps.hubResidentProviders;
8289
+ this.capTimeoutMs = deps.capTimeoutMs;
8290
+ this.snapshot = this.buildSnapshot();
8291
+ }
8292
+ /** Per-call RPC timeout: the cap method's declared override, else the default. */
8293
+ callTimeout(capName, method) {
8294
+ return this.capTimeoutMs?.(capName, method) ?? REMOTE_CALL_TIMEOUT_MS;
8295
+ }
8296
+ resolveCapRoute(capName, opts) {
8297
+ return classifyCapRoute(capName, opts, this.snapshot);
8335
8298
  }
8336
8299
  /**
8337
- * The cap index over the CURRENT children, rebuilt on first use after any
8338
- * mutation of {@link children}.
8339
- *
8340
- * This replaced a linear walk of every child's cap manifest per
8341
- * `cap-call-out`, measured at **7.1 % of hub-main's busy main thread**
8342
- * across 39 children (D456). Why it did not exist before is the whole
8343
- * design: what it indexes moves — a runner is replaced on every update, a
8344
- * crash respawns one, a child re-registers with a replacement manifest after
8345
- * init — and an index that names a dead child routes work into a socket
8346
- * nobody is reading, which is strictly worse than a slow scan.
8300
+ * Resolve the hub-local-uds route for a cap owned by a forked hub-local
8301
+ * child, IGNORING any in-hub provider registered for the same cap name.
8347
8302
  *
8348
- * So it is not maintained; it is DISCARDED. `buildCapChildIndex` is a pure
8349
- * function of `children.values()`, the only two writers of that map
8350
- * ({@link invalidateCapIndex}'s call sites) drop the index in the same
8351
- * statement, and nothing else can construct one. There is no add-on-register
8352
- * / remove-on-close path that could disagree with the map — i.e. no shadow
8353
- * registry (D3): the map remains the single authority and this holds no fact
8354
- * it does not.
8303
+ * `resolveCapRoute` gives Priority 1 to `hub-in-process`: when an in-hub
8304
+ * provider (e.g. a wrapper) is registered for the cap, the route always
8305
+ * classifies as `hub-in-process` and the hub-local-uds NATIVE child is never
8306
+ * reached. That is correct for the generic dispatch path (the wrapper is the
8307
+ * active provider), but WRONG for the native-cap fallback
8308
+ * (`setNativeFallback`), whose contract is to reach the NATIVE provider in
8309
+ * the forked vendor child so a wrapper can delegate to it. A wrapper cap
8310
+ * with a forked native (today: `snapshot`) otherwise resolves to the wrapper
8311
+ * itself, the native is never invoked, and the wrapper silently falls
8312
+ * through to its secondary strategy.
8355
8313
  *
8356
- * The operator's active-singleton preference is deliberately NOT indexed —
8357
- * it changes without any child connecting or leaving, so `resolveChildId`
8358
- * reads it live and the index only supplies the candidate list.
8314
+ * This method consults ONLY the hub-local-child authority
8315
+ * (`hubLocalChildProvides` + `getHubLocalChildId`, both deviceId-aware), so
8316
+ * it returns the native child route regardless of any in-process shadow.
8317
+ * Returns null when no hub-local child owns `(capName, deviceId)` — the
8318
+ * caller then falls through to its remote-resolution branch.
8359
8319
  */
8360
- capIndex() {
8361
- if (this.capChildIndex === null) this.capChildIndex = buildCapChildIndex(this.children.values());
8362
- return this.capChildIndex;
8363
- }
8364
- /** Drop the derived index. MUST follow every write to {@link children}. */
8365
- invalidateCapIndex() {
8366
- this.capChildIndex = null;
8320
+ resolveHubLocalUdsRoute(capName, deviceId) {
8321
+ if (!this.snapshot.hubLocalChildProvides(capName, deviceId)) return null;
8322
+ const childId = this.snapshot.getHubLocalChildId?.(capName, deviceId) ?? null;
8323
+ if (childId === null) return null;
8324
+ return {
8325
+ kind: "hub-local-uds",
8326
+ capName,
8327
+ childId
8328
+ };
8367
8329
  }
8368
8330
  /**
8369
- * Does the named child currently provide `(capName, deviceId?)`?
8370
- *
8371
- * Used by the hub proxy seam, which knows the EXACT addon (→ runner →
8372
- * childId) a provider belongs to. Unlike `resolveChildId` (which picks the
8373
- * first child owning `capName`), this targets one child — required for
8374
- * COLLECTION caps (`addon-widgets-source`, …) where many children register
8375
- * the same capName: routing by capName alone collapses every provider to
8376
- * the first child. Same deviceId-as-hint semantics as `resolveChildId`:
8377
- * a device-scoped descriptor matching `deviceId` OR a deviceId-less
8378
- * (singleton/collection) descriptor counts.
8331
+ * @param addonId Optional out-of-band PROVIDER-selection hint (Task 7.5a):
8332
+ * the addonId of the provider the CALLER already resolved (e.g.
8333
+ * `buildCapCallFn`'s `deps.addonId` for a registered grouped-runner
8334
+ * provider). Only meaningful for `agent-child-forward` — a `remote-moleculer`
8335
+ * route already encodes the provider in its action name
8336
+ * (`${addonId}.${cap}.${method}`, via `NodeCapAuthority.getAddonId`), and a
8337
+ * `hub-local-uds` caller that already knows the addonId (`buildCapCallFn`)
8338
+ * talks to `LocalChildRegistry.callCapOnChild` directly rather than through
8339
+ * `dispatch`.
8340
+ * @param callerAddonId Optional out-of-band CALLER-identity hint — the
8341
+ * addon that ORIGINATED the call, distinct from `addonId` above (which
8342
+ * names the resolved PROVIDER, not the caller). Set by
8343
+ * `onUnownedCall` from `CapCallInput.callerAddonId`
8344
+ * (`LocalChildRegistry` stamped it off the registered `childId`). Only
8345
+ * the `hub-in-process` branch consumes it — merged into `args` so a
8346
+ * diagnostic tool deep in that provider (e.g. device-manager's
8347
+ * fleet-read census) can read it without every provider method changing
8348
+ * signature, the same "extra field the provider destructures or
8349
+ * ignores" convention `nodeId` already uses on this path.
8379
8350
  */
8351
+ async dispatch(route, method, args, addonId, callerAddonId) {
8352
+ try {
8353
+ return await this.dispatchInner(route, method, args, addonId, callerAddonId);
8354
+ } catch (err) {
8355
+ if (err instanceof CapRouteError) throw err;
8356
+ if (route.kind === "hub-in-process" || err instanceof PeerAnsweredError) throw err;
8357
+ const nodeId = this.routeNodeId(route);
8358
+ const cause = err instanceof Error ? err : new Error(String(err));
8359
+ throw new CapRouteError(route.capName, method, {
8360
+ reason: "transport-failed",
8361
+ nodeId,
8362
+ rejected: [{
8363
+ kind: route.kind,
8364
+ why: cause.message
8365
+ }]
8366
+ }, cause);
8367
+ }
8368
+ }
8380
8369
  /**
8381
- * Is `childId` currently connected (has it completed its UDS handshake)?
8382
- * Coarser than {@link childProvides}: it answers "is the child reachable
8383
- * over UDS at all", regardless of which caps it has announced yet. Used by
8384
- * the route-mount fallback to decide between the handler-stripped
8385
- * `callAddonOnChild(target:'routes')` path (child reachable) and awaiting a
8386
- * cap proxy's `getRoutes()` (child not yet UDS-registered).
8370
+ * Inner dispatch: may throw CapRouteError (validation failures) or arbitrary
8371
+ * transport errors. The outer `dispatch` wraps non-CapRouteErrors.
8387
8372
  */
8388
- isChildKnown(childId) {
8389
- return this.children.has(childId);
8390
- }
8391
- childProvides(childId, capName, deviceId) {
8392
- const entry = this.children.get(childId);
8393
- if (!entry) return false;
8394
- if (deviceId !== void 0 && entry.caps.some((cap) => cap.capName === capName && cap.deviceId === deviceId)) return true;
8395
- return entry.caps.some((cap) => cap.capName === capName && cap.deviceId === void 0);
8396
- }
8397
- /**
8398
- * Per-request options for a cap call: the cap method's declared `timeoutMs`
8399
- * when one exists, else `undefined` so the channel applies its default.
8400
- * Mirrors `CapRouteResolver.callTimeout` for the remote plane.
8401
- */
8402
- capRequestOpts(input) {
8403
- const declared = this.capTimeoutMs?.(input.capName, input.method);
8404
- return typeof declared === "number" ? { timeoutMs: declared } : void 0;
8405
- }
8406
- /** Forward a cap method call to a SPECIFIC child by id over UDS; rejects if that child is absent. */
8407
- async callCapOnChild(childId, input) {
8408
- const entry = this.children.get(childId);
8409
- if (!entry) throw new Error(`no local child "${childId}" for cap "${input.capName}"`);
8410
- return entry.channel.request(this.toCapCall(input), this.capRequestOpts(input));
8411
- }
8412
- /** Forward a cap method call to the owning child over UDS; rejects if none. */
8413
- async callCap(input) {
8414
- const childId = this.resolveChildId(input.capName, input.deviceId);
8415
- const entry = childId === null ? void 0 : this.children.get(childId);
8416
- if (!entry) {
8417
- const where = input.deviceId === void 0 ? "" : ` for device ${input.deviceId}`;
8418
- throw new Error(`no local child provides cap "${input.capName}"${where}`);
8373
+ async dispatchInner(route, method, args, addonId, callerAddonId) {
8374
+ switch (route.kind) {
8375
+ case "hub-in-process": return route.ref.invoke(method, withCallerAddonId(args, callerAddonId));
8376
+ case "hub-local-uds": {
8377
+ const registry = this.hubLocalRegistry;
8378
+ if (registry === null) throw new CapRouteError(route.capName, method, {
8379
+ reason: "no-provider",
8380
+ rejected: [{
8381
+ kind: "hub-local-uds",
8382
+ why: "UDS registry not available"
8383
+ }]
8384
+ });
8385
+ const deviceId = extractDeviceId(args);
8386
+ const input = {
8387
+ capName: route.capName,
8388
+ method,
8389
+ args,
8390
+ ...deviceId !== void 0 ? { deviceId } : {}
8391
+ };
8392
+ return registry.callCapOnChild(route.childId, input);
8393
+ }
8394
+ case "remote-moleculer": {
8395
+ const addonId = this.nodeAuthority.getAddonId(route.nodeId, route.capName);
8396
+ if (addonId === null) throw new CapRouteError(route.capName, method, {
8397
+ reason: "no-provider",
8398
+ nodeId: route.nodeId,
8399
+ rejected: [{
8400
+ kind: "remote-moleculer",
8401
+ why: `no addonId known for ${route.nodeId}/${route.capName}`
8402
+ }]
8403
+ });
8404
+ const deviceId = extractDeviceId(args);
8405
+ const isNative = this.nodeAuthority.isNativeCap(route.nodeId, route.capName, deviceId);
8406
+ const action = capActionName(addonId, route.capName, method, isNative);
8407
+ return callWithServiceDiscovery(this.broker, addonId, action, args, {
8408
+ nodeID: route.nodeId,
8409
+ timeout: this.callTimeout(route.capName, method)
8410
+ });
8411
+ }
8412
+ case "agent-child-forward": {
8413
+ const deviceId = extractDeviceId(args);
8414
+ const params = {
8415
+ capName: route.capName,
8416
+ method,
8417
+ args,
8418
+ ...route.childId !== void 0 ? { childId: route.childId } : {},
8419
+ ...deviceId !== void 0 ? { deviceId } : {},
8420
+ ...addonId !== void 0 ? { addonId } : {}
8421
+ };
8422
+ return callWithServiceDiscovery(this.broker, AGENT_CAP_FWD_SERVICE, AGENT_CAP_FWD_ACTION, params, {
8423
+ nodeID: route.agentNodeId,
8424
+ timeout: this.callTimeout(route.capName, method)
8425
+ });
8426
+ }
8419
8427
  }
8420
- return entry.channel.request(this.toCapCall(input), this.capRequestOpts(input));
8421
- }
8422
- /**
8423
- * Forward an ADDON-LEVEL call (routes / custom-action) to a SPECIFIC child
8424
- * by id over UDS; rejects if that child is absent.
8425
- *
8426
- * The childId for a hub-local single-addon runner equals the addonId
8427
- * (`resolveRunnerId` returns the addonId when no `execution.group` is
8428
- * declared — no shipped addon declares one). Mirrors `callCapOnChild` for
8429
- * the cap plane; carries the two surfaces the removed per-addon Moleculer
8430
- * broker used to serve (`getRoutes` + `custom.<action>`).
8431
- */
8432
- async callAddonOnChild(childId, input) {
8433
- const entry = this.children.get(childId);
8434
- if (!entry) throw new Error(`no local child "${childId}" for addon-call (${input.target})`);
8435
- return entry.channel.request(this.toAddonCall(input));
8436
- }
8437
- /** Build the parent→child `addon-call` wire message from an addon-call input. */
8438
- toAddonCall(input) {
8439
- return {
8440
- kind: "addon-call",
8441
- addonId: input.addonId,
8442
- target: input.target,
8443
- ...input.action !== void 0 ? { action: input.action } : {},
8444
- ...input.method !== void 0 ? { method: input.method } : {},
8445
- ...input.args !== void 0 ? { args: input.args } : {},
8446
- ...input.caller !== void 0 ? { caller: input.caller } : {}
8447
- };
8448
8428
  }
8449
- /** Build the parent→child `cap-call` wire message from a routing input. */
8450
- toCapCall(input) {
8429
+ buildSnapshot() {
8430
+ const hubLocalRegistry = this.hubLocalRegistry;
8431
+ const nodeAuthority = this.nodeAuthority;
8432
+ const inProcessProviders = this.inProcessProviders;
8433
+ const hubResidentProviders = this.hubResidentProviders;
8451
8434
  return {
8452
- kind: "cap-call",
8453
- capName: input.capName,
8454
- method: input.method,
8455
- args: input.args,
8456
- ...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
8457
- ...input.addonId !== void 0 ? { addonId: input.addonId } : {}
8435
+ hubNodeId: this.hubNodeId,
8436
+ hubInProcessProvides: (cap) => inProcessProviders(cap) !== null,
8437
+ ...hubResidentProviders !== void 0 ? {
8438
+ hubResidentProvides: (cap) => hubResidentProviders(cap) !== null,
8439
+ getHubResidentProviderRef: (cap) => hubResidentProviders(cap)
8440
+ } : {},
8441
+ hubLocalChildProvides: (cap, deviceId) => hubLocalRegistry !== null && hubLocalRegistry.resolveChildId(cap, deviceId) !== null,
8442
+ nodeKnowsCap: (nodeId, cap) => nodeAuthority.nodeKnowsCap(nodeId, cap),
8443
+ nodeIsAgent: (nodeId) => nodeAuthority.nodeIsAgent(nodeId),
8444
+ nodeOnline: (nodeId) => nodeAuthority.nodeOnline(nodeId),
8445
+ listKnownNodeIds: () => nodeAuthority.listNodeIds(),
8446
+ getInProcessProviderRef: (cap) => inProcessProviders(cap),
8447
+ getHubLocalChildId: (cap, deviceId) => hubLocalRegistry !== null ? hubLocalRegistry.resolveChildId(cap, deviceId) : null,
8448
+ getAgentChildId: (agentNodeId, cap) => nodeAuthority.getAgentChildId(agentNodeId, cap)
8458
8449
  };
8459
8450
  }
8460
- listChildren() {
8461
- return [...this.children.values()].map((e) => ({
8462
- childId: e.childId,
8463
- caps: e.caps,
8464
- incarnation: e.incarnation
8465
- }));
8466
- }
8467
- /** Register the (single) child-registered handler. Only one handler is active at a time. */
8468
- onChildRegistered(handler) {
8469
- this.registeredHandler = handler;
8451
+ /** Extract a nodeId string from a route for error reporting, or undefined. */
8452
+ routeNodeId(route) {
8453
+ switch (route.kind) {
8454
+ case "hub-in-process": return this.hubNodeId;
8455
+ case "hub-local-uds": return `${this.hubNodeId}/${route.childId}`;
8456
+ case "remote-moleculer": return route.nodeId;
8457
+ case "agent-child-forward": return route.agentNodeId;
8458
+ }
8470
8459
  }
8460
+ };
8461
+ //#endregion
8462
+ //#region src/kernel/transport/claimed-get-status.ts
8463
+ var GET_STATUS_METHOD = "getStatus";
8464
+ /** The device a `getStatus` is about, or `undefined` when the call is not one. */
8465
+ function deviceOfGetStatus(call) {
8466
+ if (call.method !== "getStatus") return void 0;
8467
+ return call.deviceId ?? extractDeviceId(call.args);
8468
+ }
8469
+ async function answerClaimedGetStatus(resolve, call) {
8470
+ if (resolve === void 0) return null;
8471
+ const deviceId = deviceOfGetStatus(call);
8472
+ if (deviceId === void 0) return null;
8473
+ const answer = await resolve(call.capName, deviceId);
8474
+ if (answer.kind === "unclaimed") return null;
8475
+ return { slice: answer.kind === "claimed" ? answer.slice : null };
8476
+ }
8477
+ function readFanoutMode() {
8478
+ const raw = process.env.CAMSTACK_UDS_EVENT_FANOUT;
8479
+ if (raw === "shadow" || raw === "broadcast") return raw;
8480
+ return "filter";
8481
+ }
8482
+ /**
8483
+ * Sentinel prefix used in the no-route error thrown when `cap-call-out` has no
8484
+ * local sibling and no `onUnownedCall` fallback. `ipcParentLink` detects this
8485
+ * prefix to distinguish routing failures (safe to retry via broker) from real
8486
+ * provider errors (must NOT retry to avoid double-executing side effects).
8487
+ */
8488
+ var UDS_NO_ROUTE_PREFIX = "UDS_NO_ROUTE";
8489
+ /**
8490
+ * Parent-side authority for local addon-runners reachable over a
8491
+ * `LocalTransportServer` (UDS). Children register their cap manifest on
8492
+ * connect; the registry routes `(capName, deviceId?)` cap calls to the
8493
+ * owning child over the channel and drops a child's caps on disconnect.
8494
+ */
8495
+ var LocalChildRegistry = class {
8496
+ children = /* @__PURE__ */ new Map();
8497
+ registeredHandler = () => {};
8498
+ goneHandler = () => {};
8499
+ /** Source of {@link RegisteredChild.incarnation}; one bump per accepted connection. */
8500
+ nextIncarnation = 1;
8501
+ eventHandler = null;
8502
+ logHandler = null;
8503
+ readinessHandler = null;
8504
+ server;
8505
+ onUnownedCall;
8506
+ logger;
8507
+ getActiveSingletonAddonId;
8508
+ resolveChildIdForAddon;
8509
+ /** This node's own Moleculer nodeId (see {@link LocalChildRegistryOptions.ownNodeId}). */
8510
+ ownNodeId;
8511
+ /** See {@link LocalChildRegistryOptions.isAggregatedCollectionMethod}. */
8512
+ isAggregatedCollectionMethod;
8513
+ isAddonPinnedCall;
8514
+ /** See {@link ForwardCensus}. Mutable counters on the hot path, by design. */
8515
+ forwardedRaw = 0;
8516
+ forwardedDecoded = 0;
8471
8517
  /**
8472
- * Register the (single) child-gone handler. Only one handler is active at a
8473
- * time. The handler receives the {@link RegisteredChild.incarnation} of the
8474
- * connection that closed — carry it into whatever teardown it triggers, so a
8475
- * dead generation cannot tear down its successor's work.
8518
+ * The per-ACTION byte census (D456). `ForwardCensus` above is the same
8519
+ * question asked of the whole plane; this one asks it per `<cap>.<method>`,
8520
+ * beside the bytes that method actually moved. Both are kept: `socketFwd=`
8521
+ * says whether the negotiation is working at all, and a `raw=0` on ONE
8522
+ * method with `raw>0` on its neighbours is a different fault.
8476
8523
  */
8477
- onChildGone(handler) {
8478
- this.goneHandler = handler;
8479
- }
8524
+ actionCensus = createCapActionCensus();
8525
+ /** See {@link LocalChildRegistryOptions.capTimeoutMs}. */
8526
+ capTimeoutMs;
8527
+ /** See {@link LocalChildRegistryOptions.claimedStatus}. */
8528
+ claimedStatus;
8529
+ /** See {@link LocalChildRegistryOptions.capUsageObserver}. */
8530
+ capUsageObserver;
8531
+ /** Tracks capNames already logged as UDS-routed; one INFO line per capName per process. */
8532
+ egressRoutedCaps = /* @__PURE__ */ new Set();
8533
+ /** Active event fan-out mode, read once from `CAMSTACK_UDS_EVENT_FANOUT`. */
8534
+ fanoutMode = readFanoutMode();
8535
+ /** Per-child event fan-out counters (sent / suppressed). */
8536
+ childEventStats = /* @__PURE__ */ new Map();
8537
+ /** D467: per-category production census (cumulative). See {@link CategoryFanoutSample}. */
8538
+ categoryStats = /* @__PURE__ */ new Map();
8539
+ /** Broadcasts whose category did not fit in {@link CATEGORY_CENSUS_MAX}. */
8540
+ categoryOverflow = 0;
8541
+ /** One INFO line the first time the census cap trips, never per event. */
8542
+ categoryOverflowLogged = false;
8543
+ /** Last pattern-set string logged per child, to dedup the INFO line. */
8544
+ loggedPatternSet = /* @__PURE__ */ new Map();
8480
8545
  /**
8481
- * Register the (single) child-event handler. Invoked when a child sends an
8482
- * event via `LocalChildClient.emitEvent`. Only one handler is active at a
8483
- * time; a new handler replaces the prior one. Pass `null` to clear the
8484
- * handler entirely (used by the UDS event bridge disposer on shutdown).
8546
+ * Derived `(cap, device?) → child` index over {@link children}, or `null`
8547
+ * when it must be rebuilt. See {@link capIndex} for why it is a cache and
8548
+ * never a registry (D3, D457).
8485
8549
  */
8486
- onChildEvent(handler) {
8487
- this.eventHandler = handler;
8488
- }
8550
+ capChildIndex = null;
8489
8551
  /**
8490
- * Register the (single) child-log handler. Invoked when a child sends a log
8491
- * entry via `LocalChildClient.sendLog`. Only one handler is active.
8552
+ * Accepts either a plain positional `server` argument (backward-compatible)
8553
+ * or a full `LocalChildRegistryOptions` object.
8554
+ *
8555
+ * Positional overloads (existing call sites are unchanged):
8556
+ * new LocalChildRegistry(server)
8557
+ * new LocalChildRegistry(server, onUnownedCall)
8558
+ *
8559
+ * Options object (new call sites that pass a logger):
8560
+ * new LocalChildRegistry({ server, onUnownedCall, logger })
8492
8561
  */
8493
- onChildLog(handler) {
8494
- this.logHandler = handler;
8495
- }
8496
- /**
8497
- * Register the handler that supplies the authoritative readiness snapshot
8498
- * when a child sends a `readiness-request`. Only one handler is active.
8499
- */
8500
- onReadinessSnapshotRequest(handler) {
8501
- this.readinessHandler = handler;
8562
+ constructor(serverOrOptions, onUnownedCallArg) {
8563
+ if (serverOrOptions && !("listen" in serverOrOptions)) {
8564
+ const opts = serverOrOptions;
8565
+ this.server = opts.server;
8566
+ this.onUnownedCall = opts.onUnownedCall;
8567
+ this.logger = opts.logger;
8568
+ this.getActiveSingletonAddonId = opts.getActiveSingletonAddonId;
8569
+ this.resolveChildIdForAddon = opts.resolveChildIdForAddon;
8570
+ this.ownNodeId = opts.ownNodeId;
8571
+ this.isAggregatedCollectionMethod = opts.isAggregatedCollectionMethod;
8572
+ this.isAddonPinnedCall = opts.isAddonPinnedCall;
8573
+ this.capTimeoutMs = opts.capTimeoutMs;
8574
+ this.claimedStatus = opts.claimedStatus;
8575
+ this.capUsageObserver = opts.capUsageObserver;
8576
+ } else {
8577
+ this.server = serverOrOptions;
8578
+ this.onUnownedCall = onUnownedCallArg;
8579
+ }
8580
+ }
8581
+ async start() {
8582
+ this.logger?.info("UDS event fan-out mode", { mode: this.fanoutMode });
8583
+ this.server.onConnection((channel) => this.onConnection(channel));
8584
+ await this.server.listen();
8502
8585
  }
8503
8586
  /**
8504
- * Push a parent→child event to a specific child. Fire-and-forget (uses the
8505
- * one-way `emit` path on the channel). No-op if the child is not connected.
8587
+ * Per-child event fan-out counters (`{ sent, suppressed }`) or `null` if the
8588
+ * child has no recorded events yet. Exposed for the starvation-verification
8589
+ * surface (shadow burn-in + operator debug).
8506
8590
  */
8507
- sendEventToChild(childId, event, sourceNodeId) {
8508
- const entry = this.children.get(childId);
8509
- if (entry === void 0) return;
8510
- const msg = {
8511
- kind: "event",
8512
- event,
8513
- sourceNodeId
8591
+ getChildEventStats(childId) {
8592
+ const s = this.childEventStats.get(childId);
8593
+ return s === void 0 ? null : {
8594
+ sent: s.sent,
8595
+ suppressed: s.suppressed
8514
8596
  };
8515
- entry.channel.emit(msg);
8516
8597
  }
8517
8598
  /**
8518
- * Push a parent→child event to every registered child, optionally skipping
8519
- * one (the originating child, to avoid echo). Fire-and-forget.
8599
+ * D467: every category this node has broadcast, with its emit count and how
8600
+ * many child subscriptions matched. Cumulative for the life of the process —
8601
+ * the heartbeat differences it, the same discipline as the per-child
8602
+ * counters. Largest `broadcasts` first.
8520
8603
  */
8521
- broadcastEventToChildren(event, sourceNodeId, exceptChildId) {
8522
- const msg = {
8523
- kind: "event",
8524
- event,
8525
- sourceNodeId
8526
- };
8527
- let subscribed = 0;
8528
- for (const entry of this.children.values()) {
8529
- if (entry.childId === exceptChildId) continue;
8530
- if (this.childSubscribesTo(entry, event.category)) subscribed += 1;
8531
- const wants = this.childWantsEvent(entry, event.category);
8532
- const stats = this.statsFor(entry.childId);
8533
- if (!wants) stats.suppressed++;
8534
- if (wants || this.fanoutMode === "shadow") {
8535
- stats.sent++;
8536
- entry.channel.emit(msg);
8537
- }
8538
- }
8539
- this.recordCategoryBroadcast(event.category, subscribed);
8604
+ categoryFanout() {
8605
+ const out = [];
8606
+ for (const [category, s] of this.categoryStats) out.push({
8607
+ category,
8608
+ broadcasts: s.broadcasts,
8609
+ wanted: s.wanted
8610
+ });
8611
+ return out.toSorted((a, b) => b.broadcasts - a.broadcasts || a.category.localeCompare(b.category));
8612
+ }
8613
+ /** Broadcasts the census could not attribute because it was full (cumulative). */
8614
+ categoryCensusOverflow() {
8615
+ return this.categoryOverflow;
8540
8616
  }
8541
8617
  /**
8542
- * Record one PRODUCED event against its category. Called once per broadcast,
8543
- * outside the per-child loop, because production is what the census measures.
8618
+ * This child's socket traffic — bytes and messages, both directions, split by
8619
+ * frame kind — or `null` when the child is gone or its channel keeps no
8620
+ * counters (an in-process or test transport).
8621
+ *
8622
+ * The event counters above cover ONE kind of frame and, once surfaced on
8623
+ * 2026-08-29, accounted for 4% of hub-main's ~44 000 socket syscalls/s. This
8624
+ * is the same question asked of every frame the channel moves, and it is the
8625
+ * only per-peer attribution that exists: `/proc` reports one `rchar`/`wchar`
8626
+ * pair for the whole process and has no per-socket breakdown.
8544
8627
  */
8545
- recordCategoryBroadcast(category, subscribed) {
8546
- const existing = this.categoryStats.get(category);
8547
- if (existing !== void 0) {
8548
- existing.broadcasts += 1;
8549
- existing.wanted += subscribed;
8550
- return;
8551
- }
8552
- if (this.categoryStats.size >= 256) {
8553
- this.categoryOverflow += 1;
8554
- if (!this.categoryOverflowLogged) {
8555
- this.categoryOverflowLogged = true;
8556
- this.logger?.info("event category census full — further new categories are unattributed", {
8557
- max: 256,
8558
- firstDropped: category
8559
- });
8560
- }
8561
- return;
8562
- }
8563
- this.categoryStats.set(category, {
8564
- broadcasts: 1,
8565
- wanted: subscribed
8566
- });
8628
+ getChildSocketTraffic(childId) {
8629
+ const entry = this.children.get(childId);
8630
+ if (entry === void 0) return null;
8631
+ return entry.channel.readTraffic?.() ?? null;
8567
8632
  }
8568
8633
  /**
8569
- * E2: Send a `set-log-level` control message to a specific child.
8570
- * Returns `true` if the child is currently connected and the message was
8571
- * emitted; `false` if the child is not connected (no-op). The `false`
8572
- * return lets the caller (MoleculerService.setChildLogLevelByNodeId) fall
8573
- * back to the Moleculer `$node-mgmt.setLogLevel` action for the node.
8574
- * Mirrors the `$node-mgmt.setLogLevel` Moleculer action for UDS children.
8634
+ * Bytes this process has written to a child that the child has not yet
8635
+ * taken, live, or `null` when the channel keeps no such reading. Read on
8636
+ * every heartbeat probe (D446) — a child that stops draining is the shape
8637
+ * of an outbound-queue episode, and the number lives on the channel.
8575
8638
  */
8576
- setChildLogLevel(childId, level) {
8639
+ getChildQueuedBytes(childId) {
8577
8640
  const entry = this.children.get(childId);
8578
- if (entry === void 0) return false;
8579
- const msg = {
8580
- kind: "set-log-level",
8581
- level
8641
+ if (entry === void 0) return null;
8642
+ return entry.channel.queuedBytes?.() ?? null;
8643
+ }
8644
+ /** Cumulative {@link ForwardCensus} of this parent's `cap-call-out` routing. */
8645
+ readForwardCensus() {
8646
+ return {
8647
+ raw: this.forwardedRaw,
8648
+ decoded: this.forwardedDecoded
8582
8649
  };
8583
- entry.channel.emit(msg);
8584
- return true;
8585
8650
  }
8586
- async close() {
8587
- await this.server.close();
8651
+ /**
8652
+ * Cumulative per-action byte census: peer → `<cap>.<method>` → bytes, calls
8653
+ * and the raw/decoded split. A COPY (`cap-action-census.ts` explains why it
8654
+ * must be one); the heartbeat subtracts the previous reading.
8655
+ *
8656
+ * This is the instrument D454 closed by asking for. `socketTopBytes=` names
8657
+ * the peer that moves the bytes and `txkB=` says they are responses; neither
8658
+ * can say WHICH method originates them, and after raw-forward the bytes that
8659
+ * cross are no longer the proxy for what hub-main pays — 85.7 % of sibling
8660
+ * calls cross unread. What costs is what hub-main LOOKS at, and that is the
8661
+ * `decoded` half of an entry, next to the bytes it carried.
8662
+ */
8663
+ readActionCensus() {
8664
+ return this.actionCensus.read();
8588
8665
  }
8589
- onConnection(channel) {
8590
- let childId = null;
8591
- const incarnation = this.nextIncarnation++;
8592
- channel.observeActions?.({
8593
- label: capActionLabel,
8594
- record: (label, reqBytes, resBytes) => {
8595
- const peerId = childId ?? "(pre-register)";
8596
- if (label === null) this.actionCensus.recordUnlabelled(peerId, reqBytes + resBytes);
8597
- else this.actionCensus.record(peerId, label, reqBytes, resBytes);
8598
- }
8599
- });
8600
- channel.onEvent((body) => {
8601
- const msg = body;
8602
- if (msg.kind === "event") {
8603
- if (childId !== null) this.eventHandler?.(childId, msg.event);
8604
- return;
8605
- }
8606
- if (msg.kind === "log") {
8607
- if (childId !== null) this.logHandler?.(childId, msg);
8608
- return;
8609
- }
8610
- if (msg.kind === "event-sub") {
8611
- if (childId === null) return;
8612
- const entry = this.children.get(childId);
8613
- if (entry === void 0) return;
8614
- this.children.set(childId, {
8615
- ...entry,
8616
- eventPatterns: msg.patterns
8617
- });
8618
- this.logPatternSet(childId, msg.patterns);
8619
- return;
8620
- }
8621
- });
8622
- channel.onRequest(async (body, payload) => {
8623
- const msg = body;
8624
- if (msg.kind === "register") {
8625
- if (childId !== null && childId !== msg.childId) throw new Error(`child attempted to change identity from "${childId}" to "${msg.childId}"`);
8626
- childId = msg.childId;
8627
- const eventPatterns = msg.eventPatterns ?? null;
8628
- const superseded = this.children.get(msg.childId);
8629
- if (superseded !== void 0 && superseded.channel !== channel) this.logger?.info("local child superseded by a new connection", {
8630
- childId: msg.childId,
8631
- supersededIncarnation: superseded.incarnation,
8632
- incarnation
8633
- });
8634
- this.children.set(msg.childId, {
8635
- childId: msg.childId,
8636
- channel,
8637
- caps: msg.caps,
8638
- eventPatterns,
8639
- incarnation,
8640
- rawForward: msg.rawForward === true
8641
- });
8642
- this.invalidateCapIndex();
8643
- this.logPatternSet(msg.childId, eventPatterns);
8644
- this.registeredHandler({
8645
- childId: msg.childId,
8646
- caps: msg.caps,
8647
- incarnation,
8648
- ...msg.customActions !== void 0 ? { customActions: msg.customActions } : {}
8649
- });
8650
- return {
8651
- ok: true,
8652
- rawForward: true
8653
- };
8654
- }
8655
- if (msg.kind === "cap-call-out") {
8656
- const out = msg;
8657
- const hints = out.hints ?? liftRoutingHints(out.args);
8658
- const materialise = () => payload !== void 0 ? unpackPayload(payload) : out.args;
8659
- const buildInput = () => ({
8660
- capName: out.capName,
8661
- method: out.method,
8662
- args: materialise(),
8663
- ...out.deviceId !== void 0 ? { deviceId: out.deviceId } : {},
8664
- ...out.nodeId !== void 0 ? { nodeId: out.nodeId } : {},
8665
- ...out.native === true ? { native: true } : {},
8666
- ...childId !== null ? { callerAddonId: childId } : {}
8667
- });
8668
- const pinnedNodeId = out.nodeId ?? hints.nodeId;
8669
- const pinTargetsThisNode = pinnedNodeId !== void 0 && this.ownNodeId !== void 0 && pinnedNodeId === this.ownNodeId;
8670
- const aggregated = pinnedNodeId === void 0 && this.isAggregatedCollectionMethod?.(out.capName, out.method) === true;
8671
- const addonPinned = pinnedNodeId === void 0 && this.isAddonPinnedCall?.(out.capName, out.method, hints) === true;
8672
- const target = !(out.native === true) && !aggregated && !addonPinned && (pinnedNodeId === void 0 || pinTargetsThisNode) ? this.resolveChildId(out.capName, out.deviceId) : null;
8673
- this.recordCapUsage(childId, target, out.capName, out.method);
8674
- const actionPeer = childId ?? "(pre-register)";
8675
- const actionLabel = `${out.capName}.${out.method}`;
8676
- if (target !== null) {
8677
- if (!this.egressRoutedCaps.has(out.capName)) {
8678
- this.egressRoutedCaps.add(out.capName);
8679
- this.logger?.info("routed child egress over UDS", { capName: out.capName });
8680
- }
8681
- const entry = this.children.get(target);
8682
- if (payload !== void 0 && entry !== void 0 && entry.rawForward) {
8683
- this.forwardedRaw += 1;
8684
- this.actionCensus.recordForward(actionPeer, actionLabel, "raw");
8685
- const forward = {
8686
- kind: "cap-call",
8687
- capName: out.capName,
8688
- method: out.method,
8689
- args: void 0,
8690
- ...out.deviceId !== void 0 ? { deviceId: out.deviceId } : {}
8691
- };
8692
- const declared = this.capTimeoutMs?.(out.capName, out.method);
8693
- return entry.channel.request(forward, {
8694
- ...typeof declared === "number" ? { timeoutMs: declared } : {},
8695
- payload
8696
- });
8697
- }
8698
- this.forwardedDecoded += 1;
8699
- this.actionCensus.recordForward(actionPeer, actionLabel, "decoded");
8700
- const input = buildInput();
8701
- return entry !== void 0 ? this.callCapOnChild(target, input) : this.callCap(input);
8702
- }
8703
- this.forwardedDecoded += 1;
8704
- this.actionCensus.recordForward(actionPeer, actionLabel, "decoded");
8705
- if (this.onUnownedCall !== void 0) return this.onUnownedCall(buildInput());
8706
- throw new Error(`${UDS_NO_ROUTE_PREFIX}: cap-call-out has no local provider for "${out.capName}" and no fallback`);
8707
- }
8708
- if (msg.kind === "readiness-request") return {
8709
- kind: "readiness-snapshot",
8710
- records: this.readinessHandler?.() ?? []
8711
- };
8712
- throw new Error(`unknown child request kind: ${msg.kind}`);
8713
- });
8714
- channel.onClose(() => {
8715
- if (childId === null) return;
8716
- const entry = this.children.get(childId);
8717
- if (entry === void 0) return;
8718
- if (entry.channel !== channel) {
8719
- this.logger?.info("ignoring close of a superseded local child connection", {
8720
- childId,
8721
- incarnation,
8722
- liveIncarnation: entry.incarnation
8723
- });
8724
- return;
8725
- }
8726
- this.children.delete(childId);
8727
- this.invalidateCapIndex();
8728
- this.childEventStats.delete(childId);
8729
- this.actionCensus.forget(childId);
8730
- this.loggedPatternSet.delete(childId);
8731
- this.goneHandler(childId, incarnation);
8732
- });
8733
- }
8734
- };
8735
- //#endregion
8736
- //#region src/kernel/transport/local-child-client.ts
8737
- /** Did the parent's `register` answer say it accepts payload-borne args? */
8738
- function ackAcceptsRawForward(ack) {
8739
- if (ack === null || typeof ack !== "object") return false;
8740
- return ack.rawForward === true;
8741
- }
8742
- /**
8743
- * Child side of the local UDS transport. Connects to its parent (hub or
8744
- * agent), registers its cap manifest, and serves parent→child cap calls by
8745
- * delegating to `dispatch`. The provider implementation lives in the child;
8746
- * only routing keys + call arguments cross the wire.
8747
- *
8748
- * Additional channels beyond cap-call:
8749
- * - `emitEvent` fire-and-forget event toward the parent
8750
- * - `sendLog` fire-and-forget log entry toward the parent
8751
- * - `requestReadinessSnapshot` request/response snapshot of readiness records
8752
- * - `onEvent` register a handler for parent→child events
8753
- *
8754
- * Events and logs emitted before `start()` resolves are buffered and flushed
8755
- * on connect (mirrors the `updateCaps`/`latestCaps` pattern).
8756
- */
8757
- var LocalChildClient = class {
8758
- options;
8759
- client = null;
8760
- channel = null;
8761
- /**
8762
- * The cap set `start()` will register, kept current by `updateCaps`. Native
8763
- * device caps register on device-restore, which can race AHEAD of the UDS
8764
- * connect — buffering here lets a pre-start `updateCaps` survive (start sends
8765
- * the latest set) instead of being lost or throwing.
8766
- */
8767
- latestCaps;
8768
- /**
8769
- * Per-owner (addonId) category-pattern subscription sets, and their union.
8770
- * The union is declared to the parent (in `RegisterMessage.eventPatterns`
8771
- * pre/at-connect, or via an `event-sub` emit post-connect) so the parent can
8772
- * subscription-filter its event fan-out. Per-owner keying keeps the union
8773
- * correct if multiple event buses ever share one client (group-runner case).
8774
- */
8775
- patternsByOwner = /* @__PURE__ */ new Map();
8776
- latestEventPatterns = [];
8777
- /**
8778
- * Whether `updateEventPatterns` has ever been called. A client that never
8779
- * declared a subscription set (no event bus wired — e.g. a pure cap-call
8780
- * runner) omits `eventPatterns` from its register frame entirely, so the
8781
- * parent treats it as UNDECLARED and fails OPEN (broadcast-all). Once any
8782
- * owner declares (in production every addon context's framework
8783
- * subscriptions do), the register carries the real union — even if empty.
8784
- */
8785
- hasDeclaredEventPatterns = false;
8786
8666
  /**
8787
- * The custom-action catalogs of every hosted addon, once the runner's init
8788
- * loop has produced them (`updateCaps(caps, catalogs)` at post-init). `null`
8789
- * until then, and OMITTED from the register frame while null: the parent
8790
- * reads absence as "not described yet", and a pre-init register must never
8791
- * look like "this child declares no actions". Kept on the client so every
8792
- * later re-register (native-cap change, reconnect) re-carries it unchanged.
8667
+ * The regime the counters above were produced under, read once from
8668
+ * `CAMSTACK_UDS_EVENT_FANOUT` at construction.
8669
+ *
8670
+ * A suppressed count means "nothing WAS dropped" under `filter` and "nothing
8671
+ * WOULD have been dropped" under `shadow`. Those are different claims, and
8672
+ * until this getter existed the mode was invisible at runtime — the counters
8673
+ * were readable and the regime that produced them was not.
8793
8674
  */
8794
- latestCustomActions = null;
8795
- /** Events and logs queued while the channel is not yet open. */
8796
- pendingEmits = [];
8675
+ get eventFanoutMode() {
8676
+ return this.fanoutMode;
8677
+ }
8797
8678
  /**
8798
- * Whether the parent acknowledged raw-forward at `register`. Until it has,
8799
- * `callOut` sends args inline exactly as every child always did — a legacy
8800
- * parent would decode a payload-borne call to `args: undefined` and run the
8801
- * provider on nothing, silently. Re-read on every re-register.
8679
+ * Connected children that never declared a subscription pattern set.
8680
+ *
8681
+ * Each one is a fail-open on BOTH planes: `childWantsEvent` returns true for
8682
+ * an undeclared child ({@link ChildEntry.eventPatterns} `=== null`), and
8683
+ * `aggregateEventInterest` returns `null` — accept everything — if ANY
8684
+ * connected child is undeclared, which disables the node's inbound cross-node
8685
+ * gate as well. **One legacy runner silently turns off the filtering for the
8686
+ * whole node**, and nothing reported it, so a zero here is as load-bearing as
8687
+ * a non-zero one.
8802
8688
  */
8803
- parentRawForward = false;
8804
- /** Handler for parent→child events. Registered via `onEvent`. */
8805
- eventHandler = null;
8689
+ undeclaredChildCount() {
8690
+ let count = 0;
8691
+ for (const entry of this.children.values()) if (entry.eventPatterns === null) count += 1;
8692
+ return count;
8693
+ }
8806
8694
  /**
8807
- * Handler for parent→child addon-level calls (`routes` / `custom`).
8808
- * Registered via `onAddonCall`. Resolves the loaded addon instance and
8809
- * invokes its `addon-routes.getRoutes()` or its custom-action handler.
8810
- * Replaces the per-addon Moleculer `getRoutes` / `custom.<action>` actions
8811
- * removed in F1/F2. Only one handler is active at a time.
8695
+ * Does `entry` want to receive an event in `category`? Honours the fan-out
8696
+ * mode: `broadcast` → always; undeclared patterns (`null`) → always
8697
+ * (fail-open); else the child's declared set must match via `matchesPattern`
8698
+ * — the SAME function the child's own `deliverShared` map-key gate uses,
8699
+ * giving provable delivery parity. `shadow` is handled by the caller (it
8700
+ * counts the would-suppress but still sends).
8701
+ *
8702
+ * NOTE: this gate must NOT consult `RING_BUFFER_DENY_PATTERNS` /
8703
+ * `isHighFrequencyCategory` — those are ring-buffer STORAGE policy; a child
8704
+ * that explicitly subscribes to a ring-denied category must still RECEIVE it.
8812
8705
  */
8813
- addonCallHandler = null;
8706
+ childWantsEvent(entry, category) {
8707
+ if (this.fanoutMode === "broadcast") return true;
8708
+ return this.childSubscribesTo(entry, category);
8709
+ }
8814
8710
  /**
8815
- * Handler for `set-log-level` messages pushed by the parent.
8816
- * Registered via `onSetLogLevel`. The handler applies the new level to the
8817
- * child's local Moleculer logger (mirrors `$node-mgmt.setLogLevel`).
8818
- * E2: wired in `addon-runner.ts` to forward to the broker logger.
8711
+ * The SUBSCRIPTION answer alone, with no fan-out-mode override.
8712
+ *
8713
+ * {@link childWantsEvent} answers "will this child be sent the event", which
8714
+ * under `broadcast` is `true` for everything and therefore cannot say whether
8715
+ * anyone actually asked. The D467 census needs the second question, so the
8716
+ * two are separate functions and `broadcast` mode overrides only the first.
8819
8717
  */
8820
- setLogLevelHandler = null;
8821
- /** Callbacks registered via `onConnected`. Fired on every (re)connect. */
8822
- connectedHandlers = [];
8823
- constructor(options) {
8824
- this.options = options;
8825
- this.latestCaps = options.caps;
8718
+ childSubscribesTo(entry, category) {
8719
+ if (entry.eventPatterns === null) return true;
8720
+ return entry.eventPatterns.some((p) => matchesPattern(p, category));
8826
8721
  }
8827
8722
  /**
8828
- * Declare an owner's live category-pattern subscription set. Stores it
8829
- * per-owner (keyed by `ownerId` = addonId), recomputes the union, and — only
8830
- * if the union changed — declares it to the parent:
8831
- * - pre-connect: buffered only; `start()`'s register frame carries the set.
8832
- * - post-connect: emitted as a fire-and-forget `event-sub` (full-set
8833
- * replace).
8834
- * Idempotent on an unchanged union (no redundant `event-sub` frames).
8723
+ * D2: this node's aggregate cross-node event interest — the UNION of every
8724
+ * connected child's declared category patterns. Consumed by the parent's
8725
+ * `$event-bus` inbound gate (via `setNodeEventInterest`) so the node only
8726
+ * accepts cross-node (Moleculer) categories at least one local child wants.
8727
+ *
8728
+ * Returns `null` (fail-open — accept everything) when:
8729
+ * - NO child is currently connected (boot window before the first forked
8730
+ * child completes its UDS handshake, or every child has disconnected): a
8731
+ * node MUST NOT drop 100% of inbound cross-node events during that window,
8732
+ * and a child may connect imminently. This is distinct from the
8733
+ * declared-empty case below.
8734
+ * - ANY connected child is UNDECLARED (`eventPatterns === null`, a legacy
8735
+ * runner): a node cannot safely narrow its inbound set while a child's
8736
+ * real interest is unknown. Mirrors the per-child fail-open in
8737
+ * {@link childWantsEvent}, aggregated conservatively.
8738
+ *
8739
+ * Returns an EMPTY array only when ≥1 child is connected and EVERY connected
8740
+ * child declared an explicit empty set — a genuine "wants nothing". Reads the
8741
+ * live `children` map, so a child registering / disconnecting / sending
8742
+ * `event-sub` is reflected on the next call with no extra bookkeeping.
8835
8743
  */
8836
- updateEventPatterns(ownerId, patterns) {
8837
- this.hasDeclaredEventPatterns = true;
8838
- this.patternsByOwner.set(ownerId, patterns);
8839
- const union = this.computePatternUnion();
8840
- if (!this.patternsEqual(union, this.latestEventPatterns)) {
8841
- this.latestEventPatterns = union;
8842
- if (this.channel !== null) {
8843
- const msg = {
8844
- kind: "event-sub",
8845
- patterns: union
8846
- };
8847
- this.channel.emit(msg);
8848
- }
8744
+ aggregateEventInterest() {
8745
+ if (this.children.size === 0) return null;
8746
+ const union = /* @__PURE__ */ new Set();
8747
+ for (const entry of this.children.values()) {
8748
+ if (entry.eventPatterns === null) return null;
8749
+ for (const p of entry.eventPatterns) union.add(p);
8849
8750
  }
8751
+ return [...union];
8850
8752
  }
8851
- /** Sorted, de-duplicated union across all owners' pattern sets. */
8852
- computePatternUnion() {
8853
- const all = /* @__PURE__ */ new Set();
8854
- for (const patterns of this.patternsByOwner.values()) for (const p of patterns) all.add(p);
8855
- return [...all].toSorted();
8753
+ statsFor(childId) {
8754
+ let s = this.childEventStats.get(childId);
8755
+ if (s === void 0) {
8756
+ s = {
8757
+ sent: 0,
8758
+ suppressed: 0
8759
+ };
8760
+ this.childEventStats.set(childId, s);
8761
+ }
8762
+ return s;
8856
8763
  }
8857
- /** Order-insensitive equality — both inputs are already sorted unions here. */
8858
- patternsEqual(a, b) {
8859
- if (a.length !== b.length) return false;
8860
- for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
8861
- return true;
8764
+ /** Deduped INFO line whenever a child's declared pattern set changes. */
8765
+ logPatternSet(childId, patterns) {
8766
+ const key = patterns === null ? "<undeclared>" : JSON.stringify([...patterns].toSorted());
8767
+ if (this.loggedPatternSet.get(childId) === key) return;
8768
+ this.loggedPatternSet.set(childId, key);
8769
+ this.logger?.info("child event patterns", {
8770
+ childId,
8771
+ patterns: patterns ?? null
8772
+ });
8862
8773
  }
8863
8774
  /**
8864
- * Register a callback that fires each time the client successfully connects
8865
- * (or reconnects) to its parent. Multiple handlers may be registered; all
8866
- * are called in registration order. Used by readiness-context in UDS mode
8867
- * to trigger a snapshot hydrate on connect/reconnect.
8775
+ * Child id that can service a call to `capName` (optionally addressing
8776
+ * `deviceId`), or null.
8777
+ *
8778
+ * `deviceId` is a routing HINT, not a hard filter. A singleton cap
8779
+ * (`pipeline-runner`, `stream-broker`, …) addresses devices through its
8780
+ * METHOD ARGUMENTS — `attachCamera({ deviceId })` is one provider serving
8781
+ * many cameras — so its descriptor carries no `deviceId`. Resolving on
8782
+ * `deviceId` alone would never match it and would force the call onto the
8783
+ * broker fallback (which then hangs in service discovery). Hence:
8784
+ * 1. exact device-scoped owner (native per-device caps where each child
8785
+ * owns a disjoint device subset) is preferred, then
8786
+ * 2. a singleton owner (deviceId-less descriptor) is the fallback.
8787
+ * A cap is globally singleton XOR device-scoped, so the two tiers never
8788
+ * compete for the same capName.
8868
8789
  */
8869
- onConnected(handler) {
8870
- this.connectedHandlers.push(handler);
8790
+ resolveChildId(capName, deviceId) {
8791
+ const index = this.capIndex();
8792
+ if (deviceId !== void 0) {
8793
+ const deviceOwner = index.deviceOwner(capName, deviceId);
8794
+ if (deviceOwner !== null) return deviceOwner;
8795
+ }
8796
+ const candidates = index.singletonCandidates(capName);
8797
+ if (candidates.length === 0) return null;
8798
+ if (candidates.length === 1) return candidates[0];
8799
+ const preferredAddonId = this.getActiveSingletonAddonId?.(capName) ?? null;
8800
+ if (preferredAddonId !== null) {
8801
+ const preferredChildId = this.resolveChildIdForAddon?.(preferredAddonId) ?? preferredAddonId;
8802
+ if (candidates.includes(preferredChildId)) return preferredChildId;
8803
+ }
8804
+ return candidates[0];
8871
8805
  }
8872
8806
  /**
8873
- * Register a handler for events pushed from the parent to this child.
8874
- * Must be called before `start()` to avoid missing early events (though
8875
- * registration after start is also safe for events not yet delivered).
8876
- * Replaces any previously registered handler.
8807
+ * Publish one cap-usage observation, if a sink is wired.
8808
+ *
8809
+ * Cost discipline — this is on the hot path (D181: hub-main's event loop is
8810
+ * the cluster's only queue): no sink means one undefined check; with a sink
8811
+ * it is one object literal and one `Date.now()`, and the sink itself is O(1).
8812
+ * Wrapped so a broken observer can never fail the call it observes — the
8813
+ * registry swallows too, and BOTH matter: this catch also covers a sink that
8814
+ * is not the registry.
8877
8815
  */
8878
- onEvent(handler) {
8879
- this.eventHandler = handler;
8816
+ recordCapUsage(callerChildId, providerChildId, capName, methodName) {
8817
+ const sink = this.capUsageObserver;
8818
+ if (sink === void 0 || callerChildId === null) return;
8819
+ try {
8820
+ sink({
8821
+ callerAddonId: callerChildId,
8822
+ providerAddonId: providerChildId ?? "(unresolved: parent-routed)",
8823
+ capName,
8824
+ methodName,
8825
+ atMs: Date.now()
8826
+ });
8827
+ } catch {}
8880
8828
  }
8881
8829
  /**
8882
- * Register the handler invoked when the parent sends an `addon-call` (the
8883
- * addon-level routes / custom-action plane). The addon-runner wires this to
8884
- * resolve the loaded addon by id and dispatch to its `getRoutes()` or its
8885
- * custom-action handler. Safe to register before or after `start()`;
8886
- * replaces any prior handler.
8830
+ * The cap index over the CURRENT children, rebuilt on first use after any
8831
+ * mutation of {@link children}.
8832
+ *
8833
+ * This replaced a linear walk of every child's cap manifest per
8834
+ * `cap-call-out`, measured at **7.1 % of hub-main's busy main thread**
8835
+ * across 39 children (D456). Why it did not exist before is the whole
8836
+ * design: what it indexes moves — a runner is replaced on every update, a
8837
+ * crash respawns one, a child re-registers with a replacement manifest after
8838
+ * init — and an index that names a dead child routes work into a socket
8839
+ * nobody is reading, which is strictly worse than a slow scan.
8840
+ *
8841
+ * So it is not maintained; it is DISCARDED. `buildCapChildIndex` is a pure
8842
+ * function of `children.values()`, the only two writers of that map
8843
+ * ({@link invalidateCapIndex}'s call sites) drop the index in the same
8844
+ * statement, and nothing else can construct one. There is no add-on-register
8845
+ * / remove-on-close path that could disagree with the map — i.e. no shadow
8846
+ * registry (D3): the map remains the single authority and this holds no fact
8847
+ * it does not.
8848
+ *
8849
+ * The operator's active-singleton preference is deliberately NOT indexed —
8850
+ * it changes without any child connecting or leaving, so `resolveChildId`
8851
+ * reads it live and the index only supplies the candidate list.
8887
8852
  */
8888
- onAddonCall(handler) {
8889
- this.addonCallHandler = handler;
8853
+ capIndex() {
8854
+ if (this.capChildIndex === null) this.capChildIndex = buildCapChildIndex(this.children.values());
8855
+ return this.capChildIndex;
8856
+ }
8857
+ /** Drop the derived index. MUST follow every write to {@link children}. */
8858
+ invalidateCapIndex() {
8859
+ this.capChildIndex = null;
8890
8860
  }
8891
8861
  /**
8892
- * E2: Register a handler invoked when the parent sends a `set-log-level`
8893
- * message to this child. The handler should forward the new level to the
8894
- * child's local Moleculer broker logger (mirrors `$node-mgmt.setLogLevel`).
8895
- * Safe to register before or after `start()`. Replaces any prior handler.
8862
+ * Does the named child currently provide `(capName, deviceId?)`?
8863
+ *
8864
+ * Used by the hub proxy seam, which knows the EXACT addon (→ runner →
8865
+ * childId) a provider belongs to. Unlike `resolveChildId` (which picks the
8866
+ * first child owning `capName`), this targets one child — required for
8867
+ * COLLECTION caps (`addon-widgets-source`, …) where many children register
8868
+ * the same capName: routing by capName alone collapses every provider to
8869
+ * the first child. Same deviceId-as-hint semantics as `resolveChildId`:
8870
+ * a device-scoped descriptor matching `deviceId` OR a deviceId-less
8871
+ * (singleton/collection) descriptor counts.
8896
8872
  */
8897
- onSetLogLevel(handler) {
8898
- this.setLogLevelHandler = handler;
8873
+ /**
8874
+ * Is `childId` currently connected (has it completed its UDS handshake)?
8875
+ * Coarser than {@link childProvides}: it answers "is the child reachable
8876
+ * over UDS at all", regardless of which caps it has announced yet. Used by
8877
+ * the route-mount fallback to decide between the handler-stripped
8878
+ * `callAddonOnChild(target:'routes')` path (child reachable) and awaiting a
8879
+ * cap proxy's `getRoutes()` (child not yet UDS-registered).
8880
+ */
8881
+ isChildKnown(childId) {
8882
+ return this.children.has(childId);
8883
+ }
8884
+ childProvides(childId, capName, deviceId) {
8885
+ const entry = this.children.get(childId);
8886
+ if (!entry) return false;
8887
+ if (deviceId !== void 0 && entry.caps.some((cap) => cap.capName === capName && cap.deviceId === deviceId)) return true;
8888
+ return entry.caps.some((cap) => cap.capName === capName && cap.deviceId === void 0);
8899
8889
  }
8900
8890
  /**
8901
- * True once `start()` has successfully connected and registered. Used by
8902
- * callers (event-bus/logger bridges) to know the channel is live.
8891
+ * Per-request options for a cap call: the cap method's declared `timeoutMs`
8892
+ * when one exists, else `undefined` so the channel applies its default.
8893
+ * Mirrors `CapRouteResolver.callTimeout` for the remote plane.
8903
8894
  */
8904
- get isConnected() {
8905
- return this.channel !== null;
8895
+ capRequestOpts(input) {
8896
+ const declared = this.capTimeoutMs?.(input.capName, input.method);
8897
+ return typeof declared === "number" ? { timeoutMs: declared } : void 0;
8906
8898
  }
8907
- async start() {
8908
- if (this.client !== null) throw new Error("LocalChildClient: already started — call close() first");
8909
- const client = createLocalTransport().createClient(this.options.nodeId);
8910
- const channel = await client.connect();
8911
- channel.onRequest(async (body, payload) => {
8912
- const msg = body;
8913
- if (msg.kind === "cap-call") {
8914
- const args = payload !== void 0 ? unpackPayload(payload) : msg.args;
8915
- const result = await this.options.dispatch({
8916
- capName: msg.capName,
8917
- method: msg.method,
8918
- args,
8919
- ...msg.deviceId !== void 0 ? { deviceId: msg.deviceId } : {},
8920
- ...msg.addonId !== void 0 ? { addonId: msg.addonId } : {}
8921
- });
8922
- if (payload !== void 0 && result !== void 0) return new PayloadReply(void 0, packPayload(result));
8923
- return result;
8924
- }
8925
- if (msg.kind === "addon-call") {
8926
- if (this.addonCallHandler === null) throw new Error(`LocalChildClient: addon-call for "${msg.addonId}" arrived but no onAddonCall handler is registered`);
8927
- return this.addonCallHandler({
8928
- addonId: msg.addonId,
8929
- target: msg.target,
8930
- ...msg.action !== void 0 ? { action: msg.action } : {},
8931
- ...msg.method !== void 0 ? { method: msg.method } : {},
8932
- ...msg.args !== void 0 ? { args: msg.args } : {},
8933
- ...msg.caller !== void 0 ? { caller: msg.caller } : {}
8934
- });
8935
- }
8936
- throw new Error(`unknown parent request kind: ${msg.kind}`);
8937
- });
8938
- channel.onEvent((body) => {
8939
- const msg = body;
8940
- if (msg.kind === "event") {
8941
- this.eventHandler?.(msg.event);
8942
- return;
8943
- }
8944
- if (msg.kind === "set-log-level") this.setLogLevelHandler?.(msg.level);
8945
- });
8946
- const register = this.registerFrame(this.latestCaps);
8947
- try {
8948
- this.parentRawForward = ackAcceptsRawForward(await channel.request(register));
8949
- } catch (err) {
8950
- await client.close();
8951
- throw err;
8952
- }
8953
- this.client = client;
8954
- this.channel = channel;
8955
- this.flushPending(channel);
8956
- for (const cb of this.connectedHandlers) cb();
8899
+ /** Forward a cap method call to a SPECIFIC child by id over UDS; rejects if that child is absent. */
8900
+ async callCapOnChild(childId, input) {
8901
+ const entry = this.children.get(childId);
8902
+ if (!entry) throw new Error(`no local child "${childId}" for cap "${input.capName}"`);
8903
+ return entry.channel.request(this.toCapCall(input), this.capRequestOpts(input));
8957
8904
  }
8958
- /** Flush buffered pre-start emits over the now-open channel. */
8959
- flushPending(channel) {
8960
- for (const item of this.pendingEmits) channel.emit(item.msg);
8961
- this.pendingEmits.length = 0;
8905
+ /** Forward a cap method call to the owning child over UDS; rejects if none. */
8906
+ async callCap(input) {
8907
+ const childId = this.resolveChildId(input.capName, input.deviceId);
8908
+ const entry = childId === null ? void 0 : this.children.get(childId);
8909
+ if (!entry) {
8910
+ const where = input.deviceId === void 0 ? "" : ` for device ${input.deviceId}`;
8911
+ throw new Error(`no local child provides cap "${input.capName}"${where}`);
8912
+ }
8913
+ return entry.channel.request(this.toCapCall(input), this.capRequestOpts(input));
8962
8914
  }
8963
8915
  /**
8964
- * Re-send the cap manifest to the parent, atomically replacing the child's
8965
- * registered descriptor set. Call this after device-restore so newly
8966
- * registered native (device-scoped) caps become routable over UDS.
8916
+ * Forward an ADDON-LEVEL call (routes / custom-action) to a SPECIFIC child
8917
+ * by id over UDS; rejects if that child is absent.
8967
8918
  *
8968
- * Safe to call before `start()`: the new set is buffered and `start()` sends
8969
- * it (device-restore can race ahead of the UDS connect). After `start()`,
8970
- * the set is sent immediately.
8919
+ * The childId for a hub-local single-addon runner equals the addonId
8920
+ * (`resolveRunnerId` returns the addonId when no `execution.group` is
8921
+ * declared — no shipped addon declares one). Mirrors `callCapOnChild` for
8922
+ * the cap plane; carries the two surfaces the removed per-addon Moleculer
8923
+ * broker used to serve (`getRoutes` + `custom.<action>`).
8971
8924
  */
8972
- async updateCaps(caps, customActions) {
8973
- this.latestCaps = caps;
8974
- if (customActions !== void 0) this.latestCustomActions = customActions;
8975
- if (this.channel === null) return;
8976
- this.parentRawForward = ackAcceptsRawForward(await this.channel.request(this.registerFrame(caps)));
8925
+ async callAddonOnChild(childId, input) {
8926
+ const entry = this.children.get(childId);
8927
+ if (!entry) throw new Error(`no local child "${childId}" for addon-call (${input.target})`);
8928
+ return entry.channel.request(this.toAddonCall(input));
8977
8929
  }
8978
- /**
8979
- * The register frame: caps, plus everything a re-register must atomically
8980
- * re-carry so no separate re-sync can be missed — the subscription union
8981
- * (omitted if nothing ever declared, which keeps the fail-open shape) and the
8982
- * custom-action catalogs (omitted until the init loop produced them).
8983
- */
8984
- registerFrame(caps) {
8930
+ /** Build the parent→child `addon-call` wire message from an addon-call input. */
8931
+ toAddonCall(input) {
8985
8932
  return {
8986
- kind: "register",
8987
- childId: this.options.childId,
8988
- caps,
8989
- ...this.hasDeclaredEventPatterns ? { eventPatterns: this.latestEventPatterns } : {},
8990
- ...this.latestCustomActions !== null ? { customActions: this.latestCustomActions } : {},
8991
- rawForward: true
8933
+ kind: "addon-call",
8934
+ addonId: input.addonId,
8935
+ target: input.target,
8936
+ ...input.action !== void 0 ? { action: input.action } : {},
8937
+ ...input.method !== void 0 ? { method: input.method } : {},
8938
+ ...input.args !== void 0 ? { args: input.args } : {},
8939
+ ...input.caller !== void 0 ? { caller: input.caller } : {}
8992
8940
  };
8993
8941
  }
8994
- /**
8995
- * Fire-and-forget: send a system event to the parent for forwarding to the
8996
- * hub event bus. Safe to call before `start()` — events are buffered and
8997
- * flushed on connect.
8998
- */
8999
- emitEvent(event) {
9000
- const msg = {
9001
- kind: "event",
9002
- event
8942
+ /** Build the parent→child `cap-call` wire message from a routing input. */
8943
+ toCapCall(input) {
8944
+ return {
8945
+ kind: "cap-call",
8946
+ capName: input.capName,
8947
+ method: input.method,
8948
+ args: input.args,
8949
+ ...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
8950
+ ...input.addonId !== void 0 ? { addonId: input.addonId } : {}
9003
8951
  };
9004
- if (this.channel !== null) this.channel.emit(msg);
9005
- else this.pendingEmits.push({
9006
- kind: "event",
9007
- msg
9008
- });
8952
+ }
8953
+ listChildren() {
8954
+ return [...this.children.values()].map((e) => ({
8955
+ childId: e.childId,
8956
+ caps: e.caps,
8957
+ incarnation: e.incarnation
8958
+ }));
8959
+ }
8960
+ /** Register the (single) child-registered handler. Only one handler is active at a time. */
8961
+ onChildRegistered(handler) {
8962
+ this.registeredHandler = handler;
9009
8963
  }
9010
8964
  /**
9011
- * Fire-and-forget: send a structured log entry to the parent. Safe to call
9012
- * before `start()` — log entries are buffered and flushed on connect.
8965
+ * Register the (single) child-gone handler. Only one handler is active at a
8966
+ * time. The handler receives the {@link RegisteredChild.incarnation} of the
8967
+ * connection that closed — carry it into whatever teardown it triggers, so a
8968
+ * dead generation cannot tear down its successor's work.
9013
8969
  */
9014
- sendLog(entry) {
9015
- const msg = {
9016
- kind: "log",
9017
- ...entry
9018
- };
9019
- if (this.channel !== null) this.channel.emit(msg);
9020
- else this.pendingEmits.push({
9021
- kind: "log",
9022
- msg
9023
- });
8970
+ onChildGone(handler) {
8971
+ this.goneHandler = handler;
9024
8972
  }
9025
8973
  /**
9026
- * Request the current readiness snapshot from the parent. Returns the
9027
- * authoritative set of `IReadinessRegistryRecord` entries the hub holds.
9028
- * Requires `start()` to have been called; throws with a clear message if not.
8974
+ * Register the (single) child-event handler. Invoked when a child sends an
8975
+ * event via `LocalChildClient.emitEvent`. Only one handler is active at a
8976
+ * time; a new handler replaces the prior one. Pass `null` to clear the
8977
+ * handler entirely (used by the UDS event bridge disposer on shutdown).
9029
8978
  */
9030
- async requestReadinessSnapshot() {
9031
- if (this.channel === null) throw new Error("LocalChildClient: requestReadinessSnapshot called before start()");
9032
- return (await this.channel.request({ kind: "readiness-request" })).records;
8979
+ onChildEvent(handler) {
8980
+ this.eventHandler = handler;
9033
8981
  }
9034
8982
  /**
9035
- * Ask the parent to execute a cap call this child does NOT own. The parent
9036
- * routes to the owning local sibling over UDS, or — if no sibling owns it —
9037
- * to its `onUnownedCall` fallback (the cluster CapabilityRegistry in
9038
- * production). Throws if called before `start()`.
8983
+ * Register the (single) child-log handler. Invoked when a child sends a log
8984
+ * entry via `LocalChildClient.sendLog`. Only one handler is active.
9039
8985
  */
9040
- async callOut(input) {
9041
- if (this.channel === null) throw new Error("LocalChildClient: callOut before start");
9042
- const routing = {
9043
- ...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
9044
- ...input.nodeId !== void 0 ? { nodeId: input.nodeId } : {},
9045
- ...input.native === true ? { native: true } : {}
9046
- };
9047
- if (this.parentRawForward && input.args !== void 0) {
9048
- const hints = liftRoutingHints(input.args);
9049
- const msg = {
9050
- kind: "cap-call-out",
9051
- capName: input.capName,
9052
- method: input.method,
9053
- ...Object.keys(hints).length > 0 ? { hints } : {},
9054
- ...routing
9055
- };
9056
- const reply = await this.channel.request(msg, { payload: packPayload(input.args) });
9057
- return reply instanceof PayloadReply ? unpackPayload(reply.payload) : reply;
9058
- }
8986
+ onChildLog(handler) {
8987
+ this.logHandler = handler;
8988
+ }
8989
+ /**
8990
+ * Register the handler that supplies the authoritative readiness snapshot
8991
+ * when a child sends a `readiness-request`. Only one handler is active.
8992
+ */
8993
+ onReadinessSnapshotRequest(handler) {
8994
+ this.readinessHandler = handler;
8995
+ }
8996
+ /**
8997
+ * Push a parent→child event to a specific child. Fire-and-forget (uses the
8998
+ * one-way `emit` path on the channel). No-op if the child is not connected.
8999
+ */
9000
+ sendEventToChild(childId, event, sourceNodeId) {
9001
+ const entry = this.children.get(childId);
9002
+ if (entry === void 0) return;
9059
9003
  const msg = {
9060
- kind: "cap-call-out",
9061
- capName: input.capName,
9062
- method: input.method,
9063
- args: input.args,
9064
- ...routing
9004
+ kind: "event",
9005
+ event,
9006
+ sourceNodeId
9065
9007
  };
9066
- return this.channel.request(msg);
9067
- }
9068
- /** Disconnect from the parent. Safe to call before `start()` (no-op) and idempotent. */
9069
- async close() {
9070
- await this.client?.close();
9071
- this.client = null;
9072
- this.channel = null;
9008
+ entry.channel.emit(msg);
9073
9009
  }
9074
- };
9075
- //#endregion
9076
- //#region src/kernel/transport/cap-route.ts
9077
- function buildMessage(capName, method, detail) {
9078
- const call = method !== void 0 ? `${capName}.${method}` : capName;
9079
- const target = detail.nodeId !== void 0 ? ` to node ${detail.nodeId}` : "";
9080
- const rejectedStr = detail.rejected.map((r) => `${r.kind}=${r.why}`).join("; ");
9081
- const rejectedClause = rejectedStr.length > 0 ? ` (rejected: ${rejectedStr})` : "";
9082
- return `${call} not routable${target}: ${detail.reason}${rejectedClause}`;
9083
- }
9084
- var CapRouteError = class extends Error {
9085
- reason;
9086
- nodeId;
9087
- rejected;
9088
9010
  /**
9089
- * @param cause Optional original error that triggered this routing failure.
9090
- * Stored as `Error.cause` (TC39 standard option, Node 16.9+).
9091
- * Dispatchers wrapping transport errors MUST pass the original.
9011
+ * Push a parent→child event to every registered child, optionally skipping
9012
+ * one (the originating child, to avoid echo). Fire-and-forget.
9092
9013
  */
9093
- constructor(capName, method, detail, cause) {
9094
- super(buildMessage(capName, method, detail), cause !== void 0 ? { cause } : void 0);
9095
- this.name = "CapRouteError";
9096
- this.reason = detail.reason;
9097
- this.nodeId = detail.nodeId;
9098
- this.rejected = detail.rejected;
9014
+ broadcastEventToChildren(event, sourceNodeId, exceptChildId) {
9015
+ const msg = {
9016
+ kind: "event",
9017
+ event,
9018
+ sourceNodeId
9019
+ };
9020
+ let subscribed = 0;
9021
+ for (const entry of this.children.values()) {
9022
+ if (entry.childId === exceptChildId) continue;
9023
+ if (this.childSubscribesTo(entry, event.category)) subscribed += 1;
9024
+ const wants = this.childWantsEvent(entry, event.category);
9025
+ const stats = this.statsFor(entry.childId);
9026
+ if (!wants) stats.suppressed++;
9027
+ if (wants || this.fanoutMode === "shadow") {
9028
+ stats.sent++;
9029
+ entry.channel.emit(msg);
9030
+ }
9031
+ }
9032
+ this.recordCategoryBroadcast(event.category, subscribed);
9099
9033
  }
9100
- };
9101
- /**
9102
- * Classifies a (capName, opts) pair into a typed CapRoute dispatch descriptor.
9103
- *
9104
- * Precedence (explicit nodeId path):
9105
- * 1. hub-in-process — hub node + hubInProcessProvides
9106
- * 2. hub-local-uds — hub node + hubLocalChildProvides
9107
- * 3. node-offline → CapRouteError{reason:'node-offline'}
9108
- * 4. agent-child-forward — node is an agent AND nodeKnowsCap
9109
- * 5. remote-moleculer — any other online non-agent node
9110
- *
9111
- * Singleton path (no nodeId):
9112
- * 1. hub-in-process (hub provides in-process)
9113
- * 2. hub-local-uds (a hub-local UDS child provides it)
9114
- * 3. remote-moleculer (any online, non-hub node that knows it)
9115
- * 4. → CapRouteError{reason:'no-provider', rejected: all considered routes}
9116
- *
9117
- * PURE: no side effects, no async, no broker/registry imports.
9118
- */
9119
- function classifyCapRoute(capName, opts, snapshot) {
9120
- const rejected = [];
9121
- if (opts.nodeId !== void 0) return classifyExplicitNode(capName, opts.nodeId, opts.deviceId, snapshot, rejected);
9122
- return classifySingleton(capName, opts.deviceId, snapshot, rejected);
9123
- }
9124
- function classifyExplicitNode(capName, nodeId, deviceId, snap, rejected) {
9125
- if (nodeId === snap.hubNodeId) {
9126
- const residentProvides = snap.hubResidentProvides;
9127
- const providesOnHub = residentProvides ?? snap.hubInProcessProvides;
9128
- const getRef = residentProvides !== void 0 ? snap.getHubResidentProviderRef : snap.getInProcessProviderRef;
9129
- if (providesOnHub(capName)) {
9130
- const ref = getRef?.(capName) ?? null;
9131
- if (ref !== null) return {
9132
- kind: "hub-in-process",
9133
- capName,
9134
- ref
9135
- };
9136
- rejected.push({
9137
- kind: "hub-in-process",
9138
- why: "provider ref not available in snapshot"
9139
- });
9140
- throw new CapRouteError(capName, void 0, {
9141
- reason: "no-provider",
9142
- nodeId,
9143
- rejected
9144
- });
9034
+ /**
9035
+ * Record one PRODUCED event against its category. Called once per broadcast,
9036
+ * outside the per-child loop, because production is what the census measures.
9037
+ */
9038
+ recordCategoryBroadcast(category, subscribed) {
9039
+ const existing = this.categoryStats.get(category);
9040
+ if (existing !== void 0) {
9041
+ existing.broadcasts += 1;
9042
+ existing.wanted += subscribed;
9043
+ return;
9145
9044
  }
9146
- if (snap.hubLocalChildProvides(capName, deviceId)) {
9147
- const childId = snap.getHubLocalChildId?.(capName, deviceId) ?? null;
9148
- if (childId !== null) return {
9149
- kind: "hub-local-uds",
9150
- capName,
9151
- childId
9152
- };
9153
- rejected.push({
9154
- kind: "hub-local-uds",
9155
- why: "child id not resolvable from snapshot"
9156
- });
9157
- throw new CapRouteError(capName, void 0, {
9158
- reason: "no-provider",
9159
- nodeId,
9160
- rejected
9161
- });
9045
+ if (this.categoryStats.size >= 256) {
9046
+ this.categoryOverflow += 1;
9047
+ if (!this.categoryOverflowLogged) {
9048
+ this.categoryOverflowLogged = true;
9049
+ this.logger?.info("event category census full — further new categories are unattributed", {
9050
+ max: 256,
9051
+ firstDropped: category
9052
+ });
9053
+ }
9054
+ return;
9162
9055
  }
9163
- rejected.push({
9164
- kind: "hub-in-process",
9165
- why: "hub does not provide this cap in-process"
9166
- });
9167
- rejected.push({
9168
- kind: "hub-local-uds",
9169
- why: "no hub-local child provides this cap"
9170
- });
9171
- throw new CapRouteError(capName, void 0, {
9172
- reason: "no-provider",
9173
- nodeId,
9174
- rejected
9175
- });
9176
- }
9177
- if (!snap.nodeOnline(nodeId)) {
9178
- rejected.push({
9179
- kind: "remote-moleculer",
9180
- why: `node ${nodeId} is offline`
9181
- });
9182
- throw new CapRouteError(capName, void 0, {
9183
- reason: "node-offline",
9184
- nodeId,
9185
- rejected
9056
+ this.categoryStats.set(category, {
9057
+ broadcasts: 1,
9058
+ wanted: subscribed
9186
9059
  });
9187
9060
  }
9188
- if (snap.nodeIsAgent(nodeId)) {
9189
- if (snap.nodeKnowsCap(nodeId, capName)) return {
9190
- kind: "agent-child-forward",
9191
- capName,
9192
- agentNodeId: nodeId,
9193
- childId: snap.getAgentChildId?.(nodeId, capName) ?? void 0
9061
+ /**
9062
+ * E2: Send a `set-log-level` control message to a specific child.
9063
+ * Returns `true` if the child is currently connected and the message was
9064
+ * emitted; `false` if the child is not connected (no-op). The `false`
9065
+ * return lets the caller (MoleculerService.setChildLogLevelByNodeId) fall
9066
+ * back to the Moleculer `$node-mgmt.setLogLevel` action for the node.
9067
+ * Mirrors the `$node-mgmt.setLogLevel` Moleculer action for UDS children.
9068
+ */
9069
+ setChildLogLevel(childId, level) {
9070
+ const entry = this.children.get(childId);
9071
+ if (entry === void 0) return false;
9072
+ const msg = {
9073
+ kind: "set-log-level",
9074
+ level
9194
9075
  };
9195
- rejected.push({
9196
- kind: "agent-child-forward",
9197
- why: `agent ${nodeId} does not know cap ${capName}`
9076
+ entry.channel.emit(msg);
9077
+ return true;
9078
+ }
9079
+ async close() {
9080
+ await this.server.close();
9081
+ }
9082
+ onConnection(channel) {
9083
+ let childId = null;
9084
+ const incarnation = this.nextIncarnation++;
9085
+ channel.observeActions?.({
9086
+ label: capActionLabel,
9087
+ record: (label, reqBytes, resBytes) => {
9088
+ const peerId = childId ?? "(pre-register)";
9089
+ if (label === null) this.actionCensus.recordUnlabelled(peerId, reqBytes + resBytes);
9090
+ else this.actionCensus.record(peerId, label, reqBytes, resBytes);
9091
+ }
9198
9092
  });
9199
- throw new CapRouteError(capName, void 0, {
9200
- reason: "no-provider",
9201
- nodeId,
9202
- rejected
9093
+ channel.onEvent((body) => {
9094
+ const msg = body;
9095
+ if (msg.kind === "event") {
9096
+ if (childId !== null) this.eventHandler?.(childId, msg.event);
9097
+ return;
9098
+ }
9099
+ if (msg.kind === "log") {
9100
+ if (childId !== null) this.logHandler?.(childId, msg);
9101
+ return;
9102
+ }
9103
+ if (msg.kind === "event-sub") {
9104
+ if (childId === null) return;
9105
+ const entry = this.children.get(childId);
9106
+ if (entry === void 0) return;
9107
+ this.children.set(childId, {
9108
+ ...entry,
9109
+ eventPatterns: msg.patterns
9110
+ });
9111
+ this.logPatternSet(childId, msg.patterns);
9112
+ return;
9113
+ }
9203
9114
  });
9204
- }
9205
- return {
9206
- kind: "remote-moleculer",
9207
- capName,
9208
- nodeId
9209
- };
9210
- }
9211
- function classifySingleton(capName, deviceId, snap, rejected) {
9212
- if (snap.hubInProcessProvides(capName)) {
9213
- const ref = snap.getInProcessProviderRef?.(capName) ?? null;
9214
- if (ref !== null) return {
9215
- kind: "hub-in-process",
9216
- capName,
9217
- ref
9218
- };
9219
- rejected.push({
9220
- kind: "hub-in-process",
9221
- why: "provider ref not available in snapshot"
9115
+ channel.onRequest(async (body, payload) => {
9116
+ const msg = body;
9117
+ if (msg.kind === "register") {
9118
+ if (childId !== null && childId !== msg.childId) throw new Error(`child attempted to change identity from "${childId}" to "${msg.childId}"`);
9119
+ childId = msg.childId;
9120
+ const eventPatterns = msg.eventPatterns ?? null;
9121
+ const superseded = this.children.get(msg.childId);
9122
+ if (superseded !== void 0 && superseded.channel !== channel) this.logger?.info("local child superseded by a new connection", {
9123
+ childId: msg.childId,
9124
+ supersededIncarnation: superseded.incarnation,
9125
+ incarnation
9126
+ });
9127
+ this.children.set(msg.childId, {
9128
+ childId: msg.childId,
9129
+ channel,
9130
+ caps: msg.caps,
9131
+ eventPatterns,
9132
+ incarnation,
9133
+ rawForward: msg.rawForward === true
9134
+ });
9135
+ this.invalidateCapIndex();
9136
+ this.logPatternSet(msg.childId, eventPatterns);
9137
+ this.registeredHandler({
9138
+ childId: msg.childId,
9139
+ caps: msg.caps,
9140
+ incarnation,
9141
+ ...msg.customActions !== void 0 ? { customActions: msg.customActions } : {}
9142
+ });
9143
+ return {
9144
+ ok: true,
9145
+ rawForward: true
9146
+ };
9147
+ }
9148
+ if (msg.kind === "cap-call-out") {
9149
+ const out = msg;
9150
+ const hints = out.hints ?? liftRoutingHints(out.args);
9151
+ const materialise = () => payload !== void 0 ? unpackPayload(payload) : out.args;
9152
+ const buildInput = () => ({
9153
+ capName: out.capName,
9154
+ method: out.method,
9155
+ args: materialise(),
9156
+ ...out.deviceId !== void 0 ? { deviceId: out.deviceId } : {},
9157
+ ...out.nodeId !== void 0 ? { nodeId: out.nodeId } : {},
9158
+ ...out.native === true ? { native: true } : {},
9159
+ ...childId !== null ? { callerAddonId: childId } : {}
9160
+ });
9161
+ if (this.claimedStatus !== void 0 && out.method === "getStatus") {
9162
+ const claimed = await answerClaimedGetStatus(this.claimedStatus, {
9163
+ capName: out.capName,
9164
+ method: out.method,
9165
+ deviceId: out.deviceId ?? (payload === void 0 ? extractDeviceId(out.args) : void 0)
9166
+ });
9167
+ if (claimed !== null) {
9168
+ this.recordCapUsage(childId, null, out.capName, out.method);
9169
+ return claimed.slice;
9170
+ }
9171
+ }
9172
+ const pinnedNodeId = out.nodeId ?? hints.nodeId;
9173
+ const pinTargetsThisNode = pinnedNodeId !== void 0 && this.ownNodeId !== void 0 && pinnedNodeId === this.ownNodeId;
9174
+ const aggregated = pinnedNodeId === void 0 && this.isAggregatedCollectionMethod?.(out.capName, out.method) === true;
9175
+ const addonPinned = pinnedNodeId === void 0 && this.isAddonPinnedCall?.(out.capName, out.method, hints) === true;
9176
+ const target = !(out.native === true) && !aggregated && !addonPinned && (pinnedNodeId === void 0 || pinTargetsThisNode) ? this.resolveChildId(out.capName, out.deviceId) : null;
9177
+ this.recordCapUsage(childId, target, out.capName, out.method);
9178
+ const actionPeer = childId ?? "(pre-register)";
9179
+ const actionLabel = `${out.capName}.${out.method}`;
9180
+ if (target !== null) {
9181
+ if (!this.egressRoutedCaps.has(out.capName)) {
9182
+ this.egressRoutedCaps.add(out.capName);
9183
+ this.logger?.info("routed child egress over UDS", { capName: out.capName });
9184
+ }
9185
+ const entry = this.children.get(target);
9186
+ if (payload !== void 0 && entry !== void 0 && entry.rawForward) {
9187
+ this.forwardedRaw += 1;
9188
+ this.actionCensus.recordForward(actionPeer, actionLabel, "raw");
9189
+ const forward = {
9190
+ kind: "cap-call",
9191
+ capName: out.capName,
9192
+ method: out.method,
9193
+ args: void 0,
9194
+ ...out.deviceId !== void 0 ? { deviceId: out.deviceId } : {}
9195
+ };
9196
+ const declared = this.capTimeoutMs?.(out.capName, out.method);
9197
+ return entry.channel.request(forward, {
9198
+ ...typeof declared === "number" ? { timeoutMs: declared } : {},
9199
+ payload
9200
+ });
9201
+ }
9202
+ this.forwardedDecoded += 1;
9203
+ this.actionCensus.recordForward(actionPeer, actionLabel, "decoded");
9204
+ const input = buildInput();
9205
+ return entry !== void 0 ? this.callCapOnChild(target, input) : this.callCap(input);
9206
+ }
9207
+ this.forwardedDecoded += 1;
9208
+ this.actionCensus.recordForward(actionPeer, actionLabel, "decoded");
9209
+ if (this.onUnownedCall !== void 0) return this.onUnownedCall(buildInput());
9210
+ throw new Error(`${UDS_NO_ROUTE_PREFIX}: cap-call-out has no local provider for "${out.capName}" and no fallback`);
9211
+ }
9212
+ if (msg.kind === "readiness-request") return {
9213
+ kind: "readiness-snapshot",
9214
+ records: this.readinessHandler?.() ?? []
9215
+ };
9216
+ throw new Error(`unknown child request kind: ${msg.kind}`);
9222
9217
  });
9223
- } else rejected.push({
9224
- kind: "hub-in-process",
9225
- why: "hub does not provide this cap in-process"
9226
- });
9227
- if (snap.hubLocalChildProvides(capName, deviceId)) {
9228
- const childId = snap.getHubLocalChildId?.(capName, deviceId) ?? null;
9229
- if (childId !== null) return {
9230
- kind: "hub-local-uds",
9231
- capName,
9232
- childId
9233
- };
9234
- rejected.push({
9235
- kind: "hub-local-uds",
9236
- why: "child id not resolvable from snapshot"
9218
+ channel.onClose(() => {
9219
+ if (childId === null) return;
9220
+ const entry = this.children.get(childId);
9221
+ if (entry === void 0) return;
9222
+ if (entry.channel !== channel) {
9223
+ this.logger?.info("ignoring close of a superseded local child connection", {
9224
+ childId,
9225
+ incarnation,
9226
+ liveIncarnation: entry.incarnation
9227
+ });
9228
+ return;
9229
+ }
9230
+ this.children.delete(childId);
9231
+ this.invalidateCapIndex();
9232
+ this.childEventStats.delete(childId);
9233
+ this.actionCensus.forget(childId);
9234
+ this.loggedPatternSet.delete(childId);
9235
+ this.goneHandler(childId, incarnation);
9237
9236
  });
9238
- } else rejected.push({
9239
- kind: "hub-local-uds",
9240
- why: "no hub-local child provides this cap"
9241
- });
9242
- const knownNodes = snap.listKnownNodeIds?.() ?? [];
9243
- for (const nodeId of knownNodes) if (nodeId !== snap.hubNodeId && snap.nodeOnline(nodeId) && snap.nodeKnowsCap(nodeId, capName)) return {
9244
- kind: "remote-moleculer",
9245
- capName,
9246
- nodeId
9247
- };
9248
- rejected.push({
9249
- kind: "remote-moleculer",
9250
- why: "no online remote node knows this cap"
9251
- });
9252
- throw new CapRouteError(capName, void 0, {
9253
- reason: "no-provider",
9254
- rejected
9255
- });
9256
- }
9237
+ }
9238
+ };
9257
9239
  //#endregion
9258
- //#region src/kernel/moleculer/resilient-cap-call.ts
9259
- /** Moleculer error `type` values meaning "the service is not (yet) routable". */
9260
- var DISCOVERY_ERROR_TYPES = new Set(["SERVICE_NOT_FOUND", "SERVICE_NOT_AVAILABLE"]);
9261
- /** Default ceiling for the discovery wait — fail-fast past this. */
9262
- var DEFAULT_DISCOVERY_TIMEOUT_MS$1 = 3e4;
9263
- function isDiscoveryError(err) {
9264
- if (typeof err !== "object" || err === null) return false;
9265
- const type = err.type;
9266
- return typeof type === "string" && DISCOVERY_ERROR_TYPES.has(type);
9240
+ //#region src/kernel/transport/local-child-client.ts
9241
+ /** Did the parent's `register` answer say it accepts payload-borne args? */
9242
+ function ackAcceptsRawForward(ack) {
9243
+ if (ack === null || typeof ack !== "object") return false;
9244
+ return ack.rawForward === true;
9267
9245
  }
9268
9246
  /**
9269
- * Call a Moleculer action; on a service-discovery error, wait for the
9270
- * named service to be discovered and retry the call exactly once.
9271
- */
9272
- async function callWithServiceDiscovery(broker, serviceName, action, params, opts, discoveryTimeoutMs = DEFAULT_DISCOVERY_TIMEOUT_MS$1) {
9273
- try {
9274
- return await broker.call(action, params, opts);
9275
- } catch (err) {
9276
- if (!isDiscoveryError(err)) throw err;
9277
- await broker.waitForServices([serviceName], discoveryTimeoutMs);
9278
- return await broker.call(action, params, opts);
9279
- }
9280
- }
9281
- //#endregion
9282
- //#region src/kernel/transport/cap-route-resolver.ts
9283
- /** The Moleculer service name that Task 6's agent registers. */
9284
- var AGENT_CAP_FWD_SERVICE = "$agent-cap-fwd";
9285
- /** The Moleculer action (service.action) for agent cap forwarding. */
9286
- var AGENT_CAP_FWD_ACTION = `${AGENT_CAP_FWD_SERVICE}.forward`;
9287
- /** Default timeout for remote Moleculer cap calls (ms). */
9288
- var REMOTE_CALL_TIMEOUT_MS = 6e4;
9289
- function extractDeviceId(args) {
9290
- if (args === null || typeof args !== "object") return void 0;
9291
- const raw = Reflect.get(args, "deviceId");
9292
- return typeof raw === "number" ? raw : void 0;
9293
- }
9294
- /**
9295
- * Extract an inline `nodeId` string from a cap call's args — the per-call node
9296
- * pin a forked addon expresses by carrying `nodeId` in the input (the SAME way
9297
- * device-scoped caps carry `deviceId`, and the way the generated cap-router on
9298
- * the hub already honours an inline pin). Mirrors {@link extractDeviceId}.
9247
+ * Child side of the local UDS transport. Connects to its parent (hub or
9248
+ * agent), registers its cap manifest, and serves parent→child cap calls by
9249
+ * delegating to `dispatch`. The provider implementation lives in the child;
9250
+ * only routing keys + call arguments cross the wire.
9299
9251
  *
9300
- * This is the routing source for hub→remote-node EXECUTION pinning (e.g. the
9301
- * benchmark addon running a synthetic/decoder workload ON a chosen agent). The
9302
- * out-of-band `nodePin(op.context)` path does NOT survive the forked addon →
9303
- * hub UDS link chain, so `onUnownedCall` reads the inline pin instead. Provider
9304
- * methods that don't declare `nodeId` simply ignore the extra field (they
9305
- * destructure the fields they need); methods that DO declare it (e.g.
9306
- * `getEngineProvisioning({nodeId})`) still receive it unchanged.
9307
- */
9308
- function extractNodeId(args) {
9309
- if (args === null || typeof args !== "object") return void 0;
9310
- const raw = Reflect.get(args, "nodeId");
9311
- return typeof raw === "string" && raw.length > 0 ? raw : void 0;
9312
- }
9313
- /**
9314
- * Merge `callerAddonId` into `args` as an extra field — the SAME "provider
9315
- * destructures what it needs and ignores the rest" convention
9316
- * {@link extractNodeId} already relies on for the inline `nodeId` pin.
9317
- * `undefined` (the overwhelmingly common case: no caller hint, or the census
9318
- * consuming it is disarmed) returns `args` unchanged — no allocation. A
9319
- * non-object `args` (or `null`/array) is returned unchanged too: there is
9320
- * nowhere to attach an extra key without changing the payload's shape, and a
9321
- * provider taking a bare value never reads `callerAddonId` off it anyway.
9252
+ * Additional channels beyond cap-call:
9253
+ * - `emitEvent` fire-and-forget event toward the parent
9254
+ * - `sendLog` fire-and-forget log entry toward the parent
9255
+ * - `requestReadinessSnapshot` request/response snapshot of readiness records
9256
+ * - `onEvent` register a handler for parent→child events
9257
+ *
9258
+ * Events and logs emitted before `start()` resolves are buffered and flushed
9259
+ * on connect (mirrors the `updateCaps`/`latestCaps` pattern).
9322
9260
  */
9323
- function withCallerAddonId(args, callerAddonId) {
9324
- if (callerAddonId === void 0) return args;
9325
- if (args === null || typeof args !== "object" || Array.isArray(args)) return args;
9326
- return {
9327
- ...args,
9328
- callerAddonId
9329
- };
9330
- }
9331
- var CapRouteResolver = class {
9332
- hubNodeId;
9333
- broker;
9334
- hubLocalRegistry;
9335
- nodeAuthority;
9336
- inProcessProviders;
9337
- hubResidentProviders;
9338
- capTimeoutMs;
9339
- snapshot;
9340
- constructor(deps) {
9341
- this.hubNodeId = deps.hubNodeId;
9342
- this.broker = deps.broker;
9343
- this.hubLocalRegistry = deps.hubLocalRegistry;
9344
- this.nodeAuthority = deps.nodeAuthority;
9345
- this.inProcessProviders = deps.inProcessProviders;
9346
- this.hubResidentProviders = deps.hubResidentProviders;
9347
- this.capTimeoutMs = deps.capTimeoutMs;
9348
- this.snapshot = this.buildSnapshot();
9261
+ var LocalChildClient = class {
9262
+ options;
9263
+ client = null;
9264
+ channel = null;
9265
+ /**
9266
+ * The cap set `start()` will register, kept current by `updateCaps`. Native
9267
+ * device caps register on device-restore, which can race AHEAD of the UDS
9268
+ * connect — buffering here lets a pre-start `updateCaps` survive (start sends
9269
+ * the latest set) instead of being lost or throwing.
9270
+ */
9271
+ latestCaps;
9272
+ /**
9273
+ * Per-owner (addonId) category-pattern subscription sets, and their union.
9274
+ * The union is declared to the parent (in `RegisterMessage.eventPatterns`
9275
+ * pre/at-connect, or via an `event-sub` emit post-connect) so the parent can
9276
+ * subscription-filter its event fan-out. Per-owner keying keeps the union
9277
+ * correct if multiple event buses ever share one client (group-runner case).
9278
+ */
9279
+ patternsByOwner = /* @__PURE__ */ new Map();
9280
+ latestEventPatterns = [];
9281
+ /**
9282
+ * Whether `updateEventPatterns` has ever been called. A client that never
9283
+ * declared a subscription set (no event bus wired — e.g. a pure cap-call
9284
+ * runner) omits `eventPatterns` from its register frame entirely, so the
9285
+ * parent treats it as UNDECLARED and fails OPEN (broadcast-all). Once any
9286
+ * owner declares (in production every addon context's framework
9287
+ * subscriptions do), the register carries the real union — even if empty.
9288
+ */
9289
+ hasDeclaredEventPatterns = false;
9290
+ /**
9291
+ * The custom-action catalogs of every hosted addon, once the runner's init
9292
+ * loop has produced them (`updateCaps(caps, catalogs)` at post-init). `null`
9293
+ * until then, and OMITTED from the register frame while null: the parent
9294
+ * reads absence as "not described yet", and a pre-init register must never
9295
+ * look like "this child declares no actions". Kept on the client so every
9296
+ * later re-register (native-cap change, reconnect) re-carries it unchanged.
9297
+ */
9298
+ latestCustomActions = null;
9299
+ /** Events and logs queued while the channel is not yet open. */
9300
+ pendingEmits = [];
9301
+ /**
9302
+ * Whether the parent acknowledged raw-forward at `register`. Until it has,
9303
+ * `callOut` sends args inline exactly as every child always did — a legacy
9304
+ * parent would decode a payload-borne call to `args: undefined` and run the
9305
+ * provider on nothing, silently. Re-read on every re-register.
9306
+ */
9307
+ parentRawForward = false;
9308
+ /** Handler for parent→child events. Registered via `onEvent`. */
9309
+ eventHandler = null;
9310
+ /**
9311
+ * Handler for parent→child addon-level calls (`routes` / `custom`).
9312
+ * Registered via `onAddonCall`. Resolves the loaded addon instance and
9313
+ * invokes its `addon-routes.getRoutes()` or its custom-action handler.
9314
+ * Replaces the per-addon Moleculer `getRoutes` / `custom.<action>` actions
9315
+ * removed in F1/F2. Only one handler is active at a time.
9316
+ */
9317
+ addonCallHandler = null;
9318
+ /**
9319
+ * Handler for `set-log-level` messages pushed by the parent.
9320
+ * Registered via `onSetLogLevel`. The handler applies the new level to the
9321
+ * child's local Moleculer logger (mirrors `$node-mgmt.setLogLevel`).
9322
+ * E2: wired in `addon-runner.ts` to forward to the broker logger.
9323
+ */
9324
+ setLogLevelHandler = null;
9325
+ /** Callbacks registered via `onConnected`. Fired on every (re)connect. */
9326
+ connectedHandlers = [];
9327
+ constructor(options) {
9328
+ this.options = options;
9329
+ this.latestCaps = options.caps;
9330
+ }
9331
+ /**
9332
+ * Declare an owner's live category-pattern subscription set. Stores it
9333
+ * per-owner (keyed by `ownerId` = addonId), recomputes the union, and — only
9334
+ * if the union changed — declares it to the parent:
9335
+ * - pre-connect: buffered only; `start()`'s register frame carries the set.
9336
+ * - post-connect: emitted as a fire-and-forget `event-sub` (full-set
9337
+ * replace).
9338
+ * Idempotent on an unchanged union (no redundant `event-sub` frames).
9339
+ */
9340
+ updateEventPatterns(ownerId, patterns) {
9341
+ this.hasDeclaredEventPatterns = true;
9342
+ this.patternsByOwner.set(ownerId, patterns);
9343
+ const union = this.computePatternUnion();
9344
+ if (!this.patternsEqual(union, this.latestEventPatterns)) {
9345
+ this.latestEventPatterns = union;
9346
+ if (this.channel !== null) {
9347
+ const msg = {
9348
+ kind: "event-sub",
9349
+ patterns: union
9350
+ };
9351
+ this.channel.emit(msg);
9352
+ }
9353
+ }
9354
+ }
9355
+ /** Sorted, de-duplicated union across all owners' pattern sets. */
9356
+ computePatternUnion() {
9357
+ const all = /* @__PURE__ */ new Set();
9358
+ for (const patterns of this.patternsByOwner.values()) for (const p of patterns) all.add(p);
9359
+ return [...all].toSorted();
9360
+ }
9361
+ /** Order-insensitive equality — both inputs are already sorted unions here. */
9362
+ patternsEqual(a, b) {
9363
+ if (a.length !== b.length) return false;
9364
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
9365
+ return true;
9366
+ }
9367
+ /**
9368
+ * Register a callback that fires each time the client successfully connects
9369
+ * (or reconnects) to its parent. Multiple handlers may be registered; all
9370
+ * are called in registration order. Used by readiness-context in UDS mode
9371
+ * to trigger a snapshot hydrate on connect/reconnect.
9372
+ */
9373
+ onConnected(handler) {
9374
+ this.connectedHandlers.push(handler);
9375
+ }
9376
+ /**
9377
+ * Register a handler for events pushed from the parent to this child.
9378
+ * Must be called before `start()` to avoid missing early events (though
9379
+ * registration after start is also safe for events not yet delivered).
9380
+ * Replaces any previously registered handler.
9381
+ */
9382
+ onEvent(handler) {
9383
+ this.eventHandler = handler;
9384
+ }
9385
+ /**
9386
+ * Register the handler invoked when the parent sends an `addon-call` (the
9387
+ * addon-level routes / custom-action plane). The addon-runner wires this to
9388
+ * resolve the loaded addon by id and dispatch to its `getRoutes()` or its
9389
+ * custom-action handler. Safe to register before or after `start()`;
9390
+ * replaces any prior handler.
9391
+ */
9392
+ onAddonCall(handler) {
9393
+ this.addonCallHandler = handler;
9394
+ }
9395
+ /**
9396
+ * E2: Register a handler invoked when the parent sends a `set-log-level`
9397
+ * message to this child. The handler should forward the new level to the
9398
+ * child's local Moleculer broker logger (mirrors `$node-mgmt.setLogLevel`).
9399
+ * Safe to register before or after `start()`. Replaces any prior handler.
9400
+ */
9401
+ onSetLogLevel(handler) {
9402
+ this.setLogLevelHandler = handler;
9403
+ }
9404
+ /**
9405
+ * True once `start()` has successfully connected and registered. Used by
9406
+ * callers (event-bus/logger bridges) to know the channel is live.
9407
+ */
9408
+ get isConnected() {
9409
+ return this.channel !== null;
9410
+ }
9411
+ async start() {
9412
+ if (this.client !== null) throw new Error("LocalChildClient: already started — call close() first");
9413
+ const client = createLocalTransport().createClient(this.options.nodeId);
9414
+ const channel = await client.connect();
9415
+ channel.onRequest(async (body, payload) => {
9416
+ const msg = body;
9417
+ if (msg.kind === "cap-call") {
9418
+ const args = payload !== void 0 ? unpackPayload(payload) : msg.args;
9419
+ const result = await this.options.dispatch({
9420
+ capName: msg.capName,
9421
+ method: msg.method,
9422
+ args,
9423
+ ...msg.deviceId !== void 0 ? { deviceId: msg.deviceId } : {},
9424
+ ...msg.addonId !== void 0 ? { addonId: msg.addonId } : {}
9425
+ });
9426
+ if (payload !== void 0 && result !== void 0) return new PayloadReply(void 0, packPayload(result));
9427
+ return result;
9428
+ }
9429
+ if (msg.kind === "addon-call") {
9430
+ if (this.addonCallHandler === null) throw new Error(`LocalChildClient: addon-call for "${msg.addonId}" arrived but no onAddonCall handler is registered`);
9431
+ return this.addonCallHandler({
9432
+ addonId: msg.addonId,
9433
+ target: msg.target,
9434
+ ...msg.action !== void 0 ? { action: msg.action } : {},
9435
+ ...msg.method !== void 0 ? { method: msg.method } : {},
9436
+ ...msg.args !== void 0 ? { args: msg.args } : {},
9437
+ ...msg.caller !== void 0 ? { caller: msg.caller } : {}
9438
+ });
9439
+ }
9440
+ throw new Error(`unknown parent request kind: ${msg.kind}`);
9441
+ });
9442
+ channel.onEvent((body) => {
9443
+ const msg = body;
9444
+ if (msg.kind === "event") {
9445
+ this.eventHandler?.(msg.event);
9446
+ return;
9447
+ }
9448
+ if (msg.kind === "set-log-level") this.setLogLevelHandler?.(msg.level);
9449
+ });
9450
+ const register = this.registerFrame(this.latestCaps);
9451
+ try {
9452
+ this.parentRawForward = ackAcceptsRawForward(await channel.request(register));
9453
+ } catch (err) {
9454
+ await client.close();
9455
+ throw err;
9456
+ }
9457
+ this.client = client;
9458
+ this.channel = channel;
9459
+ this.flushPending(channel);
9460
+ for (const cb of this.connectedHandlers) cb();
9461
+ }
9462
+ /** Flush buffered pre-start emits over the now-open channel. */
9463
+ flushPending(channel) {
9464
+ for (const item of this.pendingEmits) channel.emit(item.msg);
9465
+ this.pendingEmits.length = 0;
9466
+ }
9467
+ /**
9468
+ * Re-send the cap manifest to the parent, atomically replacing the child's
9469
+ * registered descriptor set. Call this after device-restore so newly
9470
+ * registered native (device-scoped) caps become routable over UDS.
9471
+ *
9472
+ * Safe to call before `start()`: the new set is buffered and `start()` sends
9473
+ * it (device-restore can race ahead of the UDS connect). After `start()`,
9474
+ * the set is sent immediately.
9475
+ */
9476
+ async updateCaps(caps, customActions) {
9477
+ this.latestCaps = caps;
9478
+ if (customActions !== void 0) this.latestCustomActions = customActions;
9479
+ if (this.channel === null) return;
9480
+ this.parentRawForward = ackAcceptsRawForward(await this.channel.request(this.registerFrame(caps)));
9349
9481
  }
9350
- /** Per-call RPC timeout: the cap method's declared override, else the default. */
9351
- callTimeout(capName, method) {
9352
- return this.capTimeoutMs?.(capName, method) ?? REMOTE_CALL_TIMEOUT_MS;
9482
+ /**
9483
+ * The register frame: caps, plus everything a re-register must atomically
9484
+ * re-carry so no separate re-sync can be missed — the subscription union
9485
+ * (omitted if nothing ever declared, which keeps the fail-open shape) and the
9486
+ * custom-action catalogs (omitted until the init loop produced them).
9487
+ */
9488
+ registerFrame(caps) {
9489
+ return {
9490
+ kind: "register",
9491
+ childId: this.options.childId,
9492
+ caps,
9493
+ ...this.hasDeclaredEventPatterns ? { eventPatterns: this.latestEventPatterns } : {},
9494
+ ...this.latestCustomActions !== null ? { customActions: this.latestCustomActions } : {},
9495
+ rawForward: true
9496
+ };
9353
9497
  }
9354
- resolveCapRoute(capName, opts) {
9355
- return classifyCapRoute(capName, opts, this.snapshot);
9498
+ /**
9499
+ * Fire-and-forget: send a system event to the parent for forwarding to the
9500
+ * hub event bus. Safe to call before `start()` — events are buffered and
9501
+ * flushed on connect.
9502
+ */
9503
+ emitEvent(event) {
9504
+ const msg = {
9505
+ kind: "event",
9506
+ event
9507
+ };
9508
+ if (this.channel !== null) this.channel.emit(msg);
9509
+ else this.pendingEmits.push({
9510
+ kind: "event",
9511
+ msg
9512
+ });
9356
9513
  }
9357
9514
  /**
9358
- * Resolve the hub-local-uds route for a cap owned by a forked hub-local
9359
- * child, IGNORING any in-hub provider registered for the same cap name.
9360
- *
9361
- * `resolveCapRoute` gives Priority 1 to `hub-in-process`: when an in-hub
9362
- * provider (e.g. a wrapper) is registered for the cap, the route always
9363
- * classifies as `hub-in-process` and the hub-local-uds NATIVE child is never
9364
- * reached. That is correct for the generic dispatch path (the wrapper is the
9365
- * active provider), but WRONG for the native-cap fallback
9366
- * (`setNativeFallback`), whose contract is to reach the NATIVE provider in
9367
- * the forked vendor child so a wrapper can delegate to it. A wrapper cap
9368
- * with a forked native (today: `snapshot`) otherwise resolves to the wrapper
9369
- * itself, the native is never invoked, and the wrapper silently falls
9370
- * through to its secondary strategy.
9371
- *
9372
- * This method consults ONLY the hub-local-child authority
9373
- * (`hubLocalChildProvides` + `getHubLocalChildId`, both deviceId-aware), so
9374
- * it returns the native child route regardless of any in-process shadow.
9375
- * Returns null when no hub-local child owns `(capName, deviceId)` — the
9376
- * caller then falls through to its remote-resolution branch.
9515
+ * Fire-and-forget: send a structured log entry to the parent. Safe to call
9516
+ * before `start()` — log entries are buffered and flushed on connect.
9377
9517
  */
9378
- resolveHubLocalUdsRoute(capName, deviceId) {
9379
- if (!this.snapshot.hubLocalChildProvides(capName, deviceId)) return null;
9380
- const childId = this.snapshot.getHubLocalChildId?.(capName, deviceId) ?? null;
9381
- if (childId === null) return null;
9382
- return {
9383
- kind: "hub-local-uds",
9384
- capName,
9385
- childId
9518
+ sendLog(entry) {
9519
+ const msg = {
9520
+ kind: "log",
9521
+ ...entry
9386
9522
  };
9523
+ if (this.channel !== null) this.channel.emit(msg);
9524
+ else this.pendingEmits.push({
9525
+ kind: "log",
9526
+ msg
9527
+ });
9387
9528
  }
9388
9529
  /**
9389
- * @param addonId Optional out-of-band PROVIDER-selection hint (Task 7.5a):
9390
- * the addonId of the provider the CALLER already resolved (e.g.
9391
- * `buildCapCallFn`'s `deps.addonId` for a registered grouped-runner
9392
- * provider). Only meaningful for `agent-child-forward` — a `remote-moleculer`
9393
- * route already encodes the provider in its action name
9394
- * (`${addonId}.${cap}.${method}`, via `NodeCapAuthority.getAddonId`), and a
9395
- * `hub-local-uds` caller that already knows the addonId (`buildCapCallFn`)
9396
- * talks to `LocalChildRegistry.callCapOnChild` directly rather than through
9397
- * `dispatch`.
9398
- * @param callerAddonId Optional out-of-band CALLER-identity hint — the
9399
- * addon that ORIGINATED the call, distinct from `addonId` above (which
9400
- * names the resolved PROVIDER, not the caller). Set by
9401
- * `onUnownedCall` from `CapCallInput.callerAddonId`
9402
- * (`LocalChildRegistry` stamped it off the registered `childId`). Only
9403
- * the `hub-in-process` branch consumes it — merged into `args` so a
9404
- * diagnostic tool deep in that provider (e.g. device-manager's
9405
- * fleet-read census) can read it without every provider method changing
9406
- * signature, the same "extra field the provider destructures or
9407
- * ignores" convention `nodeId` already uses on this path.
9530
+ * Request the current readiness snapshot from the parent. Returns the
9531
+ * authoritative set of `IReadinessRegistryRecord` entries the hub holds.
9532
+ * Requires `start()` to have been called; throws with a clear message if not.
9408
9533
  */
9409
- async dispatch(route, method, args, addonId, callerAddonId) {
9410
- try {
9411
- return await this.dispatchInner(route, method, args, addonId, callerAddonId);
9412
- } catch (err) {
9413
- if (err instanceof CapRouteError) throw err;
9414
- if (route.kind === "hub-in-process" || err instanceof PeerAnsweredError) throw err;
9415
- const nodeId = this.routeNodeId(route);
9416
- const cause = err instanceof Error ? err : new Error(String(err));
9417
- throw new CapRouteError(route.capName, method, {
9418
- reason: "transport-failed",
9419
- nodeId,
9420
- rejected: [{
9421
- kind: route.kind,
9422
- why: cause.message
9423
- }]
9424
- }, cause);
9425
- }
9534
+ async requestReadinessSnapshot() {
9535
+ if (this.channel === null) throw new Error("LocalChildClient: requestReadinessSnapshot called before start()");
9536
+ return (await this.channel.request({ kind: "readiness-request" })).records;
9426
9537
  }
9427
9538
  /**
9428
- * Inner dispatch: may throw CapRouteError (validation failures) or arbitrary
9429
- * transport errors. The outer `dispatch` wraps non-CapRouteErrors.
9539
+ * Ask the parent to execute a cap call this child does NOT own. The parent
9540
+ * routes to the owning local sibling over UDS, or — if no sibling owns it —
9541
+ * to its `onUnownedCall` fallback (the cluster CapabilityRegistry in
9542
+ * production). Throws if called before `start()`.
9430
9543
  */
9431
- async dispatchInner(route, method, args, addonId, callerAddonId) {
9432
- switch (route.kind) {
9433
- case "hub-in-process": return route.ref.invoke(method, withCallerAddonId(args, callerAddonId));
9434
- case "hub-local-uds": {
9435
- const registry = this.hubLocalRegistry;
9436
- if (registry === null) throw new CapRouteError(route.capName, method, {
9437
- reason: "no-provider",
9438
- rejected: [{
9439
- kind: "hub-local-uds",
9440
- why: "UDS registry not available"
9441
- }]
9442
- });
9443
- const deviceId = extractDeviceId(args);
9444
- const input = {
9445
- capName: route.capName,
9446
- method,
9447
- args,
9448
- ...deviceId !== void 0 ? { deviceId } : {}
9449
- };
9450
- return registry.callCapOnChild(route.childId, input);
9451
- }
9452
- case "remote-moleculer": {
9453
- const addonId = this.nodeAuthority.getAddonId(route.nodeId, route.capName);
9454
- if (addonId === null) throw new CapRouteError(route.capName, method, {
9455
- reason: "no-provider",
9456
- nodeId: route.nodeId,
9457
- rejected: [{
9458
- kind: "remote-moleculer",
9459
- why: `no addonId known for ${route.nodeId}/${route.capName}`
9460
- }]
9461
- });
9462
- const deviceId = extractDeviceId(args);
9463
- const isNative = this.nodeAuthority.isNativeCap(route.nodeId, route.capName, deviceId);
9464
- const action = capActionName(addonId, route.capName, method, isNative);
9465
- return callWithServiceDiscovery(this.broker, addonId, action, args, {
9466
- nodeID: route.nodeId,
9467
- timeout: this.callTimeout(route.capName, method)
9468
- });
9469
- }
9470
- case "agent-child-forward": {
9471
- const deviceId = extractDeviceId(args);
9472
- const params = {
9473
- capName: route.capName,
9474
- method,
9475
- args,
9476
- ...route.childId !== void 0 ? { childId: route.childId } : {},
9477
- ...deviceId !== void 0 ? { deviceId } : {},
9478
- ...addonId !== void 0 ? { addonId } : {}
9479
- };
9480
- return callWithServiceDiscovery(this.broker, AGENT_CAP_FWD_SERVICE, AGENT_CAP_FWD_ACTION, params, {
9481
- nodeID: route.agentNodeId,
9482
- timeout: this.callTimeout(route.capName, method)
9483
- });
9484
- }
9544
+ async callOut(input) {
9545
+ if (this.channel === null) throw new Error("LocalChildClient: callOut before start");
9546
+ const routing = {
9547
+ ...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
9548
+ ...input.nodeId !== void 0 ? { nodeId: input.nodeId } : {},
9549
+ ...input.native === true ? { native: true } : {}
9550
+ };
9551
+ if (this.parentRawForward && input.args !== void 0) {
9552
+ const hints = liftRoutingHints(input.args);
9553
+ const msg = {
9554
+ kind: "cap-call-out",
9555
+ capName: input.capName,
9556
+ method: input.method,
9557
+ ...Object.keys(hints).length > 0 ? { hints } : {},
9558
+ ...routing
9559
+ };
9560
+ const reply = await this.channel.request(msg, { payload: packPayload(input.args) });
9561
+ return reply instanceof PayloadReply ? unpackPayload(reply.payload) : reply;
9485
9562
  }
9486
- }
9487
- buildSnapshot() {
9488
- const hubLocalRegistry = this.hubLocalRegistry;
9489
- const nodeAuthority = this.nodeAuthority;
9490
- const inProcessProviders = this.inProcessProviders;
9491
- const hubResidentProviders = this.hubResidentProviders;
9492
- return {
9493
- hubNodeId: this.hubNodeId,
9494
- hubInProcessProvides: (cap) => inProcessProviders(cap) !== null,
9495
- ...hubResidentProviders !== void 0 ? {
9496
- hubResidentProvides: (cap) => hubResidentProviders(cap) !== null,
9497
- getHubResidentProviderRef: (cap) => hubResidentProviders(cap)
9498
- } : {},
9499
- hubLocalChildProvides: (cap, deviceId) => hubLocalRegistry !== null && hubLocalRegistry.resolveChildId(cap, deviceId) !== null,
9500
- nodeKnowsCap: (nodeId, cap) => nodeAuthority.nodeKnowsCap(nodeId, cap),
9501
- nodeIsAgent: (nodeId) => nodeAuthority.nodeIsAgent(nodeId),
9502
- nodeOnline: (nodeId) => nodeAuthority.nodeOnline(nodeId),
9503
- listKnownNodeIds: () => nodeAuthority.listNodeIds(),
9504
- getInProcessProviderRef: (cap) => inProcessProviders(cap),
9505
- getHubLocalChildId: (cap, deviceId) => hubLocalRegistry !== null ? hubLocalRegistry.resolveChildId(cap, deviceId) : null,
9506
- getAgentChildId: (agentNodeId, cap) => nodeAuthority.getAgentChildId(agentNodeId, cap)
9563
+ const msg = {
9564
+ kind: "cap-call-out",
9565
+ capName: input.capName,
9566
+ method: input.method,
9567
+ args: input.args,
9568
+ ...routing
9507
9569
  };
9570
+ return this.channel.request(msg);
9508
9571
  }
9509
- /** Extract a nodeId string from a route for error reporting, or undefined. */
9510
- routeNodeId(route) {
9511
- switch (route.kind) {
9512
- case "hub-in-process": return this.hubNodeId;
9513
- case "hub-local-uds": return `${this.hubNodeId}/${route.childId}`;
9514
- case "remote-moleculer": return route.nodeId;
9515
- case "agent-child-forward": return route.agentNodeId;
9516
- }
9572
+ /** Disconnect from the parent. Safe to call before `start()` (no-op) and idempotent. */
9573
+ async close() {
9574
+ await this.client?.close();
9575
+ this.client = null;
9576
+ this.channel = null;
9517
9577
  }
9518
9578
  };
9519
9579
  //#endregion
@@ -9775,6 +9835,13 @@ function createParentUnownedCallHandler(deps) {
9775
9835
  });
9776
9836
  throw new Error(`no native provider for capability "${input.capName}" on device ${String(deviceId)}`);
9777
9837
  }
9838
+ const claimed = await answerClaimedGetStatus(deps.claimedStatus, {
9839
+ capName: input.capName,
9840
+ method: input.method,
9841
+ ...deviceId !== void 0 ? { deviceId } : {},
9842
+ args: input.args
9843
+ });
9844
+ if (claimed !== null) return claimed.slice;
9778
9845
  const nodeId = deps.isDataNodeIdCap?.(input.capName) === true ? void 0 : input.nodeId ?? extractNodeId(input.args);
9779
9846
  const pinnedToOtherNode = nodeId !== void 0 && nodeId !== deps.broker.nodeID;
9780
9847
  if (nodeId === void 0) {
@@ -12776,6 +12843,12 @@ Object.defineProperty(exports, "FrameDecoder", {
12776
12843
  return FrameDecoder;
12777
12844
  }
12778
12845
  });
12846
+ Object.defineProperty(exports, "GET_STATUS_METHOD", {
12847
+ enumerable: true,
12848
+ get: function() {
12849
+ return GET_STATUS_METHOD;
12850
+ }
12851
+ });
12779
12852
  Object.defineProperty(exports, "HEAP_RECLAIM_MIN_INTERVAL_MS", {
12780
12853
  enumerable: true,
12781
12854
  get: function() {
@@ -12968,6 +13041,12 @@ Object.defineProperty(exports, "adaptBrokerToCluster", {
12968
13041
  return adaptBrokerToCluster;
12969
13042
  }
12970
13043
  });
13044
+ Object.defineProperty(exports, "answerClaimedGetStatus", {
13045
+ enumerable: true,
13046
+ get: function() {
13047
+ return answerClaimedGetStatus;
13048
+ }
13049
+ });
12971
13050
  Object.defineProperty(exports, "brokerCallForCap", {
12972
13051
  enumerable: true,
12973
13052
  get: function() {
@@ -13004,12 +13083,6 @@ Object.defineProperty(exports, "buildNpmChildEnv", {
13004
13083
  return buildNpmChildEnv;
13005
13084
  }
13006
13085
  });
13007
- Object.defineProperty(exports, "buildUdsNativeCapProxy", {
13008
- enumerable: true,
13009
- get: function() {
13010
- return buildUdsNativeCapProxy;
13011
- }
13012
- });
13013
13086
  Object.defineProperty(exports, "callWithServiceDiscovery", {
13014
13087
  enumerable: true,
13015
13088
  get: function() {
@@ -13250,6 +13323,12 @@ Object.defineProperty(exports, "deviceCapResolverLink", {
13250
13323
  return deviceCapResolverLink;
13251
13324
  }
13252
13325
  });
13326
+ Object.defineProperty(exports, "deviceOfGetStatus", {
13327
+ enumerable: true,
13328
+ get: function() {
13329
+ return deviceOfGetStatus;
13330
+ }
13331
+ });
13253
13332
  Object.defineProperty(exports, "diffCapActionCensus", {
13254
13333
  enumerable: true,
13255
13334
  get: function() {