@camstack/system 1.1.31 → 1.1.33

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.
@@ -1,5 +1,5 @@
1
1
  const require_chunk = require("./chunk-Cek0wNdY.js");
2
- const require_manifest_python_deps = require("./manifest-python-deps-jWKVwa7t.js");
2
+ const require_manifest_python_deps = require("./manifest-python-deps-CAnO1wPx.js");
3
3
  const require_custom_action_registry = require("./custom-action-registry-vLYEFTtv.js");
4
4
  let node_fs = require("node:fs");
5
5
  node_fs = require_chunk.__toESM(node_fs);
@@ -1,4 +1,4 @@
1
- import { F as createUdsLoggerWithControl, I as LocalChildClient, i as createUdsAddonContext, it as setWorkerNativeCapsChangeListener, nt as getWorkerNativeCapSnapshot, ot as validateProviderRegistrations, t as installManifestPythonDeps, tt as getWorkerNativeCapProvider, vt as installManifestNativeDeps, yt as resolveAddonClass } from "./manifest-python-deps-DXMKBZW1.mjs";
1
+ import { F as createUdsLoggerWithControl, I as LocalChildClient, i as createUdsAddonContext, it as setWorkerNativeCapsChangeListener, nt as getWorkerNativeCapSnapshot, ot as validateProviderRegistrations, t as installManifestPythonDeps, tt as getWorkerNativeCapProvider, vt as installManifestNativeDeps, yt as resolveAddonClass } from "./manifest-python-deps-u-AQBQY6.mjs";
2
2
  import { t as CustomActionRegistry } from "./custom-action-registry-BEXwC-oo.mjs";
3
3
  import { register } from "node:module";
4
4
  import * as fs from "node:fs";
@@ -28,8 +28,18 @@ var BATTERY_DEFAULT_MAX_AGE_S = 3600;
28
28
  * ffmpeg fallback, caching) is this addon's concern; stream-broker only
29
29
  * publishes stream endpoints.
30
30
  */
