@camstack/system 1.1.19 → 1.1.21

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.
@@ -3360,6 +3360,156 @@ function createLocalTransport() {
3360
3360
  };
3361
3361
  }
3362
3362
  //#endregion
3363
+ //#region src/kernel/moleculer/event-bus-core.ts
3364
+ /**
3365
+ * @param retainRecent - `true` ONLY for the hub main broker bus (it serves
3366
+ * `getRecent`). Defaults to `false` — the safe one-shot default for child
3367
+ * runners + agents, which retain nothing.
3368
+ */
3369
+ function createSharedBusState(retainRecent = false) {
3370
+ return {
3371
+ handlers: /* @__PURE__ */ new Map(),
3372
+ recent: [],
3373
+ retainRecent
3374
+ };
3375
+ }
3376
+ function matchesPattern(pattern, category) {
3377
+ if (pattern === "*" || pattern === "**") return true;
3378
+ if (pattern.endsWith(".**")) return category.startsWith(pattern.slice(0, -3));
3379
+ if (pattern.endsWith(".*")) return category.startsWith(pattern.slice(0, -2));
3380
+ return pattern === category;
3381
+ }
3382
+ function extractCategoryPattern(filter) {
3383
+ if (typeof filter === "string") return filter;
3384
+ if (filter && typeof filter === "object" && "category" in filter) {
3385
+ const cat = filter.category;
3386
+ if (typeof cat === "string") return cat;
3387
+ if (Array.isArray(cat) && cat.length > 0) return String(cat[0]);
3388
+ }
3389
+ return "*";
3390
+ }
3391
+ /**
3392
+ * Match an event against the full `EventFilter`. The Map-keyed subscribe path
3393
+ * only handles the category pattern (deciding which handler set to fan out to);
3394
+ * this function applies the remaining scope dimensions (`agentId`, `addonId`,
3395
+ * `deviceId`, `source`, `since`) so per-device / per-addon subscribers don't
3396
+ * receive sibling events.
3397
+ */
3398
+ function matchesEventFilter(event, filter) {
3399
+ if (!filter || typeof filter === "string") return true;
3400
+ if (filter.source) {
3401
+ if (event.source.type !== filter.source.type || event.source.id !== filter.source.id) return false;
3402
+ }
3403
+ if (filter.agentId) {
3404
+ const eventNodeId = event.source.nodeId;
3405
+ if (!eventNodeId || eventNodeId !== filter.agentId && !eventNodeId.startsWith(`${filter.agentId}/`)) return false;
3406
+ }
3407
+ if (filter.addonId) {
3408
+ if ((event.source.addonId ?? (event.source.type === "addon" ? String(event.source.id) : void 0)) !== filter.addonId) return false;
3409
+ }
3410
+ if (filter.deviceId !== void 0) {
3411
+ if ((event.source.deviceId ?? (event.source.type === "device" ? Number(event.source.id) : void 0)) !== filter.deviceId) return false;
3412
+ }
3413
+ if (filter.since && event.timestamp < filter.since) return false;
3414
+ return true;
3415
+ }
3416
+ /**
3417
+ * Categories filtered out of the `recent[]` ring buffer.
3418
+ *
3419
+ * High-frequency, low-value-for-the-operator categories — periodic metrics
3420
+ * snapshots, per-frame inference traces, raw motion-mask dumps, model-download
3421
+ * progress, etc. They still fan out to live subscribers (UI charts that
3422
+ * explicitly listen for them), but do NOT enter the recent-events ring so
3423
+ * meaningful events (motion / phase transitions / device lifecycle / addon
3424
+ * lifecycle / recording / detection.event) survive in the last-N window without
3425
+ * being displaced by per-frame noise.
3426
+ */
3427
+ var RING_BUFFER_DENY_PATTERNS = [
3428
+ "pipeline.camera-metrics-snapshot",
3429
+ "pipeline.runner-load-snapshot",
3430
+ "pipeline.engine-metrics-snapshot",
3431
+ "pipeline.inference-result",
3432
+ "pipeline.trace",
3433
+ "pipeline.progress",
3434
+ "stream-broker.metrics-snapshot",
3435
+ "metrics.node-resources-snapshot",
3436
+ "metrics.node-processes-snapshot",
3437
+ "cluster.topology-snapshot",
3438
+ "detection.motion-analysis",
3439
+ "detection.motion-zones-raw",
3440
+ "detection.result",
3441
+ "pipeline.audio-inference-result",
3442
+ "platform-probe.phase",
3443
+ "benchmark.progress",
3444
+ "model.download.progress",
3445
+ "pipeline-analytics.frame-tracked",
3446
+ "pipeline-analytics.detection-event",
3447
+ "pipeline-analytics.track-started",
3448
+ "pipeline-analytics.track-ended",
3449
+ "motion.on-motion-changed",
3450
+ "capability.binding-changed"
3451
+ ];
3452
+ /**
3453
+ * Hard cap on the `recent[]` buffer size. Even audit-grade categories that
3454
+ * are NOT on the deny-list (e.g. `device.state-changed`) must not accumulate
3455
+ * without bound — this is the last-N window, not a durable log. Without this
3456
+ * cap the buffer grew forever (~26 MB/h/process across every broker, agent,
3457
+ * and UDS-child bus), filling the hub to 16 GB.
3458
+ */
3459
+ var MAX_RECENT_EVENTS = 1e3;
3460
+ function isHighFrequencyCategory(category) {
3461
+ for (const pattern of RING_BUFFER_DENY_PATTERNS) if (matchesPattern(pattern, category)) return true;
3462
+ return false;
3463
+ }
3464
+ function readMoleculerFanoutMode() {
3465
+ const raw = process.env.CAMSTACK_MOLECULER_EVENT_FANOUT;
3466
+ if (raw === "shadow" || raw === "broadcast") return raw;
3467
+ return "filter";
3468
+ }
3469
+ /**
3470
+ * Does this node want to accept an inbound cross-node event in `category`?
3471
+ * The receiving-side mirror of `LocalChildRegistry.childWantsEvent`:
3472
+ * - `broadcast` mode → always (gate disabled).
3473
+ * - a retaining bus (hub main) → always (it serves `getRecent` + admin-ui;
3474
+ * its interest is genuinely everything).
3475
+ * - undeclared interest (`null`) → always (fail-open).
3476
+ * - else the declared union must match via `matchesPattern` — the SAME
3477
+ * function the local subscriber map-key gate uses, giving delivery parity.
3478
+ * `shadow` is handled by the caller (it counts the would-suppress but still
3479
+ * delivers), exactly like the UDS fan-out.
3480
+ */
3481
+ function crossNodeAcceptsCategory(mode, snapshot, category) {
3482
+ if (mode === "broadcast") return true;
3483
+ if (snapshot.retainRecent) return true;
3484
+ if (snapshot.interest === null) return true;
3485
+ return snapshot.interest.some((p) => matchesPattern(p, category));
3486
+ }
3487
+ /**
3488
+ * Deliver `event` to all matching local subscribers and — ONLY on a bus that
3489
+ * opted into retention (`state.retainRecent`, i.e. the hub main process) and
3490
+ * unless the event is high-frequency — append it to the bounded `recent[]`
3491
+ * ring.
3492
+ *
3493
+ * On a non-retaining bus (child runners, agents) the retention block is skipped
3494
+ * entirely: events are one-shot / fire-and-forget, still fanned out to local
3495
+ * subscribers but never accumulated. `getRecent` on such a bus returns `[]`.
3496
+ */
3497
+ function deliverShared(state, event) {
3498
+ if (state.retainRecent && !isHighFrequencyCategory(event.category)) {
3499
+ state.recent.push(event);
3500
+ if (state.recent.length > 1e3) state.recent.splice(0, state.recent.length - MAX_RECENT_EVENTS);
3501
+ }
3502
+ for (const [pattern, set] of state.handlers) {
3503
+ if (!matchesPattern(pattern, event.category)) continue;
3504
+ for (const entry of set) {
3505
+ if (!matchesEventFilter(event, entry.filter)) continue;
3506
+ try {
3507
+ entry.handler(event);
3508
+ } catch (err) {}
3509
+ }
3510
+ }
3511
+ }
3512
+ //#endregion
3363
3513
  //#region src/kernel/moleculer/resilient-cap-call.ts
