@camstack/addon-pipeline 1.2.146 → 1.2.148

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 (37) hide show
  1. package/dist/audio-analyzer/index.js +2 -2
  2. package/dist/audio-analyzer/index.mjs +2 -2
  3. package/dist/detection-pipeline/index.js +8 -6
  4. package/dist/detection-pipeline/index.mjs +7 -5
  5. package/dist/{dist-DML3mpn9.js → dist-DLy_uz82.js} +121 -13
  6. package/dist/{dist-cZopp8Xm.mjs → dist-DjplinYP.mjs} +121 -13
  7. package/dist/{lazy-sharp-BzXIjfN8.js → lazy-sharp-BI4LsfMM.js} +1 -1
  8. package/dist/{local-frame-registry-Ctv_TzmF.mjs → local-frame-registry-77FL9ua_.mjs} +1 -1
  9. package/dist/{local-frame-registry-DO8-LuSo.js → local-frame-registry-Dr_U_xCG.js} +1 -1
  10. package/dist/motion-wasm/index.js +2 -2
  11. package/dist/motion-wasm/index.mjs +1 -1
  12. package/dist/{node-OvVqe9eh.js → node-BHFLhfkh.js} +1 -1
  13. package/dist/{node-Br6p9KvD.mjs → node-D7uxWSXD.mjs} +1 -1
  14. package/dist/pipeline-runner/index.js +5 -5
  15. package/dist/pipeline-runner/index.mjs +4 -4
  16. package/dist/{process-memory-DX25Z60Q.mjs → process-memory-B2tApNua.mjs} +1 -1
  17. package/dist/{process-memory-NWKrKfmi.js → process-memory-M8WiR3qj.js} +1 -1
  18. package/dist/recorder/index.js +619 -381
  19. package/dist/recorder/index.mjs +619 -381
  20. package/dist/{segment-demux-js-DGmFQ1Zj.mjs → segment-demux-js-3niBZUpJ.mjs} +1 -1
  21. package/dist/{segment-demux-js-CerfMroc.js → segment-demux-js-Cc_If7C_.js} +1 -1
  22. package/dist/session-decode/decode-worker-child.js +2 -2
  23. package/dist/session-decode/decode-worker-child.mjs +1 -1
  24. package/dist/stream-broker/_stub.js +2 -2
  25. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DQNgiTjz.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-QfvsYvjB.mjs} +3 -3
  26. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DXrU11Bz.mjs +26 -0
  27. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-bjYJYwAq.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CgIcw5Wl.mjs} +1 -1
  28. package/dist/stream-broker/demux-worker-child.js +1 -1
  29. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  30. package/dist/stream-broker/{hostInit-NShcJE25.mjs → hostInit-Dd2CEvDK.mjs} +3 -3
  31. package/dist/stream-broker/index.js +3 -3
  32. package/dist/stream-broker/index.mjs +3 -3
  33. package/dist/stream-broker/remoteEntry.js +1 -1
  34. package/dist/{worker-protocol-CUCQ1IPR.mjs → worker-protocol-Cvln9GmS.mjs} +1 -1
  35. package/dist/{worker-protocol-Dt3zWCQY.js → worker-protocol-basMNc_q.js} +1 -1
  36. package/package.json +1 -1
  37. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-D4-thZGg.mjs +0 -26
@@ -1,5 +1,5 @@
1
- const require_dist = require("../dist-DML3mpn9.js");
2
- const require_node = require("../node-OvVqe9eh.js");
1
+ const require_dist = require("../dist-DLy_uz82.js");
2
+ const require_node = require("../node-BHFLhfkh.js");
3
3
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
4
4
  const require_restream_intent = require("../restream-intent-Cv9x3jmu.js");
5
5
  const require_event_loop_stall_monitor = require("../event-loop-stall-monitor-DaUBcb13.js");
@@ -50,321 +50,6 @@ function subscribeEventCapture(deps) {
50
50
  });
51
51
  }
52
52
  //#endregion
