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