3364
3514
  /** Moleculer error `type` values meaning "the service is not (yet) routable". */
3365
3515
  var DISCOVERY_ERROR_TYPES = new Set(["SERVICE_NOT_FOUND", "SERVICE_NOT_AVAILABLE"]);
@@ -3754,6 +3904,11 @@ var CapRouteResolver = class {
3754
3904
  };
3755
3905
  //#endregion
3756
3906
  //#region src/kernel/transport/local-child-registry.ts
3907
+ function readFanoutMode() {
3908
+ const raw = process.env.CAMSTACK_UDS_EVENT_FANOUT;
3909
+ if (raw === "shadow" || raw === "broadcast") return raw;
3910
+ return "filter";
3911
+ }
3757
3912
  /**
3758
3913
  * Sentinel prefix used in the no-route error thrown when `cap-call-out` has no
3759
3914
  * local sibling and no `onUnownedCall` fallback. `ipcParentLink` detects this
@@ -3781,6 +3936,12 @@ var LocalChildRegistry = class {
3781
3936
  resolveChildIdForAddon;
3782
3937
  /** Tracks capNames already logged as UDS-routed; one INFO line per capName per process. */
3783
3938
  egressRoutedCaps = /* @__PURE__ */ new Set();
3939
+ /** Active event fan-out mode, read once from `CAMSTACK_UDS_EVENT_FANOUT`. */
3940
+ fanoutMode = readFanoutMode();
3941
+ /** Per-child event fan-out counters (sent / suppressed). */
3942
+ childEventStats = /* @__PURE__ */ new Map();
3943
+ /** Last pattern-set string logged per child, to dedup the INFO line. */
3944
+ loggedPatternSet = /* @__PURE__ */ new Map();
3784
3945
  /**
3785
3946
  * Accepts either a plain positional `server` argument (backward-compatible)
3786
3947
  * or a full `LocalChildRegistryOptions` object.
@@ -3806,10 +3967,92 @@ var LocalChildRegistry = class {
3806
3967
  }
3807
3968
  }
3808
3969
  async start() {
3970
+ this.logger?.info("UDS event fan-out mode", { mode: this.fanoutMode });
3809
3971
  this.server.onConnection((channel) => this.onConnection(channel));
3810
3972
  await this.server.listen();
3811
3973
  }
3812
3974
  /**
3975
+ * Per-child event fan-out counters (`{ sent, suppressed }`) or `null` if the
3976
+ * child has no recorded events yet. Exposed for the starvation-verification
3977
+ * surface (shadow burn-in + operator debug).
3978
+ */
3979
+ getChildEventStats(childId) {
3980
+ const s = this.childEventStats.get(childId);
3981
+ return s === void 0 ? null : {
3982
+ sent: s.sent,
3983
+ suppressed: s.suppressed
3984
+ };
3985
+ }
3986
+ /**
3987
+ * Does `entry` want to receive an event in `category`? Honours the fan-out
3988
+ * mode: `broadcast` → always; undeclared patterns (`null`) → always
3989
+ * (fail-open); else the child's declared set must match via `matchesPattern`
3990
+ * — the SAME function the child's own `deliverShared` map-key gate uses,
3991
+ * giving provable delivery parity. `shadow` is handled by the caller (it
3992
+ * counts the would-suppress but still sends).
3993
+ *
3994
+ * NOTE: this gate must NOT consult `RING_BUFFER_DENY_PATTERNS` /
3995
+ * `isHighFrequencyCategory` — those are ring-buffer STORAGE policy; a child
3996
+ * that explicitly subscribes to a ring-denied category must still RECEIVE it.
3997
+ */
3998
+ childWantsEvent(entry, category) {
3999
+ if (this.fanoutMode === "broadcast") return true;
4000
+ if (entry.eventPatterns === null) return true;
4001
+ return entry.eventPatterns.some((p) => matchesPattern(p, category));
4002
+ }
4003
+ /**
4004
+ * D2: this node's aggregate cross-node event interest — the UNION of every
4005
+ * connected child's declared category patterns. Consumed by the parent's
4006
+ * `$event-bus` inbound gate (via `setNodeEventInterest`) so the node only
4007
+ * accepts cross-node (Moleculer) categories at least one local child wants.
4008
+ *
4009
+ * Returns `null` (fail-open — accept everything) when:
4010
+ * - NO child is currently connected (boot window before the first forked
4011
+ * child completes its UDS handshake, or every child has disconnected): a
4012
+ * node MUST NOT drop 100% of inbound cross-node events during that window,
4013
+ * and a child may connect imminently. This is distinct from the
4014
+ * declared-empty case below.
4015
+ * - ANY connected child is UNDECLARED (`eventPatterns === null`, a legacy
4016
+ * runner): a node cannot safely narrow its inbound set while a child's
4017
+ * real interest is unknown. Mirrors the per-child fail-open in
4018
+ * {@link childWantsEvent}, aggregated conservatively.
4019
+ *
4020
+ * Returns an EMPTY array only when ≥1 child is connected and EVERY connected
4021
+ * child declared an explicit empty set — a genuine "wants nothing". Reads the
4022
+ * live `children` map, so a child registering / disconnecting / sending
4023
+ * `event-sub` is reflected on the next call with no extra bookkeeping.
4024
+ */
4025
+ aggregateEventInterest() {
4026
+ if (this.children.size === 0) return null;
4027
+ const union = /* @__PURE__ */ new Set();
4028
+ for (const entry of this.children.values()) {
4029
+ if (entry.eventPatterns === null) return null;
4030
+ for (const p of entry.eventPatterns) union.add(p);
4031
+ }
4032
+ return [...union];
4033
+ }
4034
+ statsFor(childId) {
4035
+ let s = this.childEventStats.get(childId);
4036
+ if (s === void 0) {
4037
+ s = {
4038
+ sent: 0,
4039
+ suppressed: 0
4040
+ };
4041
+ this.childEventStats.set(childId, s);
4042
+ }
4043
+ return s;
4044
+ }
4045
+ /** Deduped INFO line whenever a child's declared pattern set changes. */
4046
+ logPatternSet(childId, patterns) {
4047
+ const key = patterns === null ? "<undeclared>" : JSON.stringify([...patterns].toSorted());
4048
+ if (this.loggedPatternSet.get(childId) === key) return;
4049
+ this.loggedPatternSet.set(childId, key);
4050
+ this.logger?.info("child event patterns", {
4051
+ childId,
4052
+ patterns: patterns ?? null
4053
+ });
4054
+ }
4055
+ /**
3813
4056
  * Child id that can service a call to `capName` (optionally addressing
3814
4057
  * `deviceId`), or null.
3815
4058
  *
@@ -3988,14 +4231,20 @@ var LocalChildRegistry = class {
3988
4231
  * one (the originating child, to avoid echo). Fire-and-forget.
3989
4232
  */
