@camstack/system 1.1.19 → 1.1.20

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,133 @@ 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
+ /**
3463
+ * Deliver `event` to all matching local subscribers and — ONLY on a bus that
3464
+ * opted into retention (`state.retainRecent`, i.e. the hub main process) and
3465
+ * unless the event is high-frequency — append it to the bounded `recent[]`
3466
+ * ring.
3467
+ *
3468
+ * On a non-retaining bus (child runners, agents) the retention block is skipped
3469
+ * entirely: events are one-shot / fire-and-forget, still fanned out to local
3470
+ * subscribers but never accumulated. `getRecent` on such a bus returns `[]`.
3471
+ */
3472
+ function deliverShared(state, event) {
3473
+ if (state.retainRecent && !isHighFrequencyCategory(event.category)) {
3474
+ state.recent.push(event);
3475
+ if (state.recent.length > 1e3) state.recent.splice(0, state.recent.length - MAX_RECENT_EVENTS);
3476
+ }
3477
+ for (const [pattern, set] of state.handlers) {
3478
+ if (!matchesPattern(pattern, event.category)) continue;
3479
+ for (const entry of set) {
3480
+ if (!matchesEventFilter(event, entry.filter)) continue;
3481
+ try {
3482
+ entry.handler(event);
3483
+ } catch (err) {}
3484
+ }
3485
+ }
3486
+ }
3487
+ //#endregion
3361
3488
  //#region src/kernel/moleculer/resilient-cap-call.ts
3362
3489
  /** Moleculer error `type` values meaning "the service is not (yet) routable". */
3363
3490
  var DISCOVERY_ERROR_TYPES = new Set(["SERVICE_NOT_FOUND", "SERVICE_NOT_AVAILABLE"]);
@@ -3752,6 +3879,11 @@ var CapRouteResolver = class {
3752
3879
  };
3753
3880
  //#endregion
3754
3881
  //#region src/kernel/transport/local-child-registry.ts
3882
+ function readFanoutMode() {
3883
+ const raw = process.env.CAMSTACK_UDS_EVENT_FANOUT;
3884
+ if (raw === "shadow" || raw === "broadcast") return raw;
3885
+ return "filter";
3886
+ }
3755
3887
  /**
3756
3888
  * Sentinel prefix used in the no-route error thrown when `cap-call-out` has no
3757
3889
  * local sibling and no `onUnownedCall` fallback. `ipcParentLink` detects this
@@ -3779,6 +3911,12 @@ var LocalChildRegistry = class {
3779
3911
  resolveChildIdForAddon;
3780
3912
  /** Tracks capNames already logged as UDS-routed; one INFO line per capName per process. */
3781
3913
  egressRoutedCaps = /* @__PURE__ */ new Set();
