@camstack/addon-pipeline 1.2.93 → 1.2.94

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.
Files changed (29) hide show
  1. package/dist/{addon-utils-CTRpEiSd.js → addon-utils-DnUCCZVx.js} +1 -1
  2. package/dist/audio-analyzer/index.js +2 -2
  3. package/dist/audio-analyzer/index.mjs +1 -1
  4. package/dist/detection-pipeline/index.js +4 -4
  5. package/dist/detection-pipeline/index.mjs +2 -2
  6. package/dist/{dist-D9To_r_d.js → dist-BbRv3bM2.js} +43 -19
  7. package/dist/{dist-6POVIYmC.mjs → dist-XbrYiMV3.mjs} +43 -19
  8. package/dist/{event-loop-stall-monitor-C-VvWOxS.mjs → event-loop-stall-monitor-CFWrOZ6G.mjs} +1 -1
  9. package/dist/{event-loop-stall-monitor-CGacbzXq.js → event-loop-stall-monitor-D9hqbc68.js} +1 -1
  10. package/dist/{lazy-sharp-C3zOnmiU.js → lazy-sharp-U0EtN7_C.js} +1 -1
  11. package/dist/motion-wasm/index.js +2 -2
  12. package/dist/motion-wasm/index.mjs +1 -1
  13. package/dist/pipeline-runner/index.js +195 -14
  14. package/dist/pipeline-runner/index.mjs +194 -13
  15. package/dist/recorder/index.js +523 -65
  16. package/dist/recorder/index.mjs +522 -64
  17. package/dist/session-decode/decode-worker-child.js +4 -4
  18. package/dist/session-decode/decode-worker-child.mjs +3 -3
  19. package/dist/stream-broker/_stub.js +1 -1
  20. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-C2woB6bY.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BNOPhQ-y.mjs} +1 -1
  21. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DMyOrQHq.mjs +26 -0
  22. package/dist/stream-broker/{hostInit-VCURcZLt.mjs → hostInit-DCV2-mSi.mjs} +1 -1
  23. package/dist/stream-broker/index.js +1 -1
  24. package/dist/stream-broker/index.mjs +1 -1
  25. package/dist/stream-broker/remoteEntry.js +1 -1
  26. package/dist/{worker-protocol-Bq28qobd.js → worker-protocol-CNC-ZcU3.js} +1 -1
  27. package/dist/{worker-protocol-PzAKg8mC.mjs → worker-protocol-D0ewr67U.mjs} +1 -1
  28. package/package.json +3 -2
  29. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Dut56EeQ.mjs +0 -26
@@ -1,4 +1,4 @@
1
- import { $ as resolveRecordingProfiles, Ct as selectAssignedProfileSlots, Et as array, I as deriveRecordingMode, It as EventCategory, Mt as object, Nt as record, Pt as string, S as RecordingConfigSchema, X as recordingCapability, Z as recordingExportCapability, at as errMsg, bt as nodePin, dt as BaseAddon, f as EVENT_PAD_MS, gt as hydrateSchema, jt as number, l as DEFAULT_EVENTS_BAND_BUFFER_SEC, m as ExportRecordSchema, mt as DeviceType, tt as storageEvictableCapability, v as OpsLogEntrySchema, x as RECORDING_EXPORT_MAX_READ_BYTES } from "../dist-6POVIYmC.mjs";
1
+ import { $ as resolveRecordingProfiles, Ct as selectAssignedProfileSlots, Et as array, I as deriveRecordingMode, It as EventCategory, Mt as object, Nt as record, Pt as string, S as RecordingConfigSchema, X as recordingCapability, Z as recordingExportCapability, at as errMsg, bt as nodePin, dt as BaseAddon, f as EVENT_PAD_MS, gt as hydrateSchema, jt as number, l as DEFAULT_EVENTS_BAND_BUFFER_SEC, m as ExportRecordSchema, mt as DeviceType, tt as storageEvictableCapability, v as OpsLogEntrySchema, x as RECORDING_EXPORT_MAX_READ_BYTES } from "../dist-XbrYiMV3.mjs";
2
2
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
3
3
  import { n as createFileDataPlaneHandler, s as parseRangeHeader, t as contentTypeFor } from "../addon-utils-A2S9D7pu.mjs";
4
4
  import { randomUUID } from "node:crypto";
@@ -5837,18 +5837,232 @@ function shouldRecordAt(config, triggers, at) {
5837
5837
  return eventsBandDemanded(band, triggers, at.getTime());
5838
5838
  }
5839
5839
  /**
5840
- * Should a finalized segment `[segStartMs, segEndMs]` recorded under an
5841
- * `events` band be KEPT (else discarded)? Kept iff it overlaps any qualifying
5842
- * trigger's full window `[trigger - preBufferSec, trigger + postBufferSec]`.
5843
- * This is where `preBufferSec` retroactively retains pre-trigger footage.
5840
+ * How late the FIRST segment of an attach may be NAMED, relative to the writer
5841
+ * spawn, and still count as "the segment this attach was spawned to produce".
5842
+ *
5843
+ * ffmpeg names a segment when it OPENS it, which is after the RTSP dial and the
5844
+ * first keyframe. Measured on camera 1439 (4K, H.265) on 2026-08-18: writer
5845
+ * spawned at trigger+29.5 s, first `high` segment named at trigger+32.7 s —
5846
+ * 3.2 s. 5 s carries that with margin while staying far below the writer's own
5847
+ * "this is dead" bound (`WRITER_IDLE_MIN_MS`, 90 s), so a writer that is merely
5848
+ * slow is not mistaken for one that is producing useful footage.
5844
5849
  */