53
- //#region src/recorder/object-trigger.ts
54
- /**
55
- * Il gate fra `pipeline-analytics.detection-event` e "apri una finestra di
56
- * registrazione".
57
- *
58
- * Fratello di `recorder/event-capture.ts` e per lo stesso motivo: la qualifica
59
- * ha dei rami, e un ramo che scarta lavoro senza dirlo è indistinguibile da una
60
- * funzione mai cablata. Ogni verdetto negativo NOMINA il motivo, e il chiamante
61
- * lo logga.
62
- *
63
- * Puro: nessun bus, nessun orologio, nessuna config. La scelta di QUALI classi
64
- * ascoltare è della banda (`bandTriggerSources`); qui si decide solo se
65
- * l'evento è una detection utilizzabile e di che macro classe è.
66
- *
67
- * ## Perché la forma è DISCRIMINATA su `kind` (2026-08-29)
68
- *
69
- * Fino al 2026-08-29 questo file dichiarava UNA sola forma piatta — quella
70
- * dell'evento `object` — e la pretendeva da OGNI payload del topic. Ma
71
- * `EventCategory.PipelineAnalyticsDetectionEvent` è un topic condiviso da tre
72
- * emettitori in `addon-post-analysis/src/pipeline-analytics/index.ts`: object
73
- * (`className`, `source`, `confidence`, `zones`), motion e audio — e motion e
74
- * audio non portano `className`, perché non ne hanno una. Il risultato misurato:
75
- * `malformed` su **394 righe campionate su 394**, a 3.3–3.9 eventi/s, per
76
- * settimane. `malformed` significa "l'emettitore è andato in deriva" ed era
77
- * invece il traffico normale del topic; l'unico secchio che poteva segnalare
78
- * una vera rottura di protocollo era saturo al 100% di eventi sani, quindi
79
- * inservibile — ed è stato letto come "il trigger object non ha mai funzionato".
80
- *
81
- * Quindi: si valida prima la BUSTA comune (`deviceId`, `kind`, `timestamp`),
82
- * poi si discrimina su `kind`, e solo il ramo `object` pretende i campi
83
- * dell'object. Un `object` senza `className` resta un difetto e ha il suo nome
84
- * (`malformed-object`), distinto da una busta illeggibile.
85
- */
86
- /** Il `kind` dell'unico ramo del topic che può aprire una finestra. */
87
- var OBJECT_EVENT_KIND = "object";
88
- /** La sorgente implicita delle righe legacy senza `source` (`DetectionSourceSchema`). */
89
- var DEFAULT_DETECTION_SOURCE = "pipeline";
90
- /** Type guard, non cast: `event.data` è `unknown` finché non passa di qui. */
91
- function isDetectionEventEnvelope(value) {
92
- if (typeof value !== "object" || value === null) return false;
93
- if (!("deviceId" in value) || typeof value.deviceId !== "number") return false;
94
- if (!Number.isFinite(value.deviceId)) return false;
95
- if (!("kind" in value) || typeof value.kind !== "string") return false;
96
- if (!("timestamp" in value) || typeof value.timestamp !== "number") return false;
97
- if (!Number.isFinite(value.timestamp) || value.timestamp <= 0) return false;
98
- return true;
99
- }
100
- /** I campi che SOLO il ramo object porta. Chiamata dopo la busta. */
101
- function isObjectDetectionEventData(value) {
102
- if (!("className" in value) || typeof value.className !== "string") return false;
103
- if ("source" in value && value.source !== void 0 && typeof value.source !== "string") return false;
104
- return true;
105
- }
106
- function qualifyObjectTrigger(data) {
107
- if (!isDetectionEventEnvelope(data)) return {
108
- qualified: false,
109
- reason: "malformed"
110
- };
111
- const deviceId = data.deviceId;
112
- if (data.kind !== OBJECT_EVENT_KIND) return {
113
- qualified: false,
114
- reason: "not-object",
115
- deviceId
116
- };
117
- if (!isObjectDetectionEventData(data)) return {
118
- qualified: false,
119
- reason: "malformed-object",
120
- deviceId
121
- };
122
- if ((data.source ?? DEFAULT_DETECTION_SOURCE) !== DEFAULT_DETECTION_SOURCE) return {
123
- qualified: false,
124
- reason: "not-pipeline",
125
- deviceId
126
- };
127
- const className = data.className.trim().toLowerCase();
128
- if (!require_dist.isFirstLevelMacroClass(className)) return {
129
- qualified: false,
130
- reason: "not-a-macro",
131
- deviceId
132
- };
133
- return {
134
- qualified: true,
135
- deviceId,
136
- macroClass: className,
137
- atMs: data.timestamp
138
- };
139
- }
140
- //#endregion
141
- //#region src/recorder/object-trigger-health.ts
142
- /** Ampiezza della finestra di aggregazione. Un minuto: abbastanza corto perché
143
- * la riga arrivi entro il primo minuto di un deploy rotto, abbastanza lungo
144
- * perché a 4 eventi/s il campione sia significativo. */
145
- var OBJECT_TRIGGER_HEALTH_WINDOW_MS = 6e4;
146
- function emptyTally() {
147
- return {
148
- objectEvents: 0,
149
- qualified: 0,
150
- otherKinds: 0,
151
- byReason: /* @__PURE__ */ new Map()
152
- };
153
- }
154
- function dominantReason(byReason) {
155
- let reason = null;
156
- let best = 0;
157
- const counts = {};
158
- for (const [r, n] of byReason) {
159
- counts[r] = n;
160
- if (n > best) {
161
- best = n;
162
- reason = r;
163
- }
164
- }
165
- return {
166
- reason,
167
- counts
168
- };
169
- }
170
- var ObjectTriggerHealth = class {
171
- windowMs;
172
- minSample;
173
- buckets = /* @__PURE__ */ new Map();
174
- windowStartMs = null;
175
- constructor(windowMs = OBJECT_TRIGGER_HEALTH_WINDOW_MS, minSample = 5) {
176
- this.windowMs = windowMs;
177
- this.minSample = minSample;
178
- }
179
- /**
180
- * Registra un verdetto e, se la finestra si è chiusa con questo evento,
181
- * restituisce i report da stampare (e riapre la finestra). Nessun timer:
182
- * il feed viaggia a ~4/s, quindi la chiusura arriva sempre in tempo, e una
183
- * finestra che non si chiude è una finestra in cui non è successo nulla.
184
- */
185
- record(verdict, nowMs) {
186
- const reports = this.windowStartMs !== null && nowMs - this.windowStartMs >= this.windowMs ? this.drain(nowMs) : [];
187
- this.windowStartMs ??= nowMs;
188
- this.count(verdict);
189
- return reports;
190
- }
191
- count(verdict) {
192
- if (verdict.qualified) {
193
- const t = this.bucket(verdict.deviceId);
194
- t.objectEvents += 1;
195
- t.qualified += 1;
196
- return;
197
- }
198
- if (verdict.reason === "not-object") {
199
- this.bucket(verdict.deviceId).otherKinds += 1;
200
- return;
201
- }
202
- const key = verdict.reason === "malformed" ? null : verdict.deviceId;
203
- const t = this.bucket(key);
204
- t.objectEvents += 1;
205
- t.byReason.set(verdict.reason, (t.byReason.get(verdict.reason) ?? 0) + 1);
206
- }
207
- bucket(key) {
208
- const existing = this.buckets.get(key);
209
- if (existing !== void 0) return existing;
210
- const fresh = emptyTally();
211
- this.buckets.set(key, fresh);
212
- return fresh;
213
- }
214
- /** Chiude la finestra e restituisce un report per secchio non vuoto. */
215
- drain(nowMs) {
216
- const windowMs = this.windowStartMs === null ? 0 : nowMs - this.windowStartMs;
217
- const out = [];
218
- for (const [deviceId, t] of this.buckets) {
219
- if (t.objectEvents === 0 && t.otherKinds === 0) continue;
220
- const rejected = t.objectEvents - t.qualified;
221
- const { reason, counts } = dominantReason(t.byReason);
222
- out.push({
223
- deviceId,
224
- windowMs,
225
- objectEvents: t.objectEvents,
226
- qualified: t.qualified,
227
- rejectedPct: t.objectEvents === 0 ? 0 : Math.round(rejected / t.objectEvents * 100),
228
- otherKinds: t.otherKinds,
229
- topReason: reason,
230
- byReason: counts,
231
- totalRejection: t.objectEvents >= this.minSample && t.qualified === 0
232
- });
233
- }
234
- this.buckets.clear();
235
- this.windowStartMs = nowMs;
236
- return out;
237
- }
238
- };
239
- //#endregion
240
- //#region src/recorder/sensor-trigger.ts
241
- /**
242
- * Da `DeviceStateChanged` a "quali camere devono registrare".
243
- *
244
- * Due responsabilità, entrambe in RAM e nessuna durevole:
245
- * - la MEMORIA del fronte, per `(deviceId, capName)`, che rende `false→true`
246
- * distinguibile da una ri-emissione. Il predicato è `evaluateSensorEdge`
247
- * (`@camstack/types`) — la stessa tabella `SOURCE_CAP_ACTIVE_FIELD` del
248
- * virtual-doorbell, non una copia;
249
- * - l'INDICE INVERSO sorgente→camere, uno stato rispecchiato aggiornato FUORI
250
- * dal percorso dell'evento (D49): `setBindings` è chiamato al boot dalle
251
- * config già lette e a ogni `onConfigChanged`, che è un'RPC. Una lettura
252
- * fallibile sul percorso del trigger potrebbe rispondere "nessuno ascolta"
253
- * e perdere l'episodio, che è il fallimento contro cui D49 è scritta.
254
- *
255
- * L'ordine dei controlli è deliberato: `DeviceStateChanged` viaggia a ritmo di
256
- * bus su tutta la flotta (batteria, la `motion` nativa di ogni camera, la
257
- * connettività). L'indice inverso è una `Map.get` senza allocazioni e va per
258
- * primo; il fronte — che legge due tabelle e la slice — viene dopo. È la stessa
259
- * scelta che post-analysis scrive per la sua sottoscrizione: *"the
260
- * EVENT_KIND_BY_CAP gate runs FIRST so non-sensor cap churn exits before any
261
- * async work"*.
262
- *
263
- * Puro rispetto al mondo: nessun bus, nessun logger, nessun orologio proprio.
264
- * Il chiamante logga il verdetto — un ramo che non accende nulla lo DICE.
265
- */
266
- /**
267
- * Type GUARD, not a cast: `event.data` is `unknown`, and a version-skewed
268
- * emitter must leave this router untouched rather than route a `deviceId` of
269
- * `undefined`. Same discipline as `parseTopologySnapshotNodes`.
270
- *
271
- * A missing or non-object `slice` is REFUSED rather than defaulted to `{}`:
272
- * `evaluateSensorEdge` would read `non-boolean-value` from an empty object and
273
- * that would look like a sensor reporting nonsense, when what actually
274
- * happened is that there was nothing to read.
275
- */
276
- function parseSensorStateChanged(data) {
277
- if (typeof data !== "object" || data === null) return null;
278
- if (!("deviceId" in data) || typeof data.deviceId !== "number") return null;
279
- if (!("capName" in data) || typeof data.capName !== "string") return null;
280
- if (!("slice" in data) || typeof data.slice !== "object" || data.slice === null) return null;
281
- const slice = { ...data.slice };
282
- return {
283
- deviceId: data.deviceId,
284
- capName: data.capName,
285
- slice
286
- };
287
- }
288
- /**
289
- * sourceDeviceId → cameras whose config names it.
290
- *
291
- * From EVERY band, not only the one active now: the index only changes on a
292
- * config write, while the active band changes with the clock, and an index
293
- * that followed the clock would lose the first trigger after every band edge.
294
- * Filtering by band is already the gate's job — `bandTriggerSources` reads the
295
- * band active at the instant of evaluation.
296
- *
297
- * A disabled camera is skipped: it records nothing whatever a sensor does, and
298
- * it re-enters through `onConfigChanged`, which rebuilds this.
299
- */
300
- function buildSensorTriggerBindings(configs) {
301
- const index = /* @__PURE__ */ new Map();
302
- for (const [cameraId, config] of configs) {
303
- if (config.enabled !== true) continue;
304
- for (const band of config.bands ?? []) for (const sourceDeviceId of band.triggers?.sensorDeviceIds ?? []) {
305
- const cameras = index.get(sourceDeviceId) ?? /* @__PURE__ */ new Set();
306
- cameras.add(cameraId);
307
- index.set(sourceDeviceId, cameras);
308
- }
309
- }
310
- return index;
311
- }
312
- var SensorTriggerRouter = class {
313
- /** sourceDeviceId → camere la cui config lo nomina. */
314
- bindings = /* @__PURE__ */ new Map();
315
- /** Ultimo booleano osservato, per `${deviceId}:${capName}`. */
316
- lastValues = /* @__PURE__ */ new Map();
317
- now;
318
- startedAtMs;
319
- freshnessMs;
320
- constructor(options) {
321
- this.now = options.now;
322
- this.startedAtMs = options.startedAtMs;
323
- this.freshnessMs = options.firstSightingFreshnessMs ?? 3e4;
324
- }
325
- /**
326
- * Sostituisce l'indice inverso. NON tocca `lastValues`: un sensore tolto e
327
- * rimesso in una banda deve ritrovare il suo livello precedente, altrimenti
328
- * la prima slice dopo il rientro sarebbe una "prima osservazione" e un
329
- * contatto rimasto aperto tutto il tempo diventerebbe un fronte alto.
330
- */
331
- setBindings(bindings) {
332
- this.bindings = bindings;
333
- }
334
- /** Quali sorgenti l'indice contiene ora — per la riga di log al boot. */
335
- listenedSourceIds() {
336
- return [...this.bindings.keys()];
337
- }
338
- onStateSlice(deviceId, capName, slice) {
339
- const cameras = this.bindings.get(deviceId);
340
- if (cameras === void 0 || cameras.size === 0) return {
341
- fired: [],
342
- skipped: "not-listened"
343
- };
344
- const key = `${deviceId}:${capName}`;
345
- const verdict = require_dist.evaluateSensorEdge({
346
- capName,
347
- slice,
348
- prior: this.lastValues.get(key),
349
- startedAtMs: this.startedAtMs,
350
- nowMs: this.now(),
351
- firstSightingFreshnessMs: this.freshnessMs
352
- });
353
- if (verdict.edge === "none") {
354
- if (verdict.value !== null) this.lastValues.set(key, verdict.value);
355
- return {
356
- fired: [],
357
- skipped: verdict.reason
358
- };
359
- }
360
- this.lastValues.set(key, true);
361
- return {
362
- fired: [...cameras],
363
- skipped: null
364
- };
365
- }
366
- };
367
- //#endregion
368
53
  //#region src/recorder/init-info.ts
