@camstack/addon-pipeline 1.2.121 → 1.2.123
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/{addon-utils-DpyYShEF.js → addon-utils-DjiHGIRF.js} +1 -1
- package/dist/audio-analyzer/index.js +3 -3
- package/dist/audio-analyzer/index.mjs +2 -2
- package/dist/detection-pipeline/index.js +5 -5
- package/dist/detection-pipeline/index.mjs +3 -3
- package/dist/{dist-BJFxSye4.mjs → dist-B7rIp5qQ.mjs} +65 -21
- package/dist/{dist-CDNpiyq6.js → dist-DomdkRKL.js} +65 -21
- package/dist/{event-loop-stall-monitor-BnqO_H2Z.js → event-loop-stall-monitor-ChA4AQ1D.js} +1 -1
- package/dist/{event-loop-stall-monitor-CcHh1ovi.mjs → event-loop-stall-monitor-mvxKgrNV.mjs} +1 -1
- package/dist/{lazy-sharp-B39XqJD6.js → lazy-sharp-BSeW3qY5.js} +1 -1
- package/dist/motion-wasm/index.js +2 -2
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/pipeline-runner/index.js +4 -4
- package/dist/pipeline-runner/index.mjs +3 -3
- package/dist/{process-memory-QlU7BsBQ.js → process-memory-BtpLNnXq.js} +1 -1
- package/dist/{process-memory-BeCXh3iT.mjs → process-memory-XHs-umae.mjs} +1 -1
- package/dist/recorder/index.js +1203 -882
- package/dist/recorder/index.mjs +1202 -881
- package/dist/{segment-demux-js-DZ6bRNvA.js → segment-demux-js-CFIIJsfc.js} +1 -1
- package/dist/{segment-demux-js-BuaascIe.mjs → segment-demux-js-Cc5Jubdu.mjs} +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 +1 -1
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DN_gNdAs.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-P-WP2KSi.mjs} +3 -3
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Do2DEtRt.mjs +26 -0
- 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-F493PnEJ.mjs → hostInit-D6O20fld.mjs} +3 -3
- package/dist/stream-broker/index.js +85 -14
- package/dist/stream-broker/index.mjs +85 -14
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/dist/{worker-protocol-Djzf2BTN.mjs → worker-protocol-CrHJXGHV.mjs} +1 -1
- package/dist/{worker-protocol-BPx6jK4-.js → worker-protocol-DQ5DPOFD.js} +1 -1
- package/package.json +7 -12
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-D-NFPV_g.mjs +0 -26
package/dist/recorder/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { B as deriveRecordingMode, Dt as hydrateSchema, Gt as string, Ht as number, M as batteryCapability, Pt as selectAssignedProfileSlots, R as deriveBatteryPresence, St as BaseAddon, T as RecordingConfigSchema, Tt as DeviceType, Ut as object, Wt as record, at as recordingCapability, b as OpsLogEntrySchema, d as DEFAULT_EVENTS_BAND_BUFFER_SEC, ft as storageEvictableCapability, g as ExportRecordSchema, gt as errMsg, i as BatteryStatusSchema, jt as nodePin, m as EVENT_PAD_MS, ot as recordingExportCapability, qt as EventCategory, ut as resolveRecordingProfiles, w as RECORDING_EXPORT_MAX_READ_BYTES, wt as DeviceFeature } from "../dist-
|
|
1
|
+
import { B as deriveRecordingMode, Dt as hydrateSchema, Gt as string, Ht as number, M as batteryCapability, Pt as selectAssignedProfileSlots, R as deriveBatteryPresence, St as BaseAddon, T as RecordingConfigSchema, Tt as DeviceType, Ut as object, Wt as record, at as recordingCapability, b as OpsLogEntrySchema, d as DEFAULT_EVENTS_BAND_BUFFER_SEC, ft as storageEvictableCapability, g as ExportRecordSchema, gt as errMsg, i as BatteryStatusSchema, jt as nodePin, m as EVENT_PAD_MS, ot as recordingExportCapability, qt as EventCategory, ut as resolveRecordingProfiles, w as RECORDING_EXPORT_MAX_READ_BYTES, wt as DeviceFeature } from "../dist-B7rIp5qQ.mjs";
|
|
2
2
|
import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
|
|
3
3
|
import { r as withRecordingIntent } from "../restream-intent-B4BXZra7.mjs";
|
|
4
4
|
import { n as createFileDataPlaneHandler, s as parseRangeHeader, t as contentTypeFor } from "../addon-utils-CgBCF-mE.mjs";
|
|
@@ -5287,6 +5287,338 @@ function runFfmpeg(renderDeps, args, ext) {
|
|
|
5287
5287
|
});
|
|
5288
5288
|
});
|
|
5289
5289
|
}
|
|
5290
|
+
Object.freeze({
|
|
5291
|
+
"addons": { "getLogs": { "limit": 100 } },
|
|
5292
|
+
"audio-analyzer": { "analyseChunk": { "settings": { "__nested__": {
|
|
5293
|
+
"minConfidence": .3,
|
|
5294
|
+
"allowedClasses": []
|
|
5295
|
+
} } } },
|
|
5296
|
+
"audio-codec": {
|
|
5297
|
+
"pullEncoded": { "maxCount": 8 },
|
|
5298
|
+
"pullPcm": { "maxCount": 8 }
|
|
5299
|
+
},
|
|
5300
|
+
"battery": { "wakeForStream": { "timeoutMs": 8e3 } },
|
|
5301
|
+
"decoder": {
|
|
5302
|
+
"createSession": {
|
|
5303
|
+
"maxFps": 0,
|
|
5304
|
+
"outputFormat": "jpeg",
|
|
5305
|
+
"scale": 1,
|
|
5306
|
+
"frameSink": "callback"
|
|
5307
|
+
},
|
|
5308
|
+
"pullFrames": { "maxCount": 1 },
|
|
5309
|
+
"pullHandles": { "maxCount": 1 }
|
|
5310
|
+
},
|
|
5311
|
+
"device-adoption": { "listCandidates": {
|
|
5312
|
+
"page": 1,
|
|
5313
|
+
"pageSize": 50
|
|
5314
|
+
} },
|
|
5315
|
+
"device-manager": { "adoptionListCandidates": {
|
|
5316
|
+
"page": 1,
|
|
5317
|
+
"pageSize": 50
|
|
5318
|
+
} },
|
|
5319
|
+
"llm": { "upsertProfile": { "profile": { "__nested__": {
|
|
5320
|
+
"timeoutMs": 6e4,
|
|
5321
|
+
"connectTimeoutMs": 1e4,
|
|
5322
|
+
"firstTokenTimeoutMs": 12e4,
|
|
5323
|
+
"idleTimeoutMs": 6e4,
|
|
5324
|
+
"retry": {
|
|
5325
|
+
"enabled": false,
|
|
5326
|
+
"maxAttempts": 1
|
|
5327
|
+
},
|
|
5328
|
+
"toolsEnabled": false
|
|
5329
|
+
} } } },
|
|
5330
|
+
"llm-runtime": {
|
|
5331
|
+
"complete": { "runtime": { "__nested__": {
|
|
5332
|
+
"contextSize": 4096,
|
|
5333
|
+
"gpuLayers": 0,
|
|
5334
|
+
"parallel": 1,
|
|
5335
|
+
"flashAttention": false,
|
|
5336
|
+
"mlock": false,
|
|
5337
|
+
"noMmap": false,
|
|
5338
|
+
"extraArgs": [],
|
|
5339
|
+
"autoStart": false,
|
|
5340
|
+
"idleStopMinutes": 30
|
|
5341
|
+
} } },
|
|
5342
|
+
"ensureStarted": { "runtime": { "__nested__": {
|
|
5343
|
+
"contextSize": 4096,
|
|
5344
|
+
"gpuLayers": 0,
|
|
5345
|
+
"parallel": 1,
|
|
5346
|
+
"flashAttention": false,
|
|
5347
|
+
"mlock": false,
|
|
5348
|
+
"noMmap": false,
|
|
5349
|
+
"extraArgs": [],
|
|
5350
|
+
"autoStart": false,
|
|
5351
|
+
"idleStopMinutes": 30
|
|
5352
|
+
} } }
|
|
5353
|
+
},
|
|
5354
|
+
"mqtt-broker": { "startEmbeddedBroker": {
|
|
5355
|
+
"port": 1883,
|
|
5356
|
+
"allowAnonymous": false
|
|
5357
|
+
} },
|
|
5358
|
+
"nodes": { "getCapUsageGraph": { "windowSeconds": 60 } },
|
|
5359
|
+
"notification-output": { "send": { "notification": { "__nested__": {
|
|
5360
|
+
"format": "text",
|
|
5361
|
+
"priority": 3
|
|
5362
|
+
} } } },
|
|
5363
|
+
"notification-rules": {
|
|
5364
|
+
"createRule": { "rule": { "__nested__": {
|
|
5365
|
+
"enabled": true,
|
|
5366
|
+
"conditions": {},
|
|
5367
|
+
"media": { "attach": "best" },
|
|
5368
|
+
"throttle": {
|
|
5369
|
+
"cooldownSec": 60,
|
|
5370
|
+
"scope": "rule-device"
|
|
5371
|
+
},
|
|
5372
|
+
"priority": 3
|
|
5373
|
+
} } },
|
|
5374
|
+
"getHistory": { "filter": { "limit": 100 } },
|
|
5375
|
+
"testRule": {
|
|
5376
|
+
"rule": { "__nested__": {
|
|
5377
|
+
"enabled": true,
|
|
5378
|
+
"conditions": {},
|
|
5379
|
+
"media": { "attach": "best" },
|
|
5380
|
+
"throttle": {
|
|
5381
|
+
"cooldownSec": 60,
|
|
5382
|
+
"scope": "rule-device"
|
|
5383
|
+
},
|
|
5384
|
+
"priority": 3
|
|
5385
|
+
} },
|
|
5386
|
+
"lookbackMinutes": 60
|
|
5387
|
+
}
|
|
5388
|
+
},
|
|
5389
|
+
"osd-manager": { "setSlotBinding": { "binding": { "__nested__": {
|
|
5390
|
+
"enabled": true,
|
|
5391
|
+
"template": "${value}",
|
|
5392
|
+
"maxDecimals": 1,
|
|
5393
|
+
"fallbackText": ""
|
|
5394
|
+
} } } },
|
|
5395
|
+
"pipeline-analytics": {
|
|
5396
|
+
"getAudioEvents": { "limit": 1e3 },
|
|
5397
|
+
"getKeyEvents": { "limit": 50 },
|
|
5398
|
+
"getMotionEvents": { "limit": 1e3 },
|
|
5399
|
+
"getObjectEvents": { "limit": 1e3 },
|
|
5400
|
+
"getSensorEvents": { "limit": 1e3 },
|
|
5401
|
+
"listGroups": { "limit": 40 },
|
|
5402
|
+
"listRecentTracks": { "limit": 200 },
|
|
5403
|
+
"searchObjectEvents": {
|
|
5404
|
+
"limit": 50,
|
|
5405
|
+
"minScore": .2
|
|
5406
|
+
}
|
|
5407
|
+
},
|
|
5408
|
+
"pipeline-runner": {
|
|
5409
|
+
"attachCamera": {
|
|
5410
|
+
"detectionMode": "on-motion",
|
|
5411
|
+
"audioMode": "always-on",
|
|
5412
|
+
"motionCooldownMs": 3e4,
|
|
5413
|
+
"motionFps": 4,
|
|
5414
|
+
"detectionFps": 10,
|
|
5415
|
+
"motionSources": ["analyzer"],
|
|
5416
|
+
"pipelineEnabled": true,
|
|
5417
|
+
"zones": [],
|
|
5418
|
+
"onboardMotionDrivesAnalyzer": true,
|
|
5419
|
+
"occupancyRecheckEnabled": true,
|
|
5420
|
+
"occupancyRecheckSec": 300,
|
|
5421
|
+
"occupancyRecheckFrames": 4,
|
|
5422
|
+
"frameSource": { "kind": "local-broker" }
|
|
5423
|
+
},
|
|
5424
|
+
"reportMotion": { "source": "analyzer" }
|
|
5425
|
+
},
|
|
5426
|
+
"recording": {
|
|
5427
|
+
"renderClip": {
|
|
5428
|
+
"preRollSec": 3,
|
|
5429
|
+
"postRollSec": 7,
|
|
5430
|
+
"maxWidth": 640
|
|
5431
|
+
},
|
|
5432
|
+
"renderGif": {
|
|
5433
|
+
"preRollSec": 2,
|
|
5434
|
+
"postRollSec": 5,
|
|
5435
|
+
"maxWidth": 480,
|
|
5436
|
+
"fps": 5
|
|
5437
|
+
},
|
|
5438
|
+
"setDeviceConfig": { "config": { "__nested__": { "bands": [] } } }
|
|
5439
|
+
},
|
|
5440
|
+
"storage": { "upsertLocation": {
|
|
5441
|
+
"isDefault": false,
|
|
5442
|
+
"isSystem": false
|
|
5443
|
+
} },
|
|
5444
|
+
"storage-provider": {
|
|
5445
|
+
"beginDownload": { "location": { "__nested__": {
|
|
5446
|
+
"isDefault": false,
|
|
5447
|
+
"isSystem": false
|
|
5448
|
+
} } },
|
|
5449
|
+
"beginUpload": { "location": { "__nested__": {
|
|
5450
|
+
"isDefault": false,
|
|
5451
|
+
"isSystem": false
|
|
5452
|
+
} } },
|
|
5453
|
+
"delete": { "location": { "__nested__": {
|
|
5454
|
+
"isDefault": false,
|
|
5455
|
+
"isSystem": false
|
|
5456
|
+
} } },
|
|
5457
|
+
"exists": { "location": { "__nested__": {
|
|
5458
|
+
"isDefault": false,
|
|
5459
|
+
"isSystem": false
|
|
5460
|
+
} } },
|
|
5461
|
+
"getAvailableSpace": { "location": { "__nested__": {
|
|
5462
|
+
"isDefault": false,
|
|
5463
|
+
"isSystem": false
|
|
5464
|
+
} } },
|
|
5465
|
+
"list": { "location": { "__nested__": {
|
|
5466
|
+
"isDefault": false,
|
|
5467
|
+
"isSystem": false
|
|
5468
|
+
} } },
|
|
5469
|
+
"read": { "location": { "__nested__": {
|
|
5470
|
+
"isDefault": false,
|
|
5471
|
+
"isSystem": false
|
|
5472
|
+
} } },
|
|
5473
|
+
"resolve": { "location": { "__nested__": {
|
|
5474
|
+
"isDefault": false,
|
|
5475
|
+
"isSystem": false
|
|
5476
|
+
} } },
|
|
5477
|
+
"write": { "location": { "__nested__": {
|
|
5478
|
+
"isDefault": false,
|
|
5479
|
+
"isSystem": false
|
|
5480
|
+
} } }
|
|
5481
|
+
},
|
|
5482
|
+
"stream-broker": {
|
|
5483
|
+
"produceEventMedia": {
|
|
5484
|
+
"preSeconds": 4,
|
|
5485
|
+
"postSeconds": 6,
|
|
5486
|
+
"kinds": ["mp4"],
|
|
5487
|
+
"gifMaxWidth": 640,
|
|
5488
|
+
"gifFps": 12,
|
|
5489
|
+
"gifSpeed": 4,
|
|
5490
|
+
"speed": 4
|
|
5491
|
+
},
|
|
5492
|
+
"pullAudioChunks": { "maxCount": 8 },
|
|
5493
|
+
"pullFrameHandles": { "maxCount": 4 },
|
|
5494
|
+
"renderPreBufferClip": {
|
|
5495
|
+
"preRollSec": 3,
|
|
5496
|
+
"postRollSec": 5,
|
|
5497
|
+
"format": "gif",
|
|
5498
|
+
"maxWidth": 480,
|
|
5499
|
+
"fps": 5,
|
|
5500
|
+
"speed": 2
|
|
5501
|
+
}
|
|
5502
|
+
},
|
|
5503
|
+
"terminal-session": { "pullOutput": { "waitMs": 0 } },
|
|
5504
|
+
"user-management": {
|
|
5505
|
+
"createApiKey": { "isAdmin": false },
|
|
5506
|
+
"createUser": { "isAdmin": false }
|
|
5507
|
+
},
|
|
5508
|
+
"vector-store": { "declareIndex": { "metric": "cosine" } },
|
|
5509
|
+
"zones": {
|
|
5510
|
+
"addZone": { "zone": { "__nested__": {
|
|
5511
|
+
"kind": "polygon",
|
|
5512
|
+
"color": "#3b82f6"
|
|
5513
|
+
} } },
|
|
5514
|
+
"updateZone": { "zone": { "__nested__": {
|
|
5515
|
+
"kind": "polygon",
|
|
5516
|
+
"color": "#3b82f6"
|
|
5517
|
+
} } }
|
|
5518
|
+
}
|
|
5519
|
+
});
|
|
5520
|
+
Object.freeze({
|
|
5521
|
+
"addon-pages-source": ["listPages"],
|
|
5522
|
+
"addon-routes": ["getRoutes"],
|
|
5523
|
+
"addon-widgets-source": ["listWidgets"],
|
|
5524
|
+
"broker": ["list", "listProviders"],
|
|
5525
|
+
"custom-model-registry": ["listModels"],
|
|
5526
|
+
"data-store-provider": ["histogram", "query"],
|
|
5527
|
+
"device-export": ["listExposedDevices", "listSupportedDeviceKinds"],
|
|
5528
|
+
"device-provider": ["discoverDevices", "getDevices"],
|
|
5529
|
+
"llm": [
|
|
5530
|
+
"getDefaults",
|
|
5531
|
+
"getUsage",
|
|
5532
|
+
"listModelCatalog",
|
|
5533
|
+
"listModels",
|
|
5534
|
+
"listNodeModels",
|
|
5535
|
+
"listProfileKinds",
|
|
5536
|
+
"listProfiles",
|
|
5537
|
+
"listRuntimeNodes"
|
|
5538
|
+
],
|
|
5539
|
+
"log-destination": ["query"],
|
|
5540
|
+
"login-method": ["getLoginMethods"],
|
|
5541
|
+
"mqtt-broker": ["listBrokers"],
|
|
5542
|
+
"network-access": ["listEndpoints"],
|
|
5543
|
+
"notification-output": [
|
|
5544
|
+
"discoverTargets",
|
|
5545
|
+
"listTargetKinds",
|
|
5546
|
+
"listTargets"
|
|
5547
|
+
],
|
|
5548
|
+
"storage-provider": ["list"],
|
|
5549
|
+
"turn-provider": ["getTurnServers"],
|
|
5550
|
+
"user-passkeys": ["listPasskeys"]
|
|
5551
|
+
});
|
|
5552
|
+
//#endregion
|
|
5553
|
+
//#region src/recorder/addon/last-known-config.ts
|
|
5554
|
+
/**
|
|
5555
|
+
* Wrap `load` so a failed read serves the last value that succeeded.
|
|
5556
|
+
*
|
|
5557
|
+
* Returns a drop-in replacement for {@link LoadDeviceConfigFn}: every existing
|
|
5558
|
+
* call site gains the fallback without changing, which is the point — the
|
|
5559
|
+
* trigger post-buffer was the path that got CAUGHT writing a wrong number, but
|
|
5560
|
+
* it is one of five reads and the other four fail the same way.
|
|
5561
|
+
*/
|
|
5562
|
+
function withLastKnownConfig(deps) {
|
|
5563
|
+
const now = deps.now ?? (() => Date.now());
|
|
5564
|
+
const cache = /* @__PURE__ */ new Map();
|
|
5565
|
+
return async (deviceId) => {
|
|
5566
|
+
try {
|
|
5567
|
+
const config = await deps.load(deviceId);
|
|
5568
|
+
cache.set(deviceId, {
|
|
5569
|
+
config,
|
|
5570
|
+
atMs: now()
|
|
5571
|
+
});
|
|
5572
|
+
return config;
|
|
5573
|
+
} catch (err) {
|
|
5574
|
+
const cached = cache.get(deviceId);
|
|
5575
|
+
if (cached === void 0) {
|
|
5576
|
+
deps.logger.warn("recorder: config read failed and NOTHING is cached for this camera — no last-known-good to serve", {
|
|
5577
|
+
tags: { deviceId },
|
|
5578
|
+
meta: { error: errMsg(err) }
|
|
5579
|
+
});
|
|
5580
|
+
throw err;
|
|
5581
|
+
}
|
|
5582
|
+
const ageMs = now() - cached.atMs;
|
|
5583
|
+
deps.logger.warn("recorder: config read failed — serving the LAST-KNOWN-GOOD config (not the shipped default)", {
|
|
5584
|
+
tags: { deviceId },
|
|
5585
|
+
meta: {
|
|
5586
|
+
ageMs,
|
|
5587
|
+
error: errMsg(err)
|
|
5588
|
+
}
|
|
5589
|
+
});
|
|
5590
|
+
return cached.config;
|
|
5591
|
+
}
|
|
5592
|
+
};
|
|
5593
|
+
}
|
|
5594
|
+
//#endregion
|
|
5595
|
+
//#region src/recorder/addon/location-capacity.ts
|
|
5596
|
+
/** Human-readable failure text for a thrown value of unknown shape. */
|
|
5597
|
+
function reasonOf(err) {
|
|
5598
|
+
return err instanceof Error ? err.message : String(err);
|
|
5599
|
+
}
|
|
5600
|
+
/**
|
|
5601
|
+
* Free + total bytes on the volume backing `root`.
|
|
5602
|
+
*
|
|
5603
|
+
* Never throws: an unreachable volume is one degraded ROW, not a failed
|
|
5604
|
+
* `getStorageUsage` — a NAS that stops answering must not blank the capacity of
|
|
5605
|
+
* every other disk on the node.
|
|
5606
|
+
*/
|
|
5607
|
+
async function readLocationCapacity(root, statfs) {
|
|
5608
|
+
try {
|
|
5609
|
+
const st = await statfs(root);
|
|
5610
|
+
return {
|
|
5611
|
+
availableBytes: st.bavail * st.bsize,
|
|
5612
|
+
totalBytes: st.blocks * st.bsize
|
|
5613
|
+
};
|
|
5614
|
+
} catch (err) {
|
|
5615
|
+
return {
|
|
5616
|
+
availableBytes: null,
|
|
5617
|
+
totalBytes: null,
|
|
5618
|
+
error: reasonOf(err)
|
|
5619
|
+
};
|
|
5620
|
+
}
|
|
5621
|
+
}
|
|
5290
5622
|
//#endregion
|
|
5291
5623
|
//#region src/recorder/addon/ops-log-store.ts
|
|
5292
5624
|
/**
|
|
@@ -9177,223 +9509,494 @@ function makeBrokerCall(handle) {
|
|
|
9177
9509
|
return (fn) => handle.call(fn, BROKER_READY_TIMEOUT_MS);
|
|
9178
9510
|
}
|
|
9179
9511
|
//#endregion
|
|
9180
|
-
//#region src/recorder/addon/
|
|
9181
|
-
/**
|
|
9182
|
-
|
|
9183
|
-
|
|
9184
|
-
|
|
9512
|
+
//#region src/recorder/addon/recording-export-provider.ts
|
|
9513
|
+
/** Content type of every rendered export — the engine only ever writes MP4. */
|
|
9514
|
+
var EXPORT_CONTENT_TYPE = "video/mp4";
|
|
9515
|
+
/** Filename bound, extension included. Long enough to stay recognisable. */
|
|
9516
|
+
var EXPORT_NAME_MAX = 64;
|
|
9185
9517
|
/**
|
|
9186
|
-
*
|
|
9518
|
+
* The filename a RECIPIENT sees on the attachment.
|
|
9187
9519
|
*
|
|
9188
|
-
*
|
|
9189
|
-
*
|
|
9190
|
-
*
|
|
9520
|
+
* Derived from the operator's own export title so a delivered video is
|
|
9521
|
+
* identifiable in a chat thread, but slugified and BOUNDED: the title field
|
|
9522
|
+
* accepts 200 characters and several notifier backends silently mangle a
|
|
9523
|
+
* filename that long. No usable title degrades to the export id, never to an
|
|
9524
|
+
* empty name.
|
|
9191
9525
|
*/
|
|
9192
|
-
|
|
9193
|
-
|
|
9194
|
-
|
|
9195
|
-
return {
|
|
9196
|
-
availableBytes: st.bavail * st.bsize,
|
|
9197
|
-
totalBytes: st.blocks * st.bsize
|
|
9198
|
-
};
|
|
9199
|
-
} catch (err) {
|
|
9200
|
-
return {
|
|
9201
|
-
availableBytes: null,
|
|
9202
|
-
totalBytes: null,
|
|
9203
|
-
error: reasonOf(err)
|
|
9204
|
-
};
|
|
9205
|
-
}
|
|
9526
|
+
function exportFileName(rec) {
|
|
9527
|
+
const slug = (rec.title ?? "").replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, EXPORT_NAME_MAX - 4).replace(/-+$/g, "");
|
|
9528
|
+
return slug.length > 0 ? `${slug}.mp4` : `export-${rec.id}.mp4`;
|
|
9206
9529
|
}
|
|
9207
|
-
|
|
9208
|
-
|
|
9209
|
-
|
|
9210
|
-
|
|
9211
|
-
|
|
9212
|
-
|
|
9213
|
-
|
|
9214
|
-
|
|
9215
|
-
|
|
9216
|
-
|
|
9217
|
-
|
|
9218
|
-
|
|
9219
|
-
|
|
9220
|
-
|
|
9221
|
-
|
|
9222
|
-
|
|
9223
|
-
|
|
9224
|
-
|
|
9225
|
-
|
|
9226
|
-
|
|
9227
|
-
|
|
9228
|
-
/**
|
|
9229
|
-
|
|
9230
|
-
|
|
9231
|
-
|
|
9232
|
-
|
|
9233
|
-
|
|
9234
|
-
|
|
9235
|
-
|
|
9236
|
-
|
|
9237
|
-
|
|
9238
|
-
|
|
9239
|
-
|
|
9240
|
-
|
|
9241
|
-
|
|
9242
|
-
|
|
9243
|
-
|
|
9244
|
-
|
|
9245
|
-
|
|
9246
|
-
|
|
9247
|
-
|
|
9248
|
-
|
|
9249
|
-
|
|
9250
|
-
|
|
9251
|
-
|
|
9252
|
-
|
|
9253
|
-
|
|
9254
|
-
|
|
9255
|
-
|
|
9256
|
-
|
|
9257
|
-
|
|
9258
|
-
|
|
9259
|
-
|
|
9260
|
-
* Boot reseed. Replaces the mirror with what the store holds and returns the
|
|
9261
|
-
* rows, so a caller that must hydrate something else (a watcher, a registry)
|
|
9262
|
-
* gets them without a second read.
|
|
9263
|
-
*
|
|
9264
|
-
* **A failure returns what is already mirrored** rather than clearing it —
|
|
9265
|
-
* see contract rule 2. The count is worth logging out loud at the call site:
|
|
9266
|
-
* "loaded 0" after a container recreate is the one line that explains a
|
|
9267
|
-
* silent flood.
|
|
9268
|
-
*/
|
|
9269
|
-
async load() {
|
|
9270
|
-
try {
|
|
9271
|
-
const records = await this.store.query.query({
|
|
9272
|
-
collection: this.spec.collection,
|
|
9273
|
-
filter: { limit: this.spec.loadLimit ?? DEFAULT_LOAD_LIMIT }
|
|
9274
|
-
});
|
|
9275
|
-
const next = /* @__PURE__ */ new Map();
|
|
9276
|
-
let skipped = 0;
|
|
9277
|
-
for (const record of records) {
|
|
9278
|
-
const row = this.spec.fromRecord(record.id, record.data);
|
|
9279
|
-
if (row === null) {
|
|
9280
|
-
skipped += 1;
|
|
9281
|
-
continue;
|
|
9282
|
-
}
|
|
9283
|
-
next.set(this.spec.keyOf(row), row);
|
|
9530
|
+
/**
|
|
9531
|
+
* True when at least one indexed segment overlaps `[fromMs, toMs)`. Mirrors the
|
|
9532
|
+
* `buildPlaybackManifest` overlap idiom: a segment `[startMs, startMs+durMs)`
|
|
9533
|
+
* overlaps the window iff it starts before the window ends AND ends after the
|
|
9534
|
+
* window starts.
|
|
9535
|
+
*/
|
|
9536
|
+
async function hasFootage(index, deviceId, profile, fromMs, toMs) {
|
|
9537
|
+
return (await index.segmentsInWindow(deviceId, profile, fromMs, toMs)).some((s) => s.startMs < toMs && s.startMs + s.durMs > fromMs);
|
|
9538
|
+
}
|
|
9539
|
+
/** Canonical `profiles[]`, falling back to the legacy singular `profile`. */
|
|
9540
|
+
function resolveExportProfiles(input) {
|
|
9541
|
+
if (input.profiles !== void 0 && input.profiles.length > 0) return [...input.profiles];
|
|
9542
|
+
if (typeof input.profile === "string" && input.profile.length > 0) return [input.profile];
|
|
9543
|
+
return [];
|
|
9544
|
+
}
|
|
9545
|
+
/** Load a record or throw a clear NOT_FOUND-style error. */
|
|
9546
|
+
async function requireRecord(state, exportId) {
|
|
9547
|
+
const rec = await getExportRecord(state, exportId);
|
|
9548
|
+
if (!rec) throw new Error(`recording-export: unknown export "${exportId}"`);
|
|
9549
|
+
return rec;
|
|
9550
|
+
}
|
|
9551
|
+
/** Build the `recordingExport` provider over the injected deps. */
|
|
9552
|
+
function buildRecordingExportProvider(deps) {
|
|
9553
|
+
return {
|
|
9554
|
+
createExport: async (input) => {
|
|
9555
|
+
const { deviceId, fromMs, toMs, options } = input;
|
|
9556
|
+
const profiles = resolveExportProfiles(input);
|
|
9557
|
+
if (profiles.length < 1) throw new Error("recording-export: profiles must contain at least one profile");
|
|
9558
|
+
if (!(fromMs < toMs)) throw new Error(`recording-export: invalid range ${fromMs}..${toMs}`);
|
|
9559
|
+
for (const profile of profiles) if (!await hasFootage(deps.index, deviceId, profile, fromMs, toMs)) throw new Error(`recording-export: no footage for ${deviceId}/${profile} in ${fromMs}..${toMs}`);
|
|
9560
|
+
const now = deps.now();
|
|
9561
|
+
let first = null;
|
|
9562
|
+
for (const profile of profiles) {
|
|
9563
|
+
const record = {
|
|
9564
|
+
id: deps.newId(),
|
|
9565
|
+
deviceId,
|
|
9566
|
+
profile,
|
|
9567
|
+
fromMs,
|
|
9568
|
+
toMs,
|
|
9569
|
+
options,
|
|
9570
|
+
state: "queued",
|
|
9571
|
+
progressPct: null,
|
|
9572
|
+
fileBytes: null,
|
|
9573
|
+
expiresAt: now + options.maxLifeMs,
|
|
9574
|
+
deleteAfterDownload: options.deleteAfterDownload,
|
|
9575
|
+
downloadedAt: null,
|
|
9576
|
+
createdAt: now,
|
|
9577
|
+
createdBy: deps.createdBy(),
|
|
9578
|
+
error: null
|
|
9579
|
+
};
|
|
9580
|
+
const saved = await upsertExport(deps.state, record);
|
|
9581
|
+
deps.engine.enqueue(saved.id);
|
|
9582
|
+
first ??= saved;
|
|
9284
9583
|
}
|
|
9285
|
-
|
|
9286
|
-
|
|
9287
|
-
|
|
9288
|
-
|
|
9289
|
-
|
|
9290
|
-
|
|
9291
|
-
|
|
9292
|
-
|
|
9293
|
-
|
|
9294
|
-
|
|
9295
|
-
|
|
9296
|
-
|
|
9297
|
-
|
|
9298
|
-
|
|
9584
|
+
return first;
|
|
9585
|
+
},
|
|
9586
|
+
listExports: ({ deviceId }) => listExportsFor(deps.state, deviceId),
|
|
9587
|
+
getExport: ({ exportId }) => requireRecord(deps.state, exportId),
|
|
9588
|
+
cancelExport: async ({ exportId }) => {
|
|
9589
|
+
const existing = await requireRecord(deps.state, exportId);
|
|
9590
|
+
await deps.engine.cancel(exportId);
|
|
9591
|
+
return await getExportRecord(deps.state, exportId) ?? existing;
|
|
9592
|
+
},
|
|
9593
|
+
deleteExport: async ({ exportId }) => {
|
|
9594
|
+
const existing = await requireRecord(deps.state, exportId);
|
|
9595
|
+
await deps.removeFile(deps.outPath(existing));
|
|
9596
|
+
return await patchExport(deps.state, exportId, {
|
|
9597
|
+
state: "deleted",
|
|
9598
|
+
fileBytes: null
|
|
9599
|
+
}) ?? existing;
|
|
9600
|
+
},
|
|
9601
|
+
getDownloadUrl: async ({ exportId }) => {
|
|
9602
|
+
await requireRecord(deps.state, exportId);
|
|
9603
|
+
const url = deps.downloadUrlFor(exportId);
|
|
9604
|
+
return {
|
|
9605
|
+
url,
|
|
9606
|
+
endpoints: [url]
|
|
9607
|
+
};
|
|
9608
|
+
},
|
|
9609
|
+
readExportBytes: async ({ exportId }) => {
|
|
9610
|
+
const rec = await requireRecord(deps.state, exportId);
|
|
9611
|
+
const tags = { deviceId: rec.deviceId };
|
|
9612
|
+
if (rec.state !== "ready") {
|
|
9613
|
+
deps.logger.warn("export byte read refused: export is not ready", {
|
|
9614
|
+
tags,
|
|
9615
|
+
meta: {
|
|
9616
|
+
exportId,
|
|
9617
|
+
state: rec.state
|
|
9618
|
+
}
|
|
9619
|
+
});
|
|
9620
|
+
throw new Error(`recording-export: export "${exportId}" is not ready (${rec.state})`);
|
|
9621
|
+
}
|
|
9622
|
+
if (deps.now() >= rec.expiresAt) {
|
|
9623
|
+
deps.logger.warn("export byte read refused: export has expired", {
|
|
9624
|
+
tags,
|
|
9625
|
+
meta: {
|
|
9626
|
+
exportId,
|
|
9627
|
+
expiresAt: rec.expiresAt
|
|
9628
|
+
}
|
|
9629
|
+
});
|
|
9630
|
+
throw new Error(`recording-export: export "${exportId}" has expired`);
|
|
9631
|
+
}
|
|
9632
|
+
const file = deps.outPath(rec);
|
|
9633
|
+
let size;
|
|
9634
|
+
try {
|
|
9635
|
+
size = await deps.statBytes(file);
|
|
9636
|
+
} catch (err) {
|
|
9637
|
+
deps.logger.warn("export byte read refused: file is missing", {
|
|
9638
|
+
tags,
|
|
9639
|
+
meta: {
|
|
9640
|
+
exportId,
|
|
9641
|
+
error: String(err)
|
|
9642
|
+
}
|
|
9643
|
+
});
|
|
9644
|
+
throw new Error(`recording-export: export "${exportId}" has no file on disk`, { cause: err });
|
|
9645
|
+
}
|
|
9646
|
+
if (size > 52428800) {
|
|
9647
|
+
deps.logger.warn("export byte read refused: file is too large to inline", {
|
|
9648
|
+
tags,
|
|
9649
|
+
meta: {
|
|
9650
|
+
exportId,
|
|
9651
|
+
bytes: size,
|
|
9652
|
+
maxBytes: RECORDING_EXPORT_MAX_READ_BYTES
|
|
9653
|
+
}
|
|
9654
|
+
});
|
|
9655
|
+
throw new Error(`recording-export: export "${exportId}" is too large to read inline (${size} > ${RECORDING_EXPORT_MAX_READ_BYTES} bytes)`);
|
|
9656
|
+
}
|
|
9657
|
+
const bytes = await deps.readFile(file);
|
|
9658
|
+
return {
|
|
9659
|
+
base64: bytes.toString("base64"),
|
|
9660
|
+
contentType: EXPORT_CONTENT_TYPE,
|
|
9661
|
+
name: exportFileName({
|
|
9662
|
+
id: rec.id,
|
|
9663
|
+
...rec.options.title !== void 0 ? { title: rec.options.title } : {}
|
|
9664
|
+
}),
|
|
9665
|
+
bytes: bytes.byteLength
|
|
9666
|
+
};
|
|
9299
9667
|
}
|
|
9300
|
-
}
|
|
9301
|
-
|
|
9302
|
-
|
|
9303
|
-
|
|
9304
|
-
|
|
9305
|
-
|
|
9306
|
-
|
|
9307
|
-
|
|
9308
|
-
|
|
9309
|
-
|
|
9310
|
-
|
|
9311
|
-
|
|
9312
|
-
|
|
9313
|
-
|
|
9668
|
+
};
|
|
9669
|
+
}
|
|
9670
|
+
//#endregion
|
|
9671
|
+
//#region src/recorder/addon/recording-hub-host.ts
|
|
9672
|
+
/**
|
|
9673
|
+
* Resolve the host a REMOTE recording node should use to dial the hub-only
|
|
9674
|
+
* stream-broker's restream port.
|
|
9675
|
+
*
|
|
9676
|
+
* The implementation was promoted to the package-shared
|
|
9677
|
+
* `src/shared/hub-hostname.ts` in P2d (cross-node Phase 2) so the
|
|
9678
|
+
* pipeline-runner's remote-source leg shares the exact same resolver instead
|
|
9679
|
+
* of copying it. This module keeps the recorder-facing name as a thin alias —
|
|
9680
|
+
* resolution order and semantics are unchanged (operator override first, then
|
|
9681
|
+
* the host extracted from `CAMSTACK_HUB_URL`).
|
|
9682
|
+
*/
|
|
9683
|
+
var resolveRecordingHubHostname = resolveHubHostname;
|
|
9684
|
+
//#endregion
|
|
9685
|
+
//#region src/recorder/addon/retention-sweep.ts
|
|
9686
|
+
/**
|
|
9687
|
+
* Periodic footage-retention sweep (recording-spec §6).
|
|
9688
|
+
*
|
|
9689
|
+
* The disk-pressure eviction path only fires when a volume is TOO FULL
|
|
9690
|
+
* (`shouldSaveDiskSpace` + `minFreePercent`); it never enforces a camera's
|
|
9691
|
+
* age/size policy. This sweeper closes that gap: on an hourly cadence it prunes
|
|
9692
|
+
* footage for every device whose operator set an actionable `retention` policy,
|
|
9693
|
+
* delegating the actual selection + delete to the `recording` cap's
|
|
9694
|
+
* `pruneFootage` (which owns the retention FLOOR via `selectEvictions` — this
|
|
9695
|
+
* sweeper only decides WHICH devices to prune and WHEN).
|
|
9696
|
+
*
|
|
9697
|
+
* Owns its own timer + single-flight guard so a slow sweep is never overlapped
|
|
9698
|
+
* by the next tick — the same subsystem-owns-its-timer shape as the
|
|
9699
|
+
* export janitor. Pure over injected deps (config read + prune + logger +
|
|
9700
|
+
* clock via `setInterval`), so it is unit-testable with fake timers and a mock
|
|
9701
|
+
* `prune`. Every pass is best-effort: a per-device prune failure is logged and
|
|
9702
|
+
* never aborts the rest, and the sweep never throws.
|
|
9703
|
+
*/
|
|
9704
|
+
/**
|
|
9705
|
+
* True when the operator set an ACTIONABLE retention policy on a device — at
|
|
9706
|
+
* least one of `maxAgeDays` / `maxSizeGb` is present and positive. Mirrors the
|
|
9707
|
+
* `> 0` guards `selectEvictions` itself applies, so a `0`/absent limit (which
|
|
9708
|
+
* inherits the node default = "keep forever") is treated as NO policy and the
|
|
9709
|
+
* device is skipped. NEVER prune a device without an explicit policy — that
|
|
9710
|
+
* would delete footage the operator never asked to age out.
|
|
9711
|
+
*/
|
|
9712
|
+
function hasRetentionPolicy(config) {
|
|
9713
|
+
const retention = config.retention;
|
|
9714
|
+
if (!retention) return false;
|
|
9715
|
+
const byAge = retention.maxAgeDays != null && retention.maxAgeDays > 0;
|
|
9716
|
+
const bySize = retention.maxSizeGb != null && retention.maxSizeGb > 0;
|
|
9717
|
+
return byAge || bySize;
|
|
9718
|
+
}
|
|
9719
|
+
var RetentionSweeper = class {
|
|
9720
|
+
deps;
|
|
9721
|
+
timer = null;
|
|
9722
|
+
/** Single-flight guard: true while a sweep is in flight. */
|
|
9723
|
+
sweeping = false;
|
|
9724
|
+
constructor(deps) {
|
|
9725
|
+
this.deps = deps;
|
|
9314
9726
|
}
|
|
9315
9727
|
/**
|
|
9316
|
-
*
|
|
9317
|
-
* it
|
|
9318
|
-
*
|
|
9319
|
-
* Synchronous on purpose: the verdict is a function of the in-RAM mirror
|
|
9320
|
-
* alone, so a decision can never be gated on an I/O that might fail (D49).
|
|
9321
|
-
* The durable write is kicked off behind it and its failure changes no
|
|
9322
|
-
* verdict.
|
|
9323
|
-
*
|
|
9324
|
-
* `no-flip` leaves the held row UNTOUCHED — including any timestamp it
|
|
9325
|
-
* carries, which therefore means "when this key last CHANGED", not "when it
|
|
9326
|
-
* last spoke". That is the timestamp anyone reading the table wants.
|
|
9728
|
+
* Arm the sweep: run immediately, then on the interval. Idempotent — a second
|
|
9729
|
+
* `start()` while already armed is a no-op. The timer is `unref`'d so it never
|
|
9730
|
+
* keeps the process alive.
|
|
9327
9731
|
*/
|
|
9328
|
-
|
|
9329
|
-
|
|
9330
|
-
|
|
9331
|
-
|
|
9332
|
-
|
|
9333
|
-
|
|
9334
|
-
this.
|
|
9335
|
-
this.
|
|
9336
|
-
return held === void 0 ? "seeded" : "flip";
|
|
9732
|
+
start() {
|
|
9733
|
+
if (this.timer !== null) return;
|
|
9734
|
+
const tick = () => {
|
|
9735
|
+
this.sweep();
|
|
9736
|
+
};
|
|
9737
|
+
tick();
|
|
9738
|
+
this.timer = setInterval(tick, this.deps.intervalMs);
|
|
9739
|
+
this.timer.unref?.();
|
|
9337
9740
|
}
|
|
9338
|
-
/**
|
|
9339
|
-
|
|
9340
|
-
|
|
9341
|
-
|
|
9342
|
-
|
|
9343
|
-
* {@link DurableWriteMode}, not the call site's: two call sites that
|
|
9344
|
-
* disagreed about it would be two different durability guarantees on one
|
|
9345
|
-
* collection.
|
|
9346
|
-
*/
|
|
9347
|
-
async put(row) {
|
|
9348
|
-
const key = this.spec.keyOf(row);
|
|
9349
|
-
if (this.spec.writeMode === "write-behind") {
|
|
9350
|
-
this.mirror.set(key, row);
|
|
9351
|
-
await this.persist(row);
|
|
9352
|
-
return;
|
|
9741
|
+
/** Stop the sweep timer. Idempotent. An in-flight sweep still completes. */
|
|
9742
|
+
stop() {
|
|
9743
|
+
if (this.timer !== null) {
|
|
9744
|
+
clearInterval(this.timer);
|
|
9745
|
+
this.timer = null;
|
|
9353
9746
|
}
|
|
9354
|
-
await this.store.set.mutate({
|
|
9355
|
-
collection: this.spec.collection,
|
|
9356
|
-
key,
|
|
9357
|
-
value: this.spec.toValue(row)
|
|
9358
|
-
});
|
|
9359
|
-
this.mirror.set(key, row);
|
|
9360
|
-
}
|
|
9361
|
-
/**
|
|
9362
|
-
* Advance the mirror WITHOUT persisting, for an owner that deliberately
|
|
9363
|
-
* coalesces its writes.
|
|
9364
|
-
*
|
|
9365
|
-
* The stationary registry is the reason this exists: a parked car is
|
|
9366
|
-
* re-confirmed on every processed frame (5–30 Hz), and persisting each
|
|
9367
|
-
* confirmation would offer thousands of commits a day to the checkpoint
|
|
9368
|
-
* lottery (D96) to maintain a handful of rows. It stages the advance and
|
|
9369
|
-
* flushes on its 5-minute sweep — 288 writes a day instead of ~10⁶.
|
|
9370
|
-
*
|
|
9371
|
-
* **The cost is stated, not hidden**: a staged value that is never flushed
|
|
9372
|
-
* is lost on a crash. An owner may only stage a field whose staleness its
|
|
9373
|
-
* own TTL absorbs. Anything that GATES work must go through {@link put} or
|
|
9374
|
-
* {@link observe}.
|
|
9375
|
-
*/
|
|
9376
|
-
stage(row) {
|
|
9377
|
-
this.mirror.set(this.spec.keyOf(row), row);
|
|
9378
|
-
}
|
|
9379
|
-
/**
|
|
9380
|
-
* Drop a key from the MIRROR only — the durable row survives.
|
|
9381
|
-
*
|
|
9382
|
-
* What a scope-unbind needs: this process stops holding the value, and a
|
|
9383
|
-
* rebind reloads it from the store. Deliberately distinct from
|
|
9384
|
-
* {@link forget}, which deletes; conflating the two is how an unbind turns
|
|
9385
|
-
* into a wipe.
|
|
9386
|
-
*/
|
|
9387
|
-
evict(key) {
|
|
9388
|
-
this.mirror.delete(key);
|
|
9389
9747
|
}
|
|
9390
9748
|
/**
|
|
9391
|
-
*
|
|
9392
|
-
*
|
|
9393
|
-
*
|
|
9749
|
+
* One retention pass. Single-flighted: a call that fires while a previous
|
|
9750
|
+
* sweep is still running is a no-op. Never throws — a config-read failure
|
|
9751
|
+
* aborts THIS pass (logged); a per-device prune failure is logged and the
|
|
9752
|
+
* sweep continues with the remaining devices.
|
|
9394
9753
|
*/
|
|
9395
|
-
async
|
|
9396
|
-
this.
|
|
9754
|
+
async sweep() {
|
|
9755
|
+
if (this.sweeping) {
|
|
9756
|
+
this.deps.logger.debug("recorder: retention sweep already running — skipping this tick");
|
|
9757
|
+
return;
|
|
9758
|
+
}
|
|
9759
|
+
this.sweeping = true;
|
|
9760
|
+
try {
|
|
9761
|
+
let configs;
|
|
9762
|
+
try {
|
|
9763
|
+
configs = await this.deps.readConfigs();
|
|
9764
|
+
} catch (err) {
|
|
9765
|
+
this.deps.logger.warn("recorder: retention sweep could not read device configs", { meta: { error: errMsg(err) } });
|
|
9766
|
+
return;
|
|
9767
|
+
}
|
|
9768
|
+
const withPolicy = [...configs].filter(([, config]) => hasRetentionPolicy(config));
|
|
9769
|
+
const devicesSkipped = configs.size - withPolicy.length;
|
|
9770
|
+
this.deps.logger.info("recorder: retention sweep starting", { meta: {
|
|
9771
|
+
devices: withPolicy.map(([deviceId]) => deviceId),
|
|
9772
|
+
skipped: devicesSkipped
|
|
9773
|
+
} });
|
|
9774
|
+
let devicesSwept = 0;
|
|
9775
|
+
let bucketsPruned = 0;
|
|
9776
|
+
let bytesReclaimed = 0;
|
|
9777
|
+
for (const [deviceId] of withPolicy) {
|
|
9778
|
+
const startedMs = Date.now();
|
|
9779
|
+
try {
|
|
9780
|
+
const result = await this.deps.prune(deviceId);
|
|
9781
|
+
devicesSwept += 1;
|
|
9782
|
+
bucketsPruned += result.deletedBuckets;
|
|
9783
|
+
bytesReclaimed += result.reclaimedBytes;
|
|
9784
|
+
this.deps.logger.info("recorder: retention prune complete for device", {
|
|
9785
|
+
tags: { deviceId },
|
|
9786
|
+
meta: {
|
|
9787
|
+
deletedBuckets: result.deletedBuckets,
|
|
9788
|
+
reclaimedBytes: result.reclaimedBytes,
|
|
9789
|
+
ms: Date.now() - startedMs
|
|
9790
|
+
}
|
|
9791
|
+
});
|
|
9792
|
+
} catch (err) {
|
|
9793
|
+
this.deps.logger.warn("recorder: retention prune failed for device", {
|
|
9794
|
+
tags: { deviceId },
|
|
9795
|
+
meta: { error: errMsg(err) }
|
|
9796
|
+
});
|
|
9797
|
+
}
|
|
9798
|
+
}
|
|
9799
|
+
this.deps.logger.info("recorder: retention sweep complete", { meta: {
|
|
9800
|
+
devicesSwept,
|
|
9801
|
+
devicesSkipped,
|
|
9802
|
+
bucketsPruned,
|
|
9803
|
+
bytesReclaimed
|
|
9804
|
+
} });
|
|
9805
|
+
} finally {
|
|
9806
|
+
this.sweeping = false;
|
|
9807
|
+
}
|
|
9808
|
+
}
|
|
9809
|
+
};
|
|
9810
|
+
//#endregion
|
|
9811
|
+
//#region src/recorder/addon/retired-root-keys.ts
|
|
9812
|
+
var RECORDER_RETIRED_ROOT_KEYS = [
|
|
9813
|
+
{
|
|
9814
|
+
key: "recordingOpsLog",
|
|
9815
|
+
successor: RECORDING_OPS_LOG_COLLECTION,
|
|
9816
|
+
reason: "the audit ring is one row per operation since the flatten; as a blob it was 147 KB of the 201 KB root row, re-parsed by every unrelated reader of that row"
|
|
9817
|
+
},
|
|
9818
|
+
{
|
|
9819
|
+
key: "recordingExports",
|
|
9820
|
+
successor: RECORDING_EXPORTS_COLLECTION,
|
|
9821
|
+
reason: "one row per export job since the flatten; as a blob a once-a-second progress patch rewrote 201 KB, and an overlapping createExport erased the row (2026-08-14)"
|
|
9822
|
+
},
|
|
9823
|
+
{
|
|
9824
|
+
key: "recordingConfigs",
|
|
9825
|
+
successor: RECORDING_CONFIGS_COLLECTION,
|
|
9826
|
+
reason: "one row per camera since the flatten; as a blob, asking what camera 617 records parsed every camera in the installation"
|
|
9827
|
+
}
|
|
9828
|
+
];
|
|
9829
|
+
//#endregion
|
|
9830
|
+
//#region src/durable/durable-ledger.ts
|
|
9831
|
+
/** Default reseed cap — every current consumer's row set is installation-bounded. */
|
|
9832
|
+
var DEFAULT_LOAD_LIMIT = 1e5;
|
|
9833
|
+
var DurableLedger = class DurableLedger {
|
|
9834
|
+
mirror = /* @__PURE__ */ new Map();
|
|
9835
|
+
spec;
|
|
9836
|
+
store;
|
|
9837
|
+
logger;
|
|
9838
|
+
constructor(deps) {
|
|
9839
|
+
this.spec = deps.spec;
|
|
9840
|
+
this.store = deps.store;
|
|
9841
|
+
this.logger = deps.logger;
|
|
9842
|
+
}
|
|
9843
|
+
/**
|
|
9844
|
+
* Register the collection. MUST run at boot, before any read or write: the
|
|
9845
|
+
* SQLite backend answers 412 for an undeclared collection and takes the whole
|
|
9846
|
+
* runner down with it (the addon-ai boot-crash lesson).
|
|
9847
|
+
*/
|
|
9848
|
+
static declare(store, spec) {
|
|
9849
|
+
return store.declareCollection.mutate({
|
|
9850
|
+
collection: spec.collection,
|
|
9851
|
+
columns: [...spec.columns],
|
|
9852
|
+
...spec.indexes !== void 0 ? { indexes: [...spec.indexes] } : {}
|
|
9853
|
+
});
|
|
9854
|
+
}
|
|
9855
|
+
declare() {
|
|
9856
|
+
return DurableLedger.declare(this.store, this.spec);
|
|
9857
|
+
}
|
|
9858
|
+
/** The collection this ledger owns — for the caller's own log lines. */
|
|
9859
|
+
get collection() {
|
|
9860
|
+
return this.spec.collection;
|
|
9861
|
+
}
|
|
9862
|
+
/**
|
|
9863
|
+
* Boot reseed. Replaces the mirror with what the store holds and returns the
|
|
9864
|
+
* rows, so a caller that must hydrate something else (a watcher, a registry)
|
|
9865
|
+
* gets them without a second read.
|
|
9866
|
+
*
|
|
9867
|
+
* **A failure returns what is already mirrored** rather than clearing it —
|
|
9868
|
+
* see contract rule 2. The count is worth logging out loud at the call site:
|
|
9869
|
+
* "loaded 0" after a container recreate is the one line that explains a
|
|
9870
|
+
* silent flood.
|
|
9871
|
+
*/
|
|
9872
|
+
async load() {
|
|
9873
|
+
try {
|
|
9874
|
+
const records = await this.store.query.query({
|
|
9875
|
+
collection: this.spec.collection,
|
|
9876
|
+
filter: { limit: this.spec.loadLimit ?? DEFAULT_LOAD_LIMIT }
|
|
9877
|
+
});
|
|
9878
|
+
const next = /* @__PURE__ */ new Map();
|
|
9879
|
+
let skipped = 0;
|
|
9880
|
+
for (const record of records) {
|
|
9881
|
+
const row = this.spec.fromRecord(record.id, record.data);
|
|
9882
|
+
if (row === null) {
|
|
9883
|
+
skipped += 1;
|
|
9884
|
+
continue;
|
|
9885
|
+
}
|
|
9886
|
+
next.set(this.spec.keyOf(row), row);
|
|
9887
|
+
}
|
|
9888
|
+
this.mirror.clear();
|
|
9889
|
+
for (const [key, row] of next) this.mirror.set(key, row);
|
|
9890
|
+
if (skipped > 0) this.logger.warn("durable rows skipped as malformed — they gate NOTHING", { meta: {
|
|
9891
|
+
collection: this.spec.collection,
|
|
9892
|
+
skipped
|
|
9893
|
+
} });
|
|
9894
|
+
return [...this.mirror.values()];
|
|
9895
|
+
} catch (err) {
|
|
9896
|
+
this.logger.warn("durable load failed — keeping the state already in memory", { meta: {
|
|
9897
|
+
collection: this.spec.collection,
|
|
9898
|
+
error: String(err),
|
|
9899
|
+
held: this.mirror.size
|
|
9900
|
+
} });
|
|
9901
|
+
return [...this.mirror.values()];
|
|
9902
|
+
}
|
|
9903
|
+
}
|
|
9904
|
+
/** Every mirrored row, insertion-ordered. */
|
|
9905
|
+
snapshot() {
|
|
9906
|
+
return [...this.mirror.values()];
|
|
9907
|
+
}
|
|
9908
|
+
/** The row currently held for a key, if any. Pure RAM — never I/O. */
|
|
9909
|
+
get(key) {
|
|
9910
|
+
return this.mirror.get(key);
|
|
9911
|
+
}
|
|
9912
|
+
has(key) {
|
|
9913
|
+
return this.mirror.has(key);
|
|
9914
|
+
}
|
|
9915
|
+
get size() {
|
|
9916
|
+
return this.mirror.size;
|
|
9917
|
+
}
|
|
9918
|
+
/**
|
|
9919
|
+
* Judge ONE observation against what the ledger already accepted, and advance
|
|
9920
|
+
* it.
|
|
9921
|
+
*
|
|
9922
|
+
* Synchronous on purpose: the verdict is a function of the in-RAM mirror
|
|
9923
|
+
* alone, so a decision can never be gated on an I/O that might fail (D49).
|
|
9924
|
+
* The durable write is kicked off behind it and its failure changes no
|
|
9925
|
+
* verdict.
|
|
9926
|
+
*
|
|
9927
|
+
* `no-flip` leaves the held row UNTOUCHED — including any timestamp it
|
|
9928
|
+
* carries, which therefore means "when this key last CHANGED", not "when it
|
|
9929
|
+
* last spoke". That is the timestamp anyone reading the table wants.
|
|
9930
|
+
*/
|
|
9931
|
+
observe(row) {
|
|
9932
|
+
const equalFact = this.spec.equalFact;
|
|
9933
|
+
if (equalFact === void 0) throw new Error(`DurableLedger(${this.spec.collection}): observe() requires the spec to declare equalFact`);
|
|
9934
|
+
const key = this.spec.keyOf(row);
|
|
9935
|
+
const held = this.mirror.get(key);
|
|
9936
|
+
if (held !== void 0 && equalFact(held, row)) return "no-flip";
|
|
9937
|
+
this.mirror.set(key, row);
|
|
9938
|
+
this.persist(row);
|
|
9939
|
+
return held === void 0 ? "seeded" : "flip";
|
|
9940
|
+
}
|
|
9941
|
+
/**
|
|
9942
|
+
* Upsert a row without a verdict — the write path for a ledger whose owner
|
|
9943
|
+
* has already decided the value changed.
|
|
9944
|
+
*
|
|
9945
|
+
* The order of the mirror advance and the durable write is the spec's
|
|
9946
|
+
* {@link DurableWriteMode}, not the call site's: two call sites that
|
|
9947
|
+
* disagreed about it would be two different durability guarantees on one
|
|
9948
|
+
* collection.
|
|
9949
|
+
*/
|
|
9950
|
+
async put(row) {
|
|
9951
|
+
const key = this.spec.keyOf(row);
|
|
9952
|
+
if (this.spec.writeMode === "write-behind") {
|
|
9953
|
+
this.mirror.set(key, row);
|
|
9954
|
+
await this.persist(row);
|
|
9955
|
+
return;
|
|
9956
|
+
}
|
|
9957
|
+
await this.store.set.mutate({
|
|
9958
|
+
collection: this.spec.collection,
|
|
9959
|
+
key,
|
|
9960
|
+
value: this.spec.toValue(row)
|
|
9961
|
+
});
|
|
9962
|
+
this.mirror.set(key, row);
|
|
9963
|
+
}
|
|
9964
|
+
/**
|
|
9965
|
+
* Advance the mirror WITHOUT persisting, for an owner that deliberately
|
|
9966
|
+
* coalesces its writes.
|
|
9967
|
+
*
|
|
9968
|
+
* The stationary registry is the reason this exists: a parked car is
|
|
9969
|
+
* re-confirmed on every processed frame (5–30 Hz), and persisting each
|
|
9970
|
+
* confirmation would offer thousands of commits a day to the checkpoint
|
|
9971
|
+
* lottery (D96) to maintain a handful of rows. It stages the advance and
|
|
9972
|
+
* flushes on its 5-minute sweep — 288 writes a day instead of ~10⁶.
|
|
9973
|
+
*
|
|
9974
|
+
* **The cost is stated, not hidden**: a staged value that is never flushed
|
|
9975
|
+
* is lost on a crash. An owner may only stage a field whose staleness its
|
|
9976
|
+
* own TTL absorbs. Anything that GATES work must go through {@link put} or
|
|
9977
|
+
* {@link observe}.
|
|
9978
|
+
*/
|
|
9979
|
+
stage(row) {
|
|
9980
|
+
this.mirror.set(this.spec.keyOf(row), row);
|
|
9981
|
+
}
|
|
9982
|
+
/**
|
|
9983
|
+
* Drop a key from the MIRROR only — the durable row survives.
|
|
9984
|
+
*
|
|
9985
|
+
* What a scope-unbind needs: this process stops holding the value, and a
|
|
9986
|
+
* rebind reloads it from the store. Deliberately distinct from
|
|
9987
|
+
* {@link forget}, which deletes; conflating the two is how an unbind turns
|
|
9988
|
+
* into a wipe.
|
|
9989
|
+
*/
|
|
9990
|
+
evict(key) {
|
|
9991
|
+
this.mirror.delete(key);
|
|
9992
|
+
}
|
|
9993
|
+
/**
|
|
9994
|
+
* Drop one key, mirror and row. Best-effort on the durable half: a failed
|
|
9995
|
+
* delete leaves a row that the next load will re-mirror, which is a stale
|
|
9996
|
+
* value rather than a lost one.
|
|
9997
|
+
*/
|
|
9998
|
+
async forget(key) {
|
|
9999
|
+
this.mirror.delete(key);
|
|
9397
10000
|
try {
|
|
9398
10001
|
await this.store.delete.mutate({
|
|
9399
10002
|
collection: this.spec.collection,
|
|
@@ -9415,707 +10018,408 @@ var DurableLedger = class DurableLedger {
|
|
|
9415
10018
|
* is a method a feature opts into rather than a policy the primitive runs.
|
|
9416
10019
|
* Best-effort per row: a failed delete keeps the key (retried next prune)
|
|
9417
10020
|
* rather than aborting the sweep.
|
|
9418
|
-
*/
|
|
9419
|
-
async pruneExcept(activeKeys) {
|
|
9420
|
-
let pruned = 0;
|
|
9421
|
-
for (const key of [...this.mirror.keys()]) {
|
|
9422
|
-
if (activeKeys.has(key)) continue;
|
|
9423
|
-
try {
|
|
9424
|
-
await this.store.delete.mutate({
|
|
9425
|
-
collection: this.spec.collection,
|
|
9426
|
-
key
|
|
9427
|
-
});
|
|
9428
|
-
this.mirror.delete(key);
|
|
9429
|
-
pruned += 1;
|
|
9430
|
-
} catch (err) {
|
|
9431
|
-
this.logger.debug("durable prune delete failed", { meta: {
|
|
9432
|
-
collection: this.spec.collection,
|
|
9433
|
-
key,
|
|
9434
|
-
error: String(err)
|
|
9435
|
-
} });
|
|
9436
|
-
}
|
|
9437
|
-
}
|
|
9438
|
-
return pruned;
|
|
9439
|
-
}
|
|
9440
|
-
/**
|
|
9441
|
-
* Write-behind durable upsert. Best-effort and logged, never thrown at the
|
|
9442
|
-
* decision path: the mirror already holds the truth for this process, and the
|
|
9443
|
-
* worst a lost write can do is one silent re-seed after the next restart.
|
|
9444
|
-
*/
|
|
9445
|
-
async persist(row) {
|
|
9446
|
-
const deviceId = this.spec.deviceIdOf?.(row);
|
|
9447
|
-
try {
|
|
9448
|
-
await this.store.set.mutate({
|
|
9449
|
-
collection: this.spec.collection,
|
|
9450
|
-
key: this.spec.keyOf(row),
|
|
9451
|
-
value: this.spec.toValue(row)
|
|
9452
|
-
});
|
|
9453
|
-
} catch (err) {
|
|
9454
|
-
this.logger.warn("durable persist failed — this key may re-seed on boot", {
|
|
9455
|
-
...deviceId !== void 0 ? { tags: { deviceId } } : {},
|
|
9456
|
-
meta: {
|
|
9457
|
-
collection: this.spec.collection,
|
|
9458
|
-
key: this.spec.keyOf(row),
|
|
9459
|
-
error: String(err)
|
|
9460
|
-
}
|
|
9461
|
-
});
|
|
9462
|
-
}
|
|
9463
|
-
}
|
|
9464
|
-
};
|
|
9465
|
-
//#endregion
|
|
9466
|
-
//#region src/recorder/addon/segment-hour-ledger.ts
|
|
9467
|
-
var HOUR_MS$1 = 36e5;
|
|
9468
|
-
/**
|
|
9469
|
-
* @durable class=mirror owner=recorder
|
|
9470
|
-
* write="write-behind on every finalized segment (hour-row upsert); eviction drops paths; the archive walk reconciles disk into the row set"
|
|
9471
|
-
* retention="a row goes when the walk or eviction confirms the hour is empty — never by age, never from the ledger alone (D148)"
|
|
9472
|
-
*/
|
|
9473
|
-
var RECORDING_SEGMENT_HOURS_COLLECTION = "recorder:segment-hours";
|
|
9474
|
-
var RECORDING_SEGMENT_HOURS_COLUMNS = [
|
|
9475
|
-
{
|
|
9476
|
-
name: "key",
|
|
9477
|
-
type: "TEXT",
|
|
9478
|
-
primaryKey: true,
|
|
9479
|
-
notNull: true
|
|
9480
|
-
},
|
|
9481
|
-
{
|
|
9482
|
-
name: "deviceId",
|
|
9483
|
-
type: "INTEGER",
|
|
9484
|
-
notNull: true
|
|
9485
|
-
},
|
|
9486
|
-
{
|
|
9487
|
-
name: "profile",
|
|
9488
|
-
type: "TEXT",
|
|
9489
|
-
notNull: true
|
|
9490
|
-
},
|
|
9491
|
-
{
|
|
9492
|
-
name: "locationId",
|
|
9493
|
-
type: "TEXT",
|
|
9494
|
-
notNull: true
|
|
9495
|
-
},
|
|
9496
|
-
{
|
|
9497
|
-
name: "hourStartMs",
|
|
9498
|
-
type: "INTEGER",
|
|
9499
|
-
notNull: true
|
|
9500
|
-
},
|
|
9501
|
-
{
|
|
9502
|
-
name: "paths",
|
|
9503
|
-
type: "TEXT",
|
|
9504
|
-
notNull: true
|
|
9505
|
-
}
|
|
9506
|
-
];
|
|
9507
|
-
var RECORDING_SEGMENT_HOURS_INDEXES = [{
|
|
9508
|
-
name: "idx_recorder_segment_hours_device",
|
|
9509
|
-
columns: ["deviceId"]
|
|
9510
|
-
}, {
|
|
9511
|
-
name: "idx_recorder_segment_hours_hour",
|
|
9512
|
-
columns: ["hourStartMs"]
|
|
9513
|
-
}];
|
|
9514
|
-
/**
|
|
9515
|
-
* The largest share of a device's held hours a single walk may delete.
|
|
9516
|
-
*
|
|
9517
|
-
* On 2026-08-19 the recorder respawned while `/recordings` was an empty tmpfs —
|
|
9518
|
-
* the UD disk had not mounted — and one walk took the ledger from 873 hours to
|
|
9519
|
-
* 32 (a 96% drop). The ledger is precisely what lets the NEXT boot skip a
|
|
9520
|
-
* ~400 s recursive walk, so a single bad walk taxed every boot after it. A
|
|
9521
|
-
* retention sweep does not delete half a camera's archive in one pass; a
|
|
9522
|
-
* `readdir` over an unmounted root deletes all of it.
|
|
9523
|
-
*/
|
|
9524
|
-
var LEDGER_PRUNE_MAX_SHARE = .5;
|
|
9525
|
-
function hourStartMs(startMs) {
|
|
9526
|
-
return Math.floor(startMs / HOUR_MS$1) * HOUR_MS$1;
|
|
9527
|
-
}
|
|
9528
|
-
function segmentHourKey(deviceId, profile, locationId, hour) {
|
|
9529
|
-
return `${deviceId}|${profile}|${locationId}|${hour}`;
|
|
9530
|
-
}
|
|
9531
|
-
function hourOf(row) {
|
|
9532
|
-
const hour = hourStartMs(row.startMs);
|
|
9533
|
-
return {
|
|
9534
|
-
hourStartMs: hour,
|
|
9535
|
-
key: segmentHourKey(row.deviceId, row.profile, row.locationId, hour)
|
|
9536
|
-
};
|
|
9537
|
-
}
|
|
9538
|
-
function uniquePaths(paths) {
|
|
9539
|
-
const seen = /* @__PURE__ */ new Set();
|
|
9540
|
-
const out = [];
|
|
9541
|
-
for (const p of paths) {
|
|
9542
|
-
if (seen.has(p)) continue;
|
|
9543
|
-
seen.add(p);
|
|
9544
|
-
out.push(p);
|
|
9545
|
-
}
|
|
9546
|
-
return out;
|
|
9547
|
-
}
|
|
9548
|
-
function samePaths(a, b) {
|
|
9549
|
-
if (a.length !== b.length) return false;
|
|
9550
|
-
const set = new Set(a);
|
|
9551
|
-
for (const p of b) if (!set.has(p)) return false;
|
|
9552
|
-
return true;
|
|
9553
|
-
}
|
|
9554
|
-
function rowToValue(row) {
|
|
9555
|
-
return {
|
|
9556
|
-
deviceId: row.deviceId,
|
|
9557
|
-
profile: row.profile,
|
|
9558
|
-
locationId: row.locationId,
|
|
9559
|
-
hourStartMs: row.hourStartMs,
|
|
9560
|
-
paths: JSON.stringify(row.paths)
|
|
9561
|
-
};
|
|
9562
|
-
}
|
|
9563
|
-
function recordToRow(key, data) {
|
|
9564
|
-
const deviceId = Number(data["deviceId"]);
|
|
9565
|
-
const profile = data["profile"];
|
|
9566
|
-
const locationId = data["locationId"];
|
|
9567
|
-
const hour = Number(data["hourStartMs"]);
|
|
9568
|
-
let raw = data["paths"];
|
|
9569
|
-
if (typeof raw === "string") try {
|
|
9570
|
-
raw = JSON.parse(raw);
|
|
9571
|
-
} catch {
|
|
9572
|
-
return null;
|
|
9573
|
-
}
|
|
9574
|
-
if (!Number.isFinite(deviceId) || deviceId <= 0 || typeof profile !== "string" || profile.length === 0 || typeof locationId !== "string" || locationId.length === 0 || !Number.isFinite(hour) || !Array.isArray(raw) || !raw.every((p) => typeof p === "string" && p.length > 0)) return null;
|
|
9575
|
-
return {
|
|
9576
|
-
key,
|
|
9577
|
-
deviceId,
|
|
9578
|
-
profile,
|
|
9579
|
-
locationId,
|
|
9580
|
-
hourStartMs: hour,
|
|
9581
|
-
paths: raw
|
|
9582
|
-
};
|
|
9583
|
-
}
|
|
9584
|
-
var SPEC = {
|
|
9585
|
-
collection: RECORDING_SEGMENT_HOURS_COLLECTION,
|
|
9586
|
-
columns: RECORDING_SEGMENT_HOURS_COLUMNS,
|
|
9587
|
-
indexes: RECORDING_SEGMENT_HOURS_INDEXES,
|
|
9588
|
-
writeMode: "write-behind",
|
|
9589
|
-
loadLimit: 1e5,
|
|
9590
|
-
keyOf: (row) => row.key,
|
|
9591
|
-
toValue: rowToValue,
|
|
9592
|
-
fromRecord: recordToRow,
|
|
9593
|
-
deviceIdOf: (row) => row.deviceId
|
|
9594
|
-
};
|
|
9595
|
-
var SegmentHourLedger = class {
|
|
9596
|
-
ledger;
|
|
9597
|
-
logger;
|
|
9598
|
-
isLocationUsable;
|
|
9599
|
-
constructor(deps) {
|
|
9600
|
-
this.logger = deps.logger;
|
|
9601
|
-
this.isLocationUsable = deps.isLocationUsable ?? (() => true);
|
|
9602
|
-
this.ledger = new DurableLedger({
|
|
9603
|
-
spec: SPEC,
|
|
9604
|
-
store: deps.store,
|
|
9605
|
-
logger: deps.logger
|
|
9606
|
-
});
|
|
9607
|
-
}
|
|
9608
|
-
static declare(store) {
|
|
9609
|
-
return DurableLedger.declare(store, SPEC);
|
|
9610
|
-
}
|
|
9611
|
-
declare() {
|
|
9612
|
-
return this.ledger.declare();
|
|
9613
|
-
}
|
|
9614
|
-
async load() {
|
|
9615
|
-
const rows = await this.ledger.load();
|
|
9616
|
-
this.logger.info("recorder: segment-hour ledger loaded", { meta: {
|
|
9617
|
-
hours: rows.length,
|
|
9618
|
-
collection: RECORDING_SEGMENT_HOURS_COLLECTION
|
|
9619
|
-
} });
|
|
9620
|
-
return rows;
|
|
9621
|
-
}
|
|
9622
|
-
snapshot() {
|
|
9623
|
-
return this.ledger.snapshot();
|
|
9624
|
-
}
|
|
9625
|
-
/**
|
|
9626
|
-
* Seed {@link RecordingIndex} from the mirrored hour rows.
|
|
9627
|
-
*
|
|
9628
|
-
* Hours present in the ledger become `hydrated` so a seek in those windows
|
|
9629
|
-
* does not pay for a disk walk. Hours the ledger never held stay `unknown`
|
|
9630
|
-
* — that is the fail-toward-walk direction.
|
|
9631
|
-
*/
|
|
9632
|
-
hydrateIndex(index) {
|
|
9633
|
-
for (const hour of this.ledger.snapshot()) index.hydrateHour(hour.deviceId, hour.locationId, hour.hourStartMs, hour.paths);
|
|
9634
|
-
}
|
|
9635
|
-
/** Write-behind: a just-finalized segment joins its hour row. */
|
|
9636
|
-
async recordSegment(row) {
|
|
9637
|
-
const { key, hourStartMs: hour } = hourOf(row);
|
|
9638
|
-
const held = this.ledger.get(key);
|
|
9639
|
-
if (held !== void 0 && held.paths.includes(row.path)) return;
|
|
9640
|
-
await this.ledger.put({
|
|
9641
|
-
key,
|
|
9642
|
-
deviceId: row.deviceId,
|
|
9643
|
-
profile: row.profile,
|
|
9644
|
-
locationId: row.locationId,
|
|
9645
|
-
hourStartMs: hour,
|
|
9646
|
-
paths: held === void 0 ? [row.path] : [...held.paths, row.path]
|
|
9647
|
-
});
|
|
9648
|
-
}
|
|
9649
|
-
/**
|
|
9650
|
-
* Eviction confirmed these paths are gone from disk. Drop them; forget the
|
|
9651
|
-
* hour when it holds nothing.
|
|
9652
|
-
*/
|
|
9653
|
-
async dropSegments(rows) {
|
|
9654
|
-
const doomed = /* @__PURE__ */ new Map();
|
|
9655
|
-
for (const row of rows) {
|
|
9656
|
-
const { key } = hourOf(row);
|
|
9657
|
-
let set = doomed.get(key);
|
|
9658
|
-
if (!set) {
|
|
9659
|
-
set = /* @__PURE__ */ new Set();
|
|
9660
|
-
doomed.set(key, set);
|
|
9661
|
-
}
|
|
9662
|
-
set.add(row.path);
|
|
9663
|
-
}
|
|
9664
|
-
for (const [key, paths] of doomed) {
|
|
9665
|
-
const held = this.ledger.get(key);
|
|
9666
|
-
if (held === void 0) continue;
|
|
9667
|
-
const next = held.paths.filter((p) => !paths.has(p));
|
|
9668
|
-
if (next.length === 0) {
|
|
9669
|
-
await this.ledger.forget(key);
|
|
9670
|
-
continue;
|
|
9671
|
-
}
|
|
9672
|
-
if (next.length === held.paths.length) continue;
|
|
9673
|
-
await this.ledger.put({
|
|
9674
|
-
...held,
|
|
9675
|
-
paths: next
|
|
9676
|
-
});
|
|
9677
|
-
}
|
|
9678
|
-
}
|
|
9679
|
-
/**
|
|
9680
|
-
* After a disk walk of `deviceIds`, make the ledger match the index — with
|
|
9681
|
-
* two D148 exceptions:
|
|
9682
|
-
*
|
|
9683
|
-
* - an hour the walk did not see is dropped ONLY if it is in the past;
|
|
9684
|
-
* the current hour may still hold a live writer whose file the readdir
|
|
9685
|
-
* raced;
|
|
9686
|
-
* - the current hour UNIONs ledger paths with index paths rather than
|
|
9687
|
-
* replacing, for the same race.
|
|
9688
|
-
*
|
|
9689
|
-
* Devices not in `deviceIds` are left untouched (a partial walk must not
|
|
9690
|
-
* prune cameras it did not look at).
|
|
9691
|
-
*
|
|
9692
|
-
* And a walk this ledger does not TRUST prunes nothing at all — see
|
|
9693
|
-
* {@link refusedPrunes}. What such a walk FOUND is still real, so its paths
|
|
9694
|
-
* are unioned in; what it did not find decides nothing (D49).
|
|
9695
|
-
*/
|
|
9696
|
-
async reconcileFromIndex(index, deviceIds, nowMs) {
|
|
9697
|
-
const walked = new Set(deviceIds);
|
|
9698
|
-
const currentHour = hourStartMs(nowMs);
|
|
9699
|
-
const fromIndex = /* @__PURE__ */ new Map();
|
|
9700
|
-
for (const deviceId of deviceIds) for (const seg of index.segments(deviceId)) {
|
|
9701
|
-
const { key, hourStartMs: hour } = hourOf(seg);
|
|
9702
|
-
const existing = fromIndex.get(key);
|
|
9703
|
-
if (existing === void 0) fromIndex.set(key, {
|
|
9704
|
-
key,
|
|
9705
|
-
deviceId: seg.deviceId,
|
|
9706
|
-
profile: seg.profile,
|
|
9707
|
-
locationId: seg.locationId,
|
|
9708
|
-
hourStartMs: hour,
|
|
9709
|
-
paths: [seg.path]
|
|
9710
|
-
});
|
|
9711
|
-
else fromIndex.set(key, {
|
|
9712
|
-
...existing,
|
|
9713
|
-
paths: [...existing.paths, seg.path]
|
|
9714
|
-
});
|
|
9715
|
-
}
|
|
9716
|
-
const refused = this.refusedPrunes(walked, fromIndex);
|
|
9717
|
-
for (const row of this.ledger.snapshot()) {
|
|
9718
|
-
if (!walked.has(row.deviceId)) continue;
|
|
9719
|
-
const disk = fromIndex.get(row.key);
|
|
9720
|
-
const additiveOnly = row.hourStartMs === currentHour || refused.devices.has(row.deviceId) || refused.locations.has(locationKey(row.deviceId, row.locationId));
|
|
9721
|
-
if (disk === void 0) {
|
|
9722
|
-
if (additiveOnly) continue;
|
|
9723
|
-
await this.ledger.forget(row.key);
|
|
9724
|
-
continue;
|
|
9725
|
-
}
|
|
9726
|
-
if (additiveOnly) {
|
|
9727
|
-
const union = uniquePaths([...disk.paths, ...row.paths]);
|
|
9728
|
-
if (!samePaths(union, row.paths)) await this.ledger.put({
|
|
9729
|
-
...row,
|
|
9730
|
-
paths: union
|
|
9731
|
-
});
|
|
9732
|
-
continue;
|
|
9733
|
-
}
|
|
9734
|
-
if (!samePaths(row.paths, disk.paths)) await this.ledger.put(disk);
|
|
9735
|
-
}
|
|
9736
|
-
for (const [key, disk] of fromIndex) {
|
|
9737
|
-
if (this.ledger.has(key)) continue;
|
|
9738
|
-
await this.ledger.put(disk);
|
|
9739
|
-
}
|
|
9740
|
-
}
|
|
9741
|
-
/**
|
|
9742
|
-
* Which of this walk's deletions are REFUSED, and why.
|
|
9743
|
-
*
|
|
9744
|
-
* Two independent reasons, because the live fault of 2026-08-19 would have
|
|
9745
|
-
* escaped either one alone:
|
|
9746
|
-
*
|
|
9747
|
-
* - **the volume said no.** `PlacementService`'s marker probe knows the bytes
|
|
9748
|
-
* under a root are not the bytes we wrote. A walk of such a root found
|
|
9749
|
-
* nothing because nothing is mounted there, not because the footage is
|
|
9750
|
-
* gone.
|
|
9751
|
-
* - **the magnitude said no.** At BOOT the probe has not run yet — it runs at
|
|
9752
|
-
* the first writer attach — so on the morning the recorder respawned onto an
|
|
9753
|
-
* empty tmpfs the volume was not flagged and the prune went through: 873
|
|
9754
|
-
* hours to 32. A walk that deletes more than
|
|
9755
|
-
* {@link LEDGER_PRUNE_MAX_SHARE} of a device's held hours is refused on its
|
|
9756
|
-
* size alone.
|
|
9757
|
-
*
|
|
9758
|
-
* A refusal is one line per device (or per device+location), carrying the
|
|
9759
|
-
* delta, tagged with `deviceId` — a ledger that silently declined to prune and
|
|
9760
|
-
* a ledger with nothing to prune must never read the same.
|
|
9761
|
-
*/
|
|
9762
|
-
refusedPrunes(walked, fromIndex) {
|
|
9763
|
-
const heldByDevice = /* @__PURE__ */ new Map();
|
|
9764
|
-
const unusable = /* @__PURE__ */ new Map();
|
|
9765
|
-
for (const row of this.ledger.snapshot()) {
|
|
9766
|
-
if (!walked.has(row.deviceId)) continue;
|
|
9767
|
-
heldByDevice.set(row.deviceId, (heldByDevice.get(row.deviceId) ?? 0) + 1);
|
|
9768
|
-
if (this.isLocationUsable(row.locationId)) continue;
|
|
9769
|
-
const key = locationKey(row.deviceId, row.locationId);
|
|
9770
|
-
const seen = unusable.get(key);
|
|
9771
|
-
unusable.set(key, seen === void 0 ? {
|
|
9772
|
-
deviceId: row.deviceId,
|
|
9773
|
-
locationId: row.locationId,
|
|
9774
|
-
held: 1
|
|
9775
|
-
} : {
|
|
9776
|
-
...seen,
|
|
9777
|
-
held: seen.held + 1
|
|
9778
|
-
});
|
|
9779
|
-
}
|
|
9780
|
-
const onDiskByDevice = /* @__PURE__ */ new Map();
|
|
9781
|
-
for (const row of fromIndex.values()) onDiskByDevice.set(row.deviceId, (onDiskByDevice.get(row.deviceId) ?? 0) + 1);
|
|
9782
|
-
const devices = /* @__PURE__ */ new Set();
|
|
9783
|
-
for (const [deviceId, held] of heldByDevice) {
|
|
9784
|
-
if (held < 8) continue;
|
|
9785
|
-
const onDisk = onDiskByDevice.get(deviceId) ?? 0;
|
|
9786
|
-
if (onDisk >= held * .5) continue;
|
|
9787
|
-
devices.add(deviceId);
|
|
9788
|
-
this.logger.warn("recorder: segment-hour ledger prune REFUSED — this walk would delete most of the camera archive, which is what an unmounted root looks like; keeping the previous ledger", {
|
|
9789
|
-
tags: { deviceId },
|
|
9790
|
-
meta: {
|
|
9791
|
-
held,
|
|
9792
|
-
onDisk,
|
|
9793
|
-
dropped: held - onDisk,
|
|
9794
|
-
maxShare: LEDGER_PRUNE_MAX_SHARE
|
|
9795
|
-
}
|
|
9796
|
-
});
|
|
9797
|
-
}
|
|
9798
|
-
const locations = /* @__PURE__ */ new Set();
|
|
9799
|
-
for (const [key, entry] of unusable) {
|
|
9800
|
-
if (devices.has(entry.deviceId)) continue;
|
|
9801
|
-
locations.add(key);
|
|
9802
|
-
this.logger.warn("recorder: segment-hour ledger prune REFUSED — the volume under this location failed its probe, so the walk found nothing because nothing is mounted there", {
|
|
9803
|
-
tags: { deviceId: entry.deviceId },
|
|
9804
|
-
meta: {
|
|
9805
|
-
locationId: entry.locationId,
|
|
9806
|
-
held: entry.held,
|
|
9807
|
-
onDisk: onDiskByDevice.get(entry.deviceId) ?? 0
|
|
9808
|
-
}
|
|
9809
|
-
});
|
|
9810
|
-
}
|
|
9811
|
-
return {
|
|
9812
|
-
devices,
|
|
9813
|
-
locations
|
|
9814
|
-
};
|
|
9815
|
-
}
|
|
9816
|
-
};
|
|
9817
|
-
function locationKey(deviceId, locationId) {
|
|
9818
|
-
return `${deviceId}|${locationId}`;
|
|
9819
|
-
}
|
|
9820
|
-
//#endregion
|
|
9821
|
-
//#region src/recorder/addon/recording-export-provider.ts
|
|
9822
|
-
/** Content type of every rendered export — the engine only ever writes MP4. */
|
|
9823
|
-
var EXPORT_CONTENT_TYPE = "video/mp4";
|
|
9824
|
-
/** Filename bound, extension included. Long enough to stay recognisable. */
|
|
9825
|
-
var EXPORT_NAME_MAX = 64;
|
|
9826
|
-
/**
|
|
9827
|
-
* The filename a RECIPIENT sees on the attachment.
|
|
9828
|
-
*
|
|
9829
|
-
* Derived from the operator's own export title so a delivered video is
|
|
9830
|
-
* identifiable in a chat thread, but slugified and BOUNDED: the title field
|
|
9831
|
-
* accepts 200 characters and several notifier backends silently mangle a
|
|
9832
|
-
* filename that long. No usable title degrades to the export id, never to an
|
|
9833
|
-
* empty name.
|
|
9834
|
-
*/
|
|
9835
|
-
function exportFileName(rec) {
|
|
9836
|
-
const slug = (rec.title ?? "").replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, EXPORT_NAME_MAX - 4).replace(/-+$/g, "");
|
|
9837
|
-
return slug.length > 0 ? `${slug}.mp4` : `export-${rec.id}.mp4`;
|
|
9838
|
-
}
|
|
9839
|
-
/**
|
|
9840
|
-
* True when at least one indexed segment overlaps `[fromMs, toMs)`. Mirrors the
|
|
9841
|
-
* `buildPlaybackManifest` overlap idiom: a segment `[startMs, startMs+durMs)`
|
|
9842
|
-
* overlaps the window iff it starts before the window ends AND ends after the
|
|
9843
|
-
* window starts.
|
|
9844
|
-
*/
|
|
9845
|
-
async function hasFootage(index, deviceId, profile, fromMs, toMs) {
|
|
9846
|
-
return (await index.segmentsInWindow(deviceId, profile, fromMs, toMs)).some((s) => s.startMs < toMs && s.startMs + s.durMs > fromMs);
|
|
9847
|
-
}
|
|
9848
|
-
/** Canonical `profiles[]`, falling back to the legacy singular `profile`. */
|
|
9849
|
-
function resolveExportProfiles(input) {
|
|
9850
|
-
if (input.profiles !== void 0 && input.profiles.length > 0) return [...input.profiles];
|
|
9851
|
-
if (typeof input.profile === "string" && input.profile.length > 0) return [input.profile];
|
|
9852
|
-
return [];
|
|
9853
|
-
}
|
|
9854
|
-
/** Load a record or throw a clear NOT_FOUND-style error. */
|
|
9855
|
-
async function requireRecord(state, exportId) {
|
|
9856
|
-
const rec = await getExportRecord(state, exportId);
|
|
9857
|
-
if (!rec) throw new Error(`recording-export: unknown export "${exportId}"`);
|
|
9858
|
-
return rec;
|
|
9859
|
-
}
|
|
9860
|
-
/** Build the `recordingExport` provider over the injected deps. */
|
|
9861
|
-
function buildRecordingExportProvider(deps) {
|
|
9862
|
-
return {
|
|
9863
|
-
createExport: async (input) => {
|
|
9864
|
-
const { deviceId, fromMs, toMs, options } = input;
|
|
9865
|
-
const profiles = resolveExportProfiles(input);
|
|
9866
|
-
if (profiles.length < 1) throw new Error("recording-export: profiles must contain at least one profile");
|
|
9867
|
-
if (!(fromMs < toMs)) throw new Error(`recording-export: invalid range ${fromMs}..${toMs}`);
|
|
9868
|
-
for (const profile of profiles) if (!await hasFootage(deps.index, deviceId, profile, fromMs, toMs)) throw new Error(`recording-export: no footage for ${deviceId}/${profile} in ${fromMs}..${toMs}`);
|
|
9869
|
-
const now = deps.now();
|
|
9870
|
-
let first = null;
|
|
9871
|
-
for (const profile of profiles) {
|
|
9872
|
-
const record = {
|
|
9873
|
-
id: deps.newId(),
|
|
9874
|
-
deviceId,
|
|
9875
|
-
profile,
|
|
9876
|
-
fromMs,
|
|
9877
|
-
toMs,
|
|
9878
|
-
options,
|
|
9879
|
-
state: "queued",
|
|
9880
|
-
progressPct: null,
|
|
9881
|
-
fileBytes: null,
|
|
9882
|
-
expiresAt: now + options.maxLifeMs,
|
|
9883
|
-
deleteAfterDownload: options.deleteAfterDownload,
|
|
9884
|
-
downloadedAt: null,
|
|
9885
|
-
createdAt: now,
|
|
9886
|
-
createdBy: deps.createdBy(),
|
|
9887
|
-
error: null
|
|
9888
|
-
};
|
|
9889
|
-
const saved = await upsertExport(deps.state, record);
|
|
9890
|
-
deps.engine.enqueue(saved.id);
|
|
9891
|
-
first ??= saved;
|
|
9892
|
-
}
|
|
9893
|
-
return first;
|
|
9894
|
-
},
|
|
9895
|
-
listExports: ({ deviceId }) => listExportsFor(deps.state, deviceId),
|
|
9896
|
-
getExport: ({ exportId }) => requireRecord(deps.state, exportId),
|
|
9897
|
-
cancelExport: async ({ exportId }) => {
|
|
9898
|
-
const existing = await requireRecord(deps.state, exportId);
|
|
9899
|
-
await deps.engine.cancel(exportId);
|
|
9900
|
-
return await getExportRecord(deps.state, exportId) ?? existing;
|
|
9901
|
-
},
|
|
9902
|
-
deleteExport: async ({ exportId }) => {
|
|
9903
|
-
const existing = await requireRecord(deps.state, exportId);
|
|
9904
|
-
await deps.removeFile(deps.outPath(existing));
|
|
9905
|
-
return await patchExport(deps.state, exportId, {
|
|
9906
|
-
state: "deleted",
|
|
9907
|
-
fileBytes: null
|
|
9908
|
-
}) ?? existing;
|
|
9909
|
-
},
|
|
9910
|
-
getDownloadUrl: async ({ exportId }) => {
|
|
9911
|
-
await requireRecord(deps.state, exportId);
|
|
9912
|
-
const url = deps.downloadUrlFor(exportId);
|
|
9913
|
-
return {
|
|
9914
|
-
url,
|
|
9915
|
-
endpoints: [url]
|
|
9916
|
-
};
|
|
9917
|
-
},
|
|
9918
|
-
readExportBytes: async ({ exportId }) => {
|
|
9919
|
-
const rec = await requireRecord(deps.state, exportId);
|
|
9920
|
-
const tags = { deviceId: rec.deviceId };
|
|
9921
|
-
if (rec.state !== "ready") {
|
|
9922
|
-
deps.logger.warn("export byte read refused: export is not ready", {
|
|
9923
|
-
tags,
|
|
9924
|
-
meta: {
|
|
9925
|
-
exportId,
|
|
9926
|
-
state: rec.state
|
|
9927
|
-
}
|
|
9928
|
-
});
|
|
9929
|
-
throw new Error(`recording-export: export "${exportId}" is not ready (${rec.state})`);
|
|
9930
|
-
}
|
|
9931
|
-
if (deps.now() >= rec.expiresAt) {
|
|
9932
|
-
deps.logger.warn("export byte read refused: export has expired", {
|
|
9933
|
-
tags,
|
|
9934
|
-
meta: {
|
|
9935
|
-
exportId,
|
|
9936
|
-
expiresAt: rec.expiresAt
|
|
9937
|
-
}
|
|
9938
|
-
});
|
|
9939
|
-
throw new Error(`recording-export: export "${exportId}" has expired`);
|
|
9940
|
-
}
|
|
9941
|
-
const file = deps.outPath(rec);
|
|
9942
|
-
let size;
|
|
9943
|
-
try {
|
|
9944
|
-
size = await deps.statBytes(file);
|
|
9945
|
-
} catch (err) {
|
|
9946
|
-
deps.logger.warn("export byte read refused: file is missing", {
|
|
9947
|
-
tags,
|
|
9948
|
-
meta: {
|
|
9949
|
-
exportId,
|
|
9950
|
-
error: String(err)
|
|
9951
|
-
}
|
|
9952
|
-
});
|
|
9953
|
-
throw new Error(`recording-export: export "${exportId}" has no file on disk`, { cause: err });
|
|
9954
|
-
}
|
|
9955
|
-
if (size > 52428800) {
|
|
9956
|
-
deps.logger.warn("export byte read refused: file is too large to inline", {
|
|
9957
|
-
tags,
|
|
9958
|
-
meta: {
|
|
9959
|
-
exportId,
|
|
9960
|
-
bytes: size,
|
|
9961
|
-
maxBytes: RECORDING_EXPORT_MAX_READ_BYTES
|
|
9962
|
-
}
|
|
10021
|
+
*/
|
|
10022
|
+
async pruneExcept(activeKeys) {
|
|
10023
|
+
let pruned = 0;
|
|
10024
|
+
for (const key of [...this.mirror.keys()]) {
|
|
10025
|
+
if (activeKeys.has(key)) continue;
|
|
10026
|
+
try {
|
|
10027
|
+
await this.store.delete.mutate({
|
|
10028
|
+
collection: this.spec.collection,
|
|
10029
|
+
key
|
|
9963
10030
|
});
|
|
9964
|
-
|
|
10031
|
+
this.mirror.delete(key);
|
|
10032
|
+
pruned += 1;
|
|
10033
|
+
} catch (err) {
|
|
10034
|
+
this.logger.debug("durable prune delete failed", { meta: {
|
|
10035
|
+
collection: this.spec.collection,
|
|
10036
|
+
key,
|
|
10037
|
+
error: String(err)
|
|
10038
|
+
} });
|
|
9965
10039
|
}
|
|
9966
|
-
const bytes = await deps.readFile(file);
|
|
9967
|
-
return {
|
|
9968
|
-
base64: bytes.toString("base64"),
|
|
9969
|
-
contentType: EXPORT_CONTENT_TYPE,
|
|
9970
|
-
name: exportFileName({
|
|
9971
|
-
id: rec.id,
|
|
9972
|
-
...rec.options.title !== void 0 ? { title: rec.options.title } : {}
|
|
9973
|
-
}),
|
|
9974
|
-
bytes: bytes.byteLength
|
|
9975
|
-
};
|
|
9976
10040
|
}
|
|
9977
|
-
|
|
9978
|
-
}
|
|
10041
|
+
return pruned;
|
|
10042
|
+
}
|
|
10043
|
+
/**
|
|
10044
|
+
* Write-behind durable upsert. Best-effort and logged, never thrown at the
|
|
10045
|
+
* decision path: the mirror already holds the truth for this process, and the
|
|
10046
|
+
* worst a lost write can do is one silent re-seed after the next restart.
|
|
10047
|
+
*/
|
|
10048
|
+
async persist(row) {
|
|
10049
|
+
const deviceId = this.spec.deviceIdOf?.(row);
|
|
10050
|
+
try {
|
|
10051
|
+
await this.store.set.mutate({
|
|
10052
|
+
collection: this.spec.collection,
|
|
10053
|
+
key: this.spec.keyOf(row),
|
|
10054
|
+
value: this.spec.toValue(row)
|
|
10055
|
+
});
|
|
10056
|
+
} catch (err) {
|
|
10057
|
+
this.logger.warn("durable persist failed — this key may re-seed on boot", {
|
|
10058
|
+
...deviceId !== void 0 ? { tags: { deviceId } } : {},
|
|
10059
|
+
meta: {
|
|
10060
|
+
collection: this.spec.collection,
|
|
10061
|
+
key: this.spec.keyOf(row),
|
|
10062
|
+
error: String(err)
|
|
10063
|
+
}
|
|
10064
|
+
});
|
|
10065
|
+
}
|
|
10066
|
+
}
|
|
10067
|
+
};
|
|
9979
10068
|
//#endregion
|
|
9980
|
-
//#region src/recorder/addon/
|
|
10069
|
+
//#region src/recorder/addon/segment-hour-ledger.ts
|
|
10070
|
+
var HOUR_MS$1 = 36e5;
|
|
9981
10071
|
/**
|
|
9982
|
-
*
|
|
9983
|
-
*
|
|
9984
|
-
*
|
|
9985
|
-
* The implementation was promoted to the package-shared
|
|
9986
|
-
* `src/shared/hub-hostname.ts` in P2d (cross-node Phase 2) so the
|
|
9987
|
-
* pipeline-runner's remote-source leg shares the exact same resolver instead
|
|
9988
|
-
* of copying it. This module keeps the recorder-facing name as a thin alias —
|
|
9989
|
-
* resolution order and semantics are unchanged (operator override first, then
|
|
9990
|
-
* the host extracted from `CAMSTACK_HUB_URL`).
|
|
10072
|
+
* @durable class=mirror owner=recorder
|
|
10073
|
+
* write="write-behind on every finalized segment (hour-row upsert); eviction drops paths; the archive walk reconciles disk into the row set"
|
|
10074
|
+
* retention="a row goes when the walk or eviction confirms the hour is empty — never by age, never from the ledger alone (D148)"
|
|
9991
10075
|
*/
|
|
9992
|
-
var
|
|
9993
|
-
|
|
9994
|
-
|
|
10076
|
+
var RECORDING_SEGMENT_HOURS_COLLECTION = "recorder:segment-hours";
|
|
10077
|
+
var RECORDING_SEGMENT_HOURS_COLUMNS = [
|
|
10078
|
+
{
|
|
10079
|
+
name: "key",
|
|
10080
|
+
type: "TEXT",
|
|
10081
|
+
primaryKey: true,
|
|
10082
|
+
notNull: true
|
|
10083
|
+
},
|
|
10084
|
+
{
|
|
10085
|
+
name: "deviceId",
|
|
10086
|
+
type: "INTEGER",
|
|
10087
|
+
notNull: true
|
|
10088
|
+
},
|
|
10089
|
+
{
|
|
10090
|
+
name: "profile",
|
|
10091
|
+
type: "TEXT",
|
|
10092
|
+
notNull: true
|
|
10093
|
+
},
|
|
10094
|
+
{
|
|
10095
|
+
name: "locationId",
|
|
10096
|
+
type: "TEXT",
|
|
10097
|
+
notNull: true
|
|
10098
|
+
},
|
|
10099
|
+
{
|
|
10100
|
+
name: "hourStartMs",
|
|
10101
|
+
type: "INTEGER",
|
|
10102
|
+
notNull: true
|
|
10103
|
+
},
|
|
10104
|
+
{
|
|
10105
|
+
name: "paths",
|
|
10106
|
+
type: "TEXT",
|
|
10107
|
+
notNull: true
|
|
10108
|
+
}
|
|
10109
|
+
];
|
|
10110
|
+
var RECORDING_SEGMENT_HOURS_INDEXES = [{
|
|
10111
|
+
name: "idx_recorder_segment_hours_device",
|
|
10112
|
+
columns: ["deviceId"]
|
|
10113
|
+
}, {
|
|
10114
|
+
name: "idx_recorder_segment_hours_hour",
|
|
10115
|
+
columns: ["hourStartMs"]
|
|
10116
|
+
}];
|
|
9995
10117
|
/**
|
|
9996
|
-
*
|
|
9997
|
-
*
|
|
9998
|
-
* The disk-pressure eviction path only fires when a volume is TOO FULL
|
|
9999
|
-
* (`shouldSaveDiskSpace` + `minFreePercent`); it never enforces a camera's
|
|
10000
|
-
* age/size policy. This sweeper closes that gap: on an hourly cadence it prunes
|
|
10001
|
-
* footage for every device whose operator set an actionable `retention` policy,
|
|
10002
|
-
* delegating the actual selection + delete to the `recording` cap's
|
|
10003
|
-
* `pruneFootage` (which owns the retention FLOOR via `selectEvictions` — this
|
|
10004
|
-
* sweeper only decides WHICH devices to prune and WHEN).
|
|
10118
|
+
* The largest share of a device's held hours a single walk may delete.
|
|
10005
10119
|
*
|
|
10006
|
-
*
|
|
10007
|
-
*
|
|
10008
|
-
*
|
|
10009
|
-
*
|
|
10010
|
-
*
|
|
10011
|
-
*
|
|
10012
|
-
*/
|
|
10013
|
-
/**
|
|
10014
|
-
* True when the operator set an ACTIONABLE retention policy on a device — at
|
|
10015
|
-
* least one of `maxAgeDays` / `maxSizeGb` is present and positive. Mirrors the
|
|
10016
|
-
* `> 0` guards `selectEvictions` itself applies, so a `0`/absent limit (which
|
|
10017
|
-
* inherits the node default = "keep forever") is treated as NO policy and the
|
|
10018
|
-
* device is skipped. NEVER prune a device without an explicit policy — that
|
|
10019
|
-
* would delete footage the operator never asked to age out.
|
|
10120
|
+
* On 2026-08-19 the recorder respawned while `/recordings` was an empty tmpfs —
|
|
10121
|
+
* the UD disk had not mounted — and one walk took the ledger from 873 hours to
|
|
10122
|
+
* 32 (a 96% drop). The ledger is precisely what lets the NEXT boot skip a
|
|
10123
|
+
* ~400 s recursive walk, so a single bad walk taxed every boot after it. A
|
|
10124
|
+
* retention sweep does not delete half a camera's archive in one pass; a
|
|
10125
|
+
* `readdir` over an unmounted root deletes all of it.
|
|
10020
10126
|
*/
|
|
10021
|
-
|
|
10022
|
-
|
|
10023
|
-
|
|
10024
|
-
const byAge = retention.maxAgeDays != null && retention.maxAgeDays > 0;
|
|
10025
|
-
const bySize = retention.maxSizeGb != null && retention.maxSizeGb > 0;
|
|
10026
|
-
return byAge || bySize;
|
|
10127
|
+
var LEDGER_PRUNE_MAX_SHARE = .5;
|
|
10128
|
+
function hourStartMs(startMs) {
|
|
10129
|
+
return Math.floor(startMs / HOUR_MS$1) * HOUR_MS$1;
|
|
10027
10130
|
}
|
|
10028
|
-
|
|
10029
|
-
|
|
10030
|
-
|
|
10031
|
-
|
|
10032
|
-
|
|
10131
|
+
function segmentHourKey(deviceId, profile, locationId, hour) {
|
|
10132
|
+
return `${deviceId}|${profile}|${locationId}|${hour}`;
|
|
10133
|
+
}
|
|
10134
|
+
function hourOf(row) {
|
|
10135
|
+
const hour = hourStartMs(row.startMs);
|
|
10136
|
+
return {
|
|
10137
|
+
hourStartMs: hour,
|
|
10138
|
+
key: segmentHourKey(row.deviceId, row.profile, row.locationId, hour)
|
|
10139
|
+
};
|
|
10140
|
+
}
|
|
10141
|
+
function uniquePaths(paths) {
|
|
10142
|
+
const seen = /* @__PURE__ */ new Set();
|
|
10143
|
+
const out = [];
|
|
10144
|
+
for (const p of paths) {
|
|
10145
|
+
if (seen.has(p)) continue;
|
|
10146
|
+
seen.add(p);
|
|
10147
|
+
out.push(p);
|
|
10148
|
+
}
|
|
10149
|
+
return out;
|
|
10150
|
+
}
|
|
10151
|
+
function samePaths(a, b) {
|
|
10152
|
+
if (a.length !== b.length) return false;
|
|
10153
|
+
const set = new Set(a);
|
|
10154
|
+
for (const p of b) if (!set.has(p)) return false;
|
|
10155
|
+
return true;
|
|
10156
|
+
}
|
|
10157
|
+
function rowToValue(row) {
|
|
10158
|
+
return {
|
|
10159
|
+
deviceId: row.deviceId,
|
|
10160
|
+
profile: row.profile,
|
|
10161
|
+
locationId: row.locationId,
|
|
10162
|
+
hourStartMs: row.hourStartMs,
|
|
10163
|
+
paths: JSON.stringify(row.paths)
|
|
10164
|
+
};
|
|
10165
|
+
}
|
|
10166
|
+
function recordToRow(key, data) {
|
|
10167
|
+
const deviceId = Number(data["deviceId"]);
|
|
10168
|
+
const profile = data["profile"];
|
|
10169
|
+
const locationId = data["locationId"];
|
|
10170
|
+
const hour = Number(data["hourStartMs"]);
|
|
10171
|
+
let raw = data["paths"];
|
|
10172
|
+
if (typeof raw === "string") try {
|
|
10173
|
+
raw = JSON.parse(raw);
|
|
10174
|
+
} catch {
|
|
10175
|
+
return null;
|
|
10176
|
+
}
|
|
10177
|
+
if (!Number.isFinite(deviceId) || deviceId <= 0 || typeof profile !== "string" || profile.length === 0 || typeof locationId !== "string" || locationId.length === 0 || !Number.isFinite(hour) || !Array.isArray(raw) || !raw.every((p) => typeof p === "string" && p.length > 0)) return null;
|
|
10178
|
+
return {
|
|
10179
|
+
key,
|
|
10180
|
+
deviceId,
|
|
10181
|
+
profile,
|
|
10182
|
+
locationId,
|
|
10183
|
+
hourStartMs: hour,
|
|
10184
|
+
paths: raw
|
|
10185
|
+
};
|
|
10186
|
+
}
|
|
10187
|
+
var SPEC = {
|
|
10188
|
+
collection: RECORDING_SEGMENT_HOURS_COLLECTION,
|
|
10189
|
+
columns: RECORDING_SEGMENT_HOURS_COLUMNS,
|
|
10190
|
+
indexes: RECORDING_SEGMENT_HOURS_INDEXES,
|
|
10191
|
+
writeMode: "write-behind",
|
|
10192
|
+
loadLimit: 1e5,
|
|
10193
|
+
keyOf: (row) => row.key,
|
|
10194
|
+
toValue: rowToValue,
|
|
10195
|
+
fromRecord: recordToRow,
|
|
10196
|
+
deviceIdOf: (row) => row.deviceId
|
|
10197
|
+
};
|
|
10198
|
+
var SegmentHourLedger = class {
|
|
10199
|
+
ledger;
|
|
10200
|
+
logger;
|
|
10201
|
+
isLocationUsable;
|
|
10033
10202
|
constructor(deps) {
|
|
10034
|
-
this.
|
|
10203
|
+
this.logger = deps.logger;
|
|
10204
|
+
this.isLocationUsable = deps.isLocationUsable ?? (() => true);
|
|
10205
|
+
this.ledger = new DurableLedger({
|
|
10206
|
+
spec: SPEC,
|
|
10207
|
+
store: deps.store,
|
|
10208
|
+
logger: deps.logger
|
|
10209
|
+
});
|
|
10210
|
+
}
|
|
10211
|
+
static declare(store) {
|
|
10212
|
+
return DurableLedger.declare(store, SPEC);
|
|
10213
|
+
}
|
|
10214
|
+
declare() {
|
|
10215
|
+
return this.ledger.declare();
|
|
10216
|
+
}
|
|
10217
|
+
async load() {
|
|
10218
|
+
const rows = await this.ledger.load();
|
|
10219
|
+
this.logger.info("recorder: segment-hour ledger loaded", { meta: {
|
|
10220
|
+
hours: rows.length,
|
|
10221
|
+
collection: RECORDING_SEGMENT_HOURS_COLLECTION
|
|
10222
|
+
} });
|
|
10223
|
+
return rows;
|
|
10224
|
+
}
|
|
10225
|
+
snapshot() {
|
|
10226
|
+
return this.ledger.snapshot();
|
|
10035
10227
|
}
|
|
10036
10228
|
/**
|
|
10037
|
-
*
|
|
10038
|
-
*
|
|
10039
|
-
*
|
|
10229
|
+
* Seed {@link RecordingIndex} from the mirrored hour rows.
|
|
10230
|
+
*
|
|
10231
|
+
* Hours present in the ledger become `hydrated` so a seek in those windows
|
|
10232
|
+
* does not pay for a disk walk. Hours the ledger never held stay `unknown`
|
|
10233
|
+
* — that is the fail-toward-walk direction.
|
|
10040
10234
|
*/
|
|
10041
|
-
|
|
10042
|
-
|
|
10043
|
-
const tick = () => {
|
|
10044
|
-
this.sweep();
|
|
10045
|
-
};
|
|
10046
|
-
tick();
|
|
10047
|
-
this.timer = setInterval(tick, this.deps.intervalMs);
|
|
10048
|
-
this.timer.unref?.();
|
|
10235
|
+
hydrateIndex(index) {
|
|
10236
|
+
for (const hour of this.ledger.snapshot()) index.hydrateHour(hour.deviceId, hour.locationId, hour.hourStartMs, hour.paths);
|
|
10049
10237
|
}
|
|
10050
|
-
/**
|
|
10051
|
-
|
|
10052
|
-
|
|
10053
|
-
|
|
10054
|
-
|
|
10238
|
+
/** Write-behind: a just-finalized segment joins its hour row. */
|
|
10239
|
+
async recordSegment(row) {
|
|
10240
|
+
const { key, hourStartMs: hour } = hourOf(row);
|
|
10241
|
+
const held = this.ledger.get(key);
|
|
10242
|
+
if (held !== void 0 && held.paths.includes(row.path)) return;
|
|
10243
|
+
await this.ledger.put({
|
|
10244
|
+
key,
|
|
10245
|
+
deviceId: row.deviceId,
|
|
10246
|
+
profile: row.profile,
|
|
10247
|
+
locationId: row.locationId,
|
|
10248
|
+
hourStartMs: hour,
|
|
10249
|
+
paths: held === void 0 ? [row.path] : [...held.paths, row.path]
|
|
10250
|
+
});
|
|
10251
|
+
}
|
|
10252
|
+
/**
|
|
10253
|
+
* Eviction confirmed these paths are gone from disk. Drop them; forget the
|
|
10254
|
+
* hour when it holds nothing.
|
|
10255
|
+
*/
|
|
10256
|
+
async dropSegments(rows) {
|
|
10257
|
+
const doomed = /* @__PURE__ */ new Map();
|
|
10258
|
+
for (const row of rows) {
|
|
10259
|
+
const { key } = hourOf(row);
|
|
10260
|
+
let set = doomed.get(key);
|
|
10261
|
+
if (!set) {
|
|
10262
|
+
set = /* @__PURE__ */ new Set();
|
|
10263
|
+
doomed.set(key, set);
|
|
10264
|
+
}
|
|
10265
|
+
set.add(row.path);
|
|
10266
|
+
}
|
|
10267
|
+
for (const [key, paths] of doomed) {
|
|
10268
|
+
const held = this.ledger.get(key);
|
|
10269
|
+
if (held === void 0) continue;
|
|
10270
|
+
const next = held.paths.filter((p) => !paths.has(p));
|
|
10271
|
+
if (next.length === 0) {
|
|
10272
|
+
await this.ledger.forget(key);
|
|
10273
|
+
continue;
|
|
10274
|
+
}
|
|
10275
|
+
if (next.length === held.paths.length) continue;
|
|
10276
|
+
await this.ledger.put({
|
|
10277
|
+
...held,
|
|
10278
|
+
paths: next
|
|
10279
|
+
});
|
|
10055
10280
|
}
|
|
10056
10281
|
}
|
|
10057
10282
|
/**
|
|
10058
|
-
*
|
|
10059
|
-
*
|
|
10060
|
-
*
|
|
10061
|
-
*
|
|
10283
|
+
* After a disk walk of `deviceIds`, make the ledger match the index — with
|
|
10284
|
+
* two D148 exceptions:
|
|
10285
|
+
*
|
|
10286
|
+
* - an hour the walk did not see is dropped ONLY if it is in the past;
|
|
10287
|
+
* the current hour may still hold a live writer whose file the readdir
|
|
10288
|
+
* raced;
|
|
10289
|
+
* - the current hour UNIONs ledger paths with index paths rather than
|
|
10290
|
+
* replacing, for the same race.
|
|
10291
|
+
*
|
|
10292
|
+
* Devices not in `deviceIds` are left untouched (a partial walk must not
|
|
10293
|
+
* prune cameras it did not look at).
|
|
10294
|
+
*
|
|
10295
|
+
* And a walk this ledger does not TRUST prunes nothing at all — see
|
|
10296
|
+
* {@link refusedPrunes}. What such a walk FOUND is still real, so its paths
|
|
10297
|
+
* are unioned in; what it did not find decides nothing (D49).
|
|
10062
10298
|
*/
|
|
10063
|
-
async
|
|
10064
|
-
|
|
10065
|
-
|
|
10066
|
-
|
|
10299
|
+
async reconcileFromIndex(index, deviceIds, nowMs) {
|
|
10300
|
+
const walked = new Set(deviceIds);
|
|
10301
|
+
const currentHour = hourStartMs(nowMs);
|
|
10302
|
+
const fromIndex = /* @__PURE__ */ new Map();
|
|
10303
|
+
for (const deviceId of deviceIds) for (const seg of index.segments(deviceId)) {
|
|
10304
|
+
const { key, hourStartMs: hour } = hourOf(seg);
|
|
10305
|
+
const existing = fromIndex.get(key);
|
|
10306
|
+
if (existing === void 0) fromIndex.set(key, {
|
|
10307
|
+
key,
|
|
10308
|
+
deviceId: seg.deviceId,
|
|
10309
|
+
profile: seg.profile,
|
|
10310
|
+
locationId: seg.locationId,
|
|
10311
|
+
hourStartMs: hour,
|
|
10312
|
+
paths: [seg.path]
|
|
10313
|
+
});
|
|
10314
|
+
else fromIndex.set(key, {
|
|
10315
|
+
...existing,
|
|
10316
|
+
paths: [...existing.paths, seg.path]
|
|
10317
|
+
});
|
|
10067
10318
|
}
|
|
10068
|
-
this.
|
|
10069
|
-
|
|
10070
|
-
|
|
10071
|
-
|
|
10072
|
-
|
|
10073
|
-
|
|
10074
|
-
|
|
10075
|
-
|
|
10319
|
+
const refused = this.refusedPrunes(walked, fromIndex);
|
|
10320
|
+
for (const row of this.ledger.snapshot()) {
|
|
10321
|
+
if (!walked.has(row.deviceId)) continue;
|
|
10322
|
+
const disk = fromIndex.get(row.key);
|
|
10323
|
+
const additiveOnly = row.hourStartMs === currentHour || refused.devices.has(row.deviceId) || refused.locations.has(locationKey(row.deviceId, row.locationId));
|
|
10324
|
+
if (disk === void 0) {
|
|
10325
|
+
if (additiveOnly) continue;
|
|
10326
|
+
await this.ledger.forget(row.key);
|
|
10327
|
+
continue;
|
|
10076
10328
|
}
|
|
10077
|
-
|
|
10078
|
-
|
|
10079
|
-
|
|
10080
|
-
|
|
10081
|
-
|
|
10082
|
-
|
|
10083
|
-
|
|
10084
|
-
let bucketsPruned = 0;
|
|
10085
|
-
let bytesReclaimed = 0;
|
|
10086
|
-
for (const [deviceId] of withPolicy) {
|
|
10087
|
-
const startedMs = Date.now();
|
|
10088
|
-
try {
|
|
10089
|
-
const result = await this.deps.prune(deviceId);
|
|
10090
|
-
devicesSwept += 1;
|
|
10091
|
-
bucketsPruned += result.deletedBuckets;
|
|
10092
|
-
bytesReclaimed += result.reclaimedBytes;
|
|
10093
|
-
this.deps.logger.info("recorder: retention prune complete for device", {
|
|
10094
|
-
tags: { deviceId },
|
|
10095
|
-
meta: {
|
|
10096
|
-
deletedBuckets: result.deletedBuckets,
|
|
10097
|
-
reclaimedBytes: result.reclaimedBytes,
|
|
10098
|
-
ms: Date.now() - startedMs
|
|
10099
|
-
}
|
|
10100
|
-
});
|
|
10101
|
-
} catch (err) {
|
|
10102
|
-
this.deps.logger.warn("recorder: retention prune failed for device", {
|
|
10103
|
-
tags: { deviceId },
|
|
10104
|
-
meta: { error: errMsg(err) }
|
|
10105
|
-
});
|
|
10106
|
-
}
|
|
10329
|
+
if (additiveOnly) {
|
|
10330
|
+
const union = uniquePaths([...disk.paths, ...row.paths]);
|
|
10331
|
+
if (!samePaths(union, row.paths)) await this.ledger.put({
|
|
10332
|
+
...row,
|
|
10333
|
+
paths: union
|
|
10334
|
+
});
|
|
10335
|
+
continue;
|
|
10107
10336
|
}
|
|
10108
|
-
|
|
10109
|
-
|
|
10110
|
-
|
|
10111
|
-
|
|
10112
|
-
|
|
10113
|
-
|
|
10114
|
-
|
|
10115
|
-
|
|
10337
|
+
if (!samePaths(row.paths, disk.paths)) await this.ledger.put(disk);
|
|
10338
|
+
}
|
|
10339
|
+
for (const [key, disk] of fromIndex) {
|
|
10340
|
+
if (this.ledger.has(key)) continue;
|
|
10341
|
+
await this.ledger.put(disk);
|
|
10342
|
+
}
|
|
10343
|
+
}
|
|
10344
|
+
/**
|
|
10345
|
+
* Which of this walk's deletions are REFUSED, and why.
|
|
10346
|
+
*
|
|
10347
|
+
* Two independent reasons, because the live fault of 2026-08-19 would have
|
|
10348
|
+
* escaped either one alone:
|
|
10349
|
+
*
|
|
10350
|
+
* - **the volume said no.** `PlacementService`'s marker probe knows the bytes
|
|
10351
|
+
* under a root are not the bytes we wrote. A walk of such a root found
|
|
10352
|
+
* nothing because nothing is mounted there, not because the footage is
|
|
10353
|
+
* gone.
|
|
10354
|
+
* - **the magnitude said no.** At BOOT the probe has not run yet — it runs at
|
|
10355
|
+
* the first writer attach — so on the morning the recorder respawned onto an
|
|
10356
|
+
* empty tmpfs the volume was not flagged and the prune went through: 873
|
|
10357
|
+
* hours to 32. A walk that deletes more than
|
|
10358
|
+
* {@link LEDGER_PRUNE_MAX_SHARE} of a device's held hours is refused on its
|
|
10359
|
+
* size alone.
|
|
10360
|
+
*
|
|
10361
|
+
* A refusal is one line per device (or per device+location), carrying the
|
|
10362
|
+
* delta, tagged with `deviceId` — a ledger that silently declined to prune and
|
|
10363
|
+
* a ledger with nothing to prune must never read the same.
|
|
10364
|
+
*/
|
|
10365
|
+
refusedPrunes(walked, fromIndex) {
|
|
10366
|
+
const heldByDevice = /* @__PURE__ */ new Map();
|
|
10367
|
+
const unusable = /* @__PURE__ */ new Map();
|
|
10368
|
+
for (const row of this.ledger.snapshot()) {
|
|
10369
|
+
if (!walked.has(row.deviceId)) continue;
|
|
10370
|
+
heldByDevice.set(row.deviceId, (heldByDevice.get(row.deviceId) ?? 0) + 1);
|
|
10371
|
+
if (this.isLocationUsable(row.locationId)) continue;
|
|
10372
|
+
const key = locationKey(row.deviceId, row.locationId);
|
|
10373
|
+
const seen = unusable.get(key);
|
|
10374
|
+
unusable.set(key, seen === void 0 ? {
|
|
10375
|
+
deviceId: row.deviceId,
|
|
10376
|
+
locationId: row.locationId,
|
|
10377
|
+
held: 1
|
|
10378
|
+
} : {
|
|
10379
|
+
...seen,
|
|
10380
|
+
held: seen.held + 1
|
|
10381
|
+
});
|
|
10382
|
+
}
|
|
10383
|
+
const onDiskByDevice = /* @__PURE__ */ new Map();
|
|
10384
|
+
for (const row of fromIndex.values()) onDiskByDevice.set(row.deviceId, (onDiskByDevice.get(row.deviceId) ?? 0) + 1);
|
|
10385
|
+
const devices = /* @__PURE__ */ new Set();
|
|
10386
|
+
for (const [deviceId, held] of heldByDevice) {
|
|
10387
|
+
if (held < 8) continue;
|
|
10388
|
+
const onDisk = onDiskByDevice.get(deviceId) ?? 0;
|
|
10389
|
+
if (onDisk >= held * .5) continue;
|
|
10390
|
+
devices.add(deviceId);
|
|
10391
|
+
this.logger.warn("recorder: segment-hour ledger prune REFUSED — this walk would delete most of the camera archive, which is what an unmounted root looks like; keeping the previous ledger", {
|
|
10392
|
+
tags: { deviceId },
|
|
10393
|
+
meta: {
|
|
10394
|
+
held,
|
|
10395
|
+
onDisk,
|
|
10396
|
+
dropped: held - onDisk,
|
|
10397
|
+
maxShare: LEDGER_PRUNE_MAX_SHARE
|
|
10398
|
+
}
|
|
10399
|
+
});
|
|
10400
|
+
}
|
|
10401
|
+
const locations = /* @__PURE__ */ new Set();
|
|
10402
|
+
for (const [key, entry] of unusable) {
|
|
10403
|
+
if (devices.has(entry.deviceId)) continue;
|
|
10404
|
+
locations.add(key);
|
|
10405
|
+
this.logger.warn("recorder: segment-hour ledger prune REFUSED — the volume under this location failed its probe, so the walk found nothing because nothing is mounted there", {
|
|
10406
|
+
tags: { deviceId: entry.deviceId },
|
|
10407
|
+
meta: {
|
|
10408
|
+
locationId: entry.locationId,
|
|
10409
|
+
held: entry.held,
|
|
10410
|
+
onDisk: onDiskByDevice.get(entry.deviceId) ?? 0
|
|
10411
|
+
}
|
|
10412
|
+
});
|
|
10116
10413
|
}
|
|
10414
|
+
return {
|
|
10415
|
+
devices,
|
|
10416
|
+
locations
|
|
10417
|
+
};
|
|
10117
10418
|
}
|
|
10118
10419
|
};
|
|
10420
|
+
function locationKey(deviceId, locationId) {
|
|
10421
|
+
return `${deviceId}|${locationId}`;
|
|
10422
|
+
}
|
|
10119
10423
|
//#endregion
|
|
10120
10424
|
//#region src/recorder/loop-pacer.ts
|
|
10121
10425
|
var DEFAULT_BUSY_LAG_MS = 25;
|
|
@@ -10736,6 +11040,23 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
10736
11040
|
});
|
|
10737
11041
|
/** Continuous + events band write-path controller (ffmpeg writers + watchers). */
|
|
10738
11042
|
controller = null;
|
|
11043
|
+
/**
|
|
11044
|
+
* The controller's config read, with last-known-good fallback.
|
|
11045
|
+
*
|
|
11046
|
+
* Built ONCE and memoized, because the cache lives INSIDE the wrapper:
|
|
11047
|
+
* rebuilding it per call would throw away the very value the fallback exists
|
|
11048
|
+
* to serve. Lazy rather than a field initializer — a field initializer runs
|
|
11049
|
+
* before the constructor assigns `this.ctx`, so `this.ctx.logger` would be
|
|
11050
|
+
* `undefined` at that point.
|
|
11051
|
+
*/
|
|
11052
|
+
lastKnownGoodRead = null;
|
|
11053
|
+
get loadConfigLastKnownGood() {
|
|
11054
|
+
this.lastKnownGoodRead ??= withLastKnownConfig({
|
|
11055
|
+
load: (deviceId) => loadDeviceConfig(this.configStore(), deviceId),
|
|
11056
|
+
logger: this.ctx.logger
|
|
11057
|
+
});
|
|
11058
|
+
return this.lastKnownGoodRead;
|
|
11059
|
+
}
|
|
10739
11060
|
/** Per-device motion/audio markers for playback (B3 events feed the controller). */
|
|
10740
11061
|
eventMap = new EventMap();
|
|
10741
11062
|
/** Unsubscribe handle for the EventCapture motion subscription. */
|
|
@@ -10918,7 +11239,7 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
10918
11239
|
nodeId: this.nodeId,
|
|
10919
11240
|
ownerNodeId: ingestOwner.ownerNodeId,
|
|
10920
11241
|
segmentStore: this.segmentStore,
|
|
10921
|
-
loadConfig:
|
|
11242
|
+
loadConfig: this.loadConfigLastKnownGood,
|
|
10922
11243
|
enabledDeviceIds: async () => [...(await readDeviceConfigs(this.configStore())).keys()],
|
|
10923
11244
|
/**
|
|
10924
11245
|
* The DURABLE half of the trigger path (D8/D11). `pipelineAnalytics` rows
|