3914
+ /** Active event fan-out mode, read once from `CAMSTACK_UDS_EVENT_FANOUT`. */
3915
+ fanoutMode = readFanoutMode();
3916
+ /** Per-child event fan-out counters (sent / suppressed). */
3917
+ childEventStats = /* @__PURE__ */ new Map();
3918
+ /** Last pattern-set string logged per child, to dedup the INFO line. */
3919
+ loggedPatternSet = /* @__PURE__ */ new Map();
3782
3920
  /**
3783
3921
  * Accepts either a plain positional `server` argument (backward-compatible)
3784
3922
  * or a full `LocalChildRegistryOptions` object.
@@ -3804,10 +3942,61 @@ var LocalChildRegistry = class {
3804
3942
  }
3805
3943
  }
3806
3944
  async start() {
3945
+ this.logger?.info("UDS event fan-out mode", { mode: this.fanoutMode });
3807
3946
  this.server.onConnection((channel) => this.onConnection(channel));
3808
3947
  await this.server.listen();
3809
3948
  }
3810
3949
  /**
3950
+ * Per-child event fan-out counters (`{ sent, suppressed }`) or `null` if the
3951
+ * child has no recorded events yet. Exposed for the starvation-verification
3952
+ * surface (shadow burn-in + operator debug).
3953
+ */
3954
+ getChildEventStats(childId) {
3955
+ const s = this.childEventStats.get(childId);
3956
+ return s === void 0 ? null : {
3957
+ sent: s.sent,
3958
+ suppressed: s.suppressed
3959
+ };
3960
+ }
3961
+ /**
3962
+ * Does `entry` want to receive an event in `category`? Honours the fan-out
3963
+ * mode: `broadcast` → always; undeclared patterns (`null`) → always
3964
+ * (fail-open); else the child's declared set must match via `matchesPattern`
3965
+ * — the SAME function the child's own `deliverShared` map-key gate uses,
3966
+ * giving provable delivery parity. `shadow` is handled by the caller (it
3967
+ * counts the would-suppress but still sends).
3968
+ *
3969
+ * NOTE: this gate must NOT consult `RING_BUFFER_DENY_PATTERNS` /
3970
+ * `isHighFrequencyCategory` — those are ring-buffer STORAGE policy; a child
3971
+ * that explicitly subscribes to a ring-denied category must still RECEIVE it.
3972
+ */
3973
+ childWantsEvent(entry, category) {
3974
+ if (this.fanoutMode === "broadcast") return true;
3975
+ if (entry.eventPatterns === null) return true;
3976
+ return entry.eventPatterns.some((p) => matchesPattern(p, category));
3977
+ }
3978
+ statsFor(childId) {
3979
+ let s = this.childEventStats.get(childId);
3980
+ if (s === void 0) {
3981
+ s = {
3982
+ sent: 0,
3983
+ suppressed: 0
3984
+ };
3985
+ this.childEventStats.set(childId, s);
3986
+ }
3987
+ return s;
3988
+ }
3989
+ /** Deduped INFO line whenever a child's declared pattern set changes. */
3990
+ logPatternSet(childId, patterns) {
3991
+ const key = patterns === null ? "<undeclared>" : JSON.stringify([...patterns].toSorted());
3992
+ if (this.loggedPatternSet.get(childId) === key) return;
3993
+ this.loggedPatternSet.set(childId, key);
3994
+ this.logger?.info("child event patterns", {
3995
+ childId,
3996
+ patterns: patterns ?? null
3997
+ });
3998
+ }
3999
+ /**
3811
4000
  * Child id that can service a call to `capName` (optionally addressing
3812
4001
  * `deviceId`), or null.
3813
4002
  *
@@ -3986,14 +4175,20 @@ var LocalChildRegistry = class {
3986
4175
  * one (the originating child, to avoid echo). Fire-and-forget.
3987
4176
  */
3988
4177
  broadcastEventToChildren(event, sourceNodeId, exceptChildId) {
4178
+ const msg = {
4179
+ kind: "event",
4180
+ event,
4181
+ sourceNodeId
4182
+ };
3989
4183
  for (const entry of this.children.values()) {
3990
4184
  if (entry.childId === exceptChildId) continue;
3991
- const msg = {
3992
- kind: "event",
3993
- event,
3994
- sourceNodeId
3995
- };
3996
- entry.channel.emit(msg);
4185
+ const wants = this.childWantsEvent(entry, event.category);
4186
+ const stats = this.statsFor(entry.childId);
4187
+ if (!wants) stats.suppressed++;
4188
+ if (wants || this.fanoutMode === "shadow") {
4189
+ stats.sent++;
4190
+ entry.channel.emit(msg);
4191
+ }
3997
4192
  }
3998
4193
  }
3999
4194
  /**
@@ -4029,17 +4224,31 @@ var LocalChildRegistry = class {
4029
4224
  if (childId !== null) this.logHandler?.(childId, msg);
4030
4225
  return;
4031
4226
  }
4227
+ if (msg.kind === "event-sub") {
4228
+ if (childId === null) return;
4229
+ const entry = this.children.get(childId);
4230
+ if (entry === void 0) return;
4231
+ this.children.set(childId, {
4232
+ ...entry,
4233
+ eventPatterns: msg.patterns
4234
+ });
4235
+ this.logPatternSet(childId, msg.patterns);
4236
+ return;
4237
+ }
4032
4238
  });
4033
4239
  channel.onRequest(async (body) => {
4034
4240
  const msg = body;
4035
4241
  if (msg.kind === "register") {
4036
4242
  if (childId !== null && childId !== msg.childId) throw new Error(`child attempted to change identity from "${childId}" to "${msg.childId}"`);
4037
4243
  childId = msg.childId;
4244
+ const eventPatterns = msg.eventPatterns ?? null;
4038
4245
  this.children.set(msg.childId, {
4039
4246
  childId: msg.childId,
4040
4247
  channel,
4041
- caps: msg.caps
4248
+ caps: msg.caps,
4249
+ eventPatterns
4042
4250
  });
4251
+ this.logPatternSet(msg.childId, eventPatterns);
4043
4252
  this.registeredHandler({
4044
4253
  childId: msg.childId,
4045
4254
  caps: msg.caps
@@ -4072,7 +4281,11 @@ var LocalChildRegistry = class {
4072
4281
  throw new Error(`unknown child request kind: ${msg.kind}`);
4073
4282
  });
4074
4283
  channel.onClose(() => {
4075
- if (childId !== null && this.children.delete(childId)) this.goneHandler(childId);
4284
+ if (childId !== null && this.children.delete(childId)) {
4285
+ this.childEventStats.delete(childId);
4286
+ this.loggedPatternSet.delete(childId);
4287
+ this.goneHandler(childId);
4288
+ }
4076
4289
  });
4077
4290
  }
4078
4291
  };
@@ -4104,6 +4317,24 @@ var LocalChildClient = class {
4104
4317
  * the latest set) instead of being lost or throwing.
4105
4318
  */