3990
4233
  broadcastEventToChildren(event, sourceNodeId, exceptChildId) {
4234
+ const msg = {
4235
+ kind: "event",
4236
+ event,
4237
+ sourceNodeId
4238
+ };
3991
4239
  for (const entry of this.children.values()) {
3992
4240
  if (entry.childId === exceptChildId) continue;
3993
- const msg = {
3994
- kind: "event",
3995
- event,
3996
- sourceNodeId
3997
- };
3998
- entry.channel.emit(msg);
4241
+ const wants = this.childWantsEvent(entry, event.category);
4242
+ const stats = this.statsFor(entry.childId);
4243
+ if (!wants) stats.suppressed++;
4244
+ if (wants || this.fanoutMode === "shadow") {
4245
+ stats.sent++;
4246
+ entry.channel.emit(msg);
4247
+ }
3999
4248
  }
4000
4249
  }
4001
4250
  /**
@@ -4031,17 +4280,31 @@ var LocalChildRegistry = class {
4031
4280
  if (childId !== null) this.logHandler?.(childId, msg);
4032
4281
  return;
4033
4282
  }
4283
+ if (msg.kind === "event-sub") {
4284
+ if (childId === null) return;
4285
+ const entry = this.children.get(childId);
4286
+ if (entry === void 0) return;
4287
+ this.children.set(childId, {
4288
+ ...entry,
4289
+ eventPatterns: msg.patterns
4290
+ });
4291
+ this.logPatternSet(childId, msg.patterns);
4292
+ return;
4293
+ }
4034
4294
  });
4035
4295
  channel.onRequest(async (body) => {
4036
4296
  const msg = body;
4037
4297
  if (msg.kind === "register") {
4038
4298
  if (childId !== null && childId !== msg.childId) throw new Error(`child attempted to change identity from "${childId}" to "${msg.childId}"`);
4039
4299
  childId = msg.childId;
4300
+ const eventPatterns = msg.eventPatterns ?? null;
4040
4301
  this.children.set(msg.childId, {
4041
4302
  childId: msg.childId,
4042
4303
  channel,
4043
- caps: msg.caps
4304
+ caps: msg.caps,
4305
+ eventPatterns
4044
4306
  });
4307
+ this.logPatternSet(msg.childId, eventPatterns);
4045
4308
  this.registeredHandler({
4046
4309
  childId: msg.childId,
4047
4310
  caps: msg.caps
@@ -4074,7 +4337,11 @@ var LocalChildRegistry = class {
4074
4337
  throw new Error(`unknown child request kind: ${msg.kind}`);
4075
4338
  });
4076
4339
  channel.onClose(() => {
4077
- if (childId !== null && this.children.delete(childId)) this.goneHandler(childId);
4340
+ if (childId !== null && this.children.delete(childId)) {
4341
+ this.childEventStats.delete(childId);
4342
+ this.loggedPatternSet.delete(childId);
4343
+ this.goneHandler(childId);
4344
+ }
4078
4345
  });
4079
4346
  }
4080
4347
  };
@@ -4106,6 +4373,24 @@ var LocalChildClient = class {
4106
4373
  * the latest set) instead of being lost or throwing.
4107
4374
  */
