@camstack/addon-pipeline-orchestrator 1.1.49 → 1.1.51

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.
package/dist/index.js CHANGED
@@ -29,7 +29,7 @@ node_fs = __toESM(node_fs);
29
29
  let node_path = require("node:path");
30
30
  node_path = __toESM(node_path);
31
31
  let node_url = require("node:url");
32
- //#region ../types/dist/event-category-H4AVePnn.mjs
32
+ //#region ../types/dist/event-category-D4HJq7Mw.mjs
33
33
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
34
34
  EventCategory["SystemBoot"] = "system.boot";
35
35
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -182,6 +182,11 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
182
182
  /** Runner-sampled scrub thumbnail (~1/5 s/camera). Telemetry (D8): a lost
183
183
  * thumb is a scrub gap the recorder's keyframe backfill covers. */
184
184
  EventCategory["RecordingThumbSampled"] = "recording.thumb-sampled";
185
+ /** Export render progress (0–100). Telemetry (D8): a lost tick is a stale
186
+ * progress bar the client reconciles via `recordingExport.getExport`. */
187
+ EventCategory["RecordingExportProgress"] = "recording.export.progress";
188
+ EventCategory["RecordingExportCompleted"] = "recording.export.completed";
189
+ EventCategory["RecordingExportFailed"] = "recording.export.failed";
185
190
  EventCategory["DetectionEvent"] = "detection.event";
186
191
  EventCategory["SessionTrackNew"] = "session.track.new";
187
192
  EventCategory["SessionTrackExpired"] = "session.track.expired";
@@ -471,6 +476,25 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
471
476
  */
472
477
  EventCategory["PipelineAnalyticsStationaryChanged"] = "pipeline-analytics.stationary-changed";