31
- var SnapshotAddon = class extends _camstack_types.BaseAddon {
31
+ var SnapshotAddon = class SnapshotAddon extends _camstack_types.BaseAddon {
32
32
  cache = /* @__PURE__ */ new Map();
33
+ /**
34
+ * Cached resolution of `pipelineOrchestrator.getIngestOwner` — SnapshotAddon
35
+ * has no per-request attach hook to resolve this fresh, so it's cached with
36
+ * a short TTL and invalidated on `onConfigChanged` (covers the common case
37
+ * of an operator re-saving cluster/agent settings). A stale owner for up to
38
+ * `OWNER_CACHE_TTL_MS` just means a snapshot fallback briefly targets the
39
+ * previous ingest owner — self-heals on the next resolve.
40
+ */
41
+ ownerCache = null;
42
+ static OWNER_CACHE_TTL_MS = 3e4;
33
43
  constructor() {
34
44
  super({ staleTtlMs: 6e4 });
35
45
  }
@@ -49,6 +59,18 @@ var SnapshotAddon = class extends _camstack_types.BaseAddon {
49
59
  }
50
60
  async onShutdown() {
51
61
  this.cache.clear();
62
+ this.ownerCache = null;
63
+ }
64
+ /**
65
+ * Drop the cached ingest owner whenever addon-level settings change —
66
+ * cheap, correct-by-construction refresh point (BaseAddon calls this
67
+ * after every `updateGlobalSettings`/`updateAddonSettings`). Combined
68
+ * with the TTL in `resolveIngestOwner`, a cluster topology change is
69
+ * picked up either on the next settings save or within
70
+ * `OWNER_CACHE_TTL_MS`, whichever comes first.
71
+ */
72
+ async onConfigChanged() {
73
+ this.ownerCache = null;
52
74
  }
53
75
  globalSettingsSchema() {
54
76
  return this.schema({ sections: [{
@@ -191,11 +213,24 @@ var SnapshotAddon = class extends _camstack_types.BaseAddon {
191
213
  * first to go idle, racing every snapshot with a broker resume.
192
214
  * Now we ask the orchestrator of streams which one is warm and grab
193
215
  * from there.
216
+ *
217
+ * Source acquisition is pinned to the cluster's ingest owner (the node
218
+ * the stream-broker actually runs on) instead of the device-scoped
219
+ * `dev.cameraStreams` facade — `dev.cameraStreams` resolves against
220
+ * whichever node the cap-router picks by default, which double-pulls
221
+ * the camera (or dials a hub-local restream that isn't serving) when
222
+ * `ingestNode != hub`. `fetchSnapshotBrokerEntries` does the pinned
223
+ * fetch + owner-relative URL host rewrite; hub-owner + no reachable
224
+ * host is byte-identical to the previous unpinned call.
194
225
  */
195
226
  async grabFrameFromBroker(deviceId, preferredStreamId) {
196
- const dev = await this.ctx.fetchDevice(deviceId);
197
227
  const prefix = `${deviceId}/`;
198
- const [deviceEntries, profileSlots] = await Promise.all([dev.cameraStreams?.getRtspEntries({}) ?? [], dev.cameraStreams?.getBrokerStreams({}) ?? []]);
228
+ const owner = await this.resolveIngestOwner();
229
+ const { entries: deviceEntries, slots: profileSlots } = await fetchSnapshotBrokerEntries(this.ctx.api, {
230
+ deviceId,
231
+ ownerNodeId: owner.ownerNodeId,
232
+ reachableHost: owner.reachableHost
233
+ });
199
234
  const usable = deviceEntries.filter((e) => e.enabled && !!e.url);
200
235
  if (usable.length === 0) return null;
201
236
  if (preferredStreamId && preferredStreamId !== "auto") {
@@ -225,6 +260,35 @@ var SnapshotAddon = class extends _camstack_types.BaseAddon {
225
260
  return null;
226
261
  }
227
262
  /**
263
+ * Cached, TTL-bounded resolution of the cluster's camera-source owner.
264
+ * See `ownerCache` doc for the invalidation story.
265
+ */
266
+ async resolveIngestOwner() {
267
+ const now = Date.now();
268
+ if (this.ownerCache && now - this.ownerCache.ts < SnapshotAddon.OWNER_CACHE_TTL_MS) return this.ownerCache.owner;
269
+ const owner = await this.fetchIngestOwner();
270
+ this.ownerCache = {
271
+ owner,
272
+ ts: now
273
+ };
274
+ return owner;
275
+ }
276
+ /**
277
+ * Same graceful degrade as the recorder's `resolveIngestOwner`
278
+ * (`packages/addon-pipeline/src/recorder/addon/index.ts`) — the cap may
279
+ * not be mounted yet (pre-image-release) or the pipeline-orchestrator
280
+ * addon may be absent entirely; either way we fall back to `'hub'`,
281
+ * which is byte-identical to the previous hardcoded hub-only behaviour.
282
+ */
283
+ async fetchIngestOwner() {
284
+ try {
285
+ return await this.ctx.api.pipelineOrchestrator.getIngestOwner.query();
286
+ } catch (err) {
287
+ this.ctx.logger.warn("snapshot: getIngestOwner unavailable — defaulting to hub (cap not mounted yet?)", { meta: { error: (0, _camstack_types.errMsg)(err) } });
288
+ return { ownerNodeId: "hub" };
289
+ }
290
+ }
291
+ /**
228
292
  * Ffmpeg grab with one retry on the broker-cold-start error
229
293
  * signature. Covers the window between "client connected" and
230
294
  * "first keyframe" when a suspended broker resumes.
@@ -264,10 +328,24 @@ var SnapshotAddon = class extends _camstack_types.BaseAddon {
264
328
  * grabbing a frame is free (a consumer is already keeping the
265
329
  * stream warm). When everything is suspended, the fallback would
266
330
  * dial the camera and wake it — defeats the sleeping cache.
331
+ *
332
+ * Pinned to the ingest owner via `fetchSnapshotBrokerEntries` — same
333
+ * rationale as `grabFrameFromBroker`: the device-scoped
334
+ * `dev.cameraStreams` facade resolves against whichever node the
335
+ * cap-router picks by default, which is wrong once `ingestNode !=
336
+ * hub` (queries the hub for slots that only exist on the agent,
337
+ * always reporting no streaming broker). Owner === hub (no reachable
338
+ * host) is byte-identical to the previous unpinned call.
267
339
  */
268
340
  async hasStreamingBrokerForDevice(deviceId) {
269
341
  try {
270
- return (await (await this.ctx.fetchDevice(deviceId)).cameraStreams?.getBrokerStreams({}) ?? []).some((s) => s.status === "streaming");
342
+ const owner = await this.resolveIngestOwner();
343
+ const { slots } = await fetchSnapshotBrokerEntries(this.ctx.api, {
344
+ deviceId,
345
+ ownerNodeId: owner.ownerNodeId,
346
+ reachableHost: owner.reachableHost
347
+ });
348
+ return slots.some((s) => s.status === "streaming");
271
349
  } catch {
272
350
  return false;
273
351
  }
@@ -379,13 +457,27 @@ var SnapshotAddon = class extends _camstack_types.BaseAddon {
379
457
  async isDeviceBattery(deviceId) {
380
458
  return (await this.lookupDeviceMeta(deviceId))?.isBattery ?? false;
381
459
  }
460
+ /**
461
+ * Feeds the settings-UI stream picker. Pinned to the ingest owner via
462
+ * `fetchSnapshotBrokerEntries` — the device-scoped `dev.cameraStreams`
463
+ * facade this used to call resolves against whichever node the
464
+ * cap-router picks by default, which is empty once `ingestNode !=
465
+ * hub` (the picker would render nothing but "Auto"). Owner === hub
466
+ * (no reachable host) is byte-identical to the previous unpinned call.
467
+ */
382
468
  async getStreamOptions(deviceId) {
383
469
  const prefix = `${deviceId}/`;
384
470
  try {
471
+ const owner = await this.resolveIngestOwner();
472
+ const { entries } = await fetchSnapshotBrokerEntries(this.ctx.api, {
473
+ deviceId,
474
+ ownerNodeId: owner.ownerNodeId,
475
+ reachableHost: owner.reachableHost
476
+ });
385
477
  return [{
386
478
  value: "auto",
387
479
  label: "Auto"
388
- }, ...(await (await this.ctx.fetchDevice(deviceId)).cameraStreams?.getRtspEntries({}) ?? []).filter((e) => e.enabled).map((e) => e.brokerId.slice(prefix.length)).map((id) => ({
480
+ }, ...entries.filter((e) => e.enabled).map((e) => e.brokerId.slice(prefix.length)).map((id) => ({
389
481
  value: id,
390
482
  label: (0, _camstack_types.streamQualityLabel)(id)
391
483
  }))];
@@ -422,6 +514,39 @@ function isAbsentNativeError(msg) {
422
514
  return msg.includes("no provider for") || msg.includes("no native provider for capability");
423
515
  }
424
516
  /**
517
+ * Pinned, testable fetch of this device's RTSP restream entries + profile
518
+ * slots from the system `streamBroker` cap. Module-level (not a class
519
+ * method) so `grabFrameFromBroker`'s owner-pin + host-rewrite contract is
520
+ * directly unit-testable without instantiating the addon — mirrors
521
+ * `acquireSessionDecodeRestreamFrom` in `pipeline-runner/index.ts`.
522
+ *
523
+ * Replaces the former `dev.cameraStreams.getRtspEntries({})` /
524
+ * `getBrokerStreams({})` device-scoped facade calls, which resolve against
525
+ * whichever node the cap-router picks by default — wrong once
526
+ * `ingestNode != hub` (double-pulls the camera, or dials a hub-local
527
+ * restream nobody is serving). `getAllRtspEntries`/`listAllProfileSlots`
528
+ * are cluster-wide system-cap methods; pinning the call with `nodePin` +
529
+ * filtering by the `${deviceId}/` brokerId prefix reproduces the exact
530
+ * device-scoped result set the facade used to return.
531
+ *
532
+ * `reachableHost`, when present, rides as the cap's own `hostname` input —
533
+ * the broker rewrites the returned URLs' host itself (same mechanism
534
+ * `getStreamWithCodec` callers use for cross-node pulls), so no separate
535
+ * client-side host-substitution is needed here. Omitting `hostname`
536
+ * (owner === hub, no reachable host registered) leaves the broker's
537
+ * default `127.0.0.1` URLs untouched — byte-identical to today.
538
+ */
539
+ async function fetchSnapshotBrokerEntries(api, params) {
540
+ const { deviceId, ownerNodeId, reachableHost } = params;
541
+ const prefix = `${deviceId}/`;
542
+ const pin = (0, _camstack_types.nodePin)(ownerNodeId);
543
+ const [allEntries, allSlots] = await Promise.all([api.streamBroker.getAllRtspEntries.query(reachableHost !== void 0 ? { hostname: reachableHost } : {}, pin), api.streamBroker.listAllProfileSlots.query(void 0, pin)]);
544
+ return {
545
+ entries: allEntries.filter((e) => e.brokerId.startsWith(prefix)),
546
+ slots: allSlots.filter((s) => s.deviceId === deviceId)
547
+ };
548
+ }
549
+ /**
425
550
  * Quality ordering for broker picker: `high` > `mid` > `low` > other.
426
551
  * The streamId is the suffix after `${deviceId}/` in the brokerId.
427
552
  * Unknown labels fall through to 0 so they land at the bottom of the
@@ -1,4 +1,4 @@
1
- import { BaseAddon, DeviceFeature, DeviceType, errMsg, snapshotCapability, streamQualityLabel } from "@camstack/types";
1
+ import { BaseAddon, DeviceFeature, DeviceType, errMsg, nodePin, snapshotCapability, streamQualityLabel } from "@camstack/types";
2
2
  import { execFile } from "node:child_process";
3
3
  //#region src/builtins/snapshot/snapshot.addon.ts
4
4
  /** Default cache window for non-battery cams (seconds). 10s feels live. */
@@ -23,8 +23,18 @@ var BATTERY_DEFAULT_MAX_AGE_S = 3600;
23
23
  * ffmpeg fallback, caching) is this addon's concern; stream-broker only
24
24
  * publishes stream endpoints.
25
25
  */
26
- var SnapshotAddon = class extends BaseAddon {
26
+ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
27
27
  cache = /* @__PURE__ */ new Map();
28
+ /**
29
+ * Cached resolution of `pipelineOrchestrator.getIngestOwner` — SnapshotAddon
30
+ * has no per-request attach hook to resolve this fresh, so it's cached with
31
+ * a short TTL and invalidated on `onConfigChanged` (covers the common case
32
+ * of an operator re-saving cluster/agent settings). A stale owner for up to
33
+ * `OWNER_CACHE_TTL_MS` just means a snapshot fallback briefly targets the
34
+ * previous ingest owner — self-heals on the next resolve.
35
+ */
36
+ ownerCache = null;
37
+ static OWNER_CACHE_TTL_MS = 3e4;
28
38
  constructor() {
29
39
  super({ staleTtlMs: 6e4 });
30
40
  }
@@ -44,6 +54,18 @@ var SnapshotAddon = class extends BaseAddon {
44
54
  }
45
55
  async onShutdown() {
46
56
  this.cache.clear();
57
+ this.ownerCache = null;
58
+ }
59
+ /**
60
+ * Drop the cached ingest owner whenever addon-level settings change —
61
+ * cheap, correct-by-construction refresh point (BaseAddon calls this
62
+ * after every `updateGlobalSettings`/`updateAddonSettings`). Combined
63
+ * with the TTL in `resolveIngestOwner`, a cluster topology change is
64
+ * picked up either on the next settings save or within
65
+ * `OWNER_CACHE_TTL_MS`, whichever comes first.
66
+ */
67
+ async onConfigChanged() {
68
+ this.ownerCache = null;
47
69
  }
48
70
  globalSettingsSchema() {
49
71
  return this.schema({ sections: [{
@@ -186,11 +208,24 @@ var SnapshotAddon = class extends BaseAddon {
186
208
  * first to go idle, racing every snapshot with a broker resume.
187
209
  * Now we ask the orchestrator of streams which one is warm and grab
188
210
  * from there.
211
+ *
212
+ * Source acquisition is pinned to the cluster's ingest owner (the node
213
+ * the stream-broker actually runs on) instead of the device-scoped
214
+ * `dev.cameraStreams` facade — `dev.cameraStreams` resolves against
215
+ * whichever node the cap-router picks by default, which double-pulls
216
+ * the camera (or dials a hub-local restream that isn't serving) when
217
+ * `ingestNode != hub`. `fetchSnapshotBrokerEntries` does the pinned
218
+ * fetch + owner-relative URL host rewrite; hub-owner + no reachable
219
+ * host is byte-identical to the previous unpinned call.
189
220
  */
190
221
  async grabFrameFromBroker(deviceId, preferredStreamId) {
191
- const dev = await this.ctx.fetchDevice(deviceId);
192
222
  const prefix = `${deviceId}/`;
193
- const [deviceEntries, profileSlots] = await Promise.all([dev.cameraStreams?.getRtspEntries({}) ?? [], dev.cameraStreams?.getBrokerStreams({}) ?? []]);
223
+ const owner = await this.resolveIngestOwner();
224
+ const { entries: deviceEntries, slots: profileSlots } = await fetchSnapshotBrokerEntries(this.ctx.api, {
225
+ deviceId,
226
+ ownerNodeId: owner.ownerNodeId,
227
+ reachableHost: owner.reachableHost
228
+ });
194
229
  const usable = deviceEntries.filter((e) => e.enabled && !!e.url);
195
230
  if (usable.length === 0) return null;
196
231
  if (preferredStreamId && preferredStreamId !== "auto") {
@@ -220,6 +255,35 @@ var SnapshotAddon = class extends BaseAddon {
220
255
  return null;
221
256
  }
222
257
  /**
258
+ * Cached, TTL-bounded resolution of the cluster's camera-source owner.
259
+ * See `ownerCache` doc for the invalidation story.
260
+ */
261
+ async resolveIngestOwner() {
262
+ const now = Date.now();
263
+ if (this.ownerCache && now - this.ownerCache.ts < SnapshotAddon.OWNER_CACHE_TTL_MS) return this.ownerCache.owner;
264
+ const owner = await this.fetchIngestOwner();
265
+ this.ownerCache = {
266
+ owner,
267
+ ts: now
268
+ };
269
+ return owner;
270
+ }
271
+ /**
272
+ * Same graceful degrade as the recorder's `resolveIngestOwner`
273
+ * (`packages/addon-pipeline/src/recorder/addon/index.ts`) — the cap may
274
+ * not be mounted yet (pre-image-release) or the pipeline-orchestrator
275
+ * addon may be absent entirely; either way we fall back to `'hub'`,
276
+ * which is byte-identical to the previous hardcoded hub-only behaviour.
277
+ */
278
+ async fetchIngestOwner() {
279
+ try {
280
+ return await this.ctx.api.pipelineOrchestrator.getIngestOwner.query();
281
+ } catch (err) {
282
+ this.ctx.logger.warn("snapshot: getIngestOwner unavailable — defaulting to hub (cap not mounted yet?)", { meta: { error: errMsg(err) } });
283
+ return { ownerNodeId: "hub" };
284
+ }
285
+ }
286
+ /**
223
287
  * Ffmpeg grab with one retry on the broker-cold-start error
224
288
  * signature. Covers the window between "client connected" and
225
289
  * "first keyframe" when a suspended broker resumes.
@@ -259,10 +323,24 @@ var SnapshotAddon = class extends BaseAddon {
259
323
  * grabbing a frame is free (a consumer is already keeping the
260
324
  * stream warm). When everything is suspended, the fallback would
261
325
  * dial the camera and wake it — defeats the sleeping cache.
326
+ *
327
+ * Pinned to the ingest owner via `fetchSnapshotBrokerEntries` — same
328
+ * rationale as `grabFrameFromBroker`: the device-scoped
329
+ * `dev.cameraStreams` facade resolves against whichever node the
330
+ * cap-router picks by default, which is wrong once `ingestNode !=
331
+ * hub` (queries the hub for slots that only exist on the agent,
332
+ * always reporting no streaming broker). Owner === hub (no reachable
333
+ * host) is byte-identical to the previous unpinned call.
262
334
  */
263
335
  async hasStreamingBrokerForDevice(deviceId) {
264
336
  try {
265
- return (await (await this.ctx.fetchDevice(deviceId)).cameraStreams?.getBrokerStreams({}) ?? []).some((s) => s.status === "streaming");
337
+ const owner = await this.resolveIngestOwner();
338
+ const { slots } = await fetchSnapshotBrokerEntries(this.ctx.api, {
339
+ deviceId,
340
+ ownerNodeId: owner.ownerNodeId,
341
+ reachableHost: owner.reachableHost
342
+ });
343
+ return slots.some((s) => s.status === "streaming");
266
344
  } catch {
267
345
  return false;
268
346
  }
@@ -374,13 +452,27 @@ var SnapshotAddon = class extends BaseAddon {
374
452
  async isDeviceBattery(deviceId) {
375
453
  return (await this.lookupDeviceMeta(deviceId))?.isBattery ?? false;
376
454
  }
455
+ /**
456
+ * Feeds the settings-UI stream picker. Pinned to the ingest owner via
457
+ * `fetchSnapshotBrokerEntries` — the device-scoped `dev.cameraStreams`
458
+ * facade this used to call resolves against whichever node the
459
+ * cap-router picks by default, which is empty once `ingestNode !=
460
+ * hub` (the picker would render nothing but "Auto"). Owner === hub
461
+ * (no reachable host) is byte-identical to the previous unpinned call.
462
+ */
377
463
  async getStreamOptions(deviceId) {
378
464
  const prefix = `${deviceId}/`;
379
465
  try {
466
+ const owner = await this.resolveIngestOwner();
467
+ const { entries } = await fetchSnapshotBrokerEntries(this.ctx.api, {
468
+ deviceId,
469
+ ownerNodeId: owner.ownerNodeId,
470
+ reachableHost: owner.reachableHost
471
+ });
380
472
  return [{
381
473
  value: "auto",
382
474
  label: "Auto"
383
- }, ...(await (await this.ctx.fetchDevice(deviceId)).cameraStreams?.getRtspEntries({}) ?? []).filter((e) => e.enabled).map((e) => e.brokerId.slice(prefix.length)).map((id) => ({
475
+ }, ...entries.filter((e) => e.enabled).map((e) => e.brokerId.slice(prefix.length)).map((id) => ({
384
476
  value: id,
385
477
  label: streamQualityLabel(id)
386
478
  }))];
@@ -417,6 +509,39 @@ function isAbsentNativeError(msg) {
417
509
  return msg.includes("no provider for") || msg.includes("no native provider for capability");
418
510
  }
419
511
  /**
512
+ * Pinned, testable fetch of this device's RTSP restream entries + profile
513
+ * slots from the system `streamBroker` cap. Module-level (not a class
514
+ * method) so `grabFrameFromBroker`'s owner-pin + host-rewrite contract is
515
+ * directly unit-testable without instantiating the addon — mirrors
516
+ * `acquireSessionDecodeRestreamFrom` in `pipeline-runner/index.ts`.
517
+ *
518
+ * Replaces the former `dev.cameraStreams.getRtspEntries({})` /
519
+ * `getBrokerStreams({})` device-scoped facade calls, which resolve against
520
+ * whichever node the cap-router picks by default — wrong once
521
+ * `ingestNode != hub` (double-pulls the camera, or dials a hub-local
522
+ * restream nobody is serving). `getAllRtspEntries`/`listAllProfileSlots`
523
+ * are cluster-wide system-cap methods; pinning the call with `nodePin` +
524
+ * filtering by the `${deviceId}/` brokerId prefix reproduces the exact
525
+ * device-scoped result set the facade used to return.
526
+ *
527
+ * `reachableHost`, when present, rides as the cap's own `hostname` input —
528
+ * the broker rewrites the returned URLs' host itself (same mechanism
529
+ * `getStreamWithCodec` callers use for cross-node pulls), so no separate
530
+ * client-side host-substitution is needed here. Omitting `hostname`
531
+ * (owner === hub, no reachable host registered) leaves the broker's
532
+ * default `127.0.0.1` URLs untouched — byte-identical to today.
533
+ */
534
+ async function fetchSnapshotBrokerEntries(api, params) {
535
+ const { deviceId, ownerNodeId, reachableHost } = params;
536
+ const prefix = `${deviceId}/`;
537
+ const pin = nodePin(ownerNodeId);
538
+ const [allEntries, allSlots] = await Promise.all([api.streamBroker.getAllRtspEntries.query(reachableHost !== void 0 ? { hostname: reachableHost } : {}, pin), api.streamBroker.listAllProfileSlots.query(void 0, pin)]);
539
+ return {
540
+ entries: allEntries.filter((e) => e.brokerId.startsWith(prefix)),
541
+ slots: allSlots.filter((s) => s.deviceId === deviceId)
542
+ };
543
+ }
544
+ /**
420
545
  * Quality ordering for broker picker: `high` > `mid` > `low` > other.
421
546
  * The streamId is the suffix after `${deviceId}/` in the brokerId.
422
547
  * Unknown labels fall through to 0 so they land at the bottom of the
@@ -1,4 +1,4 @@
1
- import { ProviderRegistration, BaseAddon } from '@camstack/types';
1
+ import { AddonApi, ProfileSlot, ProviderRegistration, RtspRestreamEntry, BaseAddon } from '@camstack/types';
2
2
  interface SnapshotAddonConfig {
3
3
  /**
4
4
  * Last-resort cache age (ms): if every live capture path fails
@@ -30,9 +30,28 @@ interface SnapshotAddonConfig {
30
30
  */
31
31
  export declare class SnapshotAddon extends BaseAddon<SnapshotAddonConfig> {
32
32
  private readonly cache;
33
+ /**
34
+ * Cached resolution of `pipelineOrchestrator.getIngestOwner` — SnapshotAddon
35
+ * has no per-request attach hook to resolve this fresh, so it's cached with
36
+ * a short TTL and invalidated on `onConfigChanged` (covers the common case
37
+ * of an operator re-saving cluster/agent settings). A stale owner for up to
38
+ * `OWNER_CACHE_TTL_MS` just means a snapshot fallback briefly targets the
39
+ * previous ingest owner — self-heals on the next resolve.
40
+ */
41
+ private ownerCache;
42
+ private static readonly OWNER_CACHE_TTL_MS;
33
43
  constructor();
34
44
  protected onInitialize(): Promise<ProviderRegistration[]>;
35
45
  protected onShutdown(): Promise<void>;
46
+ /**
47
+ * Drop the cached ingest owner whenever addon-level settings change —
48
+ * cheap, correct-by-construction refresh point (BaseAddon calls this
49
+ * after every `updateGlobalSettings`/`updateAddonSettings`). Combined
50
+ * with the TTL in `resolveIngestOwner`, a cluster topology change is
51
+ * picked up either on the next settings save or within
52
+ * `OWNER_CACHE_TTL_MS`, whichever comes first.
53
+ */
54
+ protected onConfigChanged(): Promise<void>;
36
55
  protected globalSettingsSchema(): import('@camstack/types').ConfigUISchema;
37
56
  private getSnapshot;
38
57
  /**
@@ -63,8 +82,30 @@ export declare class SnapshotAddon extends BaseAddon<SnapshotAddonConfig> {
63
82
  * first to go idle, racing every snapshot with a broker resume.
64
83
  * Now we ask the orchestrator of streams which one is warm and grab
65
84
  * from there.
85
+ *
86
+ * Source acquisition is pinned to the cluster's ingest owner (the node
87
+ * the stream-broker actually runs on) instead of the device-scoped
88
+ * `dev.cameraStreams` facade — `dev.cameraStreams` resolves against
89
+ * whichever node the cap-router picks by default, which double-pulls
90
+ * the camera (or dials a hub-local restream that isn't serving) when
91
+ * `ingestNode != hub`. `fetchSnapshotBrokerEntries` does the pinned
92
+ * fetch + owner-relative URL host rewrite; hub-owner + no reachable
93
+ * host is byte-identical to the previous unpinned call.
66
94
  */
67
95
  private grabFrameFromBroker;
96
+ /**
97
+ * Cached, TTL-bounded resolution of the cluster's camera-source owner.
98
+ * See `ownerCache` doc for the invalidation story.
99
+ */
100
+ private resolveIngestOwner;
101
+ /**
102
+ * Same graceful degrade as the recorder's `resolveIngestOwner`
103
+ * (`packages/addon-pipeline/src/recorder/addon/index.ts`) — the cap may
104
+ * not be mounted yet (pre-image-release) or the pipeline-orchestrator
105
+ * addon may be absent entirely; either way we fall back to `'hub'`,
106
+ * which is byte-identical to the previous hardcoded hub-only behaviour.
107
+ */
108
+ private fetchIngestOwner;
68
109
  /**
69
110
  * Ffmpeg grab with one retry on the broker-cold-start error
70
111
  * signature. Covers the window between "client connected" and
@@ -87,6 +128,14 @@ export declare class SnapshotAddon extends BaseAddon<SnapshotAddonConfig> {
87
128
  * grabbing a frame is free (a consumer is already keeping the
88
129
  * stream warm). When everything is suspended, the fallback would
89
130
  * dial the camera and wake it — defeats the sleeping cache.
131
+ *
132
+ * Pinned to the ingest owner via `fetchSnapshotBrokerEntries` — same
133
+ * rationale as `grabFrameFromBroker`: the device-scoped
134
+ * `dev.cameraStreams` facade resolves against whichever node the
135
+ * cap-router picks by default, which is wrong once `ingestNode !=
136
+ * hub` (queries the hub for slots that only exist on the agent,
137
+ * always reporting no streaming broker). Owner === hub (no reachable
138
+ * host) is byte-identical to the previous unpinned call.
90
139
  */
91
140
  private hasStreamingBrokerForDevice;
92
141
  /**
@@ -114,7 +163,48 @@ export declare class SnapshotAddon extends BaseAddon<SnapshotAddonConfig> {
114
163
  private lookupDeviceMeta;
115
164
  /** Settings-UI helper — battery flag drives the default max-age in the field description. */
116
165
  private isDeviceBattery;
166
+ /**
167
+ * Feeds the settings-UI stream picker. Pinned to the ingest owner via
168
+ * `fetchSnapshotBrokerEntries` — the device-scoped `dev.cameraStreams`
169
+ * facade this used to call resolves against whichever node the
170
+ * cap-router picks by default, which is empty once `ingestNode !=
171
+ * hub` (the picker would render nothing but "Auto"). Owner === hub
172
+ * (no reachable host) is byte-identical to the previous unpinned call.
173
+ */
117
174
  private getStreamOptions;
118
175
  private saveDeviceSettingsPatch;
119
176
  }
177
+ /** Result of {@link fetchSnapshotBrokerEntries} — this device's RTSP entries + profile slots. */
178
+ interface SnapshotBrokerEntries {
179
+ readonly entries: readonly RtspRestreamEntry[];
180
+ readonly slots: readonly ProfileSlot[];
181
+ }
182
+ /**
183
+ * Pinned, testable fetch of this device's RTSP restream entries + profile
184
+ * slots from the system `streamBroker` cap. Module-level (not a class
185
+ * method) so `grabFrameFromBroker`'s owner-pin + host-rewrite contract is
186
+ * directly unit-testable without instantiating the addon — mirrors
187
+ * `acquireSessionDecodeRestreamFrom` in `pipeline-runner/index.ts`.
188
+ *
189
+ * Replaces the former `dev.cameraStreams.getRtspEntries({})` /
190
+ * `getBrokerStreams({})` device-scoped facade calls, which resolve against
191
+ * whichever node the cap-router picks by default — wrong once
192
+ * `ingestNode != hub` (double-pulls the camera, or dials a hub-local
193
+ * restream nobody is serving). `getAllRtspEntries`/`listAllProfileSlots`
194
+ * are cluster-wide system-cap methods; pinning the call with `nodePin` +
195
+ * filtering by the `${deviceId}/` brokerId prefix reproduces the exact
196
+ * device-scoped result set the facade used to return.
197
+ *
198
+ * `reachableHost`, when present, rides as the cap's own `hostname` input —
199
+ * the broker rewrites the returned URLs' host itself (same mechanism
200
+ * `getStreamWithCodec` callers use for cross-node pulls), so no separate
201
+ * client-side host-substitution is needed here. Omitting `hostname`
202
+ * (owner === hub, no reachable host registered) leaves the broker's
203
+ * default `127.0.0.1` URLs untouched — byte-identical to today.
204
+ */
205
+ export declare function fetchSnapshotBrokerEntries(api: AddonApi, params: {
206
+ readonly deviceId: number;
207
+ readonly ownerNodeId: string;
208
+ readonly reachableHost?: string;
209
+ }): Promise<SnapshotBrokerEntries>;
120
210
  export {};
package/dist/index.js CHANGED
@@ -20,7 +20,7 @@ const require_builtins_local_auth_local_auth_addon = require("./builtins/local-a
20
20
  require("./builtins/local-auth/index.js");
21
21
  const require_builtins_device_manager_device_manager_addon = require("./builtins/device-manager/device-manager.addon.js");
22
22
  require("./builtins/device-manager/index.js");
23
- const require_manifest_python_deps = require("./manifest-python-deps-jWKVwa7t.js");
23
+ const require_manifest_python_deps = require("./manifest-python-deps-CAnO1wPx.js");
24
24
  const require_custom_action_registry = require("./custom-action-registry-vLYEFTtv.js");
25
25
  let _camstack_types_node = require("@camstack/types/node");
26
26
  let node_http = require("node:http");
@@ -4931,32 +4931,6 @@ var CapabilityRegistry = class CapabilityRegistry {
4931
4931
  * `buildCapRouters(t, services)` contract), keeping the kernel free of any
4932
4932
  * backend coupling (self-sufficiency invariant).
4933
4933
  */
4934
- /** Auth level → procedure key. Mirrors codegen's AUTH_PROCEDURE_MAP. */
4935
- function procedureKeyFor(auth) {
4936
- if (auth === "public") return "public";
4937
- if (auth === "admin" || auth === "superAdmin") return "admin";
4938
- return "protected";
4939
- }
4940
- /** Zod schema is `z.void()` — input carries no data. Mirrors codegen's isVoidInput. */
4941
- function isVoidInput(schema) {
4942
- const def = schema._def;
4943
- return schema.constructor?.name === "ZodVoid" || def?.type === "void" || def?.typeName === "ZodVoid";
4944
- }
4945
- /** Zod schema is `z.object()` — supports `.loose()` for nodeId passthrough. Mirrors codegen's isObjectInput. */
4946
- function isObjectInput(schema) {
4947
- const def = schema._def;
4948
- return schema.constructor?.name === "ZodObject" || def?.type === "object" || def?.typeName === "ZodObject";
4949
- }
4950
- /**
4951
- * Apply `.loose()` to an object input schema when available (Zod 4) so the
4952
- * router accepts the out-of-band `nodeId` / `addonId` selector keys
4953
- * without stripping them — matching the codegen's `.input(schema.loose())`.
4954
- * Falls back to the schema unchanged when `.loose()` is absent.
4955
- */
4956
- function looseSchema(schema) {
4957
- const loose = schema.loose;
4958
- return typeof loose === "function" ? loose.call(schema) : schema;
4959
- }
4960
4934
  function methodsFor(def) {
4961
4935
  const effective = (0, _camstack_types.expandCapMethods)(def);
4962
4936
  return Object.entries(effective).map(([name, schema]) => {
@@ -4966,8 +4940,8 @@ function methodsFor(def) {
4966
4940
  kind: m.kind ?? "query",
4967
4941
  auth: m.auth ?? "protected",
4968
4942
  schema: m,
4969
- isVoid: isVoidInput(m.input),
4970
- isObject: isObjectInput(m.input)
4943
+ isVoid: (0, _camstack_types.isVoidInput)(m.input),
4944
+ isObject: (0, _camstack_types.isObjectInput)(m.input)
4971
4945
  };
4972
4946
  });
4973
4947
  }
@@ -5020,7 +4994,7 @@ function buildOneCapRouter(def, primitives, services) {
5020
4994
  const procedures = {};
5021
4995
  const getLocal = (ctx, addonId) => services.getLocalProvider(capName, mount.kind, ctx, addonId);
5022
4996
  for (const method of methodsFor(def)) {
5023
- const base = primitives.procedures[procedureKeyFor(method.auth)];
4997
+ const base = primitives.procedures[(0, _camstack_types.procedureAuthKey)(method.auth)];
5024
4998
  const inputSchema = method.schema.input;
5025
4999
  const outputSchema = method.schema.output;
5026
5000
  if (method.kind === "subscription") {
@@ -5047,7 +5021,7 @@ function buildOneCapRouter(def, primitives, services) {
5047
5021
  continue;
5048
5022
  }
5049
5023
  if (method.isObject) {
5050
- const schema = nodeIdData ? inputSchema : looseSchema(inputSchema);
5024
+ const schema = nodeIdData ? inputSchema : (0, _camstack_types.looseSchema)(inputSchema);
5051
5025
  procedures[method.name] = base.input(schema).output(outputSchema)[procKind](({ input, ctx }) => {
5052
5026
  if (nodeIdData) return requireLocal(capName, () => getLocal(ctx), services)[method.name]?.(input);
5053
5027
  if (isCollection) {
@@ -5067,20 +5041,15 @@ function buildOneCapRouter(def, primitives, services) {
5067
5041
  }
5068
5042
  return primitives.router(procedures);
5069
5043
  }
5070
- /** kebab-case → camelCase, matching the codegen router-map key naming. */
5071
- function kebabToCamel(s) {
5072
- return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
5073
- }
5074
5044
  /**
5075
- * Mount kinds the runtime builder constructs. `service-backed` IS built
5076
- * (the server supplies the provider via `getLocalProvider`); `custom` and
5077
- * `skip` are NOT (the server's hand-written override fills the slot for
5078
- * `custom`; `skip` is never mounted). `hub-only` is built but its remote
5079
- * leg is `null` — handled by the server's `remoteProxy` returning null for
5080
- * that cap.
5045
+ * Mount kinds the runtime builder constructs. Everything EXCEPT `skip` is
5046
+ * built. `server-provided` IS built (the server supplies the provider via
5047
+ * `getLocalProvider`'s server-factory map); `skip` is never mounted (legacy
5048
+ * provider shapes). `hub-only` is built but its remote leg is `null` — handled
5049
+ * by the server's `remoteProxy` returning null for that cap.
5081
5050
  */
5082
5051
  function isBuilderMounted(kind) {
5083
- return kind !== "custom" && kind !== "skip";
5052
+ return kind !== "skip";
5084
5053
  }
5085
5054
  /**
5086
5055
  * Build the runtime cap-router map: `{ <camelCapName>: <router> }` for
@@ -5095,7 +5064,7 @@ function buildCapRouters(primitives, services) {
5095
5064
  if (!isBuilderMounted((0, _camstack_types.resolveCapMount)(def).kind)) continue;
5096
5065
  const effective = (0, _camstack_types.expandCapMethods)(def);
5097
5066
  if (Object.keys(effective).length === 0) continue;
5098
- out[kebabToCamel(def.name)] = buildOneCapRouter(def, primitives, services);
5067
+ out[(0, _camstack_types.kebabToCamel)(def.name)] = buildOneCapRouter(def, primitives, services);
5099
5068
  }
5100
5069
  return out;
5101
5070
  }
@@ -5105,7 +5074,7 @@ function builderMountedCapNames() {
5105
5074
  for (const def of _camstack_types.ALL_CAPABILITY_DEFINITIONS) {
5106
5075
  if (!isBuilderMounted((0, _camstack_types.resolveCapMount)(def).kind)) continue;
5107
5076
  if (Object.keys((0, _camstack_types.expandCapMethods)(def)).length === 0) continue;
5108
- names.push(kebabToCamel(def.name));
5077
+ names.push((0, _camstack_types.kebabToCamel)(def.name));
5109
5078
  }
5110
5079
  return names;
5111
5080
  }
package/dist/index.mjs CHANGED
@@ -18,7 +18,7 @@ import { LocalAuthAddon, a as require_ms, c as __esmMin, d as __toCommonJS, f as
18
18
  import "./builtins/local-auth/index.mjs";
19
19
  import { DeviceManagerAddon } from "./builtins/device-manager/device-manager.addon.mjs";
20
20
  import "./builtins/device-manager/index.mjs";
21
- import { $ as buildUdsNativeCapProxy, A as createParentUnownedCallHandler, B as AGENT_CAP_FWD_SERVICE, C as buildLinkChain, D as HUB_CAP_FWD_ACTION, E as localProviderLink, F as createUdsLoggerWithControl, G as createLocalTransport, H as CapRouteError, I as LocalChildClient, J as SocketChannel, K as UdsLocalTransportClient, L as LocalChildRegistry, M as createUdsEventBus, N as udsChildLogToWorkerEntry, O as HUB_CAP_FWD_SERVICE, P as createUdsLogger, Q as buildNativeCapProxy, R as UDS_NO_ROUTE_PREFIX, S as brokerTransportLink, T as ipcParentLink, U as classifyCapRoute, V as CapRouteResolver, W as callWithServiceDiscovery, X as FrameDecoder, Y as localEndpointPath, Z as encodeFrame, _ as resolveHwAccel, _t as CapabilityUnavailableError, a as getWorkerDeviceRegistry, at as createAddonService, b as getCapUsageRegistry, c as setHubConnected, ct as capActionName, d as getMoleculerEventStats, dt as capServiceName, et as createBrokerDeviceManagerApi, f as registerEventBusService, ft as parseCapAction, g as createKernelHwAccel, gt as CapabilityHandle, h as AddonDepsManager, ht as DeviceRegistry, i as createUdsAddonContext, j as createUdsEventBridge, k as createHubCapForwardService, l as EVENT_TOPIC_PREFIX, lt as capActionSuffix, m as subscribePassthrough, mt as serializeTypedArrays, n as adaptBrokerToCluster, o as getOrInitReadinessRegistry, ot as validateProviderRegistrations, p as setNodeEventInterest, pt as deserializeTypedArrays, q as UdsLocalTransportServer, r as createAddonContext, rt as mountNativeCapService, s as getOrInitReadinessRegistryForClient, st as NATIVE_PROVIDER_SERVICE_INFIX, t as installManifestPythonDeps, u as getBrokerEventBus, ut as capBareAction, v as CapUsageRegistry, vt as installManifestNativeDeps, w as ipcChildLink, x as brokerCallForCap, y as __resetCapUsageRegistryForTests, yt as resolveAddonClass, z as AGENT_CAP_FWD_ACTION } from "./manifest-python-deps-DXMKBZW1.mjs";
21
+ import { $ as buildUdsNativeCapProxy, A as createParentUnownedCallHandler, B as AGENT_CAP_FWD_SERVICE, C as buildLinkChain, D as HUB_CAP_FWD_ACTION, E as localProviderLink, F as createUdsLoggerWithControl, G as createLocalTransport, H as CapRouteError, I as LocalChildClient, J as SocketChannel, K as UdsLocalTransportClient, L as LocalChildRegistry, M as createUdsEventBus, N as udsChildLogToWorkerEntry, O as HUB_CAP_FWD_SERVICE, P as createUdsLogger, Q as buildNativeCapProxy, R as UDS_NO_ROUTE_PREFIX, S as brokerTransportLink, T as ipcParentLink, U as classifyCapRoute, V as CapRouteResolver, W as callWithServiceDiscovery, X as FrameDecoder, Y as localEndpointPath, Z as encodeFrame, _ as resolveHwAccel, _t as CapabilityUnavailableError, a as getWorkerDeviceRegistry, at as createAddonService, b as getCapUsageRegistry, c as setHubConnected, ct as capActionName, d as getMoleculerEventStats, dt as capServiceName, et as createBrokerDeviceManagerApi, f as registerEventBusService, ft as parseCapAction, g as createKernelHwAccel, gt as CapabilityHandle, h as AddonDepsManager, ht as DeviceRegistry, i as createUdsAddonContext, j as createUdsEventBridge, k as createHubCapForwardService, l as EVENT_TOPIC_PREFIX, lt as capActionSuffix, m as subscribePassthrough, mt as serializeTypedArrays, n as adaptBrokerToCluster, o as getOrInitReadinessRegistry, ot as validateProviderRegistrations, p as setNodeEventInterest, pt as deserializeTypedArrays, q as UdsLocalTransportServer, r as createAddonContext, rt as mountNativeCapService, s as getOrInitReadinessRegistryForClient, st as NATIVE_PROVIDER_SERVICE_INFIX, t as installManifestPythonDeps, u as getBrokerEventBus, ut as capBareAction, v as CapUsageRegistry, vt as installManifestNativeDeps, w as ipcChildLink, x as brokerCallForCap, y as __resetCapUsageRegistryForTests, yt as resolveAddonClass, z as AGENT_CAP_FWD_ACTION } from "./manifest-python-deps-u-AQBQY6.mjs";
22
22
  import { t as CustomActionRegistry } from "./custom-action-registry-BEXwC-oo.mjs";
23
23
  import { PYTHON_VERSION, buildBinaryPath, downloadBinary, ensureBinary, ensureFfmpeg, ensurePython, findInPath, getFfmpegDownloadUrl, getPlatformInfo, getPythonDownloadUrl, installPythonPackages, installPythonRequirements } from "@camstack/types/node";
24
24
  import { request } from "node:http";
@@ -26,7 +26,7 @@ import * as fs$17 from "node:fs";
26
26
  import { accessSync, constants, existsSync, mkdirSync, readFileSync } from "node:fs";
27
27
  import * as path$39 from "node:path";
28
28
  import { dirname, isAbsolute, join, posix, resolve, win32 } from "node:path";
29
- import { ALL_CAPABILITY_DEFINITIONS, DATAPLANE_SECRET_HEADER, EventCategory, RUNTIME_DEFAULTS, ReadinessRegistry, ReadinessTimeoutError, asJsonObject, asNumber, asString, createEvent, emitDownForOwnedCaps, errMsg, expandCapMethods, lifecycleJobSchema, parseJsonObject, parseJsonUnknown, readinessKey, resolveCapMount, scopeKey } from "@camstack/types";
29
+ import { ALL_CAPABILITY_DEFINITIONS, DATAPLANE_SECRET_HEADER, EventCategory, RUNTIME_DEFAULTS, ReadinessRegistry, ReadinessTimeoutError, asJsonObject, asNumber, asString, createEvent, emitDownForOwnedCaps, errMsg, expandCapMethods, isObjectInput, isVoidInput, kebabToCamel, lifecycleJobSchema, looseSchema, parseJsonObject, parseJsonUnknown, procedureAuthKey, readinessKey, resolveCapMount, scopeKey } from "@camstack/types";
30
30
  import { X509Certificate, createHash, randomUUID, timingSafeEqual } from "node:crypto";
31
31
  import { execFile, spawn } from "node:child_process";
32
32
  import * as util$10 from "node:util";
@@ -4923,32 +4923,6 @@ var CapabilityRegistry = class CapabilityRegistry {
4923
4923
  * `buildCapRouters(t, services)` contract), keeping the kernel free of any
4924
4924
  * backend coupling (self-sufficiency invariant).
4925
4925
  */
4926
- /** Auth level → procedure key. Mirrors codegen's AUTH_PROCEDURE_MAP. */
4927
- function procedureKeyFor(auth) {
4928
- if (auth === "public") return "public";
4929
- if (auth === "admin" || auth === "superAdmin") return "admin";
4930
- return "protected";
4931
- }
4932
- /** Zod schema is `z.void()` — input carries no data. Mirrors codegen's isVoidInput. */
4933
- function isVoidInput(schema) {
4934
- const def = schema._def;
4935
- return schema.constructor?.name === "ZodVoid" || def?.type === "void" || def?.typeName === "ZodVoid";
4936
- }
4937
- /** Zod schema is `z.object()` — supports `.loose()` for nodeId passthrough. Mirrors codegen's isObjectInput. */
4938
- function isObjectInput(schema) {
4939
- const def = schema._def;
4940
- return schema.constructor?.name === "ZodObject" || def?.type === "object" || def?.typeName === "ZodObject";
4941
- }
4942
- /**
4943
- * Apply `.loose()` to an object input schema when available (Zod 4) so the
4944
- * router accepts the out-of-band `nodeId` / `addonId` selector keys
4945
- * without stripping them — matching the codegen's `.input(schema.loose())`.
4946
- * Falls back to the schema unchanged when `.loose()` is absent.
4947
- */
4948
- function looseSchema(schema) {
4949
- const loose = schema.loose;
4950
- return typeof loose === "function" ? loose.call(schema) : schema;
4951
- }
4952
4926
  function methodsFor(def) {
4953
4927
  const effective = expandCapMethods(def);
4954
4928
  return Object.entries(effective).map(([name, schema]) => {
@@ -5012,7 +4986,7 @@ function buildOneCapRouter(def, primitives, services) {
5012
4986
  const procedures = {};
5013
4987
  const getLocal = (ctx, addonId) => services.getLocalProvider(capName, mount.kind, ctx, addonId);
5014
4988
  for (const method of methodsFor(def)) {
5015
- const base = primitives.procedures[procedureKeyFor(method.auth)];
4989
+ const base = primitives.procedures[procedureAuthKey(method.auth)];
5016
4990
  const inputSchema = method.schema.input;
5017
4991
  const outputSchema = method.schema.output;
5018
4992
  if (method.kind === "subscription") {
@@ -5059,20 +5033,15 @@ function buildOneCapRouter(def, primitives, services) {
5059
5033
  }
5060
5034
  return primitives.router(procedures);
5061
5035
  }
5062
- /** kebab-case → camelCase, matching the codegen router-map key naming. */
5063
- function kebabToCamel(s) {
5064
- return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
5065
- }
5066
5036
  /**
5067
- * Mount kinds the runtime builder constructs. `service-backed` IS built
5068
- * (the server supplies the provider via `getLocalProvider`); `custom` and
5069
- * `skip` are NOT (the server's hand-written override fills the slot for
5070
- * `custom`; `skip` is never mounted). `hub-only` is built but its remote
5071
- * leg is `null` — handled by the server's `remoteProxy` returning null for
5072
- * that cap.
5037
+ * Mount kinds the runtime builder constructs. Everything EXCEPT `skip` is
5038
+ * built. `server-provided` IS built (the server supplies the provider via
5039
+ * `getLocalProvider`'s server-factory map); `skip` is never mounted (legacy
5040
+ * provider shapes). `hub-only` is built but its remote leg is `null` — handled
5041
+ * by the server's `remoteProxy` returning null for that cap.
5073
5042
  */
5074
5043
  function isBuilderMounted(kind) {
5075
- return kind !== "custom" && kind !== "skip";
5044
+ return kind !== "skip";
5076
5045
  }
5077
5046
  /**
5078
5047
  * Build the runtime cap-router map: `{ <camelCapName>: <router> }` for
@@ -51,7 +51,7 @@ export interface CapRouterServices {
51
51
  /**
52
52
  * Resolve the LOCAL provider for a cap given the effective mount kind.
53
53
  * Returns null on miss (the procedure surfaces PRECONDITION_FAILED).
54
- * For `service-backed` caps this calls the server-supplied service
54
+ * For `server-provided` caps this calls the server-supplied service
55
55
  * provider factory; for `singleton` / `device-native` / `collection`
56
56
  * it goes through the registry.
57
57
  */
@@ -110,6 +110,30 @@ export interface ParentUnownedCallDeps {
110
110
  * `CapabilityDefinition`s and the registry) injects this.
111
111
  */
112
112
  readonly resolveEmptyCollection?: (capName: string, method: string) => readonly unknown[] | null;
113
+ /**
114
+ * Optional predicate — returns `true` when `capName` is a HUB-CORE `$`-service
115
+ * cap (`system`, `addons`, `nodes`, `capabilities`, `stream-probe`, …): served
116
+ * by the hub's `$core-caps` / `$stream-probe` Moleculer services, NOT by any
117
+ * addon provider. These are the ONLY legitimate consumers of the unpinned
118
+ * `brokerCallForCap` fallback.
119
+ *
120
+ * It generalises the per-cap patches (`isDeviceNativeCap`,
121
+ * `resolveEmptyCollection`) into ONE rule: for any cap the resolver misses
122
+ * (`no-provider`) that is NOT a core `$`-service, the unpinned broker fallback
123
+ * is a STRUCTURALLY DEAD path — the cap's providers are broker-less forked
124
+ * children that expose no Moleculer service, so discovery 30s-deadlines into
125
+ * the misleading `${cap}.${cap}.${method}` error. Instead the handler retries
126
+ * the resolver briefly (the provider registers late in the post-restart boot
127
+ * window) and then throws a PRECISE "not yet registered" error. A NEW
128
+ * multi-node addon cap is covered automatically — no allowlist entry needed —
129
+ * because the discriminant is the already-maintained hub-core cap set, not a
130
+ * per-cap flag.
131
+ *
132
+ * The kernel layer has no cap registry, so the wiring side injects it.
133
+ * Omitted (or returning `false` for a core cap by mistake) ⇒ legacy behaviour
134
+ * exactly: the miss falls straight to the broker fallback with no retry.
135
+ */
136
+ readonly isCoreServiceCap?: (capName: string) => boolean;
113
137
  /** Optional logger for the broker-fallback diagnostic line. */
114
138
  readonly logger?: ParentUnownedCallLogger;
115
139
  }
@@ -4865,6 +4865,17 @@ function createUdsEventBridge(deps) {
4865
4865
  */
4866
4866
  var DEVICE_NATIVE_RETRY_ATTEMPTS = 5;
4867
4867
  var DEVICE_NATIVE_RETRY_DELAY_MS = 200;
4868
+ /**
4869
+ * Bounded-retry budget for the general provider-not-yet-registered recovery:
4870
+ * a forked hub-local child that PROVIDES a system/singleton cap (e.g.
4871
+ * `pipeline-orchestrator`) registers only once its (possibly heavy) boot
4872
+ * finishes, so a sibling calling it right after a hub restart briefly resolver-
4873
+ * misses. ~3s (12 × 250ms) covers a normal boot; past it the handler throws a
4874
+ * precise error (the caller — e.g. the ingest-owner gate — fail-opens) instead
4875
+ * of a 30s doubled-name broker deadline. Only engages for a NON-core cap miss.
4876
+ */
4877
+ var ADDON_CAP_RETRY_ATTEMPTS = 12;
4878
+ var ADDON_CAP_RETRY_DELAY_MS = 250;
4868
4879
  var delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4869
4880
  /**
4870
4881
  * Build the `onUnownedCall` handler the hub / agent passes into
@@ -4936,6 +4947,22 @@ function createParentUnownedCallHandler(deps) {
4936
4947
  ...deviceId !== void 0 ? { deviceId } : {},
4937
4948
  ...nodeId !== void 0 ? { nodeId } : {}
4938
4949
  });
4950
+ if (deps.isCoreServiceCap !== void 0 && !deps.isCoreServiceCap(input.capName)) {
4951
+ for (let attempt = 1; attempt <= ADDON_CAP_RETRY_ATTEMPTS; attempt++) {
4952
+ await delay(ADDON_CAP_RETRY_DELAY_MS);
4953
+ const retryResolver = deps.getResolver();
4954
+ if (retryResolver !== null) try {
4955
+ const route = retryResolver.resolveCapRoute(input.capName, {
4956
+ ...nodeId !== void 0 ? { nodeId } : {},
4957
+ ...deviceId !== void 0 ? { deviceId } : {}
4958
+ });
4959
+ return await retryResolver.dispatch(route, input.method, input.args);
4960
+ } catch (err) {
4961
+ if (!(err instanceof CapRouteError) || err.reason !== "no-provider") throw err;
4962
+ }
4963
+ }
4964
+ throw new Error(`no provider registered for cap "${input.capName}" (resolver miss after boot-window retry; forked-addon provider not yet registered — ${input.method})`);
4965
+ }
4939
4966
  deps.logger?.warn?.("routing child unowned cap call via broker fallback", {
4940
4967
  capName: input.capName,
4941
4968
  method: input.method
@@ -4863,6 +4863,17 @@ function createUdsEventBridge(deps) {
4863
4863
  */
4864
4864
  var DEVICE_NATIVE_RETRY_ATTEMPTS = 5;
4865
4865
  var DEVICE_NATIVE_RETRY_DELAY_MS = 200;
4866
+ /**
4867
+ * Bounded-retry budget for the general provider-not-yet-registered recovery:
4868
+ * a forked hub-local child that PROVIDES a system/singleton cap (e.g.
4869
+ * `pipeline-orchestrator`) registers only once its (possibly heavy) boot
4870
+ * finishes, so a sibling calling it right after a hub restart briefly resolver-
4871
+ * misses. ~3s (12 × 250ms) covers a normal boot; past it the handler throws a
4872
+ * precise error (the caller — e.g. the ingest-owner gate — fail-opens) instead
4873
+ * of a 30s doubled-name broker deadline. Only engages for a NON-core cap miss.
4874
+ */
4875
+ var ADDON_CAP_RETRY_ATTEMPTS = 12;
4876
+ var ADDON_CAP_RETRY_DELAY_MS = 250;
4866
4877
  var delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4867
4878
  /**
4868
4879
  * Build the `onUnownedCall` handler the hub / agent passes into
@@ -4934,6 +4945,22 @@ function createParentUnownedCallHandler(deps) {
4934
4945
  ...deviceId !== void 0 ? { deviceId } : {},
4935
4946
  ...nodeId !== void 0 ? { nodeId } : {}
4936
4947
  });
4948
+ if (deps.isCoreServiceCap !== void 0 && !deps.isCoreServiceCap(input.capName)) {
4949
+ for (let attempt = 1; attempt <= ADDON_CAP_RETRY_ATTEMPTS; attempt++) {
4950
+ await delay(ADDON_CAP_RETRY_DELAY_MS);
4951
+ const retryResolver = deps.getResolver();
4952
+ if (retryResolver !== null) try {
4953
+ const route = retryResolver.resolveCapRoute(input.capName, {
4954
+ ...nodeId !== void 0 ? { nodeId } : {},
4955
+ ...deviceId !== void 0 ? { deviceId } : {}
4956
+ });
4957
+ return await retryResolver.dispatch(route, input.method, input.args);
4958
+ } catch (err) {
4959
+ if (!(err instanceof CapRouteError) || err.reason !== "no-provider") throw err;
4960
+ }
4961
+ }
4962
+ throw new Error(`no provider registered for cap "${input.capName}" (resolver miss after boot-window retry; forked-addon provider not yet registered — ${input.method})`);
4963
+ }
4937
4964
  deps.logger?.warn?.("routing child unowned cap call via broker fallback", {
4938
4965
  capName: input.capName,
4939
4966
  method: input.method
@@ -6698,7 +6725,7 @@ function getWorkerDeviceRegistry() {
6698
6725
  * the property-access boundary and validate structurally.
6699
6726
  */
6700
6727
  function buildRemoteNativeProvider(api, cap, options) {
6701
- const capNameCamel = kebabToCamel(cap.name);
6728
+ const capNameCamel = kebabToCamel$1(cap.name);
6702
6729
  const provider = {};
6703
6730
  for (const [methodName, methodSchema] of Object.entries(cap.methods)) {
6704
6731
  const callKind = methodSchema.kind === "mutation" ? "mutate" : "query";
@@ -6719,7 +6746,7 @@ function buildRemoteNativeProvider(api, cap, options) {
6719
6746
  }
6720
6747
  return provider;
6721
6748
  }
6722
- function kebabToCamel(s) {
6749
+ function kebabToCamel$1(s) {
6723
6750
  return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
6724
6751
  }
6725
6752
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/system",
3
- "version": "1.1.31",
3
+ "version": "1.1.33",
4
4
  "description": "Core addon for CamStack — builtins, pipeline, process management, auth, logging, events",
5
5
  "keywords": [
6
6
  "camstack",