369
54
  /** Walk sibling boxes in [from, to), yielding [type, start, end]. */
370
55
  function* boxes$1(buf, view, from, to) {
@@ -820,13 +505,200 @@ var MfraTableStore = class {
820
505
  get size() {
821
506
  return this.tables.size;
822
507
  }
823
- /** Insertion order is time order, so the expired prefix is contiguous. */
824
- prune() {
825
- const cutoff = this.now() - MFRA_RETAIN_MS;
826
- for (const [key, entry] of this.tables) {
827
- if (entry.startMs >= cutoff) break;
828
- this.tables.delete(key);
508
+ /** Insertion order is time order, so the expired prefix is contiguous. */
509
+ prune() {
510
+ const cutoff = this.now() - MFRA_RETAIN_MS;
511
+ for (const [key, entry] of this.tables) {
512
+ if (entry.startMs >= cutoff) break;
513
+ this.tables.delete(key);
514
+ }
515
+ }
516
+ };
517
+ //#endregion
518
+ //#region src/recorder/object-trigger.ts
519
+ /**
520
+ * Il gate fra `pipeline-analytics.detection-event` e "apri una finestra di
521
+ * registrazione".
522
+ *
523
+ * Fratello di `recorder/event-capture.ts` e per lo stesso motivo: la qualifica
524
+ * ha dei rami, e un ramo che scarta lavoro senza dirlo è indistinguibile da una
525
+ * funzione mai cablata. Ogni verdetto negativo NOMINA il motivo, e il chiamante
526
+ * lo logga.
527
+ *
528
+ * Puro: nessun bus, nessun orologio, nessuna config. La scelta di QUALI classi
529
+ * ascoltare è della banda (`bandTriggerSources`); qui si decide solo se
530
+ * l'evento è una detection utilizzabile e di che macro classe è.
531
+ *
532
+ * ## Perché la forma è DISCRIMINATA su `kind` (2026-08-29)
533
+ *
534
+ * Fino al 2026-08-29 questo file dichiarava UNA sola forma piatta — quella
535
+ * dell'evento `object` — e la pretendeva da OGNI payload del topic. Ma
536
+ * `EventCategory.PipelineAnalyticsDetectionEvent` è un topic condiviso da tre
537
+ * emettitori in `addon-post-analysis/src/pipeline-analytics/index.ts`: object
538
+ * (`className`, `source`, `confidence`, `zones`), motion e audio — e motion e
539
+ * audio non portano `className`, perché non ne hanno una. Il risultato misurato:
540
+ * `malformed` su **394 righe campionate su 394**, a 3.3–3.9 eventi/s, per
541
+ * settimane. `malformed` significa "l'emettitore è andato in deriva" ed era
542
+ * invece il traffico normale del topic; l'unico secchio che poteva segnalare
543
+ * una vera rottura di protocollo era saturo al 100% di eventi sani, quindi
544
+ * inservibile — ed è stato letto come "il trigger object non ha mai funzionato".
545
+ *
546
+ * Quindi: si valida prima la BUSTA comune (`deviceId`, `kind`, `timestamp`),
547
+ * poi si discrimina su `kind`, e solo il ramo `object` pretende i campi
548
+ * dell'object. Un `object` senza `className` resta un difetto e ha il suo nome
549
+ * (`malformed-object`), distinto da una busta illeggibile.
550
+ */
551
+ /** Il `kind` dell'unico ramo del topic che può aprire una finestra. */
552
+ var OBJECT_EVENT_KIND = "object";
553
+ /** La sorgente implicita delle righe legacy senza `source` (`DetectionSourceSchema`). */
554
+ var DEFAULT_DETECTION_SOURCE = "pipeline";
555
+ /** Type guard, non cast: `event.data` è `unknown` finché non passa di qui. */
556
+ function isDetectionEventEnvelope(value) {
557
+ if (typeof value !== "object" || value === null) return false;
558
+ if (!("deviceId" in value) || typeof value.deviceId !== "number") return false;
559
+ if (!Number.isFinite(value.deviceId)) return false;
560
+ if (!("kind" in value) || typeof value.kind !== "string") return false;
561
+ if (!("timestamp" in value) || typeof value.timestamp !== "number") return false;
562
+ if (!Number.isFinite(value.timestamp) || value.timestamp <= 0) return false;
563
+ return true;
564
+ }
565
+ /** I campi che SOLO il ramo object porta. Chiamata dopo la busta. */
566
+ function isObjectDetectionEventData(value) {
567
+ if (!("className" in value) || typeof value.className !== "string") return false;
568
+ if ("source" in value && value.source !== void 0 && typeof value.source !== "string") return false;
569
+ return true;
570
+ }
571
+ function qualifyObjectTrigger(data) {
572
+ if (!isDetectionEventEnvelope(data)) return {
573
+ qualified: false,
574
+ reason: "malformed"
575
+ };
576
+ const deviceId = data.deviceId;
577
+ if (data.kind !== OBJECT_EVENT_KIND) return {
578
+ qualified: false,
579
+ reason: "not-object",
580
+ deviceId
581
+ };
582
+ if (!isObjectDetectionEventData(data)) return {
583
+ qualified: false,
584
+ reason: "malformed-object",
585
+ deviceId
586
+ };
587
+ if ((data.source ?? DEFAULT_DETECTION_SOURCE) !== DEFAULT_DETECTION_SOURCE) return {
588
+ qualified: false,
589
+ reason: "not-pipeline",
590
+ deviceId
591
+ };
592
+ const className = data.className.trim().toLowerCase();
593
+ if (!require_dist.isFirstLevelMacroClass(className)) return {
594
+ qualified: false,
595
+ reason: "not-a-macro",
596
+ deviceId
597
+ };
598
+ return {
599
+ qualified: true,
600
+ deviceId,
601
+ macroClass: className,
602
+ atMs: data.timestamp
603
+ };
604
+ }
605
+ //#endregion
606
+ //#region src/recorder/object-trigger-health.ts
607
+ /** Ampiezza della finestra di aggregazione. Un minuto: abbastanza corto perché
608
+ * la riga arrivi entro il primo minuto di un deploy rotto, abbastanza lungo
609
+ * perché a 4 eventi/s il campione sia significativo. */
610
+ var OBJECT_TRIGGER_HEALTH_WINDOW_MS = 6e4;
611
+ function emptyTally() {
612
+ return {
613
+ objectEvents: 0,
614
+ qualified: 0,
615
+ otherKinds: 0,
616
+ byReason: /* @__PURE__ */ new Map()
617
+ };
618
+ }
619
+ function dominantReason(byReason) {
620
+ let reason = null;
621
+ let best = 0;
622
+ const counts = {};
623
+ for (const [r, n] of byReason) {
624
+ counts[r] = n;
625
+ if (n > best) {
626
+ best = n;
627
+ reason = r;
628
+ }
629
+ }
630
+ return {
631
+ reason,
632
+ counts
633
+ };
634
+ }
635
+ var ObjectTriggerHealth = class {
636
+ windowMs;
637
+ minSample;
638
+ buckets = /* @__PURE__ */ new Map();
639
+ windowStartMs = null;
640
+ constructor(windowMs = OBJECT_TRIGGER_HEALTH_WINDOW_MS, minSample = 5) {
641
+ this.windowMs = windowMs;
642
+ this.minSample = minSample;
643
+ }
644
+ /**
645
+ * Registra un verdetto e, se la finestra si è chiusa con questo evento,
646
+ * restituisce i report da stampare (e riapre la finestra). Nessun timer:
647
+ * il feed viaggia a ~4/s, quindi la chiusura arriva sempre in tempo, e una
648
+ * finestra che non si chiude è una finestra in cui non è successo nulla.
649
+ */
650
+ record(verdict, nowMs) {
651
+ const reports = this.windowStartMs !== null && nowMs - this.windowStartMs >= this.windowMs ? this.drain(nowMs) : [];
652
+ this.windowStartMs ??= nowMs;
653
+ this.count(verdict);
654
+ return reports;
655
+ }
656
+ count(verdict) {
657
+ if (verdict.qualified) {
658
+ const t = this.bucket(verdict.deviceId);
659
+ t.objectEvents += 1;
660
+ t.qualified += 1;
661
+ return;
662
+ }
663
+ if (verdict.reason === "not-object") {
664
+ this.bucket(verdict.deviceId).otherKinds += 1;
665
+ return;
666
+ }
667
+ const key = verdict.reason === "malformed" ? null : verdict.deviceId;
668
+ const t = this.bucket(key);
669
+ t.objectEvents += 1;
670
+ t.byReason.set(verdict.reason, (t.byReason.get(verdict.reason) ?? 0) + 1);
671
+ }
672
+ bucket(key) {
673
+ const existing = this.buckets.get(key);
674
+ if (existing !== void 0) return existing;
675
+ const fresh = emptyTally();
676
+ this.buckets.set(key, fresh);
677
+ return fresh;
678
+ }
679
+ /** Chiude la finestra e restituisce un report per secchio non vuoto. */
680
+ drain(nowMs) {
681
+ const windowMs = this.windowStartMs === null ? 0 : nowMs - this.windowStartMs;
682
+ const out = [];
683
+ for (const [deviceId, t] of this.buckets) {
684
+ if (t.objectEvents === 0 && t.otherKinds === 0) continue;
685
+ const rejected = t.objectEvents - t.qualified;
686
+ const { reason, counts } = dominantReason(t.byReason);
687
+ out.push({
688
+ deviceId,
689
+ windowMs,
690
+ objectEvents: t.objectEvents,
691
+ qualified: t.qualified,
692
+ rejectedPct: t.objectEvents === 0 ? 0 : Math.round(rejected / t.objectEvents * 100),
693
+ otherKinds: t.otherKinds,
694
+ topReason: reason,
695
+ byReason: counts,
696
+ totalRejection: t.objectEvents >= this.minSample && t.qualified === 0
697
+ });
829
698
  }
699
+ this.buckets.clear();
700
+ this.windowStartMs = nowMs;
701
+ return out;
830
702
  }
831
703
  };
832
704
  var PendingGate = class {
@@ -950,14 +822,14 @@ function parseSegmentPath(relPath) {
950
822
  //#region src/recorder/recording-index.ts
951
823
  /** Hour bucket, the granularity the path convention (`.../YYYY/MM/DD/HH/`)
952
824
  * already gives us — so "have I walked this?" costs one Set lookup. */
953
- var HOUR_MS$5 = 36e5;
825
+ var HOUR_MS$6 = 36e5;
954
826
  /** A device walked end-to-end. Cheaper and clearer than inserting every hour of
955
827
  * the retention window into the set. */
956
828
  var ALL_HOURS = Symbol("all-hours");
957
829
  /** The hour buckets a half-open span touches. */
958
830
  function* hourBuckets(fromMs, toMs) {
959
- const first = Math.floor(fromMs / HOUR_MS$5) * HOUR_MS$5;
960
- for (let h = first; h < toMs; h += HOUR_MS$5) yield h;
831
+ const first = Math.floor(fromMs / HOUR_MS$6) * HOUR_MS$6;
832
+ for (let h = first; h < toMs; h += HOUR_MS$6) yield h;
961
833
  }
962
834
  /**
963
835
  * Index of the first element whose `startMs` is GREATER than `startMs` (upper
@@ -1247,7 +1119,7 @@ var RecordingIndex = class {
1247
1119
  changed = true;
1248
1120
  }
1249
1121
  if (changed) this.invalidateSorted(deviceId);
1250
- this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$5);
1122
+ this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$6);
1251
1123
  }
1252
1124
  /**
1253
1125
  * Insert/replace one finalized segment (idempotent by path).
@@ -1493,22 +1365,41 @@ var RecordingIndex = class {
1493
1365
  for (const m of this.byDevice.values()) for (const s of m.values()) if (s.locationId === locationId) out.push(s);
1494
1366
  return out.toSorted((a, b) => a.startMs - b.startMs);
1495
1367
  }
1496
- /** Binary-search a start-sorted slice for the segment containing epochMs. */
1368
+ /**
1369
+ * Binary-search a start-sorted slice for the segment containing `epochMs`,
1370
+ * then walk backwards under {@link maxDurMs} while an earlier row could still
1371
+ * cover it.
1372
+ *
1373
+ * ## Why the last row starting at-or-before the instant is not the answer
1374
+ *
1375
+ * It is the answer whenever it covers — and that case is unchanged, so the
1376
+ * row this returns is the row it always returned. But the view is sorted by
1377
+ * `startMs` and coverage is `startMs + durMs`, which is NOT sorted: a SHORT
1378
+ * segment starting inside a longer overlapping one (a restart re-recording an
1379
+ * hour) lands last and does not cover, while the long one enclosing it does.
1380
+ * Stopping there reported a gap over footage that is on disk — a hole in the
1381
+ * operator's timeline, and a `locateSegment` / `nearestCoveredEdge` answer
1382
+ * that snapped past playable video.
1383
+ *
1384
+ * The walk is bounded exactly as {@link segmentsOverlapping} and
1385
+ * {@link coveredEdgeBefore} bound theirs: once a candidate's ceiling
1386
+ * `startMs + maxDurMs` no longer reaches past `epochMs`, no earlier row's can
1387
+ * either — earlier rows start no later. The ceiling is checked AFTER the row
1388
+ * itself, so a stale-narrow ceiling could only lengthen the walk, never skip
1389
+ * a coverer.
1390
+ *
1391
+ * Boundary convention preserved verbatim: the window is `[startMs, startMs +
1392
+ * durMs)`. `epochMs === startMs` is covered; `epochMs === startMs + durMs` is
1393
+ * not. Among several covering rows the LATEST-starting one wins, which is
1394
+ * what the plain binary search returned.
1395
+ */
1497
1396
  segmentAtIn(segs, epochMs) {
1498
- if (segs.length === 0) return null;
1499
- let lo = 0;
1500
- let hi = segs.length - 1;
1501
- let cand = -1;
1502
- while (lo <= hi) {
1503
- const mid = lo + hi >> 1;
1504
- if (segs[mid].startMs <= epochMs) {
1505
- cand = mid;
1506
- lo = mid + 1;
1507
- } else hi = mid - 1;
1397
+ for (let i = upperBoundByStart(segs, epochMs) - 1; i >= 0; i--) {
1398
+ const s = segs[i];
1399
+ if (epochMs < s.startMs + s.durMs) return s;
1400
+ if (s.startMs + this.maxDurMs <= epochMs) break;
1508
1401
  }
1509
- if (cand < 0) return null;
1510
- const seg = segs[cand];
1511
- return epochMs < seg.startMs + seg.durMs ? seg : null;
1402
+ return null;
1512
1403
  }
1513
1404
  /** The segment whose [startMs, startMs+durMs) window contains `epochMs`, or null if `epochMs` falls in a gap / there is no footage. */
1514
1405
  segmentAt(deviceId, profile, epochMs) {
@@ -1643,12 +1534,17 @@ function resolveEvictionDomain(locations, locationId, realpath = node_fs.realpat
1643
1534
  * of disk decommissioning: mark the old location read-only (Phase 2), then
1644
1535
  * relocate its footage here, then delete the location.
1645
1536
  *
1646
- * Mechanics, per file: throttled copy size-verify delete source index
1647
- * re-point. The copy is `copy-if-absent` (an equal-size destination is
1648
- * adopted, not re-copied), so cancel/crash/re-run RESUMES instead of
1649
- * restarting. Throttling is file-level pacing (segments are ~1–8 MB /
1650
- * ~4 s, so per-file granularity bounds the rate tightly enough) the drain
1651
- * is a background chore that must never starve live writers.
1537
+ * Mechanics, per HOUR (D291): throttled copy + size-verify of every file in the
1538
+ * hour → re-point the DURABLE hour row delete the sources → re-point the RAM
1539
+ * index. The hour is the batch because the durable index is hour-granular, and
1540
+ * the sources outlive the durable write on purpose: between the copy and the
1541
+ * delete both locations hold the hour, so no crash can leave the index naming a
1542
+ * location without the bytes. The copy is `copy-if-absent` (an equal-size
1543
+ * destination is adopted, not re-copied), so cancel/crash/re-run RESUMES
1544
+ * instead of restarting, and a cancel inside an hour leaves that hour WHOLLY
1545
+ * readable at the source. Throttling is file-level pacing (segments are
1546
+ * ~1–8 MB / ~4 s, so per-file granularity bounds the rate tightly enough) —
1547
+ * the drain is a background chore that must never starve live writers.
1652
1548
  *
1653
1549
  * Runs INSIDE the recorder process — container uid, same-uid files, no host
1654
1550
  * chown trap (see docs/history 2026-07-29).
@@ -1667,8 +1563,16 @@ function resolveEvictionDomain(locations, locationId, realpath = node_fs.realpat
1667
1563
  * strand the queue behind it.
1668
1564
  */
1669
1565
  var DEFAULT_THROTTLE_MBPS = 40;
1566
+ /** The durable index's bucket, and therefore this mover's batch. */
1567
+ var HOUR_MS$5 = 36e5;
1670
1568
  /** Progress callback cadence (files) — cheap enough to fire often. */
1671
1569
  var PROGRESS_EVERY_FILES = 25;
1570
+ /** Free space a destination must keep when it declares no `config.minFreePercent`
1571
+ * — the same default `roots.ts` documents for placement. */
1572
+ var DEFAULT_MIN_FREE_PERCENT$1 = 10;
1573
+ /** Prefix every destination-full error carries, so the coordinator can report
1574
+ * "drain stalled: destination full" instead of a raw ENOSPC string. */
1575
+ var DESTINATION_FULL_PREFIX = "destination full";
1672
1576
  function snapshot(j) {
1673
1577
  return {
1674
1578
  jobId: j.jobId,
@@ -1685,25 +1589,27 @@ function snapshot(j) {
1685
1589
  error: j.error
1686
1590
  };
1687
1591
  }
1688
- /** One file's move: copy-if-absent → verify size → delete source. Returns the
1689
- * bytes that MOVED (0 when the destination was already complete). */
1690
- async function moveFile(src, dst, srcBytes) {
1691
- let copied = 0;
1592
+ /**
1593
+ * One file's COPY: copy-if-absent verify size. The source is left in place.
1594
+ *
1595
+ * Split out of {@link moveFile} because the durable index re-point has to
1596
+ * happen while BOTH locations still hold the bytes — see
1597
+ * `SegmentHourLedger.repointSegments`. Returns the bytes that were copied
1598
+ * (0 when the destination already held a complete copy).
1599
+ */
1600
+ async function copyVerified(src, dst, srcBytes) {
1692
1601
  let dstBytes = null;
1693
1602
  try {
1694
1603
  dstBytes = (await node_fs.promises.stat(dst)).size;
1695
1604
  } catch {
1696
1605
  dstBytes = null;
1697
1606
  }
1698
- if (dstBytes !== srcBytes) {
1699
- await node_fs.promises.mkdir(node_path.default.dirname(dst), { recursive: true });
1700
- await node_fs.promises.copyFile(src, dst);
1701
- const after = await node_fs.promises.stat(dst);
1702
- if (after.size !== srcBytes) throw new Error(`relocate verify failed: ${dst} is ${after.size}B, expected ${srcBytes}B`);
1703
- copied = srcBytes;
1704
- }
1705
- await node_fs.promises.rm(src, { force: true });
1706
- return copied;
1607
+ if (dstBytes === srcBytes) return 0;
1608
+ await node_fs.promises.mkdir(node_path.default.dirname(dst), { recursive: true });
1609
+ await node_fs.promises.copyFile(src, dst);
1610
+ const after = await node_fs.promises.stat(dst);
1611
+ if (after.size !== srcBytes) throw new Error(`relocate verify failed: ${dst} is ${after.size}B, expected ${srcBytes}B`);
1612
+ return srcBytes;
1707
1613
  }
1708
1614
  var RelocateEngine = class {
1709
1615
  deps;
@@ -1826,15 +1732,15 @@ var RelocateEngine = class {
1826
1732
  const bytesPerMs = job.throttleMbps * 1024 * 1024 / 1e3;
1827
1733
  try {
1828
1734
  const samePhysicalRoot = await rootsPhysicallySame(from.root, to.root);
1735
+ const upFront = await this.headroomRefusal(job, to, samePhysicalRoot);
1736
+ if (upFront !== null) throw new Error(upFront);
1829
1737
  const segRows = job.entities.includes("segments") === false ? [] : this.deps.index.segmentsOnLocation(from.id).filter((r) => job.deviceId === null || r.deviceId === job.deviceId).filter((r) => job.profiles.length === 0 || job.profiles.includes(r.profile)).filter((r) => profileBelongsToClass(r.profile, job.footageClass)).filter((r) => job.sinceMs === null || r.startMs >= job.sinceMs).sort((a, b) => a.startMs - b.startMs);
1830
1738
  job.filesTotal = segRows.length;
1831
- for (const row of segRows) {
1739
+ for (const [, hourRows] of groupByHour(segRows)) {
1832
1740
  if (job.cancelRequested) break;
1833
- await this.moveSegment(row, from, to, samePhysicalRoot);
1834
- job.filesMoved++;
1835
- job.bytesMoved += row.bytes;
1836
- if (job.filesMoved % PROGRESS_EVERY_FILES === 0) this.deps.onProgress?.(snapshot(job));
1837
- await sleep(row.bytes / bytesPerMs);
1741
+ const refusal = await this.headroomRefusal(job, to, samePhysicalRoot);
1742
+ if (refusal !== null) throw new Error(refusal);
1743
+ await this.moveHour(job, hourRows, from, to, samePhysicalRoot, sleep, bytesPerMs);
1838
1744
  }
1839
1745
  async function rootsPhysicallySame(from, to) {
1840
1746
  if (physicalRootKey(from) === physicalRootKey(to)) return true;
@@ -1861,15 +1767,76 @@ var RelocateEngine = class {
1861
1767
  this.deps.onFinished?.(snapshot(job));
1862
1768
  }
1863
1769
  }
1864
- /** Move one segment file and re-point its index row to the target location
1865
- * (same location-relative path the layout is identical on every root).
1866
- * A source that is already gone (evicted, never flushed, ENOSPC) is dropped
1867
- * from the index and skipped — one ghost file must not abort a drain. */
1868
- async moveSegment(row, from, to, samePhysicalRoot) {
1869
- if (!samePhysicalRoot) try {
1870
- await moveFile(node_path.default.join(from.root, row.path), node_path.default.join(to.root, row.path), row.bytes);
1871
- } catch (err) {
1872
- if (isEnoent(err)) {
1770
+ /**
1771
+ * Why this drain must not put another hour on `to`, or `null` to proceed.
1772
+ *
1773
+ * Three deliberate non-refusals:
1774
+ *
1775
+ * - **Same physical root.** The move is a re-stamp, not a copy: it consumes
1776
+ * one hour of transient duplication and gives it straight back. Refusing
1777
+ * there would block the one relocation that RELIEVES a full disk.
1778
+ * - **Unknown free space.** `null` is "the question was not answered", not
1779
+ * "there is no room". Refusing on unknown would strand every drain on a
1780
+ * filesystem `statfs` cannot describe, and the fallback — ENOSPC — is
1781
+ * already safe: D291 copies and verifies before any durable re-point, so
1782
+ * a full destination loses nothing. It is logged, because a check that
1783
+ * silently never fires is worse than no check.
1784
+ * - **`maxUsedGb`.** That is a cap on what recordings may OCCUPY, arbitrated
1785
+ * by the eviction sweep against the archive ledger — not free space on a
1786
+ * volume, and not a question a mover can answer with one syscall.
1787
+ */
1788
+ async headroomRefusal(job, to, samePhysicalRoot) {
1789
+ if (samePhysicalRoot) return null;
1790
+ const free = await (this.deps.freePercentOf ?? volumeFreePercent)(to.root);
1791
+ if (free === null) {
1792
+ this.deps.logger.warn("relocate: destination free space is UNREADABLE — draining anyway, a full disk will surface as ENOSPC", {
1793
+ ...job.deviceId !== null ? { tags: { deviceId: job.deviceId } } : {},
1794
+ meta: {
1795
+ jobId: job.jobId,
1796
+ toLocationId: to.id,
1797
+ root: to.root
1798
+ }
1799
+ });
1800
+ return null;
1801
+ }
1802
+ const floor = to.minFreePercent ?? DEFAULT_MIN_FREE_PERCENT$1;
1803
+ if (free >= floor) return null;
1804
+ this.deps.logger.warn("relocate: destination is at its free-space floor — drain stops here", {
1805
+ ...job.deviceId !== null ? { tags: { deviceId: job.deviceId } } : {},
1806
+ meta: {
1807
+ jobId: job.jobId,
1808
+ toLocationId: to.id,
1809
+ freePercent: Math.round(free * 100) / 100,
1810
+ minFreePercent: floor,
1811
+ filesMoved: job.filesMoved
1812
+ }
1813
+ });
1814
+ return `${DESTINATION_FULL_PREFIX}: ${to.id} has ${(Math.round(free * 100) / 100).toString()}% free, below its ${floor.toString()}% floor — everything not yet moved is still readable at ${job.fromLocationId}`;
1815
+ }
1816
+ /**
1817
+ * Move ONE hour of one (device, profile): copy+verify every file, re-point
1818
+ * the durable hour row, delete the sources, re-point the RAM index.
1819
+ *
1820
+ * The order is the whole point. Between the copy and the delete both
1821
+ * locations hold every byte, so the ledger re-point in the middle can only
1822
+ * ever name a location that HAS the file — which is what makes a crash
1823
+ * recoverable instead of a black window. A ledger that refuses (a failed
1824
+ * point read: D49/D287) stops this hour dead with nothing deleted; the
1825
+ * copies at the target are adopted by the next run.
1826
+ */
1827
+ async moveHour(job, rows, from, to, samePhysicalRoot, sleep, bytesPerMs) {
1828
+ const movable = [];
1829
+ let copiedBytes = 0;
1830
+ if (samePhysicalRoot) movable.push(...rows);
1831
+ else for (const row of rows) {
1832
+ if (job.cancelRequested) return;
1833
+ const src = node_path.default.join(from.root, row.path);
1834
+ const dst = node_path.default.join(to.root, row.path);
1835
+ try {
1836
+ copiedBytes += await copyVerified(src, dst, row.bytes);
1837
+ movable.push(row);
1838
+ } catch (err) {
1839
+ if (!isEnoent(err)) throw err;
1873
1840
  this.deps.index.removeSegments([row.path]);
1874
1841
  this.deps.logger.warn("relocate skipped missing source", {
1875
1842
  tags: { deviceId: row.deviceId },
@@ -1878,17 +1845,70 @@ var RelocateEngine = class {
1878
1845
  locationId: from.id
1879
1846
  }
1880
1847
  });
1881
- return;
1882
1848
  }
1883
- throw err;
1849
+ await sleep(row.bytes / bytesPerMs);
1884
1850
  }
1885
- this.deps.index.removeSegments([row.path]);
1886
- this.deps.index.addSegment({
1887
- ...row,
1888
- locationId: to.id
1851
+ if (movable.length === 0) return;
1852
+ if (job.cancelRequested) return;
1853
+ const ledger = this.deps.ledger?.() ?? null;
1854
+ if (ledger === null) this.deps.logger.warn("relocate: NO durable hour ledger on this node — the re-point is RAM-only and will not survive a restart", {
1855
+ tags: { deviceId: movable[0].deviceId },
1856
+ meta: {
1857
+ jobId: job.jobId,
1858
+ toLocationId: to.id
1859
+ }
1889
1860
  });
1861
+ else if (!await ledger.repointSegments(movable, to.id)) {
1862
+ this.deps.logger.warn("relocate: hour NOT moved — the durable ledger refused the re-point, so the source bytes are kept", {
1863
+ tags: { deviceId: movable[0].deviceId },
1864
+ meta: {
1865
+ jobId: job.jobId,
1866
+ files: movable.length,
1867
+ toLocationId: to.id
1868
+ }
1869
+ });
1870
+ return;
1871
+ }
1872
+ if (!samePhysicalRoot) for (const row of movable) await node_fs.promises.rm(node_path.default.join(from.root, row.path), { force: true });
1873
+ for (const row of movable) {
1874
+ this.deps.index.removeSegments([row.path]);
1875
+ this.deps.index.addSegment({
1876
+ ...row,
1877
+ locationId: to.id
1878
+ });
1879
+ }
1880
+ job.filesMoved += movable.length;
1881
+ job.bytesMoved += copiedBytes;
1882
+ if (job.filesMoved % PROGRESS_EVERY_FILES < movable.length) this.deps.onProgress?.(snapshot(job));
1890
1883
  }
1891
1884
  };
1885
+ /**
1886
+ * The job's rows bucketed by the hour row that describes them — `(device,
1887
+ * profile, hour)`, the durable ledger's own key minus its location half.
1888
+ * Insertion order preserves the caller's start-ascending sort, so a drain
1889
+ * still empties the oldest footage first.
1890
+ */
1891
+ function groupByHour(rows) {
1892
+ const out = /* @__PURE__ */ new Map();
1893
+ for (const row of rows) {
1894
+ const key = `${row.deviceId}|${row.profile}|${Math.floor(row.startMs / HOUR_MS$5)}`;
1895
+ const found = out.get(key);
1896
+ if (found) found.push(row);
1897
+ else out.set(key, [row]);
1898
+ }
1899
+ return out;
1900
+ }
1901
+ /** Free space on the volume behind `root`, as a percentage of total blocks.
1902
+ * `null` when the volume cannot be described — never a number that guesses. */
1903
+ async function volumeFreePercent(root) {
1904
+ try {
1905
+ const st = await node_fs.promises.statfs(root);
1906
+ if (st.blocks <= 0) return null;
1907
+ return st.bavail / st.blocks * 100;
1908
+ } catch {
1909
+ return null;
1910
+ }
1911
+ }
1892
1912
  function isEnoent(err) {
1893
1913
  return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
1894
1914
  }
@@ -2211,6 +2231,134 @@ var macrotask$3 = () => new Promise((resolve) => {
2211
2231
  setImmediate(resolve);
2212
2232
  });
2213
2233
  //#endregion
2234
+ //#region src/recorder/sensor-trigger.ts
2235
+ /**
2236
+ * Da `DeviceStateChanged` a "quali camere devono registrare".
2237
+ *
2238
+ * Due responsabilità, entrambe in RAM e nessuna durevole:
2239
+ * - la MEMORIA del fronte, per `(deviceId, capName)`, che rende `false→true`
2240
+ * distinguibile da una ri-emissione. Il predicato è `evaluateSensorEdge`
2241
+ * (`@camstack/types`) — la stessa tabella `SOURCE_CAP_ACTIVE_FIELD` del
2242
+ * virtual-doorbell, non una copia;
2243
+ * - l'INDICE INVERSO sorgente→camere, uno stato rispecchiato aggiornato FUORI
2244
+ * dal percorso dell'evento (D49): `setBindings` è chiamato al boot dalle
2245
+ * config già lette e a ogni `onConfigChanged`, che è un'RPC. Una lettura
2246
+ * fallibile sul percorso del trigger potrebbe rispondere "nessuno ascolta"
2247
+ * e perdere l'episodio, che è il fallimento contro cui D49 è scritta.
2248
+ *
2249
+ * L'ordine dei controlli è deliberato: `DeviceStateChanged` viaggia a ritmo di
2250
+ * bus su tutta la flotta (batteria, la `motion` nativa di ogni camera, la
2251
+ * connettività). L'indice inverso è una `Map.get` senza allocazioni e va per
2252
+ * primo; il fronte — che legge due tabelle e la slice — viene dopo. È la stessa
2253
+ * scelta che post-analysis scrive per la sua sottoscrizione: *"the
2254
+ * EVENT_KIND_BY_CAP gate runs FIRST so non-sensor cap churn exits before any
2255
+ * async work"*.
2256
+ *
2257
+ * Puro rispetto al mondo: nessun bus, nessun logger, nessun orologio proprio.
2258
+ * Il chiamante logga il verdetto — un ramo che non accende nulla lo DICE.
2259
+ */
2260
+ /**
2261
+ * Type GUARD, not a cast: `event.data` is `unknown`, and a version-skewed
2262
+ * emitter must leave this router untouched rather than route a `deviceId` of
2263
+ * `undefined`. Same discipline as `parseTopologySnapshotNodes`.
2264
+ *
2265
+ * A missing or non-object `slice` is REFUSED rather than defaulted to `{}`:
2266
+ * `evaluateSensorEdge` would read `non-boolean-value` from an empty object and
2267
+ * that would look like a sensor reporting nonsense, when what actually
2268
+ * happened is that there was nothing to read.
2269
+ */
2270
+ function parseSensorStateChanged(data) {
2271
+ if (typeof data !== "object" || data === null) return null;
2272
+ if (!("deviceId" in data) || typeof data.deviceId !== "number") return null;
2273
+ if (!("capName" in data) || typeof data.capName !== "string") return null;
2274
+ if (!("slice" in data) || typeof data.slice !== "object" || data.slice === null) return null;
2275
+ const slice = { ...data.slice };
2276
+ return {
2277
+ deviceId: data.deviceId,
2278
+ capName: data.capName,
2279
+ slice
2280
+ };
2281
+ }
2282
+ /**
2283
+ * sourceDeviceId → cameras whose config names it.
2284
+ *
2285
+ * From EVERY band, not only the one active now: the index only changes on a
2286
+ * config write, while the active band changes with the clock, and an index
2287
+ * that followed the clock would lose the first trigger after every band edge.
2288
+ * Filtering by band is already the gate's job — `bandTriggerSources` reads the
2289
+ * band active at the instant of evaluation.
2290
+ *
2291
+ * A disabled camera is skipped: it records nothing whatever a sensor does, and
2292
+ * it re-enters through `onConfigChanged`, which rebuilds this.
2293
+ */
2294
+ function buildSensorTriggerBindings(configs) {
2295
+ const index = /* @__PURE__ */ new Map();
2296
+ for (const [cameraId, config] of configs) {
2297
+ if (config.enabled !== true) continue;
2298
+ for (const band of config.bands ?? []) for (const sourceDeviceId of band.triggers?.sensorDeviceIds ?? []) {
2299
+ const cameras = index.get(sourceDeviceId) ?? /* @__PURE__ */ new Set();
2300
+ cameras.add(cameraId);
2301
+ index.set(sourceDeviceId, cameras);
2302
+ }
2303
+ }
2304
+ return index;
2305
+ }
2306
+ var SensorTriggerRouter = class {
2307
+ /** sourceDeviceId → camere la cui config lo nomina. */
2308
+ bindings = /* @__PURE__ */ new Map();
2309
+ /** Ultimo booleano osservato, per `${deviceId}:${capName}`. */
2310
+ lastValues = /* @__PURE__ */ new Map();
2311
+ now;
2312
+ startedAtMs;
2313
+ freshnessMs;
2314
+ constructor(options) {
2315
+ this.now = options.now;
2316
+ this.startedAtMs = options.startedAtMs;
2317
+ this.freshnessMs = options.firstSightingFreshnessMs ?? 3e4;
2318
+ }
2319
+ /**
2320
+ * Sostituisce l'indice inverso. NON tocca `lastValues`: un sensore tolto e
2321
+ * rimesso in una banda deve ritrovare il suo livello precedente, altrimenti
2322
+ * la prima slice dopo il rientro sarebbe una "prima osservazione" e un
2323
+ * contatto rimasto aperto tutto il tempo diventerebbe un fronte alto.
2324
+ */
2325
+ setBindings(bindings) {
2326
+ this.bindings = bindings;
2327
+ }
2328
+ /** Quali sorgenti l'indice contiene ora — per la riga di log al boot. */
2329
+ listenedSourceIds() {
2330
+ return [...this.bindings.keys()];
2331
+ }
2332
+ onStateSlice(deviceId, capName, slice) {
2333
+ const cameras = this.bindings.get(deviceId);
2334
+ if (cameras === void 0 || cameras.size === 0) return {
2335
+ fired: [],
2336
+ skipped: "not-listened"
2337
+ };
2338
+ const key = `${deviceId}:${capName}`;
2339
+ const verdict = require_dist.evaluateSensorEdge({
2340
+ capName,
2341
+ slice,
2342
+ prior: this.lastValues.get(key),
2343
+ startedAtMs: this.startedAtMs,
2344
+ nowMs: this.now(),
2345
+ firstSightingFreshnessMs: this.freshnessMs
2346
+ });
2347
+ if (verdict.edge === "none") {
2348
+ if (verdict.value !== null) this.lastValues.set(key, verdict.value);
2349
+ return {
2350
+ fired: [],
2351
+ skipped: verdict.reason
2352
+ };
2353
+ }
2354
+ this.lastValues.set(key, true);
2355
+ return {
2356
+ fired: [...cameras],
2357
+ skipped: null
2358
+ };
2359
+ }
2360
+ };
2361
+ //#endregion
2214
2362
  //#region src/recorder/still/still-source.ts
2215
2363
  /**
2216
2364
  * Resolve the low segment covering `epochMs` and the seek offset from that
@@ -12466,11 +12614,17 @@ var SegmentHourLedger = class {
12466
12614
  offset
12467
12615
  }
12468
12616
  });
12617
+ let met = false;
12469
12618
  for (const record of records) {
12470
12619
  chosen.push(record.id);
12471
12620
  const bytes = record.data["bytes"];
12472
12621
  covered += typeof bytes === "number" ? bytes : 0;
12622
+ if (covered >= targetBytes) {
12623
+ met = true;
12624
+ break;
12625
+ }
12473
12626
  }
12627
+ if (met) break;
12474
12628
  offset += records.length;
12475
12629
  if (records.length < 32) {
12476
12630
  exhausted = true;
@@ -12645,6 +12799,89 @@ var SegmentHourLedger = class {
12645
12799
  }
12646
12800
  }
12647
12801
  /**
12802
+ * Move `rows` from the hour rows they are listed in today to the SAME hours
12803
+ * at `toLocationId` — the DURABLE half of a relocation.
12804
+ *
12805
+ * Without this the mover re-points only `RecordingIndex`, which is RAM. Since
12806
+ * D287 a closed hour is hydrated from the ledger FIRST and the disk is not
12807
+ * consulted at all when the ledger covered the window, so a relocated hour
12808
+ * comes back at its OLD location on the next boot — paths under a root that
12809
+ * no longer holds the file. Playback does not stat (that syscall per segment
12810
+ * was removed deliberately), so the result is a silent black window; and once
12811
+ * the drained location is deleted, `collectRangeSegments` drops the rows
12812
+ * outright and the footage is invisible. That is the shape of the 2 131
12813
+ * `recordings:high` ghost segments already on this hub.
12814
+ *
12815
+ * **The ordering is the invariant, not an implementation detail.** The
12816
+ * destination row is written BEFORE the source row is shrunk, and the caller
12817
+ * has not yet deleted the source bytes — so the only state a crash can leave
12818
+ * is "listed at BOTH locations, both of which hold the file", which resolves
12819
+ * correctly whichever row hydrate happens to apply last. The reverse order
12820
+ * has a window in which the path is in NO row, and a ledger-known hour marks
12821
+ * the index `hydrated`: nothing would ever walk that hour again, and the
12822
+ * segment would sit on disk permanently unreachable.
12823
+ *
12824
+ * Returns `false` when NOTHING was repointed and the caller must delete
12825
+ * nothing. An hour whose detail could not be read decides nothing (D49) —
12826
+ * the same refusal {@link dropSegments} makes for eviction.
12827
+ */
12828
+ async repointSegments(rows, toLocationId) {
12829
+ const byHour = /* @__PURE__ */ new Map();
12830
+ for (const row of rows) {
12831
+ if (row.locationId === toLocationId) continue;
12832
+ const { key } = hourOf(row);
12833
+ const entry = byHour.get(key);
12834
+ if (entry === void 0) byHour.set(key, {
12835
+ row,
12836
+ paths: new Set([row.path])
12837
+ });
12838
+ else entry.paths.add(row.path);
12839
+ }
12840
+ for (const [sourceKey, { row, paths }] of byHour) {
12841
+ const held = await this.resident(sourceKey, row.deviceId);
12842
+ if (held === null) {
12843
+ this.logger.warn("recorder: hour detail unavailable — this relocation is REFUSED and nothing is deleted; the segments stay where they are", {
12844
+ tags: { deviceId: row.deviceId },
12845
+ meta: {
12846
+ key: sourceKey,
12847
+ toLocationId,
12848
+ paths: paths.size
12849
+ }
12850
+ });
12851
+ return false;
12852
+ }
12853
+ const targetKey = segmentHourKey(row.deviceId, row.profile, toLocationId, held.hourStartMs);
12854
+ const target = this.ledger.has(targetKey) ? await this.resident(targetKey, row.deviceId) : void 0;
12855
+ if (target === null) {
12856
+ this.logger.warn("recorder: TARGET hour detail unavailable — this relocation is REFUSED; appending to a summary would rewrite the hour as these paths alone", {
12857
+ tags: { deviceId: row.deviceId },
12858
+ meta: {
12859
+ key: targetKey,
12860
+ fromLocationId: row.locationId,
12861
+ paths: paths.size
12862
+ }
12863
+ });
12864
+ return false;
12865
+ }
12866
+ const merged = uniquePaths([...target?.paths ?? [], ...paths]);
12867
+ await this.ledger.put(segmentHourRow({
12868
+ key: targetKey,
12869
+ deviceId: row.deviceId,
12870
+ profile: row.profile,
12871
+ locationId: toLocationId,
12872
+ hourStartMs: held.hourStartMs,
12873
+ paths: merged
12874
+ }));
12875
+ const next = held.paths.filter((p) => !paths.has(p));
12876
+ if (next.length === 0) await this.ledger.forget(sourceKey);
12877
+ else await this.ledger.put(segmentHourRow({
12878
+ ...held,
12879
+ paths: next
12880
+ }));
12881
+ }
12882
+ return true;
12883
+ }
12884
+ /**
12648
12885
  * After a disk walk of `deviceIds`, make the ledger match the index — with
12649
12886
  * two D148 exceptions:
12650
12887
  *
@@ -13736,6 +13973,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
13736
13973
  const relocateEngine = new RelocateEngine({
13737
13974
  index: this.index,
13738
13975
  locations: () => this.resolvedLocations,
13976
+ ledger: () => this.segmentHours,
13739
13977
  logger: this.ctx.logger.child("relocate"),
13740
13978
  now: () => Date.now(),
13741
13979
  newId: () => (0, node_crypto.randomUUID)(),