4108
4375
  latestCaps;
4376
+ /**
4377
+ * Per-owner (addonId) category-pattern subscription sets, and their union.
4378
+ * The union is declared to the parent (in `RegisterMessage.eventPatterns`
4379
+ * pre/at-connect, or via an `event-sub` emit post-connect) so the parent can
4380
+ * subscription-filter its event fan-out. Per-owner keying keeps the union
4381
+ * correct if multiple event buses ever share one client (group-runner case).
4382
+ */
4383
+ patternsByOwner = /* @__PURE__ */ new Map();
4384
+ latestEventPatterns = [];
4385
+ /**
4386
+ * Whether `updateEventPatterns` has ever been called. A client that never
4387
+ * declared a subscription set (no event bus wired — e.g. a pure cap-call
4388
+ * runner) omits `eventPatterns` from its register frame entirely, so the
4389
+ * parent treats it as UNDECLARED and fails OPEN (broadcast-all). Once any
4390
+ * owner declares (in production every addon context's framework
4391
+ * subscriptions do), the register carries the real union — even if empty.
4392
+ */
4393
+ hasDeclaredEventPatterns = false;
4109
4394
  /** Events and logs queued while the channel is not yet open. */
4110
4395
  pendingEmits = [];
4111
4396
  /** Handler for parent→child events. Registered via `onEvent`. */
@@ -4132,6 +4417,42 @@ var LocalChildClient = class {
4132
4417
  this.latestCaps = options.caps;
4133
4418
  }