4106
4319
  latestCaps;
4320
+ /**
4321
+ * Per-owner (addonId) category-pattern subscription sets, and their union.
4322
+ * The union is declared to the parent (in `RegisterMessage.eventPatterns`
4323
+ * pre/at-connect, or via an `event-sub` emit post-connect) so the parent can
4324
+ * subscription-filter its event fan-out. Per-owner keying keeps the union
4325
+ * correct if multiple event buses ever share one client (group-runner case).
4326
+ */
4327
+ patternsByOwner = /* @__PURE__ */ new Map();
4328
+ latestEventPatterns = [];
4329
+ /**
4330
+ * Whether `updateEventPatterns` has ever been called. A client that never
4331
+ * declared a subscription set (no event bus wired — e.g. a pure cap-call
4332
+ * runner) omits `eventPatterns` from its register frame entirely, so the
4333
+ * parent treats it as UNDECLARED and fails OPEN (broadcast-all). Once any
4334
+ * owner declares (in production every addon context's framework
4335
+ * subscriptions do), the register carries the real union — even if empty.
4336
+ */
4337
+ hasDeclaredEventPatterns = false;
4107
4338
  /** Events and logs queued while the channel is not yet open. */
4108
4339
  pendingEmits = [];
4109
4340
  /** Handler for parent→child events. Registered via `onEvent`. */
@@ -4130,6 +4361,42 @@ var LocalChildClient = class {
4130
4361
  this.latestCaps = options.caps;
4131
4362
  }
4132
4363
  /**
4364
+ * Declare an owner's live category-pattern subscription set. Stores it
4365
+ * per-owner (keyed by `ownerId` = addonId), recomputes the union, and — only
4366
+ * if the union changed — declares it to the parent:
4367
+ * - pre-connect: buffered only; `start()`'s register frame carries the set.
4368
+ * - post-connect: emitted as a fire-and-forget `event-sub` (full-set
4369
+ * replace).
4370
+ * Idempotent on an unchanged union (no redundant `event-sub` frames).
4371
+ */
4372
+ updateEventPatterns(ownerId, patterns) {
4373
+ this.hasDeclaredEventPatterns = true;
4374
+ this.patternsByOwner.set(ownerId, patterns);
4375
+ const union = this.computePatternUnion();
4376
+ if (!this.patternsEqual(union, this.latestEventPatterns)) {
4377
+ this.latestEventPatterns = union;
4378
+ if (this.channel !== null) {
4379
+ const msg = {
4380
+ kind: "event-sub",
4381
+ patterns: union
4382
+ };
4383
+ this.channel.emit(msg);
4384
+ }
4385
+ }
4386
+ }
4387
+ /** Sorted, de-duplicated union across all owners' pattern sets. */
4388
+ computePatternUnion() {
4389
+ const all = /* @__PURE__ */ new Set();
4390
+ for (const patterns of this.patternsByOwner.values()) for (const p of patterns) all.add(p);
4391
+ return [...all].toSorted();
4392
+ }
4393
+ /** Order-insensitive equality — both inputs are already sorted unions here. */
4394
+ patternsEqual(a, b) {
4395
+ if (a.length !== b.length) return false;
4396
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
4397
+ return true;
4398
+ }
4399
+ /**
4133
4400
  * Register a callback that fires each time the client successfully connects
4134
4401
  * (or reconnects) to its parent. Multiple handlers may be registered; all
4135
4402
  * are called in registration order. Used by readiness-context in UDS mode
@@ -4208,7 +4475,8 @@ var LocalChildClient = class {
4208
4475
  const register = {
4209
4476
  kind: "register",
4210
4477
  childId: this.options.childId,
4211
- caps: this.latestCaps
4478
+ caps: this.latestCaps,
4479
+ ...this.hasDeclaredEventPatterns ? { eventPatterns: this.latestEventPatterns } : {}
4212
4480
  };
4213
4481
  try {
4214
4482
  await channel.request(register);
@@ -4241,7 +4509,8 @@ var LocalChildClient = class {
4241
4509
  const register = {
4242
4510
  kind: "register",
4243
4511
  childId: this.options.childId,
4244
- caps
4512
+ caps,
4513
+ ...this.hasDeclaredEventPatterns ? { eventPatterns: this.latestEventPatterns } : {}
4245
4514
  };
4246
4515
  await this.channel.request(register);
4247
4516
  }
@@ -4402,105 +4671,6 @@ function udsChildLogToWorkerEntry(childId, entry) {
4402
4671
  };
4403
4672
  }
4404
4673
  //#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
4674
  //#region src/kernel/transport/uds-event-bus.ts
4505
4675
  /**
4506
4676
  * Create a UDS-backed `IEventBus` for use inside a forked child process.
@@ -4515,6 +4685,9 @@ function createUdsEventBus(client, addonId) {
4515
4685
  client.onEvent((event) => {
4516
4686
  deliverShared(state, event);
4517
4687
  });
4688
+ const reportPatterns = () => {
4689
+ client.updateEventPatterns(addonId, [...state.handlers.keys()]);
4690
+ };
4518
4691
  return {
4519
4692
  emit(event) {
4520
4693
  const enriched = {
@@ -4534,8 +4707,13 @@ function createUdsEventBus(client, addonId) {
4534
4707
  const set = state.handlers.get(pattern) ?? /* @__PURE__ */ new Set();
