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