4134
4419
  /**
4420
+ * Declare an owner's live category-pattern subscription set. Stores it
4421
+ * per-owner (keyed by `ownerId` = addonId), recomputes the union, and — only
4422
+ * if the union changed — declares it to the parent:
4423
+ * - pre-connect: buffered only; `start()`'s register frame carries the set.
4424
+ * - post-connect: emitted as a fire-and-forget `event-sub` (full-set
4425
+ * replace).
4426
+ * Idempotent on an unchanged union (no redundant `event-sub` frames).
4427
+ */
4428
+ updateEventPatterns(ownerId, patterns) {
4429
+ this.hasDeclaredEventPatterns = true;
4430
+ this.patternsByOwner.set(ownerId, patterns);
4431
+ const union = this.computePatternUnion();
4432
+ if (!this.patternsEqual(union, this.latestEventPatterns)) {
4433
+ this.latestEventPatterns = union;
4434
+ if (this.channel !== null) {
4435
+ const msg = {
4436
+ kind: "event-sub",
4437
+ patterns: union
4438
+ };
4439
+ this.channel.emit(msg);
4440
+ }
4441
+ }
4442
+ }
4443
+ /** Sorted, de-duplicated union across all owners' pattern sets. */
4444
+ computePatternUnion() {
4445
+ const all = /* @__PURE__ */ new Set();
4446
+ for (const patterns of this.patternsByOwner.values()) for (const p of patterns) all.add(p);
4447
+ return [...all].toSorted();
4448
+ }
4449
+ /** Order-insensitive equality — both inputs are already sorted unions here. */
4450
+ patternsEqual(a, b) {
4451
+ if (a.length !== b.length) return false;
4452
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
4453
+ return true;
4454
+ }
4455
+ /**
4135
4456
  * Register a callback that fires each time the client successfully connects
4136
4457
  * (or reconnects) to its parent. Multiple handlers may be registered; all
4137
4458
  * are called in registration order. Used by readiness-context in UDS mode
@@ -4210,7 +4531,8 @@ var LocalChildClient = class {
4210
4531
  const register = {
4211
4532
  kind: "register",
4212
4533
  childId: this.options.childId,
4213
- caps: this.latestCaps
4534
+ caps: this.latestCaps,
4535
+ ...this.hasDeclaredEventPatterns ? { eventPatterns: this.latestEventPatterns } : {}
4214
4536
  };
4215
4537
  try {
4216
4538
  await channel.request(register);
@@ -4243,7 +4565,8 @@ var LocalChildClient = class {
4243
4565
  const register = {
4244
4566
  kind: "register",
4245
4567
  childId: this.options.childId,
4246
- caps
4568
+ caps,
4569
+ ...this.hasDeclaredEventPatterns ? { eventPatterns: this.latestEventPatterns } : {}
4247
4570
  };
4248
4571
  await this.channel.request(register);
4249
4572
  }
@@ -4404,105 +4727,6 @@ function udsChildLogToWorkerEntry(childId, entry) {
4404
4727
  };
4405
4728
  }
4406
4729
  //#endregion
4407
- //#region src/kernel/moleculer/event-bus-core.ts
4408
- function createSharedBusState() {
4409
- return {
4410
- handlers: /* @__PURE__ */ new Map(),
4411
- recent: []
4412
- };
4413
- }
4414
- function matchesPattern(pattern, category) {
4415
- if (pattern === "*" || pattern === "**") return true;
4416
- if (pattern.endsWith(".**")) return category.startsWith(pattern.slice(0, -3));
4417
- if (pattern.endsWith(".*")) return category.startsWith(pattern.slice(0, -2));
4418
- return pattern === category;
4419
- }
4420
- function extractCategoryPattern(filter) {
4421
- if (typeof filter === "string") return filter;
4422
- if (filter && typeof filter === "object" && "category" in filter) {
4423
- const cat = filter.category;
4424
- if (typeof cat === "string") return cat;
4425
- if (Array.isArray(cat) && cat.length > 0) return String(cat[0]);
4426
- }
4427
- return "*";
4428
- }
4429
- /**
4430
- * Match an event against the full `EventFilter`. The Map-keyed subscribe path
4431
- * only handles the category pattern (deciding which handler set to fan out to);
4432
- * this function applies the remaining scope dimensions (`agentId`, `addonId`,
4433
- * `deviceId`, `source`, `since`) so per-device / per-addon subscribers don't
4434
- * receive sibling events.
4435
- */
4436
- function matchesEventFilter(event, filter) {
4437
- if (!filter || typeof filter === "string") return true;
4438
- if (filter.source) {
4439
- if (event.source.type !== filter.source.type || event.source.id !== filter.source.id) return false;
4440
- }
4441
- if (filter.agentId) {
4442
- const eventNodeId = event.source.nodeId;
4443
- if (!eventNodeId || eventNodeId !== filter.agentId && !eventNodeId.startsWith(`${filter.agentId}/`)) return false;
4444
- }
4445
- if (filter.addonId) {
4446
- if ((event.source.addonId ?? (event.source.type === "addon" ? String(event.source.id) : void 0)) !== filter.addonId) return false;
4447
- }
4448
- if (filter.deviceId !== void 0) {
4449
- if ((event.source.deviceId ?? (event.source.type === "device" ? Number(event.source.id) : void 0)) !== filter.deviceId) return false;
4450
- }
4451
- if (filter.since && event.timestamp < filter.since) return false;
4452
- return true;
4453
- }
4454
- /**
4455
- * Categories filtered out of the `recent[]` ring buffer.
4456
- *
4457
- * High-frequency, low-value-for-the-operator categories — periodic metrics
4458
- * snapshots, per-frame inference traces, raw motion-mask dumps, model-download
4459
- * progress, etc. They still fan out to live subscribers (UI charts that
4460
- * explicitly listen for them), but do NOT enter the recent-events ring so
4461
- * meaningful events (motion / phase transitions / device lifecycle / addon
4462
- * lifecycle / recording / detection.event) survive in the last-N window without
4463
- * being displaced by per-frame noise.
4464
- */
4465
- var RING_BUFFER_DENY_PATTERNS = [
4466
- "pipeline.camera-metrics-snapshot",
4467
- "pipeline.runner-load-snapshot",
4468
- "pipeline.engine-metrics-snapshot",
4469
- "pipeline.inference-result",
4470
- "pipeline.trace",
4471
- "pipeline.progress",
4472
- "stream-broker.metrics-snapshot",
4473
- "metrics.node-resources-snapshot",
4474
- "metrics.node-processes-snapshot",
4475
- "cluster.topology-snapshot",
4476
- "detection.motion-analysis",
4477
- "detection.motion-zones-raw",
4478
- "detection.result",
4479
- "pipeline.audio-inference-result",
4480
- "platform-probe.phase",
4481
- "benchmark.progress",
4482
- "model.download.progress",
4483
- "capability.binding-changed"
4484
- ];
4485
- function isHighFrequencyCategory(category) {
4486
- for (const pattern of RING_BUFFER_DENY_PATTERNS) if (matchesPattern(pattern, category)) return true;
4487
- return false;
4488
- }
4489
- /**
4490
- * Deliver `event` to all matching local subscribers and — unless it is
4491
- * high-frequency — append it to the `recent[]` ring.
4492
- */
4493
- function deliverShared(state, event) {
4494
- if (!isHighFrequencyCategory(event.category)) state.recent.push(event);
4495
- for (const [pattern, set] of state.handlers) {
4496
- if (!matchesPattern(pattern, event.category)) continue;
4497
- for (const entry of set) {
4498
- if (!matchesEventFilter(event, entry.filter)) continue;
4499
- try {
4500
- entry.handler(event);
4501
- } catch (err) {}
4502
- }
4503
- }
4504
- }
4505
- //#endregion
4506
4730
  //#region src/kernel/transport/uds-event-bus.ts