473
478
  /**
479
+ * Fired by `addon-post-analysis` when a package-drop is confirmed inside
480
+ * a package zone — a newly-appeared stationary object of a package class
481
+ * that cleared the class / zone / dwell / size gates. Payload:
482
+ * `PipelineAnalyticsPackageDeliveredPayload` carrying `{ deviceId,
483
+ * entryId, className, zoneIds, keyFrameMediaKey?, bbox, timestamp }`.
484
+ * Telemetry (D8): the durable record is the `package-events` store row;
485
+ * this bus topic drives notifier rules + live UI. See
486
+ * docs/superpowers/specs/2026-07-17-package-zones-design.md §5.1.
487
+ */
488
+ EventCategory["PipelineAnalyticsPackageDelivered"] = "pipeline-analytics.package-delivered";
489
+ /**
490
+ * Fired by `addon-post-analysis` when a previously-delivered package
491
+ * leaves its zone (the stationary entry departed — moved or swept).
492
+ * Payload: `PipelineAnalyticsPackagePickedUpPayload` carrying
493
+ * `{ deviceId, entryId, deliveredEventId, className, timestamp }`.
494
+ * Telemetry (D8). See package-zones-design §5.2.
495
+ */
496
+ EventCategory["PipelineAnalyticsPackagePickedUp"] = "pipeline-analytics.package-picked-up";
497
+ /**
474
498
  * Fired by `addon-post-analysis` whenever a gallery face row changes:
475
499
  * `kind:'buffered'` a new detected face was persisted, `'assigned'` /
476
500
  * `'unassigned'` its identity link changed, `'deleted'` the row was
@@ -5209,6 +5233,25 @@ function _instanceof(cls, params = {}) {
5209
5233
  };
5210
5234
  return inst;
5211
5235
  }
5236
+ //#endregion
5237
+ //#region ../../node_modules/zod/v4/classic/compat.js
5238
+ /** @deprecated Use the raw string literal codes instead, e.g. "invalid_type". */
5239
+ var ZodIssueCode = {
5240
+ invalid_type: "invalid_type",
5241
+ too_big: "too_big",
5242
+ too_small: "too_small",
5243
+ invalid_format: "invalid_format",
5244
+ not_multiple_of: "not_multiple_of",
5245
+ unrecognized_keys: "unrecognized_keys",
5246
+ invalid_union: "invalid_union",
5247
+ invalid_key: "invalid_key",
5248
+ invalid_element: "invalid_element",
5249
+ invalid_value: "invalid_value",
5250
+ custom: "custom"
5251
+ };
5252
+ /** @deprecated Do not use. Stub definition, only included for zod-to-json-schema compatibility. */
5253
+ var ZodFirstPartyTypeKind;
5254
+ ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5212
5255
  Object.fromEntries([
5213
5256
  {
5214
5257
  id: "overview",
@@ -6494,6 +6537,19 @@ function scopeKey(scope) {
6494
6537
  case "device": return `device:${scope.deviceId}`;
6495
6538
  }
6496
6539
  }
6540
+ /**
6541
+ * Producer transition order within ONE generation: `starting → ready →
6542
+ * down`. Used by `hydrate` to accept only forward moves for
6543
+ * same-generation snapshot records (a snapshot can be ahead of a lost
6544
+ * delta, never behind it, within one producer generation).
6545
+ */
6546
+ function stateRank(state) {
6547
+ switch (state) {
6548
+ case "starting": return 0;
6549
+ case "ready": return 1;
6550
+ case "down": return 2;
6551
+ }
6552
+ }
6497
6553
  function scopesEqual(a, b) {
6498
6554
  if (a.type !== b.type) return false;
6499
6555
  if (a.type === "global" || b.type === "global") return true;
@@ -6544,43 +6600,70 @@ var ReadinessRegistry = class {
6544
6600
  return Array.from(this.snapshot.values());
6545
6601
  }
6546
6602
  /**
6547
- * Hydrate the snapshot from an authoritative source. Entries already
6548
- * present locally are skipped live deltas (received via the event
6549
- * bus subscription) always take precedence over the snapshot. For
6550
- * each newly hydrated entry, a one-shot transition is dispatched to
6551
- * matching subscriptions so pending `awaitReady` callers unblock
6552
- * without having to wait for a fresh event.
6603
+ * Hydrate the snapshot from an authoritative source a RECONCILE, not
6604
+ * an add-only merge (D8: readiness events are telemetry and may be
6605
+ * lost; the on-reconnect/periodic snapshot re-pull is the repair path,
6606
+ * so it MUST be able to advance a stale record the 2026-07-17
6607
+ * "still awaiting platform-probe forever" wedge was this method
6608
+ * skipping every already-seen key).
6553
6609
  *
6554
- * Local `epoch` is reset to 1 per entry consumer-side epoch is
6555
- * derived from observed generation transitions, so this mirrors the
6556
- * value that would have been assigned had the consumer observed the
6557
- * first `ready` event directly.
6610
+ * - **Unseen keys** are added (epoch 1 mirrors the value the
6611
+ * consumer would have derived from the first observed `ready`).
6612
+ * - **Existing keys** are UPDATED when the authoritative record
6613
+ * carries a different generation (producer restart / synthetic-down
6614
+ * superseded — epoch bumps on a new-generation `ready`, mirroring
6615
+ * `ingest`), or a FORWARD state move within the same generation
6616
+ * (`starting → ready → down`; within one producer generation a
6617
+ * snapshot can only be ahead of a lost delta, never behind it).
6618
+ * - **Never a same-generation regress** — a snapshot pulled just
6619
+ * before a live delta landed must not un-ready a record (the next
6620
+ * reconcile round converges it anyway).
6621
+ * - **Never a record this process is authoritative for** — node-scoped
6622
+ * records for our own nodeId, and device/global records we emitted,
6623
+ * keep local truth (an authority's stale copy of OUR record must
6624
+ * not regress us).
6625
+ *
6626
+ * Every applied entry dispatches a one-shot transition to matching
6627
+ * subscriptions so pending `awaitReady` callers unblock without having
6628
+ * to wait for a fresh event.
6558
6629
  */
6559
6630
  hydrate(records) {
6560
6631
  const now = this.now();
6561
6632
  for (const record of records) {
6562
6633
  const key = readinessKey(record.capName, record.scope);
6563
- if (this.snapshot.has(key)) continue;
6634
+ const prev = this.snapshot.get(key) ?? null;
6635
+ let epoch;
6636
+ let durationInPrevState;
6637
+ if (prev !== null) {
6638
+ if (this.isLocallyAuthoritative(prev)) continue;
6639
+ const sameGeneration = prev.generation === record.generation;
6640
+ if (sameGeneration && stateRank(record.state) <= stateRank(prev.state)) continue;
6641
+ epoch = !sameGeneration && record.state === "ready" ? prev.epoch + 1 : prev.epoch;
6642
+ durationInPrevState = Math.max(0, now - prev.lastChange);
6643
+ } else {
6644
+ epoch = 1;
6645
+ durationInPrevState = 0;
6646
+ }
6564
6647
  const hydrated = {
6565
6648
  capName: record.capName,
6566
6649
  scope: record.scope,
6567
6650
  state: record.state,
6568
6651
  generation: record.generation,
6569
- epoch: 1,
6652
+ epoch,
6570
6653
  lastChange: now,
6571
6654
  sourceNodeId: record.sourceNodeId
6572
6655
  };
6573
6656
  this.snapshot.set(key, hydrated);
6574
- if (this.logger) this.logger.debug(`readiness: ${record.capName} (${scopeKey(record.scope)}) → ${record.state} (hydrated, gen=${record.generation.slice(0, 6)})`);
6657
+ if (this.logger) this.logger.debug(`readiness: ${record.capName} (${scopeKey(record.scope)}) → ${record.state} (hydrated${prev !== null ? " update" : ""}, gen=${record.generation.slice(0, 6)})`);
6575
6658
  const transition = {
6576
6659
  capName: record.capName,
6577
6660
  scope: record.scope,
6578
6661
  state: record.state,
6579
- epoch: 1,
6662
+ epoch,
6580
6663
  generation: record.generation,
6581
6664
  sourceNodeId: "hydrated",
6582
6665
  ts: now,
6583
- durationInPrevState: 0
6666
+ durationInPrevState
6584
6667
  };
6585
6668
  for (const sub of this.subscriptions) {
6586
6669
  if (sub.capName !== record.capName) continue;
@@ -6593,6 +6676,17 @@ var ReadinessRegistry = class {
6593
6676
  }
6594
6677
  }
6595
6678
  }
6679
+ /**
6680
+ * True when THIS process is the origin authority for `record` — a
6681
+ * snapshot pulled from elsewhere must never overwrite it:
6682
+ * - node-scoped records whose `scope.nodeId` is our own node id
6683
+ * (this process — or a child bridged onto our bus — emits them);
6684
+ * - device/global-scoped records whose latest transition WE emitted.
6685
+ */
6686
+ isLocallyAuthoritative(record) {
6687
+ if (record.scope.type === "node") return record.scope.nodeId === this.sourceNodeId;
6688
+ return record.sourceNodeId === this.sourceNodeId;
6689
+ }
6596
6690
  /** Shallow copy of the full snapshot — mainly for diagnostics/tests. */
6597
6691
  getAll() {
6598
6692
  return new Map(this.snapshot);
@@ -7679,6 +7773,24 @@ var RecordingRetentionSchema = object({
7679
7773
  maxSizeGb: number().min(0).optional()
7680
7774
  });
7681
7775
  /**
7776
+ * Scrub-thumbnail fidelity preset — the single per-camera selector bundling the
7777
+ * sprite tile RESOLUTION + JPEG QUALITY the recorder packs timeline-scrub
7778
+ * previews at. Five graduated steps; absent on a config = `standard` (the
7779
+ * shipped default, matching `sheet-geometry`/`sheet-composer`).
7780
+ *
7781
+ * Existing sheets are IMMUTABLE — a changed preset applies to NEW windows only.
7782
+ * Each window's index sidecar carries its own tile dims, so a camera whose
7783
+ * preset changed over time renders every historical window at the dims it was
7784
+ * written with.
7785
+ */
7786
+ var ScrubThumbnailPresetSchema = _enum([
7787
+ "minimal",
7788
+ "low",
7789
+ "standard",
7790
+ "high",
7791
+ "max"
7792
+ ]);
7793
+ /**
7682
7794
  * The full per-camera recording intent — the wire shape of a RecordingTarget.
7683
7795
  *
7684
7796
  * `mode` is the authoritative storage choice; `schedule`/`triggers`/`pre`/`post`
@@ -7715,7 +7827,14 @@ var RecordingConfigSchema = object({
7715
7827
  * derived into bands once via `migrateConfigToBands`.
7716
7828
  */
7717
7829
  bands: array(RecordingBandSchema).optional(),
7718
- retention: RecordingRetentionSchema.optional()
7830
+ retention: RecordingRetentionSchema.optional(),
7831
+ /**
7832
+ * Per-camera scrub-thumbnail fidelity preset (resolution + JPEG quality for
7833
+ * timeline-scrub sprite previews). Absent = `standard`. Applies to NEW
7834
+ * windows only — existing sheets are immutable, and each window's index
7835
+ * carries its own tile dims so mixed-preset history renders correctly.
7836
+ */
7837
+ scrubThumbnails: ScrubThumbnailPresetSchema.optional()
7719
7838
  });
7720
7839
  /**
7721
7840
  * `StorageLocationType` — an addon-declared id that identifies the *kind* of
@@ -13348,7 +13467,11 @@ array(ZoneRuleSchema).readonly();
13348
13467
  * Extend the enum here when a new gating consumer comes online (audio
13349
13468
  * gating, alert filtering, …) — no other surface needs to change.
13350
13469
  */
13351
- var ZoneRuleStageEnum = _enum(["motion", "detection"]);
13470
+ var ZoneRuleStageEnum = _enum([
13471
+ "motion",
13472
+ "detection",
13473
+ "package"
13474
+ ]);
13352
13475
  /**
13353
13476
  * Zone rules capability — per-camera CRUD over the {@link ZoneRule}
13354
13477
  * arrays that decide how each pipeline stage uses the polygon zones.
@@ -13392,16 +13515,24 @@ var zoneRulesCapability = {
13392
13515
  })
13393
13516
  },
13394
13517
  /**
13395
- * Runtime-state slice — both stages mirrored together so consumers
13518
+ * Runtime-state slice — every stage mirrored together so consumers
13396
13519
  * see one reactive handle (`device.state.zoneRules.value`) instead
13397
- * of two. Bulk-replace mutations on either stage write the full
13398
- * `{motion, detection}` shape, so subscribers always get the
13520
+ * of one per stage. Bulk-replace mutations on any stage write the full
13521
+ * `{motion, detection, package}` shape, so subscribers always get the
13399
13522
  * complete current set. Consumers that only care about one stage
13400
13523
  * just read the matching property.
13524
+ *
13525
+ * `package` backs the package-drop detector — a package zone is a
13526
+ * `ZoneRule` on the `'package'` stage referencing drawn polygons
13527
+ * (see docs/superpowers/specs/2026-07-17-package-zones-design.md §3.1).
13528
+ * The orchestrator provider that writes this stage lands in a later
13529
+ * slice; until then the mirror carries only `{motion, detection}` and
13530
+ * consumers read `package` as absent (treat as `[]`).
13401
13531
  */
13402
13532
  runtimeState: object({
13403
13533
  motion: array(ZoneRuleSchema).readonly(),
13404
- detection: array(ZoneRuleSchema).readonly()
13534
+ detection: array(ZoneRuleSchema).readonly(),
13535
+ package: array(ZoneRuleSchema).readonly()
13405
13536
  })
13406
13537
  };
13407
13538
  var ProviderStatusSchema = object({
@@ -16650,6 +16781,7 @@ var EventKindIconSchema = _enum([
16650
16781
  "smoke",
16651
16782
  "water",
16652
16783
  "button",
16784
+ "package",
16653
16785
  "generic"
16654
16786
  ]);
16655
16787
  var EventKindCategorySchema = _enum([
@@ -16657,7 +16789,8 @@ var EventKindCategorySchema = _enum([
16657
16789
  "audio",
16658
16790
  "detection",
16659
16791
  "sensor",
16660
- "custom"
16792
+ "custom",
16793
+ "package"
16661
16794
  ]);
16662
16795
  var EventKindDescriptorSchema = object({
16663
16796
  /** Stable kind id (e.g. 'motion', 'person', 'contact'). */
@@ -20514,6 +20647,108 @@ method(object({
20514
20647
  auth: "admin"
20515
20648
  });
20516
20649
  /**
20650
+ * `recordingExport` cap — render a footage time range into a single downloadable
20651
+ * MP4 (regular / accelerated / decelerated / timelapse, ± audio), kept for a
20652
+ * bounded lifetime with a durable history, auto-expiry, and optional
20653
+ * delete-after-download.
20654
+ *
20655
+ * Like `recording` this is a `scope:'system', mode:'singleton'` cap: the
20656
+ * recorder self-gates so exactly ONE node (the designated `recordingNodeId`,
20657
+ * default `hub`) registers it. Device-scoped methods carry `deviceId` in their
20658
+ * input and dispatch to that single provider; the render runs on the node that
20659
+ * owns the footage (no cross-node segment transfer). Download rides the
20660
+ * framework addon data-plane. State persists in a DurableState blob (NOT
20661
+ * SQLite); history rows survive file deletion for audit.
20662
+ */
20663
+ /** Playback-speed multiplier for the render (1 = realtime). */
20664
+ var ExportSpeedSchema = number().min(.25).max(32);
20665
+ /** Timelapse cadence — sample one source frame per `everyMs`, output at `outputFps`. */
20666
+ var ExportTimelapseSchema = object({
20667
+ everyMs: number().int().positive(),
20668
+ outputFps: number().int().min(1).max(60).optional()
20669
+ });
20670
+ /**
20671
+ * Render options. `speed` and `timelapse` are mutually exclusive. `includeAudio`
20672
+ * is honoured only for a realtime-ish speed (0.5–2×); timelapse is always
20673
+ * silent. `maxLifeMs` bounds how long the finished file is kept;
20674
+ * `deleteAfterDownload` removes it shortly after the first complete download.
20675
+ */
20676
+ var ExportOptionsSchema = object({
20677
+ speed: ExportSpeedSchema.optional(),
20678
+ timelapse: ExportTimelapseSchema.optional(),
20679
+ includeAudio: boolean(),
20680
+ maxLifeMs: number().int().positive(),
20681
+ deleteAfterDownload: boolean(),
20682
+ title: string().max(200).optional()
20683
+ }).superRefine((v, ctx) => {
20684
+ if (v.speed !== void 0 && v.timelapse !== void 0) ctx.addIssue({
20685
+ code: ZodIssueCode.custom,
20686
+ message: "speed and timelapse are mutually exclusive",
20687
+ path: ["timelapse"]
20688
+ });
20689
+ });
20690
+ var ExportStateSchema = _enum([
20691
+ "queued",
20692
+ "rendering",
20693
+ "ready",
20694
+ "failed",
20695
+ "expired",
20696
+ "deleted"
20697
+ ]);
20698
+ /** One export job / history row. */
20699
+ var ExportRecordSchema = object({
20700
+ id: string(),
20701
+ deviceId: number(),
20702
+ profile: string(),
20703
+ fromMs: number(),
20704
+ toMs: number(),
20705
+ options: ExportOptionsSchema,
20706
+ state: ExportStateSchema,
20707
+ /** 0–100 while rendering; null otherwise. */
20708
+ progressPct: number().nullable(),
20709
+ /** File size once ready; null before. */
20710
+ fileBytes: number().nullable(),
20711
+ expiresAt: number(),
20712
+ deleteAfterDownload: boolean(),
20713
+ /** Epoch of the first complete download; null until then. */
20714
+ downloadedAt: number().nullable(),
20715
+ createdAt: number(),
20716
+ /** User id/name that requested the export. */
20717
+ createdBy: string(),
20718
+ /** Failure reason when state is 'failed'; null otherwise. */
20719
+ error: string().nullable()
20720
+ });
20721
+ /** Candidate download URLs (LAN first, then operator extra hosts). */
20722
+ var ExportDownloadSchema = object({
20723
+ url: string(),
20724
+ endpoints: array(string())
20725
+ });
20726
+ method(object({
20727
+ deviceId: number(),
20728
+ profile: string(),
20729
+ fromMs: number(),
20730
+ toMs: number(),
20731
+ options: ExportOptionsSchema
20732
+ }), ExportRecordSchema, {
20733
+ kind: "mutation",
20734
+ auth: "protected"
20735
+ }), method(object({ deviceId: number().optional() }), array(ExportRecordSchema), {
20736
+ kind: "query",
20737
+ auth: "protected"
20738
+ }), method(object({ exportId: string() }), ExportRecordSchema, {
20739
+ kind: "query",
20740
+ auth: "protected"
20741
+ }), method(object({ exportId: string() }), ExportRecordSchema, {
20742
+ kind: "mutation",
20743
+ auth: "protected"
20744
+ }), method(object({ exportId: string() }), ExportRecordSchema, {
20745
+ kind: "mutation",
20746
+ auth: "protected"
20747
+ }), method(object({ exportId: string() }), ExportDownloadSchema, {
20748
+ kind: "query",
20749
+ auth: "protected"
20750
+ });
20751
+ /**
20517
20752
  * One publishable camera stream as its OWNING PROVIDER describes it — the same
20518
20753
  * payload the (legacy) push `streamBroker.publishCameraStream` carried, minus
20519
20754
  * `deviceId`.
@@ -24437,6 +24672,42 @@ Object.freeze({
24437
24672
  addonId: null,
24438
24673
  access: "create"
24439
24674
  },
24675
+ "recordingExport.cancelExport": {
24676
+ capName: "recordingExport",
24677
+ capScope: "system",
24678
+ addonId: null,
24679
+ access: "create"
24680
+ },
24681
+ "recordingExport.createExport": {
24682
+ capName: "recordingExport",
24683
+ capScope: "system",
24684
+ addonId: null,
24685
+ access: "create"
24686
+ },
24687
+ "recordingExport.deleteExport": {
24688
+ capName: "recordingExport",
24689
+ capScope: "system",
24690
+ addonId: null,
24691
+ access: "delete"
24692
+ },
24693
+ "recordingExport.getDownloadUrl": {
24694
+ capName: "recordingExport",
24695
+ capScope: "system",
24696
+ addonId: null,
24697
+ access: "view"
24698
+ },
24699
+ "recordingExport.getExport": {
24700
+ capName: "recordingExport",
24701
+ capScope: "system",
24702
+ addonId: null,
24703
+ access: "view"
24704
+ },
24705
+ "recordingExport.listExports": {
24706
+ capName: "recordingExport",
24707
+ capScope: "system",
24708
+ addonId: null,
24709
+ access: "view"
24710
+ },
24440
24711
  "sceneMonitor.captureReference": {
24441
24712
  capName: "scene-monitor",
24442
24713
  capScope: "device",
@@ -30518,6 +30789,27 @@ var PIPELINE_EXECUTOR_CAP = "pipeline-executor";
30518
30789
  var PipelineSettingsStore = class PipelineSettingsStore {
30519
30790
  deps;
30520
30791
  static CATALOG_CACHE_TTL_MS = 3e4;
30792
+ /**
30793
+ * One reconcile round for {@link awaitExecutorCatalog}: the BOUNDED
30794
+ * registry wait between two authoritative provider probes. Matches the
30795
+ * registry's own "still awaiting" diagnostic cadence.
30796
+ */
30797
+ static EXECUTOR_RECONCILE_ROUND_MS = 3e4;
30798
+ /**
30799
+ * Pacing between provider probes when the registry claims `ready` but
30800
+ * the provider is not answering yet (stale registry record) — without
30801
+ * it that combination would spin hot.
30802
+ */
30803
+ static EXECUTOR_READY_PROBE_PACE_MS = 2e3;
30804
+ /**
30805
+ * Round bound for {@link seedAgentSettingsFromCatalog}: seeding is
30806
+ * invoked from event handlers (`handleDetectionPipelineReadiness`) that
30807
+ * must not accumulate unbounded loops across provider flaps — on
30808
+ * exhaustion it returns `null` and the periodic pending-retry sweep /
30809
+ * the next readiness epoch retries. `waitForAgentAndCatalog` keeps the
30810
+ * UNBOUNDED reconcile (cameras are never dispatched with empty steps).
30811
+ */
30812
+ static SEED_MAX_RECONCILE_ROUNDS = 4;
30521
30813
  constructor(deps) {
30522
30814
  this.deps = deps;
30523
30815
  }
@@ -30784,11 +31076,7 @@ var PipelineSettingsStore = class PipelineSettingsStore {
30784
31076
  * absent.
30785
31077
  */
30786
31078
  async seedAgentSettingsFromCatalog(nodeId) {
30787
- await this.deps.acquireCapability(PIPELINE_EXECUTOR_CAP, {
30788
- type: "node",
30789
- nodeId
30790
- });
30791
- const catalog = await this.getCatalogForAgent(nodeId);
31079
+ const catalog = await this.awaitExecutorCatalog(nodeId, { maxRounds: PipelineSettingsStore.SEED_MAX_RECONCILE_ROUNDS });
30792
31080
  if (!catalog) return null;
30793
31081
  const existing = (await this.readAgentSettingsMap())[nodeId];
30794
31082
  const nextAddonDefaults = seedAgentAddonDefaults(existing?.addonDefaults ?? {}, catalog);
@@ -30892,15 +31180,64 @@ var PipelineSettingsStore = class PipelineSettingsStore {
30892
31180
  return resolved;
30893
31181
  }
30894
31182
  /**
30895
- * Block until `pipeline-executor` is ready on `nodeId` AND
31183
+ * Loss-proof `pipeline-executor` gate (D8 reconcile against the
31184
+ * authority, never wait solely on an event).
31185
+ *
31186
+ * Readiness events are telemetry and MAY be lost across process/node
31187
+ * hops (2026-07-17 incident: the agent's executor came back callable
31188
+ * after an update, its `ready` delta never reached the hub registry,
31189
+ * and the former `acquireCapability(…, Infinity)` gate wedged every
31190
+ * dispatch forever). Each round of this loop therefore:
31191
+ *
31192
+ * 1. Probes the PROVIDER itself — `getCatalogForAgent` is a live
31193
+ * `pipelineExecutor.getSchema` RPC routed to `nodeId` (30s success
31194
+ * cache). The provider answering IS readiness, whatever the local
31195
+ * registry believes.
31196
+ * 2. On a miss, falls back to a BOUNDED registry wait
31197
+ * (`EXECUTOR_RECONCILE_ROUND_MS`) — the event-driven fast path that
31198
+ * keeps the healthy cold-boot case latency-free (the wait resolves
31199
+ * the instant the `ready` delta lands). A lost event costs at most
31200
+ * one round, never forever.
31201
+ *
31202
+ * If the registry claims `ready` but the probe still misses (stale
31203
+ * record for a dead provider), rounds pace at
31204
+ * `EXECUTOR_READY_PROBE_PACE_MS` instead of spinning hot.
31205
+ *
31206
+ * Returns `null` only when `maxRounds` is exhausted (bounded callers)
31207
+ * or the store was disposed mid-wait.
31208
+ */
31209
+ async awaitExecutorCatalog(nodeId, opts = {}) {
31210
+ const maxRounds = opts.maxRounds ?? Number.POSITIVE_INFINITY;
31211
+ const startedAt = Date.now();
31212
+ for (let round = 1; !this.disposed; round++) {
31213
+ const catalog = await this.getCatalogForAgent(nodeId);
31214
+ if (catalog) return catalog;
31215
+ if (round >= maxRounds) return null;
31216
+ if (round > 1) this.deps.logger.warn("executor reconcile: provider not answering yet — waiting one more round", {
31217
+ tags: { nodeId },
31218
+ meta: {
31219
+ round,
31220
+ elapsedMs: Date.now() - startedAt
31221
+ }
31222
+ });
31223
+ const readyPerRegistry = await this.deps.acquireCapability(PIPELINE_EXECUTOR_CAP, {
31224
+ type: "node",
31225
+ nodeId
31226
+ }, { timeoutMs: PipelineSettingsStore.EXECUTOR_RECONCILE_ROUND_MS }).then(() => true, () => false);
31227
+ if (this.disposed) break;
31228
+ if (readyPerRegistry) await sleep$1(PipelineSettingsStore.EXECUTOR_READY_PROBE_PACE_MS);
31229
+ }
31230
+ return null;
31231
+ }
31232
+ /**
31233
+ * Block until `pipeline-executor` answers on `nodeId` AND
30896
31234
  * `getCatalogForAgent` returns a non-null catalog AND `agentSettings`
30897
31235
  * for that node has populated `addonDefaults`.
30898
31236
  *
30899
- * `acquireCapability` defaults to infinite wait nowa single call
30900
- * carries the readiness gate. The thin retry below only handles
30901
- * the secondary case where the cap is "ready" but a derived call
30902
- * (getSchema) still returns null due to Moleculer service-registry
30903
- * propagation lag.
31237
+ * The executor gate is {@link awaitExecutorCatalog}reconcile-driven
31238
+ * and UNBOUNDED here (cameras must never be dispatched with empty
31239
+ * steps), but it converges as soon as the provider answers even when
31240
+ * every readiness event was lost.
30904
31241
  *
30905
31242
  * Smell #20 fix: the retry loop used to be `while (true)` with no exit
30906
31243
  * — a resolve in flight at addon shutdown would spin against a torn-
@@ -30910,16 +31247,9 @@ var PipelineSettingsStore = class PipelineSettingsStore {
30910
31247
  * timing (2s backoff, same retry conditions) is unchanged.
30911
31248
  */
30912
31249
  async waitForAgentAndCatalog(nodeId) {
30913
- await this.deps.acquireCapability(PIPELINE_EXECUTOR_CAP, {
30914
- type: "node",
30915
- nodeId
30916
- });
30917
31250
  while (!this.disposed) {
30918
- const catalog = await this.getCatalogForAgent(nodeId);
30919
- if (!catalog) {
30920
- await sleep$1(2e3);
30921
- continue;
30922
- }
31251
+ const catalog = await this.awaitExecutorCatalog(nodeId);
31252
+ if (!catalog) break;
30923
31253
  let agent = (await this.readAgentSettingsMap())[nodeId];
30924
31254
  if (!agent || Object.keys(agent.addonDefaults ?? {}).length === 0) {
30925
31255
  if (!await this.seedAgentSettingsFromCatalog(nodeId)) {
@@ -31945,7 +32275,8 @@ var RulesArraySchema = array(ZoneRuleSchema);
31945
32275
  */
31946
32276
  var ZoneRulesBlockSchema = object({
31947
32277
  motion: unknown().optional(),
31948
- detection: unknown().optional()
32278
+ detection: unknown().optional(),
32279
+ package: unknown().optional()
31949
32280
  }).passthrough();
31950
32281
  var ZoneRulesProvider = class {
31951
32282
  ctx;