4535
4708
  set.add(entry);
4536
4709
  state.handlers.set(pattern, set);
4710
+ reportPatterns();
4537
4711
  return () => {
4538
- state.handlers.get(pattern)?.delete(entry);
4712
+ const current = state.handlers.get(pattern);
4713
+ if (current === void 0) return;
4714
+ current.delete(entry);
4715
+ if (current.size === 0) state.handlers.delete(pattern);
4716
+ reportPatterns();
4539
4717
  };
4540
4718
  },
4541
4719
  getRecent(filter, limit) {
@@ -5663,12 +5841,20 @@ function clusterEventTopic(category) {
5663
5841
  * brokers GC naturally.
5664
5842
  */
5665
5843
  var brokerBusState = /* @__PURE__ */ new WeakMap();
5666
- function getSharedBusState(broker) {
5844
+ /**
5845
+ * @param retainRecent - `true` from the hub-main opt-in call site
5846
+ * (`event-bus.service.ts`). If the bus was already created by a non-retaining
5847
+ * caller (e.g. `process-service`, the `$event-bus` handler, or the per-addon
5848
+ * wrapper) we UPGRADE it in place so the opt-in wins regardless of call
5849
+ * order. Only the hub main process ever passes `true`; agents + child runners
5850
+ * never do, so their bus stays one-shot.
5851
+ */
5852
+ function getSharedBusState(broker, retainRecent = false) {
5667
5853
  let state = brokerBusState.get(broker);
5668
5854
  if (!state) {
5669
- state = createSharedBusState();
5855
+ state = createSharedBusState(retainRecent);
5670
5856
  brokerBusState.set(broker, state);
5671
- }
5857
+ } else if (retainRecent && !state.retainRecent) state.retainRecent = true;
5672
5858
  return state;
5673
5859
  }
5674
5860
  /** Brokers that already have the `$event-bus` service installed. */
@@ -5715,9 +5901,16 @@ function registerEventBusService(broker) {
5715
5901
  } catch {}
5716
5902
  }
5717
5903
  }
5718
- function getBrokerEventBus(broker) {
5904
+ /**
5905
+ * @param options.retainRecent - Pass `true` ONLY from the hub main process
5906
+ * (`EventBusService.attachBroker`), the single process that serves the
5907
+ * `getRecent`/audit capability. Every other caller (agents, child runners,
5908
+ * the per-addon wrapper, the `$event-bus` handler) omits it → the bus retains
5909
+ * nothing and `getRecent` returns `[]`. Events are one-shot everywhere else.
5910
+ */
5911
+ function getBrokerEventBus(broker, options) {
5719
5912
  const bkr = broker;
5720
- const state = getSharedBusState(broker);
5913
+ const state = getSharedBusState(broker, options?.retainRecent ?? false);
5721
5914
  registerEventBusService(broker);
5722
5915
  const warnTimestamps = (() => {
5723
5916
  let m = brokerBusWarnTimestamps.get(broker);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/system",
3
- "version": "1.1.19",
3
+ "version": "1.1.20",
4
4
  "description": "Core addon for CamStack — builtins, pipeline, process management, auth, logging, events",
5
5
  "keywords": [
6
6
  "camstack",