4507
4731
  /**
4508
4732
  * Create a UDS-backed `IEventBus` for use inside a forked child process.
@@ -4517,6 +4741,9 @@ function createUdsEventBus(client, addonId) {
4517
4741
  client.onEvent((event) => {
4518
4742
  deliverShared(state, event);
4519
4743
  });
4744
+ const reportPatterns = () => {
4745
+ client.updateEventPatterns(addonId, [...state.handlers.keys()]);
4746
+ };
4520
4747
  return {
4521
4748
  emit(event) {
4522
4749
  const enriched = {
@@ -4536,8 +4763,13 @@ function createUdsEventBus(client, addonId) {
4536
4763
  const set = state.handlers.get(pattern) ?? /* @__PURE__ */ new Set();
4537
4764
  set.add(entry);
4538
4765
  state.handlers.set(pattern, set);
4766
+ reportPatterns();
4539
4767
  return () => {
4540
- state.handlers.get(pattern)?.delete(entry);
4768
+ const current = state.handlers.get(pattern);
4769
+ if (current === void 0) return;
4770
+ current.delete(entry);
4771
+ if (current.size === 0) state.handlers.delete(pattern);
4772
+ reportPatterns();
4541
4773
  };
4542
4774
  },
4543
4775
  getRecent(filter, limit) {
@@ -4571,7 +4803,7 @@ var MAX_RECENT = 512;
4571
4803
  * and clears the child-event handler. Call it on process shutdown.
4572
4804
  */
4573
4805
  function createUdsEventBridge(deps) {
4574
- const { registry, parentBus, parentNodeId } = deps;
4806
+ const { registry, parentBus, parentNodeId, subscribePassthrough } = deps;
4575
4807
  /**
4576
4808
  * In-memory set of event ids for events that originated from one of this
4577
4809
  * bridge's UDS children and have already been fanned to siblings + the
@@ -4590,10 +4822,11 @@ function createUdsEventBridge(deps) {
4590
4822
  }
4591
4823
  parentBus.emit(event);
4592
4824
  });
4593
- const unsubscribe = parentBus.subscribe({}, (event) => {
4825
+ const relayHandler = (event) => {
4594
4826
  if (recentlyFannedIds.has(event.id)) return;
4595
4827
  registry.broadcastEventToChildren(event, parentNodeId, void 0);
4596
- });
4828
+ };
4829
+ const unsubscribe = subscribePassthrough !== void 0 ? subscribePassthrough(relayHandler) : parentBus.subscribe({}, relayHandler);
4597
4830
  return () => {
4598
4831
  unsubscribe();
4599
4832
  registry.onChildEvent(null);
@@ -5665,18 +5898,183 @@ function clusterEventTopic(category) {
5665
5898
  * brokers GC naturally.
5666
5899
  */
5667
5900
  var brokerBusState = /* @__PURE__ */ new WeakMap();
5668
- function getSharedBusState(broker) {
5901
+ /**
5902
+ * @param retainRecent - `true` from the hub-main opt-in call site
5903
+ * (`event-bus.service.ts`). If the bus was already created by a non-retaining
5904
+ * caller (e.g. `process-service`, the `$event-bus` handler, or the per-addon
5905
+ * wrapper) we UPGRADE it in place so the opt-in wins regardless of call
5906
+ * order. Only the hub main process ever passes `true`; agents + child runners
5907
+ * never do, so their bus stays one-shot.
5908
+ */
5909
+ function getSharedBusState(broker, retainRecent = false) {
5669
5910
  let state = brokerBusState.get(broker);
5670
5911
  if (!state) {
5671
- state = createSharedBusState();
5912
+ state = createSharedBusState(retainRecent);
5672
5913
  brokerBusState.set(broker, state);
5673
- }
5914
+ } else if (retainRecent && !state.retainRecent) state.retainRecent = true;
5674
5915
  return state;
5675
5916
  }
5676
5917
  /** Brokers that already have the `$event-bus` service installed. */
5677
5918
  var eventBusServiceInstalled = /* @__PURE__ */ new WeakSet();
5678
5919
  var brokerBusWarnTimestamps = /* @__PURE__ */ new WeakMap();
5679
5920
  /**
5921
+ * Per-broker node-interest oracle. Returns the FORKED-CHILD interest (the union
5922
+ * of this node's UDS children's declared category patterns), or `null` when it
5923
+ * is undeclared/unknown (a legacy child, or no forked child has connected yet)
5924
+ * → fail-open. Absent (no entry) is also fail-open: pre-D2 behaviour is
5925
+ * preserved until a node wires its interest via {@link setNodeEventInterest}.
5926
+ *
5927
+ * IMPORTANT: this oracle covers ONLY forked/UDS children. In-process
5928
+ * ('broker'-mode) subscribers on this same broker (e.g. each addon context's
5929
+ * `device.bindings-changed` cache-invalidation sub) are tracked SEPARATELY in
5930
+ * {@link brokerLocalInterest}. The gate unions BOTH so it can never starve a
5931
+ * live local subscriber of either kind.
5932
+ */
5933
+ var brokerNodeInterest = /* @__PURE__ */ new WeakMap();
5934
+ /**
5935
+ * Per-broker IN-PROCESS subscriber interest: category pattern → live refcount.
5936
+ * Populated by `getBrokerEventBus(...).subscribe` (every in-process /
5937
+ * 'broker'-mode subscription) and decremented by its unsubscribe. Does NOT
5938
+ * include the UDS event bridge's pass-through relay subscription — that is a
5939
+ * conduit to forked children (whose real interest the oracle already reports),
5940
+ * not a genuine local consumer, so it registers via {@link subscribePassthrough}
5941
+ * which bypasses this surface. Keeping the bridge out is what lets the gate
5942
+ * actually filter (otherwise the bridge's `*` would make every category wanted).
5943
+ */
5944
+ var brokerLocalInterest = /* @__PURE__ */ new WeakMap();
5945
+ function bumpLocalInterest(broker, pattern) {
5946
+ let m = brokerLocalInterest.get(broker);
5947
+ if (m === void 0) {
5948
+ m = /* @__PURE__ */ new Map();
5949
+ brokerLocalInterest.set(broker, m);
5950
+ }
5951
+ m.set(pattern, (m.get(pattern) ?? 0) + 1);
5952
+ }
5953
+ function dropLocalInterest(broker, pattern) {
5954
+ const m = brokerLocalInterest.get(broker);
5955
+ if (m === void 0) return;
5956
+ const next = (m.get(pattern) ?? 0) - 1;
5957
+ if (next <= 0) m.delete(pattern);
5958
+ else m.set(pattern, next);
5959
+ }
5960
+ /** Snapshot of the in-process subscriber category patterns for `broker`. */
5961
+ function localSubscriberPatterns(broker) {
5962
+ const m = brokerLocalInterest.get(broker);
5963
+ return m === void 0 ? [] : [...m.keys()];
5964
+ }
5965
+ /**
5966
+ * Subscribe a PASS-THROUGH relay handler to the broker's shared bus WITHOUT
5967
+ * registering it in the local-interest surface. Used exclusively by the UDS
5968
+ * event bridge for its cluster/parent-local → children fan-out subscription:
5969
+ * the bridge must receive every event the gate lets through so it can relay to
5970
+ * forked children, but it is not itself a terminal consumer, so it must not
5971
+ * count toward this node's interest (a counted `*` would defeat the filter).
5972
+ * Returns an unsubscribe function.
5973
+ */
5974
+ function subscribePassthrough(broker, handler) {
5975
+ const state = getSharedBusState(broker);
5976
+ const entry = {
5977
+ filter: {},
5978
+ categoryPattern: "*",
5979
+ handler
5980
+ };
5981
+ const set = state.handlers.get("*") ?? /* @__PURE__ */ new Set();
5982
+ set.add(entry);
5983
+ state.handlers.set("*", set);
5984
+ return () => {
5985
+ state.handlers.get("*")?.delete(entry);
5986
+ };
5987
+ }
5988
+ /** Per-broker cross-node inbound counters (delivered / would-suppress). */
5989
+ var brokerCrossNodeStats = /* @__PURE__ */ new WeakMap();
5990
+ /**
5991
+ * Install (or clear, with `null`) the per-node interest oracle consulted by the
5992
+ * `$event-bus` inbound handler to decide whether an inbound cross-node event is
5993
+ * wanted by any local subscriber on this node. The oracle is read PER-EVENT so a
5994
+ * child (un)subscribing takes effect immediately with no re-registration.
5995
+ *
5996
+ * Only non-retaining nodes (agents) meaningfully filter — a retaining bus (hub
5997
+ * main) always accepts every category (it serves `getRecent` + the admin-ui live
5998
+ * stream). Installing the oracle on the hub too is therefore harmless and keeps
5999
+ * the wiring uniform.
6000
+ */
6001
+ function setNodeEventInterest(broker, oracle) {
6002
+ if (oracle === null) brokerNodeInterest.delete(broker);
6003
+ else brokerNodeInterest.set(broker, oracle);
6004
+ }
6005
+ /**
6006
+ * Cross-node inbound counters for `broker`, or `null` if no cross-node event has
6007
+ * been processed yet. Exposed for the D2 shadow burn-in + operator debug —
6008
+ * mirror of `LocalChildRegistry.getChildEventStats`.
6009
+ */
6010
+ function getMoleculerEventStats(broker) {
6011
+ const s = brokerCrossNodeStats.get(broker);
6012
+ return s === void 0 ? null : {
6013
+ delivered: s.delivered,
6014
+ suppressed: s.suppressed
6015
+ };
6016
+ }
6017
+ function crossNodeStatsFor(broker) {
6018
+ let s = brokerCrossNodeStats.get(broker);
6019
+ if (s === void 0) {
6020
+ s = {
6021
+ delivered: 0,
6022
+ suppressed: 0
6023
+ };
6024
+ brokerCrossNodeStats.set(broker, s);
6025
+ }
6026
+ return s;
6027
+ }
6028
+ /**
6029
+ * Compute this node's COMBINED cross-node interest snapshot for the gate:
6030
+ * - `retainRecent` — from the shared bus (hub main → accept everything).
6031
+ * - `interest` — union of (a) forked-child interest (the oracle) and (b) live
6032
+ * in-process subscriber patterns. `null` (fail-open) when the oracle reports
6033
+ * `null` (undeclared / no forked child yet / oracle threw) — we cannot
6034
+ * safely narrow while a forked child's real interest is unknown, and a boot
6035
+ * window with no children yet must not drop everything.
6036
+ *
6037
+ * The oracle invocation is wrapped in try/catch and fails OPEN on throw — it
6038
+ * sits in front of the per-handler try/catch, in the subsystem that previously
6039
+ * caused an OOM, so a throwing oracle must never break inbound delivery.
6040
+ */
6041
+ function nodeInterestSnapshot(broker) {
6042
+ const state = getSharedBusState(broker);
6043
+ let childInterest;
6044
+ try {
6045
+ const oracle = brokerNodeInterest.get(broker);
6046
+ childInterest = oracle ? oracle() : null;
6047
+ } catch {
6048
+ childInterest = null;
6049
+ }
6050
+ if (childInterest === null) return {
6051
+ retainRecent: state.retainRecent,
6052
+ interest: null
6053
+ };
6054
+ const local = localSubscriberPatterns(broker);
6055
+ const interest = [...new Set([...local, ...childInterest])];
6056
+ return {
6057
+ retainRecent: state.retainRecent,
6058
+ interest
6059
+ };
6060
+ }
6061
+ /**
6062
+ * Decide + account for an inbound cross-node event on `broker`. Returns whether
6063
+ * the event should be delivered to the local bus. Honours the
6064
+ * `CAMSTACK_MOLECULER_EVENT_FANOUT` mode (`mode`): in `shadow` it still delivers
6065
+ * but records the would-suppress; in `filter` it drops unwanted categories.
6066
+ * The interest is the UNION of forked-child AND in-process subscribers — the
6067
+ * gate never starves a live local subscriber of either kind.
6068
+ */
6069
+ function acceptInboundCrossNode(broker, mode, category) {
6070
+ const wants = crossNodeAcceptsCategory(mode, nodeInterestSnapshot(broker), category);
6071
+ const stats = crossNodeStatsFor(broker);
6072
+ if (!wants) stats.suppressed++;
6073
+ const deliver = wants || mode === "shadow";
6074
+ if (deliver) stats.delivered++;
6075
+ return deliver;
6076
+ }
6077
+ /**
5680
6078
  * Register the `$event-bus` service on a broker. MUST be called BEFORE
5681
6079
  * `broker.start()` — Moleculer announces a service's event
5682
6080
  * subscriptions to remote nodes only during the discovery handshake;
@@ -5700,6 +6098,7 @@ function registerEventBusService(broker) {
5700
6098
  if (eventBusServiceInstalled.has(broker)) return;
5701
6099
  eventBusServiceInstalled.add(broker);
5702
6100
  const bkr = broker;
6101
+ const fanoutMode = readMoleculerFanoutMode();
5703
6102
  try {
5704
6103
  bkr.createService({
5705
6104
  name: "$event-bus",
@@ -5707,6 +6106,7 @@ function registerEventBusService(broker) {
5707
6106
  const event = ctx.params;
5708
6107
  if (!event) return;
5709
6108
  if (event.sourceNodeId === bkr.nodeID) return;
6109
+ if (!acceptInboundCrossNode(broker, fanoutMode, event.category)) return;
5710
6110
  deliverShared(getSharedBusState(broker), event);
5711
6111
  } }
5712
6112
  });
@@ -5717,9 +6117,16 @@ function registerEventBusService(broker) {
5717
6117
  } catch {}
5718
6118
  }
5719
6119
  }
5720
- function getBrokerEventBus(broker) {
6120
+ /**
6121
+ * @param options.retainRecent - Pass `true` ONLY from the hub main process
6122
+ * (`EventBusService.attachBroker`), the single process that serves the
6123
+ * `getRecent`/audit capability. Every other caller (agents, child runners,
6124
+ * the per-addon wrapper, the `$event-bus` handler) omits it → the bus retains
6125
+ * nothing and `getRecent` returns `[]`. Events are one-shot everywhere else.
6126
+ */
6127
+ function getBrokerEventBus(broker, options) {
5721
6128
  const bkr = broker;
5722
- const state = getSharedBusState(broker);
6129
+ const state = getSharedBusState(broker, options?.retainRecent ?? false);
5723
6130
  registerEventBusService(broker);
5724
6131
  const warnTimestamps = (() => {
5725
6132
  let m = brokerBusWarnTimestamps.get(broker);
@@ -5760,8 +6167,9 @@ function getBrokerEventBus(broker) {
5760
6167
  const set = state.handlers.get(pattern) ?? /* @__PURE__ */ new Set();
5761
6168
  set.add(entry);
5762
6169
  state.handlers.set(pattern, set);
6170
+ bumpLocalInterest(broker, pattern);
5763
6171
  return () => {
5764
- state.handlers.get(pattern)?.delete(entry);
6172
+ if (state.handlers.get(pattern)?.delete(entry) === true) dropLocalInterest(broker, pattern);
5765
6173
  };
5766
6174
  },
5767
6175
  getRecent(filter, limit) {
@@ -6961,6 +7369,12 @@ Object.defineProperty(exports, "getCapUsageRegistry", {
6961
7369
  return getCapUsageRegistry;
6962
7370
  }
6963
7371
  });
7372
+ Object.defineProperty(exports, "getMoleculerEventStats", {
7373
+ enumerable: true,
7374
+ get: function() {
7375
+ return getMoleculerEventStats;
7376
+ }
7377
+ });
6964
7378
  Object.defineProperty(exports, "getOrInitReadinessRegistry", {
6965
7379
  enumerable: true,
6966
7380
  get: function() {
@@ -7069,12 +7483,24 @@ Object.defineProperty(exports, "setHubConnected", {
7069
7483
  return setHubConnected;
7070
7484
  }
7071
7485
  });
7486
+ Object.defineProperty(exports, "setNodeEventInterest", {
7487
+ enumerable: true,
7488
+ get: function() {
7489
+ return setNodeEventInterest;
7490
+ }
7491
+ });
7072
7492
  Object.defineProperty(exports, "setWorkerNativeCapsChangeListener", {
7073
7493
  enumerable: true,
7074
7494
  get: function() {
7075
7495
  return setWorkerNativeCapsChangeListener;
7076
7496
  }
7077
7497
  });
7498
+ Object.defineProperty(exports, "subscribePassthrough", {
7499
+ enumerable: true,
7500
+ get: function() {
7501
+ return subscribePassthrough;
7502
+ }
7503
+ });
7078
7504
  Object.defineProperty(exports, "udsChildLogToWorkerEntry", {
7079
7505
  enumerable: true,
7080
7506
  get: function() {