5845
- function segmentRetainedForEvents(segStartMs, segEndMs, band, triggers) {
5850
+ var FIRST_SEGMENT_GRACE_MS = 5e3;
5851
+ /**
5852
+ * Latest segment START this attach may still produce and have kept, regardless
5853
+ * of where the operator's post-buffer ended: one full segment (nothing can be
5854
+ * finalized sooner) plus {@link FIRST_SEGMENT_GRACE_MS} of spawn→first-name
5855
+ * latency.
5856
+ */
5857
+ function attachRetainDeadlineMs(attach) {
5858
+ return attach.attachedAtMs + attach.segmentSeconds * 1e3 + FIRST_SEGMENT_GRACE_MS;
5859
+ }
5860
+ /**
5861
+ * Should a finalized segment `[segStartMs, segEndMs]` recorded under an
5862
+ * `events` band be KEPT (else discarded)? Kept iff it overlaps a qualifying
5863
+ * trigger's full window `[trigger - preBufferSec, trigger + postBufferSec]` —
5864
+ * this is where `preBufferSec` retroactively retains pre-trigger footage — OR
5865
+ * it is the footage `attach` was spawned to produce (below).
5866
+ *
5867
+ * ## Why the attach matters here
5868
+ *
5869
+ * The LIVE gate ({@link shouldRecordAt}) is evaluated at wall clock; this KEEP
5870
+ * gate is evaluated on segment timestamps. They shared one window, and a writer
5871
+ * physically cannot finalize anything before `spawn + segmentSeconds + dial`.
5872
+ * So the last ~`segmentSeconds` of every demand window was a zone where the
5873
+ * recorder was GUARANTEED to attach, pull the camera, and delete 100% of the
5874
+ * result: on camera 1439 a trigger reached the recorder 29.5 s into its 30 s
5875
+ * post-buffer (hub overload, `busLagMs=142173`), ffmpeg ran 31 s, and all six
5876
+ * finalized segments were unlinked.
5877
+ *
5878
+ * The allowance is deliberately NOT a wider `postBufferSec`: it is anchored to
5879
+ * `attachedAtMs`, so a PUNCTUAL attach (the normal case) has a deadline far
5880
+ * inside `trigger + postMs` and retains exactly what it retained before. It
5881
+ * only ever extends the tail of a LATE attach, by at most one segment plus the
5882
+ * grace, and only for a trigger whose live window was still open AT ATTACH — the
5883
+ * precise statement of "if the live gate said yes, the keep gate honours the
5884
+ * first segment that decision produces". A stale trigger extends nothing.
5885
+ */
5886
+ function segmentRetainedForEvents(segStartMs, segEndMs, band, triggers, attach) {
5846
5887
  const { preMs, postMs } = resolveBandBufferMs(band);
5847
- const overlaps = (lastMs) => lastMs != null && segStartMs <= lastMs + postMs && segEndMs >= lastMs - preMs;
5888
+ const deadlineMs = attachRetainDeadlineMs(attach);
5889
+ const overlaps = (lastMs) => {
5890
+ if (lastMs == null) return false;
5891
+ if (segEndMs < lastMs - preMs) return false;
5892
+ if (segStartMs <= lastMs + postMs) return true;
5893
+ return attach.attachedAtMs <= lastMs + postMs && segStartMs <= deadlineMs;
5894
+ };
5848
5895
  if (band.triggers?.motion && overlaps(triggers.lastMotionMs)) return true;
5849
5896
  if (band.triggers?.audioThresholdDbfs != null && overlaps(triggers.lastAudioMs)) return true;
5850
5897
  return false;
5851
5898
  }
5899
+ /**
5900
+ * By how many ms a discarded segment missed being kept — its start past the
5901
+ * latest start that WOULD have been retained, mirroring
5902
+ * {@link segmentRetainedForEvents}'s upper bound exactly (post-buffer end, or
5903
+ * the attach allowance for a trigger that was still live at attach). Null when
5904
+ * no qualifying trigger exists at all, which is a different fact and says so.
5905
+ *
5906
+ * Negative means the segment was dropped on the PRE-buffer side (it ended
5907
+ * before `trigger - preBufferSec`), not for being late.
5908
+ *
5909
+ * Reporting-only. The discard branch has to name a number: "outside the window"
5910
+ * with no magnitude made a 2.5 s miss and a 10-minute miss indistinguishable.
5911
+ */
5912
+ function segmentMissedByMs(segStartMs, band, triggers, attach) {
5913
+ const { postMs } = resolveBandBufferMs(band);
5914
+ const deadlineMs = attachRetainDeadlineMs(attach);
5915
+ const latestKeptStart = (lastMs) => {
5916
+ if (lastMs == null) return null;
5917
+ const windowEndMs = lastMs + postMs;
5918
+ return attach.attachedAtMs <= windowEndMs ? Math.max(windowEndMs, deadlineMs) : windowEndMs;
5919
+ };
5920
+ const bounds = [band.triggers?.motion === true ? latestKeptStart(triggers.lastMotionMs) : null, band.triggers?.audioThresholdDbfs != null ? latestKeptStart(triggers.lastAudioMs) : null].filter((ms) => ms != null);
5921
+ if (bounds.length === 0) return null;
5922
+ return segStartMs - Math.max(...bounds);
5923
+ }
5924
+ /** Local-calendar instant for `HH:MM` on the given local Y/M/D (day may overflow). */
5925
+ function localInstantMs(year, month, day, hhmm) {
5926
+ const [hours, minutes] = hhmm.split(":");
5927
+ return new Date(year, month, day, Number(hours), Number(minutes), 0, 0).getTime();
5928
+ }
5929
+ /**
5930
+ * Every instant inside the horizon at which `bandActiveAt` COULD change value:
5931
+ * each band's start and end on each local day, plus each local midnight (the
5932
+ * `days` membership boundary). A superset — cheap to compute (a few dozen
5933
+ * entries) and filtered against `activeBandAt` below.
5934
+ */
5935
+ function candidateEdgesMs(bands, nowMs) {
5936
+ const base = new Date(nowMs);
5937
+ const year = base.getFullYear();
5938
+ const month = base.getMonth();
5939
+ const day = base.getDate();
5940
+ const out = [];
5941
+ for (let offset = 0; offset <= 8; offset++) {
5942
+ out.push(new Date(year, month, day + offset, 0, 0, 0, 0).getTime());
5943
+ for (const band of bands) {
5944
+ out.push(localInstantMs(year, month, day + offset, band.start));
5945
+ out.push(localInstantMs(year, month, day + offset, band.end));
5946
+ }
5947
+ }
5948
+ return out;
5949
+ }
5950
+ /**
5951
+ * The next instant at which a DIFFERENT band (or no band) covers the clock, or
5952
+ * `null` when none exists inside {@link BAND_EDGE_HORIZON_DAYS} — the always-on
5953
+ * band (`start === end`, empty `days`) being the common case that legitimately
5954
+ * has no edge at all.
5955
+ *
5956
+ * Identity is by band REFERENCE, which is what makes this exact: two distinct
5957
+ * band objects with the same fields yield an edge that changes nothing, which
5958
+ * costs one wasted wake-up and never costs footage.
5959
+ */
5960
+ function nextBandEdgeMs(config, nowMs) {
5961
+ const bands = config.bands;
5962
+ if (!bands || bands.length === 0) return null;
5963
+ const current = activeBandAt({ bands }, new Date(nowMs));
5964
+ const candidates = [...new Set(candidateEdgesMs(bands, nowMs))].filter((ms) => ms > nowMs).toSorted((a, b) => a - b);
5965
+ for (const ms of candidates) if (activeBandAt({ bands }, new Date(ms)) !== current) return ms;
5966
+ return null;
5967
+ }
5968
+ /**
5969
+ * The first instant an `events` band's demand window is CLOSED, given the
5970
+ * triggers it listens for, or `null` when no window is open at `nowMs`.
5971
+ *
5972
+ * Demand is an OR across the listened trigger kinds, so an open window ends
5973
+ * with the LATEST of them — a motion trigger followed by an audio trigger 5 s
5974
+ * later keeps the camera attached until the audio one expires, exactly as the
5975
+ * sliding window already behaved.
5976
+ */
5977
+ function eventsWindowCloseMs(band, triggers, nowMs) {
5978
+ if (band.mode !== "events") return null;
5979
+ const { postMs } = resolveBandBufferMs(band);
5980
+ const ends = [];
5981
+ if (band.triggers?.motion === true && triggers.lastMotionMs != null) ends.push(triggers.lastMotionMs + postMs);
5982
+ if (band.triggers?.audioThresholdDbfs != null && triggers.lastAudioMs != null) ends.push(triggers.lastAudioMs + postMs);
5983
+ const open = ends.filter((ms) => ms >= nowMs);
5984
+ if (open.length === 0) return null;
5985
+ return Math.max(...open) + 1;
5986
+ }
5987
+ /**
5988
+ * THE next instant the controller must re-evaluate this device, or `null` when
5989
+ * nothing can change without an event the controller already receives (a
5990
+ * trigger or a config write).
5991
+ *
5992
+ * Whichever of the two sources comes first wins, and the reason is carried so
5993
+ * the wake-up is attributable in the log without correlating timestamps.
5994
+ */
5995
+ function nextDecisionChangeAt(input) {
5996
+ const { config, triggers, nowMs } = input;
5997
+ if (!config.enabled) return null;
5998
+ const bands = config.bands;
5999
+ if (!bands || bands.length === 0) return null;
6000
+ const band = activeBandAt({ bands }, new Date(nowMs));
6001
+ const edgeMs = nextBandEdgeMs(config, nowMs);
6002
+ const closeMs = band === null ? null : eventsWindowCloseMs(band, triggers, nowMs);
6003
+ if (closeMs === null) return edgeMs === null ? null : {
6004
+ atMs: edgeMs,
6005
+ reason: "band-edge"
6006
+ };
6007
+ if (edgeMs === null || closeMs <= edgeMs) return {
6008
+ atMs: closeMs,
6009
+ reason: "window-close"
6010
+ };
6011
+ return {
6012
+ atMs: edgeMs,
6013
+ reason: "band-edge"
6014
+ };
6015
+ }
6016
+ //#endregion
6017
+ //#region src/recorder/addon/device-wakeups.ts
6018
+ /** Node truncates a `setTimeout` delay past this to 1 ms — clamp instead. */
6019
+ var MAX_TIMER_DELAY_MS = 2147483647;
6020
+ var DeviceWakeups = class {
6021
+ deps;
6022
+ timers = /* @__PURE__ */ new Map();
6023
+ constructor(deps) {
6024
+ this.deps = deps;
6025
+ }
6026
+ /**
6027
+ * Wake this device at `atMs`, replacing any wake-up already armed for it.
6028
+ * `reason` is carried into the log line so a wake is attributable without
6029
+ * correlating it against the band model by hand.
6030
+ */
6031
+ arm(deviceId, atMs, reason) {
6032
+ this.cancel(deviceId);
6033
+ const delayMs = Math.min(MAX_TIMER_DELAY_MS, Math.max(1, atMs - this.deps.now()));
6034
+ const timer = setTimeout(() => {
6035
+ this.timers.delete(deviceId);
6036
+ this.deps.fire(deviceId);
6037
+ }, delayMs);
6038
+ timer.unref?.();
6039
+ this.timers.set(deviceId, timer);
6040
+ this.deps.logger.debug("recorder: device wake-up armed", {
6041
+ tags: { deviceId },
6042
+ meta: {
6043
+ atMs,
6044
+ delayMs,
6045
+ reason
6046
+ }
6047
+ });
6048
+ }
6049
+ /** Drop this device's pending wake-up, if any. Idempotent. */
6050
+ cancel(deviceId) {
6051
+ const timer = this.timers.get(deviceId);
6052
+ if (timer === void 0) return;
6053
+ clearTimeout(timer);
6054
+ this.timers.delete(deviceId);
6055
+ }
6056
+ /** Drop every pending wake-up — shutdown and maintenance pause. */
6057
+ cancelAll() {
6058
+ for (const timer of this.timers.values()) clearTimeout(timer);
6059
+ this.timers.clear();
6060
+ }
6061
+ /** Devices with a wake-up pending — for tests and for shutdown assertions. */
6062
+ get pendingCount() {
6063
+ return this.timers.size;
6064
+ }
6065
+ };
5852
6066
  //#endregion
5853
6067
  //#region src/recorder/addon/periodic-pass.ts
5854
6068
  function startPeriodicPass(deps) {
@@ -6432,19 +6646,37 @@ var SegmentWriter = class {
6432
6646
  * - a `live.m3u8` `SegmentWatcher` that hands each finalized flat segment to
6433
6647
  * the v2 `SegmentStore.onFinalized` (stat + relocate + index).
6434
6648
  *
6435
- * Per device it decides whether a CONTINUOUS band is active right now
6436
- * (`shouldRecordContinuousAt`) and ensures the writer set is running iff so.
6437
- * It re-evaluates on three triggers:
6438
- * 1. `setDeviceConfig` (operator changed the bands / enable),
6439
- * 2. a periodic pass (catches band boundaries crossed with no config change),
6440
- * 3. a `stream-broker` ready transition (boot restore via `ReadinessRestore`).
6441
- *
6442
- * There are TWO periodic passes, on separate timers and never overlapping
6443
- * themselves (`periodic-pass.ts`): a cheap LIVENESS pass that catches writers
6444
- * pinned but producing nothing, and an expensive CONVERGENCE pass that
6445
- * re-evaluates bands and placement. They were one pass until 2026-08-13, when a
6446
- * convergence sweep wedged on unbounded broker RPCs and took the liveness
6447
- * watchdog down with it for 9-27 minutes at a time.
6649
+ * Per device it decides whether the active band demands recording right now
6650
+ * (`shouldRecordAt`) and ensures the writer set is running iff so. Convergence
6651
+ * is EVENT-DRIVEN; the periodic pass is a backstop, not the mechanism:
6652
+ * 1. `setDeviceConfig` (operator changed the bands / enable) — an RPC,
6653
+ * 2. a qualifying motion/audio trigger (`onTrigger`) opens a window,
6654
+ * 3. a per-device WAKE-UP armed for the exact instant the decision could
6655
+ * change on its own (`decision-schedule.ts` + `device-wakeups.ts`): an
6656
+ * `events` window closing at `lastTrigger + postBufferSec`, or a band edge,
6657
+ * 4. a `stream-broker` ready transition (boot restore via `ReadinessRestore`),
6658
+ * 5. the periodic RECONCILE (below), for everything the four above can lose.
6659
+ *
6660
+ * (3) is why a detach is now punctual. A trigger only ever OPENED a window — the
6661
+ * demand test is a sliding `atMs - lastMotionMs <= postMs` anchored on the LAST
6662
+ * trigger, so nothing called `evaluateDevice` at the moment it closed and the
6663
+ * detach waited for the next pass. A 4K camera stayed dialled, decoded and
6664
+ * written for up to a whole tick past the operator's `postBufferSec`.
6665
+ *
6666
+ * There are THREE periodic passes, each on its own timer and each non-overlapping
6667
+ * (`periodic-pass.ts`):
6668
+ * - LIVENESS ({@link LIVENESS_TICK_MS}) — cheap, in-memory: is a pinned writer
6669
+ * producing nothing?
6670
+ * - PLACEMENT ({@link PLACEMENT_TICK_MS}) — cheap (a statfs per location), and
6671
+ * deliberately NOT on the attach beat, so a cold broker cannot delay it.
6672
+ * - RECONCILE ({@link RECONCILE_TICK_MS}) — the only one that can touch the
6673
+ * broker, and the only backstop for a lost wake-up or a failed attach.
6674
+ *
6675
+ * They were ONE pass until 2026-08-13, when a convergence sweep wedged on
6676
+ * unbounded broker RPCs and took the liveness watchdog down with it for 9-27
6677
+ * minutes at a time; placement was split out of the second on 2026-08-18, after
6678
+ * a converge pass blocked for 60 001 ms (3 x {@link ATTACH_RPC_TIMEOUT_MS})
6679
+ * cold-dialling ~40 RTSP sources.
6448
6680
  *
6449
6681
  * EVENTS bands are treated as "not recording continuously" in B2 — B3 adds
6450
6682
  * trigger-gating. The enable intent is persisted in durable-state by the
@@ -6457,8 +6689,45 @@ var SegmentWriter = class {
6457
6689
  * `ReadinessRegistry` is the single source of truth.
6458
6690
  */
6459
6691
  var DEFAULT_WATCH_INTERVAL_MS = 2e3;
6460
- /** Periodic re-evaluation to catch band boundaries crossed with no config change. */
6461
- var TICK_MS = 3e4;
6692
+ /**
6693
+ * Liveness cadence — "is a pinned writer producing nothing?". Unchanged: the
6694
+ * question is an in-memory scan over `active`, it costs nothing, and its bound
6695
+ * (`writerIdleBoundMs`, floor 90 s) is sized against this beat.
6696
+ */
6697
+ var LIVENESS_TICK_MS = 3e4;
6698
+ /**
6699
+ * Placement cadence — unchanged from the beat it used to share with
6700
+ * convergence, because nothing about placement wanted to be rarer. What changed
6701
+ * is that it no longer rides a pass that can block on broker RPCs: it is one
6702
+ * `statfs` per location and it re-plans only, so a plan reaches a camera at its
6703
+ * NEXT attach.
6704
+ */
6705
+ var PLACEMENT_TICK_MS = 3e4;
6706
+ /**
6707
+ * Reconcile cadence — the SAFETY NET, not the mechanism.
6708
+ *
6709
+ * It was 30 s because it WAS the mechanism: nothing else ever closed an `events`
6710
+ * window or crossed a band edge, so the loop had to keep asking. Both are now
6711
+ * armed for their exact instant (`decision-schedule.ts`), a config change is an
6712
+ * RPC, and a broker recovery is a readiness transition — so this pass exists
6713
+ * only for what those cannot cover: a wake-up lost because the event path threw
6714
+ * before it could arm one, an attach that failed with no later ready transition
6715
+ * to ride, a timer lost to a runner respawn, and clock drift.
6716
+ *
6717
+ * 2 minutes, and the number is chosen against a bound this recorder already
6718
+ * accepts. The liveness watchdog tolerates a pinned-but-silent writer for
6719
+ * `WRITER_IDLE_MIN_MS` (90 s) before it recovers it; a device that WANTS
6720
+ * recording but is detached is the same class of fault — footage is not being
6721
+ * written and only a periodic check will notice — so its recovery is put on the
6722
+ * same order rather than an order slower. Four times rarer than the beat that
6723
+ * blocked for 60 001 ms on 2026-08-18, while still bounding the worst case at
6724
+ * ~2 minutes of footage for a camera whose event path failed entirely.
6725
+ *
6726
+ * Going rarer than this trades real footage for CPU that the pass no longer
6727
+ * spends anyway: with the broker-readiness pre-check below, a reconcile over a
6728
+ * healthy fleet is N config reads and no RPC at all.
6729
+ */
6730
+ var RECONCILE_TICK_MS = 12e4;
6462
6731
  /**
6463
6732
  * Bound on the broker lease-release RPC (`releaseStreamWithCodec`) during
6464
6733
  * teardown. The UDS request default is 60s, but the runner supervisor's
@@ -6497,11 +6766,15 @@ var RELEASE_RPC_TIMEOUT_MS = 2e3;
6497
6766
  */
6498
6767
  var ATTACH_RPC_TIMEOUT_MS = 2e4;
6499
6768
  /**
6500
- * A pass longer than this is reported with its duration. Equal to the pass
6501
- * cadence: a pass that cannot finish inside its own interval is the condition
6502
- * that used to go unlogged for half an hour.
6769
+ * A pass longer than this is reported with its duration. Each pass uses its OWN
6770
+ * cadence as the budget: a pass that cannot finish inside its own interval is
6771
+ * the condition that used to go unlogged for half an hour. Keeping it per-pass
6772
+ * matters now that the three cadences differ — a 40 s reconcile is healthy
6773
+ * against a 120 s beat and was a red flag against a 30 s one.
6503
6774
  */
6504
- var PASS_SLOW_AFTER_MS = TICK_MS;
6775
+ function passSlowAfterMs(intervalMs) {
6776
+ return intervalMs;
6777
+ }
6505
6778
  /**
6506
6779
  * Race `promise` against a deadline. Handlers stay attached to the losing
6507
6780
  * promise, so a post-deadline settlement can never surface as a process-level
@@ -6584,6 +6857,14 @@ var RecordingController = class {
6584
6857
  */
6585
6858
  lastSegmentAt = /* @__PURE__ */ new Map();
6586
6859
  /**
6860
+ * Per `deviceId:profile`, the `attachedAtMs` of the attach that has retained
6861
+ * at least one `events` segment. Read only to LEVEL the discard log: an
6862
+ * attach that kept nothing is a wasted pull (warn), a later discard from one
6863
+ * that kept something is the tail of the window (info). Bounded by
6864
+ * device×profile like {@link lastSegmentAt}, and cleared with it on detach.
6865
+ */
6866
+ attachRetained = /* @__PURE__ */ new Map();
6867
+ /**
6587
6868
  * Devices already named by {@link reportUnrecordable} — one warn per device
6588
6869
  * per "enabled but can never record" episode, re-armed as soon as the device
6589
6870
  * records again.
@@ -6597,24 +6878,79 @@ var RecordingController = class {
6597
6878
  */
6598
6879
  skippedProfiles = /* @__PURE__ */ new Map();
6599
6880
  restore = null;
6881
+ /**
6882
+ * One pending wake-up per device, armed for the exact instant its recording
6883
+ * decision could change on its own — an `events` window closing or a band
6884
+ * edge. THE reason a detach is punctual instead of up to a pass late.
6885
+ */
6886
+ wakeups;
6600
6887
  /** Cheap, never-delayed: is any pinned writer producing nothing? */
6601
6888
  livenessPass = null;
6602
- /** Expensive: re-evaluate bands + placement for every configured device. */
6603
- convergePass = null;
6889
+ /** Cheap, and off the attach beat: re-plan where each (device, profile) writes. */
6890
+ placementPass = null;
6891
+ /** The backstop: re-evaluate every tracked-or-configured device. */
6892
+ reconcilePass = null;
6604
6893
  stopped = false;
6605
6894
  /** A reversible maintenance lease. Unlike `stopped`, it never changes
6606
6895
  * persisted recording intent and `resume()` restarts normal convergence. */
6607
6896
  paused = false;
6608
6897
  constructor(deps) {
6609
6898
  this.deps = deps;
6899
+ this.wakeups = new DeviceWakeups({
6900
+ logger: deps.logger,
6901
+ now: () => this.now(),
6902
+ fire: (deviceId) => this.onWakeup(deviceId)
6903
+ });
6610
6904
  }
6611
6905
  now() {
6612
6906
  return this.deps.now?.() ?? Date.now();
6613
6907
  }
6614
6908
  /**
6909
+ * A per-device wake-up came due: re-decide. `evaluateDevice` re-arms (or
6910
+ * cancels) the next one as part of deciding, so the chain is self-sustaining.
6911
+ *
6912
+ * A throw here breaks that chain — the device keeps whatever state it had and
6913
+ * has no timer behind it — so it is NAMED, and the reconcile is what picks it
6914
+ * up. Silence would read as "the window never closed".
6915
+ */
6916
+ onWakeup(deviceId) {
6917
+ this.evaluateDevice(deviceId).catch((err) => {
6918
+ this.deps.logger.warn("recorder: scheduled re-evaluation failed — no wake-up re-armed (the reconcile is the backstop)", {
6919
+ tags: { deviceId },
6920
+ meta: { error: errMsg(err) }
6921
+ });
6922
+ });
6923
+ }
6924
+ /**
6925
+ * Arm (or cancel) this device's next wake-up from the config it was just
6926
+ * evaluated against.
6927
+ *
6928
+ * Called from `evaluateDevice` BEFORE the attach/detach work, not after: the
6929
+ * attach chain can throw (broker not ready, stream unpublished) and the
6930
+ * schedule does not depend on whether it succeeded. Arming first means a
6931
+ * failed attach still gets its band edge, and only a failure to READ the
6932
+ * config can lose a wake-up.
6933
+ */
6934
+ scheduleNextDecision(deviceId, config) {
6935
+ if (this.stopped || this.paused) {
6936
+ this.wakeups.cancel(deviceId);
6937
+ return;
6938
+ }
6939
+ const next = nextDecisionChangeAt({
6940
+ config,
6941
+ triggers: this.triggersFor(deviceId),
6942
+ nowMs: this.now()
6943
+ });
6944
+ if (next === null) {
6945
+ this.wakeups.cancel(deviceId);
6946
+ return;
6947
+ }
6948
+ this.wakeups.arm(deviceId, next.atMs, next.reason);
6949
+ }
6950
+ /**
6615
6951
  * Boot restore: seed the readiness-gated queue with every persisted device and
6616
6952
  * (re)evaluate each on every `stream-broker` ready transition. Also arms the
6617
- * periodic tick. Idempotent — safe to call once after the index is hydrated.
6953
+ * periodic passes. Idempotent — safe to call once after the index is hydrated.
6618
6954
  */
6619
6955
  async start() {
6620
6956
  if (this.stopped) return;
@@ -6635,45 +6971,83 @@ var RecordingController = class {
6635
6971
  meta: { error: errMsg(err) }
6636
6972
  })
6637
6973
  });
6638
- if (this.convergePass === null) {
6974
+ if (this.reconcilePass === null) {
6639
6975
  this.livenessPass = startPeriodicPass({
6640
6976
  name: "liveness",
6641
- intervalMs: TICK_MS,
6642
- slowAfterMs: PASS_SLOW_AFTER_MS,
6977
+ intervalMs: LIVENESS_TICK_MS,
6978
+ slowAfterMs: passSlowAfterMs(LIVENESS_TICK_MS),
6643
6979
  logger: this.deps.logger,
6644
6980
  now: () => this.now(),
6645
6981
  run: () => this.checkIdleWriters()
6646
6982
  });
6647
- this.convergePass = startPeriodicPass({
6648
- name: "convergence",
6649
- intervalMs: TICK_MS,
6650
- slowAfterMs: PASS_SLOW_AFTER_MS,
6983
+ this.placementPass = startPeriodicPass({
6984
+ name: "placement",
6985
+ intervalMs: PLACEMENT_TICK_MS,
6986
+ slowAfterMs: passSlowAfterMs(PLACEMENT_TICK_MS),
6651
6987
  logger: this.deps.logger,
6652
6988
  now: () => this.now(),
6653
- run: () => this.converge()
6989
+ run: () => this.replanPlacement()
6990
+ });
6991
+ this.reconcilePass = startPeriodicPass({
6992
+ name: "reconcile",
6993
+ intervalMs: RECONCILE_TICK_MS,
6994
+ slowAfterMs: passSlowAfterMs(RECONCILE_TICK_MS),
6995
+ logger: this.deps.logger,
6996
+ now: () => this.now(),
6997
+ run: () => this.reconcile()
6654
6998
  });
6655
6999
  }
6656
7000
  await this.restore.start(ids.map((id) => [id, true]));
6657
7001
  }
6658
- /** Re-evaluate every currently-tracked OR active device on the periodic pass. */
6659
- async converge() {
6660
- if (this.stopped) return;
7002
+ /**
7003
+ * Every device the recorder is responsible for right now: those with writers
7004
+ * attached, plus every one with a persisted config. A failure to enumerate the
7005
+ * persisted set degrades to "the active ones" rather than aborting the pass.
7006
+ */
7007
+ async trackedDeviceIds() {
6661
7008
  const ids = new Set(this.active.keys());
6662
- let persisted = [];
6663
7009
  try {
6664
- persisted = await this.deps.enabledDeviceIds();
6665
- } catch {}
6666
- for (const id of persisted) ids.add(id);
7010
+ for (const id of await this.deps.enabledDeviceIds()) ids.add(id);
7011
+ } catch (err) {
7012
+ this.deps.logger.warn("recorder controller: could not enumerate persisted devices — this pass covers the active ones only", { meta: {
7013
+ error: errMsg(err),
7014
+ activeDevices: this.active.size
7015
+ } });
7016
+ }
7017
+ return [...ids];
7018
+ }
7019
+ /**
7020
+ * Re-plan where each (device, profile) writes. Its OWN pass: cheap (no I/O
7021
+ * beyond a statfs per location), and it must not be starved by an attach that
7022
+ * is waiting on a 20 s broker timeout. It changes the PLAN only — a running
7023
+ * writer keeps its root until it next attaches, which is the boundary rule.
7024
+ */
7025
+ async replanPlacement() {
7026
+ if (this.stopped || this.paused) return;
7027
+ if (this.deps.onPlacementTick === void 0) return;
7028
+ const ids = await this.trackedDeviceIds();
6667
7029
  try {
6668
- await this.deps.onPlacementTick?.([...ids]);
7030
+ await this.deps.onPlacementTick(ids);
6669
7031
  } catch (err) {
6670
7032
  this.deps.logger.warn("recorder controller: placement recompute failed", { meta: { error: errMsg(err) } });
6671
7033
  }
6672
- await runBounded([...ids], 8, async (id) => {
7034
+ }
7035
+ /**
7036
+ * The backstop pass: re-evaluate every tracked-or-configured device.
7037
+ *
7038
+ * Nothing here is the normal path any more — a window close, a band edge, a
7039
+ * config change and a broker recovery all reach `evaluateDevice` on their own.
7040
+ * This exists for the four things that cannot: a wake-up the event path threw
7041
+ * before arming, an attach that failed with no later readiness transition to
7042
+ * ride, a timer lost to a runner respawn, and clock drift.
7043
+ */
7044
+ async reconcile() {
7045
+ if (this.stopped) return;
7046
+ await runBounded(await this.trackedDeviceIds(), 8, async (id) => {
6673
7047
  try {
6674
7048
  await this.evaluateDevice(id);
6675
7049
  } catch (err) {
6676
- this.deps.logger.warn("recorder controller: convergence evaluate failed", {
7050
+ this.deps.logger.warn("recorder controller: reconcile evaluate failed", {
6677
7051
  tags: { deviceId: id },
6678
7052
  meta: { error: errMsg(err) }
6679
7053
  });
@@ -6697,8 +7071,9 @@ var RecordingController = class {
6697
7071
  * idle source can re-dial cleanly), then re-evaluate: if the band still demands
6698
7072
  * recording, `evaluateDevice` re-queues + re-attaches a fresh writer set. If
6699
7073
  * the broker is not ready, the re-attach throws and the device stays on the
6700
- * readiness queue (retried on the next broker ready) — and the periodic tick is
6701
- * the backstop. Bounded OUTER retry (TICK_MS cadence), never a tight loop.
7074
+ * readiness queue (retried on the next broker ready) — and the periodic
7075
+ * reconcile is the backstop. Bounded OUTER retry (RECONCILE_TICK_MS cadence),
7076
+ * never a tight loop.
6702
7077
  */
6703
7078
  async recoverWriterGaveUp(deviceId) {
6704
7079
  if (this.stopped) return;
@@ -6708,7 +7083,7 @@ var RecordingController = class {
6708
7083
  await this.evaluateDevice(deviceId);
6709
7084
  } catch (err) {
6710
7085
  this.restore?.add(deviceId, true);
6711
- this.deps.logger.warn("recorder: writer give-up recovery deferred (retries on next broker ready/tick)", {
7086
+ this.deps.logger.warn("recorder: writer give-up recovery deferred (retries on next broker ready/reconcile)", {
6712
7087
  tags: { deviceId },
6713
7088
  meta: { error: errMsg(err) }
6714
7089
  });
@@ -6759,10 +7134,16 @@ var RecordingController = class {
6759
7134
  }
6760
7135
  /**
6761
7136
  * Record a qualifying trigger (motion/audio) for a device and converge: an
6762
- * `events` band whose window this opens attaches now; the periodic tick
6763
- * detaches it once `postBufferSec` elapses. Called from the addon's
6764
- * EventCapture subscription. The qualification test (motion detected / audio
6765
- * over threshold) is done upstream by the time we're here, it qualified.
7137
+ * `events` band whose window this opens attaches now, and the same
7138
+ * `evaluateDevice` arms the wake-up that will CLOSE it at
7139
+ * `atMs + postBufferSec`. Called from the addon's EventCapture subscription.
7140
+ * The qualification test (motion detected / audio over threshold) is done
7141
+ * upstream — by the time we're here, it qualified.
7142
+ *
7143
+ * A fresher trigger re-arms the wake-up later, which is exactly how the
7144
+ * sliding window already behaved: continuous motion stays ONE recording,
7145
+ * because `evaluateDevice` short-circuits at "already recording — converged"
7146
+ * and only the timer moves.
6766
7147
  */
6767
7148
  async onTrigger(deviceId, source, atMs) {
6768
7149
  if (this.stopped) return;
@@ -6774,7 +7155,7 @@ var RecordingController = class {
6774
7155
  try {
6775
7156
  await this.evaluateDevice(deviceId);
6776
7157
  } catch (err) {
6777
- this.deps.logger.warn("recorder: trigger attach deferred — stream not available yet (retries on next broker ready/tick)", {
7158
+ this.deps.logger.warn("recorder: trigger attach deferred — stream not available yet (retries on next broker ready/reconcile)", {
6778
7159
  tags: { deviceId },
6779
7160
  meta: {
6780
7161
  source,
@@ -6795,7 +7176,9 @@ var RecordingController = class {
6795
7176
  if (this.stopped || this.paused) return;
6796
7177
  const config = await this.deps.loadConfig(deviceId);
6797
7178
  if (this.stopped || this.paused) return;
6798
- if (!shouldRecordAt(config, this.triggersFor(deviceId), new Date(this.now()))) {
7179
+ const wantRecording = shouldRecordAt(config, this.triggersFor(deviceId), new Date(this.now()));
7180
+ this.scheduleNextDecision(deviceId, config);
7181
+ if (!wantRecording) {
6799
7182
  this.reportUnrecordable(deviceId, config);
6800
7183
  await this.detachDevice(deviceId);
6801
7184
  this.restore?.remove(deviceId);
@@ -6854,6 +7237,15 @@ var RecordingController = class {
6854
7237
  });
6855
7238
  }
6856
7239
  /**
7240
+ * Throw before spending a single broker RPC when the registry already says the
7241
+ * `stream-broker` on the owner node is not ready. See
7242
+ * {@link RecordingControllerDeps.brokerReady}; absent → assume ready.
7243
+ */
7244
+ failFastIfBrokerCold(deviceId) {
7245
+ if (this.deps.brokerReady?.() !== false) return;
7246
+ throw new Error(`recorder controller: stream-broker not ready on ${this.deps.ownerNodeId} — attach for device ${deviceId} deferred to the next ready transition`);
7247
+ }
7248
+ /**
6857
7249
  * The attach body — extracted so the {@link attaching} in-flight guard in
6858
7250
  * `evaluateDevice` wraps it cleanly. Resolves the profiles, spawns a
6859
7251
  * SegmentWriter + watcher per profile, and records the set in `active`.
@@ -6865,6 +7257,7 @@ var RecordingController = class {
6865
7257
  */
6866
7258
  async performAttach(deviceId, config) {
6867
7259
  this.restore?.add(deviceId, true);
7260
+ this.failFastIfBrokerCold(deviceId);
6868
7261
  const profiles = await this.resolveProfiles(deviceId, config.profiles);
6869
7262
  if (profiles.length === 0) throw new Error(`recorder controller: no assigned broker sources for device ${deviceId}`);
6870
7263
  const segmentSeconds = config.segmentSeconds ?? this.deps.segmentSeconds;
@@ -6922,6 +7315,16 @@ var RecordingController = class {
6922
7315
  }
6923
7316
  }
6924
7317
  async runFillMissingProfiles(deviceId, config, current, skipped) {
7318
+ if (this.deps.brokerReady?.() === false) {
7319
+ this.deps.logger.info("recorder: skipped-profile retry deferred — stream-broker not ready (healthy profiles keep recording)", {
7320
+ tags: { deviceId },
7321
+ meta: {
7322
+ profiles: [...skipped],
7323
+ ownerNodeId: this.deps.ownerNodeId
7324
+ }
7325
+ });
7326
+ return;
7327
+ }
6925
7328
  const have = new Set(current.map((r) => r.profile));
6926
7329
  const segmentSeconds = config.segmentSeconds ?? this.deps.segmentSeconds;
6927
7330
  const added = [];
@@ -7014,7 +7417,10 @@ var RecordingController = class {
7014
7417
  intervalMs: this.deps.watchIntervalMs > 0 ? this.deps.watchIntervalMs : DEFAULT_WATCH_INTERVAL_MS,
7015
7418
  onFinalized: async (startMs, durMs, flatAbsPath) => {
7016
7419
  this.lastSegmentAt.set(idleKey(deviceId, profile), this.now());
7017
- await this.handleFinalizedSegment(deviceId, profile, placement, startMs, durMs, flatAbsPath);
7420
+ await this.handleFinalizedSegment(deviceId, profile, placement, {
7421
+ attachedAtMs: writerStartedMs,
7422
+ segmentSeconds
7423
+ }, startMs, durMs, flatAbsPath);
7018
7424
  },
7019
7425
  logger: deviceLog
7020
7426
  }),
@@ -7031,13 +7437,16 @@ var RecordingController = class {
7031
7437
  * keep/discard gate touches `events`-mode segments only, so the continuous
7032
7438
  * path is unchanged.
7033
7439
  */
7034
- async handleFinalizedSegment(deviceId, profile, placement, startMs, durMs, flatAbsPath) {
7440
+ async handleFinalizedSegment(deviceId, profile, placement, attach, startMs, durMs, flatAbsPath) {
7035
7441
  try {
7036
7442
  const band = activeBandAt({ bands: (await this.deps.loadConfig(deviceId)).bands ?? [] }, new Date(startMs + durMs));
7037
- if (band?.mode === "events" && !segmentRetainedForEvents(startMs, startMs + durMs, band, this.triggersFor(deviceId))) {
7443
+ const triggers = this.triggersFor(deviceId);
7444
+ if (band?.mode === "events" && !segmentRetainedForEvents(startMs, startMs + durMs, band, triggers, attach)) {
7445
+ this.reportDiscardedSegment(deviceId, profile, attach, band, triggers, startMs, durMs);
7038
7446
  await promises.unlink(flatAbsPath).catch(() => {});
7039
7447
  return;
7040
7448
  }
7449
+ if (band?.mode === "events") this.attachRetained.set(idleKey(deviceId, profile), attach.attachedAtMs);
7041
7450
  await this.deps.segmentStore.onFinalized({
7042
7451
  deviceId,
7043
7452
  profile,
@@ -7058,6 +7467,47 @@ var RecordingController = class {
7058
7467
  }
7059
7468
  }
7060
7469
  /**
7470
+ * Name a discarded `events`-band segment. This branch deletes recorded
7471
+ * footage and used to write NOTHING: on camera 1439 the operator saw `pinned`
7472
+ * then `unpinned` with an empty timeline in between and no line anywhere said
7473
+ * why — the recorder had spawned ffmpeg, pulled 4K RTSP for 31 s and unlinked
7474
+ * every segment it produced.
7475
+ *
7476
+ * Level is chosen, not defaulted. A discard at the TAIL of a window is the
7477
+ * feature working (`info`) — the writer keeps running until the next converge
7478
+ * tick and those trailing segments were never asked for. A discard from an
7479
+ * attach that has retained NOTHING is a fault (`warn`): the whole pull was
7480
+ * wasted, which is exactly the shape of the 1439 loss and of any future
7481
+ * regression in the attach allowance.
7482
+ */
7483
+ reportDiscardedSegment(deviceId, profile, attach, band, triggers, startMs, durMs) {
7484
+ const { preMs, postMs } = resolveBandBufferMs(band);
7485
+ const keptAnything = this.attachRetained.get(idleKey(deviceId, profile)) === attach.attachedAtMs;
7486
+ const meta = {
7487
+ profile,
7488
+ startMs,
7489
+ durMs,
7490
+ lastMotionMs: triggers.lastMotionMs,
7491
+ lastAudioMs: triggers.lastAudioMs,
7492
+ postMs,
7493
+ preMs,
7494
+ attachedAtMs: attach.attachedAtMs,
7495
+ missedByMs: segmentMissedByMs(startMs, band, triggers, attach),
7496
+ keptAnythingFromThisAttach: keptAnything
7497
+ };
7498
+ if (keptAnything) {
7499
+ this.deps.logger.info("recorder: discarding events segment outside every trigger window (tail of the window)", {
7500
+ tags: { deviceId },
7501
+ meta
7502
+ });
7503
+ return;
7504
+ }
7505
+ this.deps.logger.warn("recorder: discarding events segment — this attach has retained NOTHING, the whole pull is wasted", {
7506
+ tags: { deviceId },
7507
+ meta
7508
+ });
7509
+ }
7510
+ /**
7061
7511
  * Resolve the storage location a (device, profile) writes to, at an attach
7062
7512
  * boundary. Delegates to the injected {@link RecordingControllerDeps.placeProfile}
7063
7513
  * when the addon supplied one; otherwise to the pure `resolvePlacement` in
@@ -7099,7 +7549,10 @@ var RecordingController = class {
7099
7549
  if (!recordings) return;
7100
7550
  this.active.delete(deviceId);
7101
7551
  this.skippedProfiles.delete(deviceId);
7102
- for (const r of recordings) this.lastSegmentAt.delete(idleKey(deviceId, r.profile));
7552
+ for (const r of recordings) {
7553
+ this.lastSegmentAt.delete(idleKey(deviceId, r.profile));
7554
+ this.attachRetained.delete(idleKey(deviceId, r.profile));
7555
+ }
7103
7556
  await Promise.all(recordings.map((r) => this.teardownProfile(deviceId, r)));
7104
7557
  this.deps.logger.info("recorder unpinned broker source(s) — recording stopped", {
7105
7558
  tags: { deviceId },
@@ -7110,6 +7563,7 @@ var RecordingController = class {
7110
7563
  async pause() {
7111
7564
  if (this.stopped || this.paused) return;
7112
7565
  this.paused = true;
7566
+ this.wakeups.cancelAll();
7113
7567
  while (this.attachingTasks.size > 0) await Promise.allSettled([...this.attachingTasks.values()]);
7114
7568
  await Promise.all(Array.from(this.active.keys()).map((deviceId) => this.detachDevice(deviceId)));
7115
7569
  }
@@ -7125,13 +7579,16 @@ var RecordingController = class {
7125
7579
  }
7126
7580
  for (const deviceId of ids) await this.evaluateDevice(deviceId);
7127
7581
  }
7128
- /** Tear EVERYTHING down: timer, restore subscription, every writer/watcher/lease. */
7582
+ /** Tear EVERYTHING down: timers, restore subscription, every writer/watcher/lease. */
7129
7583
  async stop() {
7130
7584
  this.stopped = true;
7585
+ this.wakeups.cancelAll();
7131
7586
  this.livenessPass?.stop();
7132
7587
  this.livenessPass = null;
7133
- this.convergePass?.stop();
7134
- this.convergePass = null;
7588
+ this.placementPass?.stop();
7589
+ this.placementPass = null;
7590
+ this.reconcilePass?.stop();
7591
+ this.reconcilePass = null;
7135
7592
  this.restore?.stop();
7136
7593
  this.restore = null;
7137
7594
  await Promise.all(Array.from(this.active.keys()).map((deviceId) => this.detachDevice(deviceId)));
@@ -8665,7 +9122,8 @@ var RecorderV2Addon = class extends BaseAddon {
8665
9122
  onBrokerReady: (handler) => this.ctx.onCapabilityStateChange("stream-broker", brokerScope(ingestOwner.ownerNodeId), (state) => {
8666
9123
  if (state === "ready") handler();
8667
9124
  }),
8668
- brokerCall: makeBrokerCall(brokerHandle)
9125
+ brokerCall: makeBrokerCall(brokerHandle),
9126
+ brokerReady: () => brokerHandle.isReady
8669
9127
  });
8670
9128
  const relocateEngine = new RelocateEngine({
8671
9129
  index: this.index,