@camstack/addon-pipeline 1.2.199 → 1.2.201

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/audio-analyzer/index.js +2 -2
  2. package/dist/audio-analyzer/index.mjs +2 -2
  3. package/dist/{default-detection-model-O1uBusV2.mjs → default-detection-model-fUMLQHB2.mjs} +1 -1
  4. package/dist/{default-detection-model-CMe44Kid.js → default-detection-model-nKgB-_mg.js} +1 -1
  5. package/dist/detection-pipeline/index.js +5 -5
  6. package/dist/detection-pipeline/index.mjs +4 -4
  7. package/dist/{dist-DmCrC2yx.js → dist-Bd875pfj.js} +912 -606
  8. package/dist/{dist-B23u1tmN.mjs → dist-D2jcEuZD.mjs} +865 -607
  9. package/dist/{lazy-sharp-DBHsD2lh.js → lazy-sharp-B4KrLp0u.js} +1 -1
  10. package/dist/motion-wasm/index.js +2 -2
  11. package/dist/motion-wasm/index.mjs +1 -1
  12. package/dist/{node-D1MLn6oV.mjs → node-CmfXSgr3.mjs} +420 -381
  13. package/dist/{node-DCSTbt6C.js → node-dwhLC9Fh.js} +423 -378
  14. package/dist/pipeline-runner/index.js +8 -7
  15. package/dist/pipeline-runner/index.mjs +7 -6
  16. package/dist/{process-memory-9lvvhHym.js → process-memory-BDRJK5Vq.js} +1 -1
  17. package/dist/{process-memory-P24NTDb1.mjs → process-memory-CH5qO7AX.mjs} +1 -1
  18. package/dist/recorder/index.js +1208 -1015
  19. package/dist/recorder/index.mjs +1203 -1010
  20. package/dist/restream-intent-B1Difcnq.js +313 -0
  21. package/dist/restream-intent-DWe2kInY.mjs +248 -0
  22. package/dist/{prebuffer-DmBYRQWg.mjs → retire-root-keys-Csb8z2f3.js} +12 -64
  23. package/dist/{prebuffer-DqdeLSsk.js → retire-root-keys-DZvlso2l.mjs} +1 -99
  24. package/dist/{segment-demux-js-Bm0woPkn.mjs → segment-demux-js-BHTuEBhC.mjs} +1 -1
  25. package/dist/{segment-demux-js-Ck_F5bsO.js → segment-demux-js-cmfLROII.js} +1 -1
  26. package/dist/session-decode/decode-worker-child.js +25 -5
  27. package/dist/session-decode/decode-worker-child.mjs +24 -4
  28. package/dist/stream-broker/_stub.js +2 -2
  29. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Sq93hI-g.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DZcCQR5t.mjs} +3 -3
  30. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CrzkEz67.mjs +26 -0
  31. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Dj_qoc1e.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-cYr6Swud.mjs} +1 -1
  32. package/dist/stream-broker/demux-worker-child.js +1 -1
  33. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  34. package/dist/stream-broker/{hostInit-COt259tU.mjs → hostInit-CZt5Lq_M.mjs} +3 -3
  35. package/dist/stream-broker/index.js +181 -48
  36. package/dist/stream-broker/index.mjs +172 -39
  37. package/dist/stream-broker/remoteEntry.js +1 -1
  38. package/dist/{worker-protocol-c1r1Yddg.js → worker-protocol--PcFsRrE.js} +1 -1
  39. package/dist/{worker-protocol-BxpGZ0Dt.mjs → worker-protocol-D4GyRNnY.mjs} +1 -1
  40. package/package.json +5 -1
  41. package/dist/restream-intent-B4BXZra7.mjs +0 -72
  42. package/dist/restream-intent-Cv9x3jmu.js +0 -89
  43. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BcjLrxhF.mjs +0 -26
@@ -1,9 +1,9 @@
1
- import { Bt as hydrateSchema, Ct as storageEvictableCapability, D as RecordingSignalStatusSchema, E as RecordingConfigSchema, Ft as BaseAddon, H as deriveBatteryPresence, Jt as sleep$1, Lt as DeviceFeature, Ot as errMsg, P as batteryCapability, R as customAction, Rt as DeviceType, T as RECORDING_EXPORT_MAX_READ_BYTES, Tt as stripRetiredBandPreBufferSec, V as defineCustomActions, W as deriveRecordingMode, Wt as nodePin, Xt as array, Y as evaluateSensorEdge, Yt as _enum, Zt as boolean, _t as recordingSignalCapability, an as string, at as migrateLegacyDeviceSignalConfig, d as DEFAULT_EVENTS_BAND_BUFFER_SEC, et as isFirstLevelMacroClass, g as ExportRecordSchema, gt as recordingExportCapability, ht as recordingCapability, i as BatteryStatusSchema, in as record, nn as object, qt as selectAssignedProfileSlots, rn as preprocess, sn as EventCategory, tn as number, tt as loadContributionCapability, x as OpsLogEntrySchema, xt as resolveRecordingProfiles } from "../dist-B23u1tmN.mjs";
2
- import { i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-D1MLn6oV.mjs";
1
+ import { At as storageEvictableCapability, Ct as recordingSignalCapability, D as RecordingSignalStatusSchema, E as RecordingConfigSchema, Et as resolveLocationMode, Gt as DeviceFeature, H as deriveBatteryPresence, It as errMsg, Jt as hydrateSchema, Kt as DeviceType, Nt as stripRetiredBandPreBufferSec, Ot as resolveRecordingProfiles, P as batteryCapability, Q as evictionPolicyOfLocation, Qt as nodePin, R as customAction, St as recordingExportCapability, T as RECORDING_EXPORT_MAX_READ_BYTES, Ut as BaseAddon, V as defineCustomActions, W as deriveRecordingMode, Y as evaluateSensorEdge, Z as evictionPolicyForMode, an as boolean, ct as mayReadLocation, d as DEFAULT_EVENTS_BAND_BUFFER_SEC, dn as preprocess, dt as modeMayWrite, et as gbToBytes, fn as record, g as ExportRecordSchema, hn as EventCategory, i as BatteryStatusSchema, in as array, it as loadContributionCapability, jt as storageOccupancyCapability, ln as number, lt as mayWriteToLocation, nn as sleep$1, pn as string, rn as _enum, rt as isFirstLevelMacroClass, tn as selectAssignedProfileSlots, un as object, ut as migrateLegacyDeviceSignalConfig, x as OpsLogEntrySchema, xt as recordingCapability } from "../dist-D2jcEuZD.mjs";
2
+ import { a as physicalRootOf, i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-CmfXSgr3.mjs";
3
3
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
4
- import { r as withRecordingIntent } from "../restream-intent-B4BXZra7.mjs";
4
+ import { c as PREBUFFER_MAX_MS, r as RECORDING_PRE_ROLL_REQUEST_MS, s as withRecordingIntent } from "../restream-intent-DWe2kInY.mjs";
5
5
  import { n as startEventLoopStallMonitor } from "../event-loop-stall-monitor-DXLNBMkY.mjs";
6
- import { a as retireRootKeys, o as RowMapStore, t as PREBUFFER_MAX_MS } from "../prebuffer-DmBYRQWg.mjs";
6
+ import { n as RowMapStore, t as retireRootKeys } from "../retire-root-keys-DZvlso2l.mjs";
7
7
  import { randomUUID } from "node:crypto";
8
8
  import { createReadStream, promises, realpathSync } from "node:fs";
9
9
  import path from "node:path";
@@ -1512,14 +1512,17 @@ var RecordingIndex = class {
1512
1512
  /**
1513
1513
  * The physical-device key of a location root: `realpath` of the resolved path,
1514
1514
  * or the resolved path string itself when realpath throws (root missing).
1515
+ *
1516
+ * ONE derivation, shared with the orchestrator's same-type root refusal (D389)
1517
+ * — two implementations of "is this the same disk" is two answers waiting to
1518
+ * disagree. This side deliberately DROPS the `resolved` flag: an unresolvable
1519
+ * root falls back to the lexical path, which can only over-partition (treat one
1520
+ * disk as two) and never merge two different disks, the safe failure mode for a
1521
+ * data-loss guard. The orchestrator needs the flag, because a refusal must not
1522
+ * be built on a read that failed.
1515
1523
  */
1516
1524
  function physicalRootKey(root, realpath = realpathSync) {
1517
- const resolved = path.resolve(root);
1518
- try {
1519
- return realpath(resolved);
1520
- } catch {
1521
- return resolved;
1522
- }
1525
+ return physicalRootOf(root, realpath).key;
1523
1526
  }
1524
1527
  /**
1525
1528
  * The eviction DOMAIN of `locationId`: the set of location ids (INCLUDING
@@ -1534,211 +1537,578 @@ function resolveEvictionDomain(locations, locationId, realpath = realpathSync) {
1534
1537
  for (const l of locations) if (physicalRootKey(l.root, realpath) === selfKey) domain.add(l.id);
1535
1538
  return domain;
1536
1539
  }
1537
- //#endregion
1538
- //#region src/recorder/relocate-engine.ts
1540
+ var RECORDING_LOCATION_TYPES = new Set(["recordings", "recordingsLow"]);
1539
1541
  /**
1540
- * Footage relocation engine (entity-routing spec, Phase 4) — the mover half
1541
- * of disk decommissioning: mark the old location read-only (Phase 2), then
1542
- * relocate its footage here, then delete the location.
1543
- *
1544
- * Mechanics, per HOUR (D291): throttled copy + size-verify of every file in the
1545
- * hour → re-point the DURABLE hour row → delete the sources → re-point the RAM
1546
- * index. The hour is the batch because the durable index is hour-granular, and
1547
- * the sources outlive the durable write on purpose: between the copy and the
1548
- * delete both locations hold the hour, so no crash can leave the index naming a
1549
- * location without the bytes. The copy is `copy-if-absent` (an equal-size
1550
- * destination is adopted, not re-copied), so cancel/crash/re-run RESUMES
1551
- * instead of restarting, and a cancel inside an hour leaves that hour WHOLLY
1552
- * readable at the source. Throttling is file-level pacing (segments are
1553
- * ~1–8 MB / ~4 s, so per-file granularity bounds the rate tightly enough) —
1554
- * the drain is a background chore that must never starve live writers.
1555
- *
1556
- * Runs INSIDE the recorder process — container uid, same-uid files, no host
1557
- * chown trap (see docs/history 2026-07-29).
1558
- *
1559
- * ── Phase 2 (D116, multi-location) ────────────────────────────────────────
1560
- * Two additions the OPERATOR-ARMED rebalance needs:
1561
- *
1562
- * - A move is scoped to one CAMERA (and optionally to specific profiles),
1563
- * not just to a whole disk. The rebalance's unit is a (camera, profile)
1564
- * pile, because that is the unit the placement plan assigns.
1565
- * - Concurrent starts QUEUE instead of being refused. The engine is still
1566
- * single-flight — two movers on one disk thrash both — but a rebalance
1567
- * enqueues one job per pile, and refusing the second turned a
1568
- * fifteen-camera rebalance into fifteen manual retries. Jobs run FIFO; a
1569
- * job cancelled while queued never runs, and a job that FAILS does not
1570
- * strand the queue behind it.
1542
+ * The recordings CLASS a profile belongs to. The one place this rule is
1543
+ * written: `resolvePlacement`, the planner's demand type and the placement
1544
+ * census must agree on it, and three copies of `profile === 'low' ? … : …` is
1545
+ * how they stop agreeing.
1571
1546
  */
1572
- var DEFAULT_THROTTLE_MBPS = 40;
1573
- /** The durable index's bucket, and therefore this mover's batch. */
1574
- var HOUR_MS$7 = 36e5;
1575
- /** Progress callback cadence (files) — cheap enough to fire often. */
1576
- var PROGRESS_EVERY_FILES = 25;
1577
- /** Free space a destination must keep when it declares no `config.minFreePercent`
1578
- * — the same default `roots.ts` documents for placement. */
1579
- var DEFAULT_MIN_FREE_PERCENT$1 = 10;
1580
- /** Prefix every destination-full error carries, so the coordinator can report
1581
- * "drain stalled: destination full" instead of a raw ENOSPC string. */
1582
- var DESTINATION_FULL_PREFIX = "destination full";
1547
+ function placementTypeForProfile(profile) {
1548
+ return profile === "low" ? "recordingsLow" : "recordings";
1549
+ }
1583
1550
  /**
1584
- * Ghost rows one job may FORGET, as a share of its own census — and the floor
1585
- * below which the share is not the bound.
1586
- *
1587
- * D148 is why this is bounded at all: the ledger forgets only on a trusted walk
1588
- * or an eviction, so that a transient read error cannot erase an operator's
1589
- * index of real footage. A per-path ENOENT on a path derived from the row is
1590
- * strong evidence about THAT path (D296) and weak evidence about a mount: a
1591
- * root that vanished answers ENOENT for every path it is asked about, and looks
1592
- * exactly like a location that is genuinely empty. The bound is what makes the
1593
- * difference survivable — past it the mover keeps moving, keeps skipping, and
1594
- * REPORTS, so an operator gets a reconcile to act on instead of an archive that
1595
- * quietly emptied itself.
1551
+ * Narrow a resolved location list to the ones placement may write to: the
1552
+ * enabled ones — ALL of them, together. A location the operator has not opted
1553
+ * in (`enabled === false`) is still READ, age-swept and drained — it is simply
1554
+ * never a write target, exactly like a read-only one.
1596
1555
  *
1597
- * 17.5 % of `recordingsLow:default` was ghosts on 2026-08-30 (11 074 of
1598
- * 63 346), so a quarter admits a real reconcile of that size in one pass while
1599
- * refusing a whole-location erase.
1556
+ * When no enabled location is writable EVERY location serves instead, loudly.
1557
+ * The orchestrator refuses to leave a recordings class without an enabled
1558
+ * location, so this is reachable only through a hand-edited store — and a
1559
+ * recorder that writes nowhere is worse than one that writes under protest.
1600
1560
  */
1601
- var GHOST_FORGET_SHARE = .25;
1602
- function snapshot(j) {
1561
+ function placementPool(locations) {
1562
+ const writable = locations.filter((l) => locationMayBeWritten(l));
1563
+ if (writable.length > 0) return {
1564
+ locations: writable,
1565
+ fellBackToDisabled: false
1566
+ };
1603
1567
  return {
1604
- jobId: j.jobId,
1605
- state: j.state,
1606
- fromLocationId: j.fromLocationId,
1607
- toLocationId: j.toLocationId,
1608
- deviceId: j.deviceId,
1609
- entities: [...j.entities],
1610
- filesMoved: j.filesMoved,
1611
- bytesMoved: j.bytesMoved,
1612
- filesTotal: j.filesTotal,
1613
- rowsReconciled: j.rowsReconciled,
1614
- rowsForgotten: j.rowsForgotten,
1615
- startedAt: j.startedAt,
1616
- finishedAt: j.finishedAt,
1617
- error: j.error
1568
+ locations: locations.filter((l) => locationMayBeRead(l)),
1569
+ fellBackToDisabled: true
1618
1570
  };
1619
1571
  }
1620
1572
  /**
1621
- * The row's source file is not on disk.
1573
+ * The state question, put to the ONE interpreter.
1622
1574
  *
1623
- * Carries `code: 'ENOENT'` because that is how every caller here already tells
1624
- * a ghost row from a real failure, and {@link copyVerified} now discovers the
1625
- * absence with a `stat` rather than by letting `copyFile` raise it.
1575
+ * The legacy mirrors are handed over too, so a location built by a caller that
1576
+ * has not moved to `recordingsLocationOfMode` yet is folded by the SAME rule
1577
+ * that folds an unmigrated persisted row (`mode` absent → `readOnly` → `enabled`
1578
+ * → active). There is still exactly one interpretation; the mirrors and this
1579
+ * argument disappear together next release.
1626
1580
  */
1627
- var RelocateSourceMissingError = class extends Error {
1628
- code = "ENOENT";
1629
- constructor(src) {
1630
- super(`relocate source missing: ${src}`);
1631
- this.name = "RelocateSourceMissingError";
1632
- }
1633
- };
1581
+ function stateOf(location) {
1582
+ return {
1583
+ ...location.mode !== void 0 ? { mode: location.mode } : {},
1584
+ ...location.enabled !== void 0 ? { enabled: location.enabled } : {},
1585
+ config: location.readOnly === true ? { readOnly: true } : {}
1586
+ };
1587
+ }
1588
+ /** The resolved state of a location, for a message or a log line. */
1589
+ function locationMode(location) {
1590
+ return resolveLocationMode(stateOf(location));
1591
+ }
1592
+ /** May the recorder write here? THE policy, never a local re-derivation. */
1593
+ function locationMayBeWritten(location) {
1594
+ return mayWriteToLocation(stateOf(location));
1595
+ }
1634
1596
  /**
1635
- * One file's COPY: copy-if-absent → verify the destination against the SOURCE'S
1636
- * OWN SIZE. The source is left in place.
1637
- *
1638
- * Split out of {@link moveFile} because the durable index re-point has to
1639
- * happen while BOTH locations still hold the bytes — see
1640
- * `SegmentHourLedger.repointSegments`.
1641
- *
1642
- * **`recordedBytes` is never the verify's reference.** It used to be, and it
1643
- * cannot be: `fs.copyFile` truncates and replaces, so the destination always
1644
- * equals the source, and comparing that result against a number the LEDGER
1645
- * remembers can only ever fail when the ledger is wrong. On 2026-08-30 that
1646
- * reported a byte-perfect copy as corruption and killed a 110 749-file drain
1647
- * on its 12 440th file:
1648
- *
1649
- * relocate verify failed: …/1787926410000-9966-274432.m4s
1650
- * is 783801B, expected 274432B
1651
- *
1652
- * Both files measured 783801 B on the hub. What is faithful is "the
1653
- * destination equals the source", and that is what is asserted here; the
1654
- * recorded number survives only as the reference for a destination whose
1655
- * source is already GONE, where there is nothing else left to compare against.
1597
+ * A resolved location, with every derived mirror written from the ONE state.
1598
+ * Every `RecordingsLocation` in the recorder comes from here.
1656
1599
  */
1657
- async function copyVerified(src, dst, recordedBytes) {
1658
- const dstBytes = await fileSize(dst);
1659
- const srcBytes = await fileSize(src);
1660
- if (dstBytes !== null && dstBytes === (srcBytes ?? recordedBytes)) return {
1661
- copied: 0,
1662
- srcBytes: dstBytes
1663
- };
1664
- if (srcBytes === null) throw new RelocateSourceMissingError(src);
1665
- await promises.mkdir(path.dirname(dst), { recursive: true });
1666
- await promises.copyFile(src, dst);
1667
- const after = await promises.stat(dst);
1668
- if (after.size !== srcBytes) throw new Error(`relocate verify failed: ${dst} is ${after.size}B, expected ${srcBytes}B`);
1600
+ function recordingsLocationOfMode(base, mode) {
1669
1601
  return {
1670
- copied: srcBytes,
1671
- srcBytes
1602
+ ...base,
1603
+ mode,
1604
+ readOnly: !modeMayWrite(mode),
1605
+ enabled: modeMayWrite(mode)
1672
1606
  };
1673
1607
  }
1674
- /** A file's size, or `null` when it is not there. */
1675
- async function fileSize(p) {
1676
- try {
1677
- return (await promises.stat(p)).size;
1678
- } catch {
1679
- return null;
1680
- }
1608
+ /** May the recorder read here? A `disabled` location may not — and that is an
1609
+ * operator CHOICE, reported as unavailable, never as an unknown location. */
1610
+ function locationMayBeRead(location) {
1611
+ return mayReadLocation(stateOf(location));
1681
1612
  }
1682
- var RelocateEngine = class {
1683
- deps;
1684
- jobs = /* @__PURE__ */ new Map();
1685
- /** Job ids waiting for the mover, in enqueue order. */
1686
- queue = [];
1687
- /** Set while a job is executing — the single-flight latch. */
1688
- draining = false;
1689
- constructor(deps) {
1690
- this.deps = deps;
1691
- }
1692
- list() {
1693
- return [...this.jobs.values()].sort((a, b) => b.startedAt - a.startedAt).map(snapshot);
1613
+ /**
1614
+ * Route a recording profile to a storage location, for a (camera, profile) the
1615
+ * PLANNER has not placed yet — the first attach of a new camera, before the
1616
+ * 30 s pass runs.
1617
+ *
1618
+ * The rule, in order:
1619
+ * 1. the location's declared `type` — `low` prefers `recordingsLow`, every
1620
+ * other profile prefers `recordings`; when the preferred type has only
1621
+ * read-only instances, any writable recordings-type location serves;
1622
+ * 2. among those, the one with the most MEASURED headroom
1623
+ * (`min(free − minFree floor, maxUsedGb − used)`) — the same signal the
1624
+ * planner balances on, so the bootstrap pick and the plan agree instead of
1625
+ * the plan having to undo the pick at the next tick;
1626
+ * 3. ties, and a pool where nothing was measured, fall to the
1627
+ * lexicographically first id — deterministic, never "whichever came back
1628
+ * first from the cap".
1629
+ *
1630
+ * There is no preferred instance any more (D383): every enabled location of a
1631
+ * class is a write target, and which one a camera gets is a capacity question,
1632
+ * not a flag. Throws when NO writable location exists — recording cannot start,
1633
+ * and that must be loud.
1634
+ */
1635
+ function resolvePlacement(locations, profile) {
1636
+ if (locations.length === 0) throw new Error("recorder controller: no recordings storage locations resolved");
1637
+ const writable = locations.filter((l) => locationMayBeWritten(l));
1638
+ if (writable.length === 0) throw new Error("recorder controller: every recordings location is read-only — nowhere to write");
1639
+ const preferType = profile === "low" ? "recordingsLow" : "recordings";
1640
+ const preferred = writable.filter((l) => l.type === preferType);
1641
+ return emptiestOf(preferred.length > 0 ? preferred : writable);
1642
+ }
1643
+ /** The candidate with the most measured headroom; ties (and an unmeasured
1644
+ * pool, where every headroom is absent) by lexicographic id. */
1645
+ function emptiestOf(candidates) {
1646
+ return candidates.toSorted((a, b) => {
1647
+ const byHeadroom = (b.headroomBytes ?? 0) - (a.headroomBytes ?? 0);
1648
+ return byHeadroom !== 0 ? byHeadroom : a.id.localeCompare(b.id);
1649
+ })[0];
1650
+ }
1651
+ /**
1652
+ * Attribute a discovered segment on an aliased physical root by its profile,
1653
+ * not by whichever logical location happened to be hydrated last. Stock
1654
+ * `recordings` and `recordingsLow` deliberately share a root: high/mid belong
1655
+ * to `recordings`, while low belongs to `recordingsLow`.
1656
+ */
1657
+ function locationForHydratedProfile(aliases, profile) {
1658
+ const preferredType = profile === "low" ? "recordingsLow" : "recordings";
1659
+ const preferred = aliases.filter((location) => location.type === preferredType).toSorted((a, b) => a.id.localeCompare(b.id))[0];
1660
+ const fallback = aliases.toSorted((a, b) => a.id.localeCompare(b.id))[0];
1661
+ if (preferred) return preferred;
1662
+ if (fallback) return fallback;
1663
+ throw new Error("recorder: no location aliases to attribute hydrated profile");
1664
+ }
1665
+ function aliasesByRoot(locations) {
1666
+ const grouped = /* @__PURE__ */ new Map();
1667
+ for (const location of locations) {
1668
+ const aliases = grouped.get(location.root);
1669
+ if (aliases) aliases.push(location);
1670
+ else grouped.set(location.root, [location]);
1694
1671
  }
1695
- /**
1696
- * Cancel a running OR queued job. A running job stops after its current
1697
- * file; a queued job is terminated where it stands and never runs — which is
1698
- * why this returns true for both, and why the cancelled job still gets its
1699
- * `onFinished` audit row.
1700
- */
1701
- cancel(jobId) {
1702
- const job = this.jobs.get(jobId);
1703
- if (!job) return false;
1704
- if (job.state === "running") {
1705
- job.cancelRequested = true;
1706
- return true;
1707
- }
1708
- if (job.state !== "queued") return false;
1709
- job.cancelRequested = true;
1710
- job.state = "cancelled";
1711
- job.finishedAt = this.deps.now();
1712
- const at = this.queue.indexOf(jobId);
1713
- if (at >= 0) this.queue.splice(at, 1);
1714
- this.deps.onFinished?.(snapshot(job));
1715
- return true;
1672
+ return grouped;
1673
+ }
1674
+ /** One numeric key lifted out of a location's untyped `config` record, or
1675
+ * nothing at all when it is absent or not a finite number. Absent is a real
1676
+ * answer here: the caller falls back to a documented default, and a garbage
1677
+ * value must never become a capacity limit. */
1678
+ function numericConfig(config, key, field) {
1679
+ const raw = config[key];
1680
+ if (typeof raw !== "number" || !Number.isFinite(raw)) return {};
1681
+ return { [field]: raw };
1682
+ }
1683
+ /**
1684
+ * Resolve every recordings storage location (`recordings` + `recordingsLow`)
1685
+ * to `{ id, root }`. Returns an empty list when the storage cap is unreachable
1686
+ * or no recordings location exists — callers degrade rather than crash.
1687
+ */
1688
+ async function resolveRecordingsLocations(api, logger) {
1689
+ try {
1690
+ const recordings = (await api.storage.listLocations.query({})).filter((l) => RECORDING_LOCATION_TYPES.has(l.type));
1691
+ return Promise.all(recordings.map(async (l) => recordingsLocationOfMode({
1692
+ id: l.id,
1693
+ type: l.type,
1694
+ root: await api.storage.resolve.query({
1695
+ location: l.id,
1696
+ relativePath: ""
1697
+ }),
1698
+ ...numericConfig(l.config, "minFreePercent", "minFreePercent"),
1699
+ ...numericConfig(l.config, "maxUsedGb", "maxUsedGb")
1700
+ }, resolveLocationMode(l))));
1701
+ } catch (err) {
1702
+ logger.warn("recorder: listLocations failed — no recordings locations resolved", { meta: { error: errMsg(err) } });
1703
+ return [];
1716
1704
  }
1717
- /**
1718
- * Enqueue a relocation and return its job id. Throws only on an input the
1719
- * mover could never satisfy (unknown/identical/read-only locations) —
1720
- * validated SYNCHRONOUSLY so a caller arming a rebalance learns about a bad
1721
- * target before any job runs.
1722
- *
1723
- * Concurrency: the returned job may be `queued` rather than `running`. The
1724
- * engine still moves one job at a time.
1725
- */
1726
- start(input) {
1727
- const locs = this.deps.locations();
1728
- const from = locs.find((l) => l.id === input.fromLocationId);
1729
- const to = locs.find((l) => l.id === input.toLocationId);
1730
- if (!from) throw new Error(`unknown source location: ${input.fromLocationId}`);
1731
- if (!to) throw new Error(`unknown target location: ${input.toLocationId}`);
1732
- if (from.id === to.id) throw new Error("source and target locations are identical");
1733
- if (to.readOnly) throw new Error(`target location is read-only: ${to.id}`);
1734
- if (to.enabled === false) this.deps.logger.warn("relocate: target location is DISABLED (standby) — moving to it because the operator named it; placement still never chooses it for new writes", {
1735
- ...input.deviceId !== void 0 ? { tags: { deviceId: input.deviceId } } : {},
1736
- meta: {
1737
- fromLocationId: from.id,
1738
- toLocationId: to.id,
1739
- footageClass: input.footageClass ?? null
1705
+ }
1706
+ /**
1707
+ * Hydrate the v2 `RecordingIndex` for one device from every recordings location
1708
+ * by recursively walking `<root>/<deviceId>/` for `*.m4s` segments and feeding
1709
+ * the location-relative paths (`<deviceId>/<profile>/Y/M/D/H/<file>.m4s`, which
1710
+ * `parseSegmentPath` understands) to `index.hydrateDevice`. A missing device dir
1711
+ * (no footage yet) and any per-location failure are warned + skipped
1712
+ * (best-effort), so one bad volume never blocks the rest of the hydrate.
1713
+ */
1714
+ async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger, options = {}) {
1715
+ const yieldBetween = options.yieldBetween ?? macrotask$3;
1716
+ const chunkSize = options.chunkSize ?? HYDRATE_CHUNK;
1717
+ for (const [root, aliases] of aliasesByRoot(locations)) {
1718
+ const deviceDir = path.join(root, String(deviceId));
1719
+ const startedMs = Date.now();
1720
+ let entries;
1721
+ try {
1722
+ entries = await promises.readdir(deviceDir, { recursive: true });
1723
+ } catch (err) {
1724
+ if (err.code === "ENOENT") {
1725
+ for (const location of aliases) index.hydrateDevice(deviceId, location.id, []);
1726
+ continue;
1727
+ }
1728
+ logger.warn("recorder: hydrateDevice walk failed for location", {
1729
+ tags: { deviceId },
1730
+ meta: {
1731
+ locationIds: aliases.map((location) => location.id),
1732
+ error: errMsg(err)
1733
+ }
1734
+ });
1735
+ continue;
1736
+ }
1737
+ const walkedMs = Date.now();
1738
+ const locationForProfile = /* @__PURE__ */ new Map();
1739
+ const pathsByLocation = /* @__PURE__ */ new Map();
1740
+ let handled = 0;
1741
+ for (const entry of entries) {
1742
+ if (!entry.endsWith(".m4s")) continue;
1743
+ const relative = `${deviceId}/${entry.split(path.sep).join("/")}`;
1744
+ const profile = relative.split("/")[1] ?? "";
1745
+ let locationId = locationForProfile.get(profile);
1746
+ if (locationId === void 0) {
1747
+ locationId = locationForHydratedProfile(aliases, profile).id;
1748
+ locationForProfile.set(profile, locationId);
1749
+ }
1750
+ const paths = pathsByLocation.get(locationId);
1751
+ if (paths) paths.push(relative);
1752
+ else pathsByLocation.set(locationId, [relative]);
1753
+ handled += 1;
1754
+ if (handled % chunkSize === 0) await yieldBetween();
1755
+ }
1756
+ for (const location of aliases) if (!pathsByLocation.has(location.id)) pathsByLocation.set(location.id, []);
1757
+ let indexed = 0;
1758
+ const reuseBefore = index.hydrateReuseStats();
1759
+ for (const [locationId, relPaths] of pathsByLocation) {
1760
+ index.hydrateDevice(deviceId, locationId, relPaths);
1761
+ indexed += relPaths.length;
1762
+ }
1763
+ const reuseAfter = index.hydrateReuseStats();
1764
+ const reused = reuseAfter.rowsReused - reuseBefore.rowsReused;
1765
+ const allocated = reuseAfter.rowsAllocated - reuseBefore.rowsAllocated;
1766
+ logger.info("recorder: hydrated a device from one recordings root", {
1767
+ tags: { deviceId },
1768
+ meta: {
1769
+ root,
1770
+ entries: entries.length,
1771
+ indexed,
1772
+ reused,
1773
+ allocated,
1774
+ locationIds: [...pathsByLocation.keys()],
1775
+ walkMs: walkedMs - startedMs,
1776
+ ms: Date.now() - startedMs
1777
+ }
1778
+ });
1779
+ }
1780
+ }
1781
+ /** Entries classified between yields in {@link hydrateDeviceFromStorage}. */
1782
+ var HYDRATE_CHUNK = 2e4;
1783
+ var macrotask$3 = () => new Promise((resolve) => {
1784
+ setImmediate(resolve);
1785
+ });
1786
+ /** `<deviceId>/<profile>/YYYY/MM/DD/HH` for an hour bucket (paths are UTC). */
1787
+ function hourDirRelPath(deviceId, profile, hourStartMs) {
1788
+ const d = new Date(hourStartMs);
1789
+ const p2 = (n) => String(n).padStart(2, "0");
1790
+ return `${deviceId}/${profile}/${d.getUTCFullYear()}/${p2(d.getUTCMonth() + 1)}/${p2(d.getUTCDate())}/${p2(d.getUTCHours())}`;
1791
+ }
1792
+ var HOUR_MS$7 = 36e5;
1793
+ /**
1794
+ * Hydrate ONLY the hours a window spans, instead of walking the archive.
1795
+ *
1796
+ * `hydrateDeviceFromStorage` is a recursive `readdir` over the whole device
1797
+ * subtree — ">1M files and takes minutes" per its own comment. Until it lands,
1798
+ * every read of past footage sees an empty index, which the server reports as
1799
+ * "no footage": measured after a deploy, a camera-day held 7 segments and grew
1800
+ * only at the rate of NEW writes for ~9 minutes.
1801
+ *
1802
+ * The path convention makes the window addressable directly: one SHALLOW
1803
+ * `readdir` per (profile, hour), ~450 entries each. A day is ~72 of them. The
1804
+ * background full walk still runs, for the consumers that need the whole
1805
+ * archive (footprint, eviction domains, retention).
1806
+ *
1807
+ * Hours already walked are skipped, so a drag across an hour costs nothing the
1808
+ * second time.
1809
+ *
1810
+ * ## The ledger is the first stop for a CLOSED hour (D287)
1811
+ *
1812
+ * Boot no longer replays the archive into the index, so a ledger-known hour
1813
+ * arrives here `unknown` and pays ONE point read of its hour row instead of a
1814
+ * `readdir` — measured at ~650 ms per hour directory on the live share under
1815
+ * load, against the array the scrub feeder is competing for. Hours the ledger
1816
+ * does not know, and every hour if the read fails, fall through to the walk
1817
+ * exactly as before: the failure direction is toward the disk, never toward an
1818
+ * hour that renders empty because nobody read it.
1819
+ *
1820
+ * **The live hour is never served from the ledger.** It is the one hour whose
1821
+ * durable row is knowably behind the disk: a segment finalized after the last
1822
+ * persist and before a crash is on the array and not in the row. Boot used to
1823
+ * mark every ledger hour `hydrated`, which made that tail invisible to
1824
+ * playback and the timeline FOREVER, not merely under-reported. Excluding the
1825
+ * live hour costs exactly one hour-directory read per process — the walk marks
1826
+ * it, so the second reader pays nothing — and it is what turns a lost tail
1827
+ * back into a recoverable one.
1828
+ *
1829
+ * A CLOSED hour already walked is skipped, which is what makes that exclusion
1830
+ * affordable: without it, every window touching the live hour would re-walk
1831
+ * all of its hours. The past is immutable, so re-reading a closed hour
1832
+ * directory can only ever return what the index already holds; eviction
1833
+ * removes rows through the index directly.
1834
+ */
1835
+ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations, logger, ledger) {
1836
+ if (index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
1837
+ const liveHour = Math.floor(Date.now() / HOUR_MS$7) * HOUR_MS$7;
1838
+ if (ledger !== void 0) {
1839
+ const startedMs = Date.now();
1840
+ const hours = await ledger.detailForWindow(deviceId, fromMs, toMs);
1841
+ let segments = 0;
1842
+ let served = 0;
1843
+ for (const hour of hours) {
1844
+ if (hour.hourStartMs >= liveHour) continue;
1845
+ index.hydrateHour(deviceId, hour.locationId, hour.hourStartMs, hour.paths);
1846
+ segments += hour.paths.length;
1847
+ served += 1;
1848
+ }
1849
+ if (served > 0) logger.info("recorder: hydrated a window from the hour ledger", {
1850
+ tags: { deviceId },
1851
+ meta: {
1852
+ hours: served,
1853
+ segments,
1854
+ ms: Date.now() - startedMs
1855
+ }
1856
+ });
1857
+ if (index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
1858
+ }
1859
+ for (const [root, aliases] of aliasesByRoot(locations)) {
1860
+ const deviceDir = path.join(root, String(deviceId));
1861
+ let profiles;
1862
+ try {
1863
+ profiles = (await promises.readdir(deviceDir)).filter((e) => e === "high" || e === "mid" || e === "low");
1864
+ } catch (err) {
1865
+ if (err.code !== "ENOENT") logger.warn("recorder: hydrateWindow profile listing failed", {
1866
+ tags: { deviceId },
1867
+ meta: {
1868
+ locationIds: aliases.map((location) => location.id),
1869
+ error: errMsg(err)
1870
+ }
1871
+ });
1872
+ continue;
1873
+ }
1874
+ const first = Math.floor(fromMs / HOUR_MS$7) * HOUR_MS$7;
1875
+ const reuseBefore = index.hydrateReuseStats();
1876
+ let hoursRead = 0;
1877
+ let repaired = 0;
1878
+ for (let hour = first; hour < toMs; hour += HOUR_MS$7) {
1879
+ if (hour < liveHour && index.hydrationOf(deviceId, hour, hour + HOUR_MS$7) === "hydrated") continue;
1880
+ for (const profile of profiles) {
1881
+ const rel = hourDirRelPath(deviceId, profile, hour);
1882
+ let files;
1883
+ try {
1884
+ files = await promises.readdir(path.join(root, rel));
1885
+ } catch {
1886
+ continue;
1887
+ }
1888
+ hoursRead += 1;
1889
+ const locationId = locationForHydratedProfile(aliases, profile).id;
1890
+ const relPaths = files.filter((f) => f.endsWith(".m4s")).map((f) => `${rel}/${f}`);
1891
+ index.hydrateHour(deviceId, locationId, hour, relPaths);
1892
+ if (ledger !== void 0 && relPaths.length > 0 && hour + HOUR_MS$7 <= Date.now() && !ledger.knowsHour(deviceId, profile, locationId, hour)) {
1893
+ repaired += 1;
1894
+ await ledger.adoptRebuiltHour(deviceId, profile, locationId, hour, relPaths);
1895
+ }
1896
+ }
1897
+ }
1898
+ index.markHydrated(deviceId, fromMs, toMs);
1899
+ if (hoursRead === 0) continue;
1900
+ const reuseAfter = index.hydrateReuseStats();
1901
+ const reused = reuseAfter.rowsReused - reuseBefore.rowsReused;
1902
+ const allocated = reuseAfter.rowsAllocated - reuseBefore.rowsAllocated;
1903
+ logger.info("recorder: hydrated a window from one recordings root", {
1904
+ tags: { deviceId },
1905
+ meta: {
1906
+ root,
1907
+ hoursRead,
1908
+ repaired,
1909
+ reused,
1910
+ allocated
1740
1911
  }
1741
1912
  });
1913
+ }
1914
+ }
1915
+ //#endregion
1916
+ //#region src/recorder/relocate-engine.ts
1917
+ /**
1918
+ * Footage relocation engine (entity-routing spec, Phase 4) — the mover half
1919
+ * of disk decommissioning: mark the old location read-only (Phase 2), then
1920
+ * relocate its footage here, then delete the location.
1921
+ *
1922
+ * Mechanics, per HOUR (D291): throttled copy + size-verify of every file in the
1923
+ * hour → re-point the DURABLE hour row → delete the sources → re-point the RAM
1924
+ * index. The hour is the batch because the durable index is hour-granular, and
1925
+ * the sources outlive the durable write on purpose: between the copy and the
1926
+ * delete both locations hold the hour, so no crash can leave the index naming a
1927
+ * location without the bytes. The copy is `copy-if-absent` (an equal-size
1928
+ * destination is adopted, not re-copied), so cancel/crash/re-run RESUMES
1929
+ * instead of restarting, and a cancel inside an hour leaves that hour WHOLLY
1930
+ * readable at the source. Throttling is file-level pacing (segments are
1931
+ * ~1–8 MB / ~4 s, so per-file granularity bounds the rate tightly enough) —
1932
+ * the drain is a background chore that must never starve live writers.
1933
+ *
1934
+ * Runs INSIDE the recorder process — container uid, same-uid files, no host
1935
+ * chown trap (see docs/history 2026-07-29).
1936
+ *
1937
+ * ── Phase 2 (D116, multi-location) ────────────────────────────────────────
1938
+ * Two additions the OPERATOR-ARMED rebalance needs:
1939
+ *
1940
+ * - A move is scoped to one CAMERA (and optionally to specific profiles),
1941
+ * not just to a whole disk. The rebalance's unit is a (camera, profile)
1942
+ * pile, because that is the unit the placement plan assigns.
1943
+ * - Concurrent starts QUEUE instead of being refused. The engine is still
1944
+ * single-flight — two movers on one disk thrash both — but a rebalance
1945
+ * enqueues one job per pile, and refusing the second turned a
1946
+ * fifteen-camera rebalance into fifteen manual retries. Jobs run FIFO; a
1947
+ * job cancelled while queued never runs, and a job that FAILS does not
1948
+ * strand the queue behind it.
1949
+ */
1950
+ var DEFAULT_THROTTLE_MBPS = 40;
1951
+ /** The durable index's bucket, and therefore this mover's batch. */
1952
+ var HOUR_MS$6 = 36e5;
1953
+ /** Progress callback cadence (files) — cheap enough to fire often. */
1954
+ var PROGRESS_EVERY_FILES = 25;
1955
+ /** Free space a destination must keep when it declares no `config.minFreePercent`
1956
+ * — the same default `roots.ts` documents for placement. */
1957
+ var DEFAULT_MIN_FREE_PERCENT$1 = 10;
1958
+ /** Prefix every destination-full error carries, so the coordinator can report
1959
+ * "drain stalled: destination full" instead of a raw ENOSPC string. */
1960
+ var DESTINATION_FULL_PREFIX = "destination full";
1961
+ /**
1962
+ * Ghost rows one job may FORGET, as a share of its own census — and the floor
1963
+ * below which the share is not the bound.
1964
+ *
1965
+ * D148 is why this is bounded at all: the ledger forgets only on a trusted walk
1966
+ * or an eviction, so that a transient read error cannot erase an operator's
1967
+ * index of real footage. A per-path ENOENT on a path derived from the row is
1968
+ * strong evidence about THAT path (D296) and weak evidence about a mount: a
1969
+ * root that vanished answers ENOENT for every path it is asked about, and looks
1970
+ * exactly like a location that is genuinely empty. The bound is what makes the
1971
+ * difference survivable — past it the mover keeps moving, keeps skipping, and
1972
+ * REPORTS, so an operator gets a reconcile to act on instead of an archive that
1973
+ * quietly emptied itself.
1974
+ *
1975
+ * 17.5 % of `recordingsLow:default` was ghosts on 2026-08-30 (11 074 of
1976
+ * 63 346), so a quarter admits a real reconcile of that size in one pass while
1977
+ * refusing a whole-location erase.
1978
+ */
1979
+ var GHOST_FORGET_SHARE = .25;
1980
+ function snapshot(j) {
1981
+ return {
1982
+ jobId: j.jobId,
1983
+ state: j.state,
1984
+ fromLocationId: j.fromLocationId,
1985
+ toLocationId: j.toLocationId,
1986
+ deviceId: j.deviceId,
1987
+ entities: [...j.entities],
1988
+ filesMoved: j.filesMoved,
1989
+ bytesMoved: j.bytesMoved,
1990
+ filesTotal: j.filesTotal,
1991
+ rowsReconciled: j.rowsReconciled,
1992
+ rowsForgotten: j.rowsForgotten,
1993
+ startedAt: j.startedAt,
1994
+ finishedAt: j.finishedAt,
1995
+ error: j.error
1996
+ };
1997
+ }
1998
+ /**
1999
+ * The row's source file is not on disk.
2000
+ *
2001
+ * Carries `code: 'ENOENT'` because that is how every caller here already tells
2002
+ * a ghost row from a real failure, and {@link copyVerified} now discovers the
2003
+ * absence with a `stat` rather than by letting `copyFile` raise it.
2004
+ */
2005
+ var RelocateSourceMissingError = class extends Error {
2006
+ code = "ENOENT";
2007
+ constructor(src) {
2008
+ super(`relocate source missing: ${src}`);
2009
+ this.name = "RelocateSourceMissingError";
2010
+ }
2011
+ };
2012
+ /**
2013
+ * One file's COPY: copy-if-absent → verify the destination against the SOURCE'S
2014
+ * OWN SIZE. The source is left in place.
2015
+ *
2016
+ * Split out of {@link moveFile} because the durable index re-point has to
2017
+ * happen while BOTH locations still hold the bytes — see
2018
+ * `SegmentHourLedger.repointSegments`.
2019
+ *
2020
+ * **`recordedBytes` is never the verify's reference.** It used to be, and it
2021
+ * cannot be: `fs.copyFile` truncates and replaces, so the destination always
2022
+ * equals the source, and comparing that result against a number the LEDGER
2023
+ * remembers can only ever fail when the ledger is wrong. On 2026-08-30 that
2024
+ * reported a byte-perfect copy as corruption and killed a 110 749-file drain
2025
+ * on its 12 440th file:
2026
+ *
2027
+ * relocate verify failed: …/1787926410000-9966-274432.m4s
2028
+ * is 783801B, expected 274432B
2029
+ *
2030
+ * Both files measured 783801 B on the hub. What is faithful is "the
2031
+ * destination equals the source", and that is what is asserted here; the
2032
+ * recorded number survives only as the reference for a destination whose
2033
+ * source is already GONE, where there is nothing else left to compare against.
2034
+ */
2035
+ async function copyVerified(src, dst, recordedBytes) {
2036
+ const dstBytes = await fileSize(dst);
2037
+ const srcBytes = await fileSize(src);
2038
+ if (dstBytes !== null && dstBytes === (srcBytes ?? recordedBytes)) return {
2039
+ copied: 0,
2040
+ srcBytes: dstBytes
2041
+ };
2042
+ if (srcBytes === null) throw new RelocateSourceMissingError(src);
2043
+ await promises.mkdir(path.dirname(dst), { recursive: true });
2044
+ await promises.copyFile(src, dst);
2045
+ const after = await promises.stat(dst);
2046
+ if (after.size !== srcBytes) throw new Error(`relocate verify failed: ${dst} is ${after.size}B, expected ${srcBytes}B`);
2047
+ return {
2048
+ copied: srcBytes,
2049
+ srcBytes
2050
+ };
2051
+ }
2052
+ /** A file's size, or `null` when it is not there. */
2053
+ async function fileSize(p) {
2054
+ try {
2055
+ return (await promises.stat(p)).size;
2056
+ } catch {
2057
+ return null;
2058
+ }
2059
+ }
2060
+ var RelocateEngine = class {
2061
+ deps;
2062
+ jobs = /* @__PURE__ */ new Map();
2063
+ /** Job ids waiting for the mover, in enqueue order. */
2064
+ queue = [];
2065
+ /** Set while a job is executing — the single-flight latch. */
2066
+ draining = false;
2067
+ constructor(deps) {
2068
+ this.deps = deps;
2069
+ }
2070
+ list() {
2071
+ return [...this.jobs.values()].sort((a, b) => b.startedAt - a.startedAt).map(snapshot);
2072
+ }
2073
+ /**
2074
+ * Cancel a running OR queued job. A running job stops after its current
2075
+ * file; a queued job is terminated where it stands and never runs — which is
2076
+ * why this returns true for both, and why the cancelled job still gets its
2077
+ * `onFinished` audit row.
2078
+ */
2079
+ cancel(jobId) {
2080
+ const job = this.jobs.get(jobId);
2081
+ if (!job) return false;
2082
+ if (job.state === "running") {
2083
+ job.cancelRequested = true;
2084
+ return true;
2085
+ }
2086
+ if (job.state !== "queued") return false;
2087
+ job.cancelRequested = true;
2088
+ job.state = "cancelled";
2089
+ job.finishedAt = this.deps.now();
2090
+ const at = this.queue.indexOf(jobId);
2091
+ if (at >= 0) this.queue.splice(at, 1);
2092
+ this.deps.onFinished?.(snapshot(job));
2093
+ return true;
2094
+ }
2095
+ /**
2096
+ * Enqueue a relocation and return its job id. Throws only on an input the
2097
+ * mover could never satisfy (unknown/identical/read-only locations) —
2098
+ * validated SYNCHRONOUSLY so a caller arming a rebalance learns about a bad
2099
+ * target before any job runs.
2100
+ *
2101
+ * Concurrency: the returned job may be `queued` rather than `running`. The
2102
+ * engine still moves one job at a time.
2103
+ */
2104
+ start(input) {
2105
+ const locs = this.deps.locations();
2106
+ const from = locs.find((l) => l.id === input.fromLocationId);
2107
+ const to = locs.find((l) => l.id === input.toLocationId);
2108
+ if (!from) throw new Error(`unknown source location: ${input.fromLocationId}`);
2109
+ if (!to) throw new Error(`unknown target location: ${input.toLocationId}`);
2110
+ if (from.id === to.id) throw new Error("source and target locations are identical");
2111
+ if (!locationMayBeWritten(to)) throw new Error(`target location is read-only (mode: ${locationMode(to)}): ${to.id}`);
1742
2112
  const job = {
1743
2113
  jobId: this.deps.newId(),
1744
2114
  state: "queued",
@@ -2276,7 +2646,7 @@ function profilesOfClass(footageClass) {
2276
2646
  function groupByHour(rows) {
2277
2647
  const out = /* @__PURE__ */ new Map();
2278
2648
  for (const row of rows) {
2279
- const key = `${row.deviceId}|${row.profile}|${Math.floor(row.startMs / HOUR_MS$7)}`;
2649
+ const key = `${row.deviceId}|${row.profile}|${Math.floor(row.startMs / HOUR_MS$6)}`;
2280
2650
  const found = out.get(key);
2281
2651
  if (found) found.push(row);
2282
2652
  else out.set(key, [row]);
@@ -2465,7 +2835,7 @@ var SegmentStore = class SegmentStore {
2465
2835
  else byBucket.set(bucketDir, [r]);
2466
2836
  }
2467
2837
  const chunkSize = this.deps.evictChunkSize ?? SegmentStore.DEFAULT_EVICT_CHUNK;
2468
- const yieldBetween = this.deps.yieldBetween ?? macrotask$3;
2838
+ const yieldBetween = this.deps.yieldBetween ?? macrotask$2;
2469
2839
  let bucketsRemoved = 0;
2470
2840
  let handled = 0;
2471
2841
  for (const [bucketDir, bucketRows] of byBucket) {
@@ -2613,7 +2983,7 @@ var SegmentStore = class SegmentStore {
2613
2983
  return held.every((p) => doomed.has(p));
2614
2984
  }
2615
2985
  };
2616
- var macrotask$3 = () => new Promise((resolve) => {
2986
+ var macrotask$2 = () => new Promise((resolve) => {
2617
2987
  setImmediate(resolve);
2618
2988
  });
2619
2989
  //#endregion
@@ -3125,6 +3495,12 @@ function intersect(a, b) {
3125
3495
  * The cap's `locationId` is used directly as the storage `location` (identity); see Plan 2d
3126
3496
  * notes. Wiring this class to the cap + the SegmentStore happens in Plan 2e.
3127
3497
  */
3498
+ /**
3499
+ * One class, two cap surfaces: `storage-evictable` (what may be deleted under
3500
+ * pressure) and `storage-occupancy` (what is HELD, D388). They live together
3501
+ * because they read the same archive, and they are separate CAPS because they
3502
+ * are separate quantities — see `getOccupancy`.
3503
+ */
3128
3504
  var StorageEvictableProvider = class {
3129
3505
  deps;
3130
3506
  orphanLane;
@@ -3142,9 +3518,9 @@ var StorageEvictableProvider = class {
3142
3518
  * reported evictable nor selected as victims). */
3143
3519
  domainOf(locationId) {
3144
3520
  const domain = this.deps.resolveDomain?.(locationId) ?? new Set([locationId]);
3145
- if (!this.deps.isReadOnly) return domain;
3521
+ if (!this.deps.isEvictionFrozen) return domain;
3146
3522
  const writable = /* @__PURE__ */ new Set();
3147
- for (const id of domain) if (!this.deps.isReadOnly(id)) writable.add(id);
3523
+ for (const id of domain) if (!this.deps.isEvictionFrozen(id)) writable.add(id);
3148
3524
  return writable;
3149
3525
  }
3150
3526
  /**
@@ -3163,6 +3539,41 @@ var StorageEvictableProvider = class {
3163
3539
  }
3164
3540
  return { bytes: (await archive.accountingForLocations(this.domainOf(input.locationId))).bytes };
3165
3541
  }
3542
+ /**
3543
+ * The `storage-occupancy` answer (D388) — a DIFFERENT quantity from
3544
+ * {@link getEvictableUsage}, and the difference is the whole reason the cap
3545
+ * exists:
3546
+ *
3547
+ * - it is the location's OWN bytes, not its eviction domain's. Two aliases
3548
+ * of one root report their own halves instead of the same combined total
3549
+ * twice;
3550
+ * - it counts a location whose eviction policy is `never` (a `readonly` or
3551
+ * `disabled` disk), which `getEvictableUsage` deliberately reports as 0.
3552
+ * Those are the disks an operator retiring hardware is staring at.
3553
+ *
3554
+ * A location this recorder holds nothing on is OMITTED, never reported as
3555
+ * zero, and an archive it cannot measure yields NO reports at all — unknown
3556
+ * is the truth when nothing could be read.
3557
+ */
3558
+ async getOccupancy(input) {
3559
+ const archive = this.deps.archive();
3560
+ if (archive === null) {
3561
+ this.deps.logger.warn("recorder: occupancy unavailable — no footage archive to measure, reporting NOTHING (a location with no report reads as unknown, never as empty)", { meta: { locationIds: [...input.locationIds] } });
3562
+ return [];
3563
+ }
3564
+ const measuredAtMs = this.deps.now?.() ?? Date.now();
3565
+ const out = [];
3566
+ for (const locationId of input.locationIds) {
3567
+ const acct = await archive.accountingForLocations(new Set([locationId]));
3568
+ if (acct.bytes <= 0) continue;
3569
+ out.push({
3570
+ locationId,
3571
+ ownedBytes: acct.bytes,
3572
+ measuredAtMs
3573
+ });
3574
+ }
3575
+ return out;
3576
+ }
3166
3577
  async evict(input) {
3167
3578
  const archive = this.deps.archive();
3168
3579
  if (archive === null) {
@@ -3429,7 +3840,7 @@ async function profileHasFootage(root, deviceId, profile) {
3429
3840
  //#endregion
3430
3841
  //#region src/recorder/addon/calendar-index.ts
3431
3842
  var DAY_MS$3 = 864e5;
3432
- var HOUR_MS$6 = 36e5;
3843
+ var HOUR_MS$5 = 36e5;
3433
3844
  /**
3434
3845
  * UTC ms → the UTC epoch of LOCAL midnight of the day it falls in.
3435
3846
  *
@@ -3467,8 +3878,8 @@ var RANGE_MERGE_GAP_MS$1 = 5e3;
3467
3878
  * deploy gap). Unknown hours still fall through to one directory read.
3468
3879
  */
3469
3880
  function hourSegmentsFromIndex(index, deviceId, profile, hourStartMs) {
3470
- const hour = Math.floor(hourStartMs / HOUR_MS$6) * HOUR_MS$6;
3471
- const hourEnd = hour + HOUR_MS$6;
3881
+ const hour = Math.floor(hourStartMs / HOUR_MS$5) * HOUR_MS$5;
3882
+ const hourEnd = hour + HOUR_MS$5;
3472
3883
  if (index.hydrationOf(deviceId, hour, hourEnd) !== "hydrated") return null;
3473
3884
  const rows = [];
3474
3885
  for (const s of index.segmentsStartingIn(deviceId, profile, hour, hourEnd)) rows.push({
@@ -3625,7 +4036,7 @@ var CalendarIndex = class {
3625
4036
  }));
3626
4037
  const days = /* @__PURE__ */ new Set();
3627
4038
  for (const { day, hours } of perDay) for (const h of hours) {
3628
- const at = day + h * HOUR_MS$6;
4039
+ const at = day + h * HOUR_MS$5;
3629
4040
  if (at < fromMs || at >= toMs) continue;
3630
4041
  days.add(localDayStart(at, tzOffsetMinutes));
3631
4042
  }
@@ -3687,7 +4098,7 @@ var CalendarIndex = class {
3687
4098
  const day = utcDayOf(utcDayMs);
3688
4099
  for (let h = 0; h < 24; h++) {
3689
4100
  await this.awaitQuiet();
3690
- await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$6, true);
4101
+ await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$5, true);
3691
4102
  }
3692
4103
  }
3693
4104
  /**
@@ -3708,7 +4119,7 @@ var CalendarIndex = class {
3708
4119
  */
3709
4120
  async segmentsOfChecked(deviceId, profile, hourStartMs, background = false) {
3710
4121
  if (!background) this.lastForegroundAt = this.deps.now?.() ?? Date.now();
3711
- const hour = Math.floor(hourStartMs / HOUR_MS$6) * HOUR_MS$6;
4122
+ const hour = Math.floor(hourStartMs / HOUR_MS$5) * HOUR_MS$5;
3712
4123
  const fromIndex = this.deps.indexHour?.(deviceId, profile, hour);
3713
4124
  if (fromIndex !== null && fromIndex !== void 0) return {
3714
4125
  segments: fromIndex,
@@ -3716,7 +4127,7 @@ var CalendarIndex = class {
3716
4127
  };
3717
4128
  const key = `${deviceId}:${profile}:${hour}`;
3718
4129
  const now = this.deps.now?.() ?? Date.now();
3719
- const isCurrentHour = hour === Math.floor(now / HOUR_MS$6) * HOUR_MS$6;
4130
+ const isCurrentHour = hour === Math.floor(now / HOUR_MS$5) * HOUR_MS$5;
3720
4131
  const cached = this.hourSegs.get(key);
3721
4132
  if (cached !== void 0) {
3722
4133
  if (!isCurrentHour) return {
@@ -3780,7 +4191,7 @@ var CalendarIndex = class {
3780
4191
  */
3781
4192
  async dayRanges(deviceId, profile, utcDayMs) {
3782
4193
  const day = utcDayOf(utcDayMs);
3783
- return mergeRanges((await mapBounded(Array.from({ length: 24 }, (_, h) => day + h * HOUR_MS$6), READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour))).flat(), RANGE_MERGE_GAP_MS$1);
4194
+ return mergeRanges((await mapBounded(Array.from({ length: 24 }, (_, h) => day + h * HOUR_MS$5), READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour))).flat(), RANGE_MERGE_GAP_MS$1);
3784
4195
  }
3785
4196
  /**
3786
4197
  * Exact ranges of `[fromMs, toMs)`, scanning ONLY the hours the window
@@ -3794,9 +4205,9 @@ var CalendarIndex = class {
3794
4205
  */
3795
4206
  async rangesIn(deviceId, profile, fromMs, toMs) {
3796
4207
  if (toMs <= fromMs) return [];
3797
- const first = Math.floor(fromMs / HOUR_MS$6) * HOUR_MS$6;
4208
+ const first = Math.floor(fromMs / HOUR_MS$5) * HOUR_MS$5;
3798
4209
  const hours = [];
3799
- for (let hour = first; hour < toMs; hour += HOUR_MS$6) hours.push(hour);
4210
+ for (let hour = first; hour < toMs; hour += HOUR_MS$5) hours.push(hour);
3800
4211
  const perHour = await mapBounded(hours, READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour));
3801
4212
  const ranges = [];
3802
4213
  for (const r of mergeRanges(perHour.flat(), RANGE_MERGE_GAP_MS$1)) {
@@ -4323,7 +4734,7 @@ function handleStoreCensusAction(census, logger, input) {
4323
4734
  }
4324
4735
  //#endregion
4325
4736
  //#region src/recorder/addon/directory-handler.ts
4326
- var HOUR_MS$5 = 36e5;
4737
+ var HOUR_MS$4 = 36e5;
4327
4738
  /** A window wider than this is refused: it is a client bug, and answering it
4328
4739
  * would walk the archive on the read path — the thing this replaces. */
4329
4740
  var MAX_WINDOW_MS = 32 * 864e5;
@@ -4370,9 +4781,9 @@ function withDirectoryRoute(deps, next) {
4370
4781
  return;
4371
4782
  }
4372
4783
  const rows = [];
4373
- const firstHour = Math.floor(fromMs / HOUR_MS$5) * HOUR_MS$5;
4784
+ const firstHour = Math.floor(fromMs / HOUR_MS$4) * HOUR_MS$4;
4374
4785
  let hydrated = true;
4375
- for (let hour = firstHour; hour < toMs; hour += HOUR_MS$5) {
4786
+ for (let hour = firstHour; hour < toMs; hour += HOUR_MS$4) {
4376
4787
  const { segments, complete } = await deps.source.segmentsOfChecked(deviceId, profile, hour);
4377
4788
  if (!complete) hydrated = false;
4378
4789
  for (const s of segments) {
@@ -4558,21 +4969,16 @@ async function wipeClassSubtrees(input) {
4558
4969
  }
4559
4970
  //#endregion
4560
4971
  //#region src/recorder/addon/location-discard-policy.ts
4561
- /**
4562
- * Server-side eligibility for discarding a frozen location's leftover
4563
- * class subtree. Same rules as the admin-ui card: frozen, known class,
4564
- * writable sibling of the same type. The last writable of a type is
4565
- * refused here even if the UI is bypassed.
4566
- */
4567
4972
  var DISCARD_SUBTREES = {
4568
4973
  eventMedia: ["events"],
4569
4974
  recordingsLow: ["low"],
4570
4975
  recordings: ["high", "mid"]
4571
4976
  };
4977
+ /** Frozen = the system will not remove this footage on its own: eviction
4978
+ * policy `never` (D385), i.e. `readonly` or `disabled`. A `drain` location is
4979
+ * NOT frozen — the ratchet is already removing it. */
4572
4980
  function locationIsFrozen(location) {
4573
- const enabled = location.enabled !== false;
4574
- const readOnly = location.config["readOnly"] === true;
4575
- return !enabled || readOnly;
4981
+ return evictionPolicyOfLocation(location) === "never";
4576
4982
  }
4577
4983
  function discardEligible(location, siblings) {
4578
4984
  if (!(location.type in DISCARD_SUBTREES)) return false;
@@ -4580,9 +4986,7 @@ function discardEligible(location, siblings) {
4580
4986
  return siblings.some((s) => {
4581
4987
  if (s.id === location.id) return false;
4582
4988
  if (s.type !== location.type) return false;
4583
- const enabled = s.enabled !== false;
4584
- const readOnly = s.config["readOnly"] === true;
4585
- return enabled && !readOnly;
4989
+ return mayWriteToLocation(s);
4586
4990
  });
4587
4991
  }
4588
4992
  function assertDiscardAllowed(location, all, localNodeId) {
@@ -4791,6 +5195,20 @@ function createFootageDiscardJob(deps) {
4791
5195
  };
4792
5196
  }
4793
5197
  //#endregion
5198
+ //#region src/recorder/addon/location-resolve.ts
5199
+ /**
5200
+ * A location this process may READ from.
5201
+ *
5202
+ * Throws with a message that names WHICH of the two it is — never one message
5203
+ * for both. The caller does not have to branch: the message is the product.
5204
+ */
5205
+ function requireReadableLocation(locations, locationId, context) {
5206
+ const location = locations.find((candidate) => candidate.id === locationId);
5207
+ if (location === void 0) throw new Error(`${context}: unknown location ${locationId}`);
5208
+ if (!locationMayBeRead(location)) throw new Error(`${context}: recordings location ${locationId} is UNAVAILABLE — the operator switched it off (mode: ${location.mode}). Its footage is still on disk; set the location back to readonly or active to read it.`);
5209
+ return location;
5210
+ }
5211
+ //#endregion
4794
5212
  //#region src/recorder/addon/export-dense-map.ts
4795
5213
  /**
4796
5214
  * Shortest clip worth keeping, in ms.
@@ -6778,8 +7196,7 @@ async function evictVictims(deps, deviceId, victims) {
6778
7196
  function resolveSegmentFile(deps, deviceId, profile, startMs) {
6779
7197
  const seg = deps.index.segmentStartingAt(deviceId, profile, startMs);
6780
7198
  if (!seg) throw new Error(`recording: no segment at start ${startMs} for ${deviceId}/${profile}`);
6781
- const loc = deps.locations().find((l) => l.id === seg.locationId);
6782
- if (!loc) throw new Error(`recording: unknown location ${seg.locationId}`);
7199
+ const loc = requireReadableLocation(deps.locations(), seg.locationId, "recording");
6783
7200
  return {
6784
7201
  seg,
6785
7202
  abs: path.join(loc.root, seg.path)
@@ -7052,7 +7469,7 @@ function buildRecordingProvider(deps) {
7052
7469
  }
7053
7470
  const locationUsages = await Promise.all([...byPhysical.values()].map(async (group) => {
7054
7471
  const first = group[0];
7055
- const cap = group.some((l) => l.enabled !== false) ? await deps.capacity(first.root) : {
7472
+ const cap = group.some((l) => locationMayBeWritten(l)) ? await deps.capacity(first.root) : {
7056
7473
  availableBytes: null,
7057
7474
  totalBytes: null
7058
7475
  };
@@ -7570,528 +7987,162 @@ var RENDER_TIMEOUT_MS = 3e4;
7570
7987
  */
7571
7988
  var FOOTAGE_NOT_READY = "footage-not-yet-finalized";
7572
7989
  /**
7573
- * GIF for the widest notifier reach (every backend that takes an image takes a
7574
- * gif); `fps` decimates and lanczos downscales.
7575
- */
7576
- async function renderGif(renderDeps, input) {
7577
- const out = await renderFootage(renderDeps, input, "gif", (playlistPath, outPath) => [
7578
- "-y",
7579
- "-i",
7580
- playlistPath,
7581
- "-vf",
7582
- `fps=${input.fps},scale=${input.maxWidth}:-2:flags=lanczos`,
7583
- "-loop",
7584
- "0",
7585
- "-f",
7586
- "gif",
7587
- outPath
7588
- ]);
7589
- return {
7590
- gifBase64: out.bytes.toString("base64"),
7591
- fromMs: out.fromMs,
7592
- toMs: out.toMs
7593
- };
7594
- }
7595
- /**
7596
- * MP4 clip — the "send a clip, not a gif" attachment. Re-encodes rather than
7597
- * stream-copying: a concat of segments whose encoder parameters differ (a
7598
- * profile switch, a broker redial) produces an mp4 no player will scrub, and a
7599
- * notification attachment that will not play is worse than none. `faststart`
7600
- * puts the moov up front so a chat client can play it without the whole file.
7601
- */
7602
- async function renderClip(renderDeps, input) {
7603
- const out = await renderFootage(renderDeps, input, "mp4", (playlistPath, outPath) => [
7604
- "-y",
7605
- "-i",
7606
- playlistPath,
7607
- "-vf",
7608
- `scale=${input.maxWidth}:-2:flags=lanczos`,
7609
- "-c:v",
7610
- "libx264",
7611
- "-preset",
7612
- "veryfast",
7613
- "-crf",
7614
- "28",
7615
- "-profile:v",
7616
- "main",
7617
- "-pix_fmt",
7618
- "yuv420p",
7619
- "-movflags",
7620
- "+faststart",
7621
- "-an",
7622
- "-f",
7623
- "mp4",
7624
- outPath
7625
- ]);
7626
- return {
7627
- clipBase64: out.bytes.toString("base64"),
7628
- mime: "video/mp4",
7629
- fromMs: out.fromMs,
7630
- toMs: out.toMs,
7631
- bytes: out.bytes.byteLength
7632
- };
7633
- }
7634
- /** The shared playlist → ffmpeg → bytes pipeline. */
7635
- async function renderFootage(renderDeps, input, ext, buildArgs) {
7636
- const now = renderDeps.now ?? Date.now;
7637
- const fromMs = input.aroundMs - input.preRollSec * 1e3;
7638
- const toMs = input.aroundMs + input.postRollSec * 1e3;
7639
- if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || toMs <= fromMs) throw new Error(`invalid ${ext} window [${fromMs}, ${toMs}) for device ${input.deviceId} — aroundMs=${input.aroundMs} preRollSec=${input.preRollSec} postRollSec=${input.postRollSec}`);
7640
- const renderId = `${ext}-${now()}-${Math.floor(Math.random() * 1e6)}`;
7641
- const availableEnd = latestFinalizedEnd(renderDeps, input.deviceId);
7642
- if (availableEnd === null || availableEnd < toMs) throw new Error(`${FOOTAGE_NOT_READY}: [${fromMs}, ${toMs}) for device ${input.deviceId} — finalized footage ends at ${availableEnd ?? "never"}`);
7643
- const window = {
7644
- fromMs,
7645
- toMs
7646
- };
7647
- const playlist = await writeExportSourcePlaylist({
7648
- deps: renderDeps.deps,
7649
- exportsDir: renderDeps.exportsDir,
7650
- exportId: renderId,
7651
- deviceId: input.deviceId,
7652
- profile: "low",
7653
- fromMs: window.fromMs,
7654
- toMs: window.toMs
7655
- });
7656
- if (playlist === null) throw new Error(`no footage covers [${window.fromMs}, ${window.toMs}) for device ${input.deviceId}`);
7657
- const outPath = path.join(path.dirname(playlist.path), `${renderId}.${ext}`);
7658
- try {
7659
- await runFfmpeg(renderDeps, buildArgs(playlist.path, outPath), ext);
7660
- const bytes = await promises.readFile(outPath);
7661
- if (bytes.byteLength === 0) throw new Error(`${ext} render produced an empty file`);
7662
- return {
7663
- bytes,
7664
- fromMs: window.fromMs,
7665
- toMs: window.toMs
7666
- };
7667
- } finally {
7668
- await promises.rm(playlist.path, { force: true }).catch(() => {});
7669
- await promises.rm(playlist.concatPath, { force: true }).catch(() => {});
7670
- await promises.rm(outPath, { force: true }).catch(() => {});
7671
- }
7672
- }
7673
- /**
7674
- * The end of the newest FINALIZED `low` segment, or null when none exists.
7675
- *
7676
- * One scalar off the tail of the live sorted view — it used to COPY the
7677
- * device's whole `low` archive to fold a maximum out of it, per render.
7678
- */
7679
- function latestFinalizedEnd(renderDeps, deviceId) {
7680
- return renderDeps.deps.index.latestEnd(deviceId, "low");
7681
- }
7682
- /**
7683
- * Run one ffmpeg and GUARANTEE the child is gone before the promise settles.
7684
- *
7685
- * The previous shape leaked on two paths: a `spawn` error (`ENOENT`, a bad
7686
- * path) rejected while a process could still be alive, and a `close` with a
7687
- * non-zero code rejected without ever reaping stdio. These renders fire on
7688
- * every notification with a gif or clip, so a leak here is a slow accumulation
7689
- * of ffmpeg processes on the recording node — the kind that shows up days later
7690
- * as "the hub got slow" (operator, 2026-07-30).
7691
- *
7692
- * `settle` is idempotent and always kills first: killing an already-exited pid
7693
- * throws ESRCH, which is swallowed, so the cost of being wrong is nothing while
7694
- * the cost of NOT killing is a zombie per render.
7695
- */
7696
- function runFfmpeg(renderDeps, args, ext) {
7697
- return new Promise((resolve, reject) => {
7698
- const proc = renderDeps.spawn(renderDeps.ffmpegPath ?? "ffmpeg", args);
7699
- let done = false;
7700
- let timer = null;
7701
- const settle = (err) => {
7702
- if (done) return;
7703
- done = true;
7704
- if (timer !== null) {
7705
- clearTimeout(timer);
7706
- timer = null;
7707
- }
7708
- if (proc.exitCode === null && proc.signalCode === null) try {
7709
- proc.kill("SIGKILL");
7710
- } catch {}
7711
- try {
7712
- proc.stderr?.removeAllListeners();
7713
- proc.stdout?.destroy();
7714
- proc.stderr?.destroy();
7715
- } catch {}
7716
- if (err === null) resolve();
7717
- else reject(err);
7718
- };
7719
- timer = setTimeout(() => settle(/* @__PURE__ */ new Error(`${ext} render timed out`)), RENDER_TIMEOUT_MS);
7720
- let stderrTail = "";
7721
- proc.stderr?.on("data", (c) => {
7722
- stderrTail = (stderrTail + c.toString()).slice(-400);
7723
- });
7724
- proc.on("error", (err) => settle(err instanceof Error ? err : new Error(String(err))));
7725
- proc.on("close", (code) => {
7726
- settle(code === 0 ? null : /* @__PURE__ */ new Error(`ffmpeg ${ext} render exited ${code}: ${stderrTail}`));
7727
- });
7728
- });
7729
- }
7730
- //#endregion
7731
- //#region src/recorder/addon/roots.ts
7732
- /**
7733
- * Storage-root resolution + boot hydrate for the recorder addon.
7734
- *
7735
- * Recordings live in the managed `recordings` / `recordingsLow` storage
7736
- * locations (operator-configurable, multi-disk-ready), resolved through the
7737
- * `storage` cap. The v2 `RecordingIndex` is hydrated by walking each location
7738
- * root's `<deviceId>/` subtree on disk — segment paths encode start/dur/bytes,
7739
- * so the in-memory index can never drift from disk. (We walk the filesystem
7740
- * directly instead of `storage.list` because that cap lists only a directory's
7741
- * IMMEDIATE children, not the `<deviceId>/<profile>/Y/M/D/H/*.m4s` tree — using
7742
- * it dropped the entire index to ~empty on every restart/rescan; the recorder
7743
- * already does local fs I/O for stat/move on these same roots.)
7744
- */
7745
- /** The two recordings location types the recorder addon writes/reads. */
7746
- var PLACEMENT_LOCATION_TYPES = ["recordings", "recordingsLow"];
7747
- var RECORDING_LOCATION_TYPES = new Set(PLACEMENT_LOCATION_TYPES);
7748
- /**
7749
- * The recordings CLASS a profile belongs to. The one place this rule is
7750
- * written: `resolvePlacement`, the planner's demand type and the repoint check
7751
- * must agree on it, and three copies of `profile === 'low' ? … : …` is how they
7752
- * stop agreeing.
7753
- */
7754
- function placementTypeForProfile(profile) {
7755
- return profile === "low" ? "recordingsLow" : "recordings";
7756
- }
7757
- /** The profile that routes to each class — the probe `authorityLocations` asks
7758
- * `resolvePlacement` with, so the answer is derived from the same function the
7759
- * unassigned path uses rather than from a second reading of `isDefault`. */
7760
- var CLASS_PROBE_PROFILE = {
7761
- recordings: "high",
7762
- recordingsLow: "low"
7763
- };
7764
- /**
7765
- * Where each recordings class writes when NOTHING has planned it — the location
7766
- * `resolvePlacement` picks, which is the type default unless the default is
7767
- * gone, read-only, or absent from the pool.
7768
- *
7769
- * This is the AUTHORITY a stored assignment is measured against (D298). A
7770
- * repoint (`storage.setDefaultLocations`, an operator making another disk the
7771
- * default, a default deleted) changes exactly this map, and a stored assignment
7772
- * that survives that change is a plan that outlived the pool it was planned
7773
- * for.
7774
- *
7775
- * A class with nothing writable is ABSENT from the map, not guessed at: `place`
7776
- * already throws loudly on that path, and an authority nobody can compute must
7777
- * invalidate nothing.
7778
- */
7779
- function authorityLocations(pool) {
7780
- const out = /* @__PURE__ */ new Map();
7781
- for (const type of PLACEMENT_LOCATION_TYPES) try {
7782
- out.set(type, resolvePlacement(pool, CLASS_PROBE_PROFILE[type]).id);
7783
- } catch {}
7784
- return out;
7785
- }
7786
- /**
7787
- * Narrow a resolved location list to the ones placement may write to: the
7788
- * operator-enabled ones. A location the operator has not opted in
7789
- * (`enabled === false`) is still READ, age-swept and drained — it is simply
7790
- * never a write target, exactly like a read-only one.
7791
- *
7792
- * When no enabled location is writable the type DEFAULTS serve instead, loudly:
7793
- * the default of a type is guaranteed enabled by the orchestrator, so this can
7794
- * only happen through a hand-edited store — and a recorder that writes nowhere
7795
- * is worse than one that writes to the default and complains.
7796
- */
7797
- function placementPool(locations) {
7798
- const enabled = locations.filter((l) => l.enabled !== false);
7799
- if (enabled.some((l) => !l.readOnly)) return {
7800
- locations: enabled,
7801
- fellBackToDefault: false
7802
- };
7803
- const defaults = locations.filter((l) => l.isDefault === true);
7804
- if (defaults.some((l) => !l.readOnly)) return {
7805
- locations: defaults,
7806
- fellBackToDefault: true
7807
- };
7808
- return {
7809
- locations,
7810
- fellBackToDefault: true
7811
- };
7812
- }
7813
- /**
7814
- * Route a recording profile to its storage location by the location's declared
7815
- * `type`: the `low` profile prefers the `recordingsLow` location, every other
7816
- * profile the `recordings` location. READ-ONLY locations are never write
7817
- * targets (the drain model): when the preferred type has only read-only
7818
- * instances, any writable recordings-type location serves. Throws when NO
7819
- * writable location exists — recording cannot start, and that must be loud.
7820
- */
7821
- function resolvePlacement(locations, profile) {
7822
- if (locations.length === 0) throw new Error("recorder controller: no recordings storage locations resolved");
7823
- const writable = locations.filter((l) => !l.readOnly);
7824
- if (writable.length === 0) throw new Error("recorder controller: every recordings location is read-only — nowhere to write");
7825
- const preferType = profile === "low" ? "recordingsLow" : "recordings";
7826
- const preferred = writable.filter((l) => l.type === preferType);
7827
- return preferred.find((l) => l.isDefault) ?? preferred[0] ?? writable.find((l) => l.isDefault) ?? writable[0];
7828
- }
7829
- /**
7830
- * Attribute a discovered segment on an aliased physical root by its profile,
7831
- * not by whichever logical location happened to be hydrated last. Stock
7832
- * `recordings` and `recordingsLow` deliberately share a root: high/mid belong
7833
- * to `recordings`, while low belongs to `recordingsLow`.
7834
- */
7835
- function locationForHydratedProfile(aliases, profile) {
7836
- const preferredType = profile === "low" ? "recordingsLow" : "recordings";
7837
- const preferred = aliases.filter((location) => location.type === preferredType).toSorted((a, b) => a.id.localeCompare(b.id))[0];
7838
- const fallback = aliases.toSorted((a, b) => a.id.localeCompare(b.id))[0];
7839
- if (preferred) return preferred;
7840
- if (fallback) return fallback;
7841
- throw new Error("recorder: no location aliases to attribute hydrated profile");
7842
- }
7843
- function aliasesByRoot(locations) {
7844
- const grouped = /* @__PURE__ */ new Map();
7845
- for (const location of locations) {
7846
- const aliases = grouped.get(location.root);
7847
- if (aliases) aliases.push(location);
7848
- else grouped.set(location.root, [location]);
7849
- }
7850
- return grouped;
7851
- }
7852
- /** One numeric key lifted out of a location's untyped `config` record, or
7853
- * nothing at all when it is absent or not a finite number. Absent is a real
7854
- * answer here: the caller falls back to a documented default, and a garbage
7855
- * value must never become a capacity limit. */
7856
- function numericConfig(config, key, field) {
7857
- const raw = config[key];
7858
- if (typeof raw !== "number" || !Number.isFinite(raw)) return {};
7859
- return { [field]: raw };
7860
- }
7861
- /**
7862
- * Resolve every recordings storage location (`recordings` + `recordingsLow`)
7863
- * to `{ id, root }`. Returns an empty list when the storage cap is unreachable
7864
- * or no recordings location exists — callers degrade rather than crash.
7990
+ * GIF for the widest notifier reach (every backend that takes an image takes a
7991
+ * gif); `fps` decimates and lanczos downscales.
7865
7992
  */
7866
- async function resolveRecordingsLocations(api, logger) {
7867
- try {
7868
- const recordings = (await api.storage.listLocations.query({})).filter((l) => RECORDING_LOCATION_TYPES.has(l.type));
7869
- return Promise.all(recordings.map(async (l) => ({
7870
- id: l.id,
7871
- type: l.type,
7872
- root: await api.storage.resolve.query({
7873
- location: l.id,
7874
- relativePath: ""
7875
- }),
7876
- readOnly: l.config["readOnly"] === true,
7877
- isDefault: l.isDefault,
7878
- ...l.enabled === false ? { enabled: false } : {},
7879
- ...numericConfig(l.config, "minFreePercent", "minFreePercent"),
7880
- ...numericConfig(l.config, "maxUsedGb", "maxUsedGb")
7881
- })));
7882
- } catch (err) {
7883
- logger.warn("recorder: listLocations failed — no recordings locations resolved", { meta: { error: errMsg(err) } });
7884
- return [];
7885
- }
7993
+ async function renderGif(renderDeps, input) {
7994
+ const out = await renderFootage(renderDeps, input, "gif", (playlistPath, outPath) => [
7995
+ "-y",
7996
+ "-i",
7997
+ playlistPath,
7998
+ "-vf",
7999
+ `fps=${input.fps},scale=${input.maxWidth}:-2:flags=lanczos`,
8000
+ "-loop",
8001
+ "0",
8002
+ "-f",
8003
+ "gif",
8004
+ outPath
8005
+ ]);
8006
+ return {
8007
+ gifBase64: out.bytes.toString("base64"),
8008
+ fromMs: out.fromMs,
8009
+ toMs: out.toMs
8010
+ };
7886
8011
  }
7887
8012
  /**
7888
- * Hydrate the v2 `RecordingIndex` for one device from every recordings location
7889
- * by recursively walking `<root>/<deviceId>/` for `*.m4s` segments and feeding
7890
- * the location-relative paths (`<deviceId>/<profile>/Y/M/D/H/<file>.m4s`, which
7891
- * `parseSegmentPath` understands) to `index.hydrateDevice`. A missing device dir
7892
- * (no footage yet) and any per-location failure are warned + skipped
7893
- * (best-effort), so one bad volume never blocks the rest of the hydrate.
8013
+ * MP4 clip — the "send a clip, not a gif" attachment. Re-encodes rather than
8014
+ * stream-copying: a concat of segments whose encoder parameters differ (a
8015
+ * profile switch, a broker redial) produces an mp4 no player will scrub, and a
8016
+ * notification attachment that will not play is worse than none. `faststart`
8017
+ * puts the moov up front so a chat client can play it without the whole file.
7894
8018
  */
7895
- async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger, options = {}) {
7896
- const yieldBetween = options.yieldBetween ?? macrotask$2;
7897
- const chunkSize = options.chunkSize ?? HYDRATE_CHUNK;
7898
- for (const [root, aliases] of aliasesByRoot(locations)) {
7899
- const deviceDir = path.join(root, String(deviceId));
7900
- const startedMs = Date.now();
7901
- let entries;
7902
- try {
7903
- entries = await promises.readdir(deviceDir, { recursive: true });
7904
- } catch (err) {
7905
- if (err.code === "ENOENT") {
7906
- for (const location of aliases) index.hydrateDevice(deviceId, location.id, []);
7907
- continue;
7908
- }
7909
- logger.warn("recorder: hydrateDevice walk failed for location", {
7910
- tags: { deviceId },
7911
- meta: {
7912
- locationIds: aliases.map((location) => location.id),
7913
- error: errMsg(err)
7914
- }
7915
- });
7916
- continue;
7917
- }
7918
- const walkedMs = Date.now();
7919
- const locationForProfile = /* @__PURE__ */ new Map();
7920
- const pathsByLocation = /* @__PURE__ */ new Map();
7921
- let handled = 0;
7922
- for (const entry of entries) {
7923
- if (!entry.endsWith(".m4s")) continue;
7924
- const relative = `${deviceId}/${entry.split(path.sep).join("/")}`;
7925
- const profile = relative.split("/")[1] ?? "";
7926
- let locationId = locationForProfile.get(profile);
7927
- if (locationId === void 0) {
7928
- locationId = locationForHydratedProfile(aliases, profile).id;
7929
- locationForProfile.set(profile, locationId);
7930
- }
7931
- const paths = pathsByLocation.get(locationId);
7932
- if (paths) paths.push(relative);
7933
- else pathsByLocation.set(locationId, [relative]);
7934
- handled += 1;
7935
- if (handled % chunkSize === 0) await yieldBetween();
7936
- }
7937
- for (const location of aliases) if (!pathsByLocation.has(location.id)) pathsByLocation.set(location.id, []);
7938
- let indexed = 0;
7939
- const reuseBefore = index.hydrateReuseStats();
7940
- for (const [locationId, relPaths] of pathsByLocation) {
7941
- index.hydrateDevice(deviceId, locationId, relPaths);
7942
- indexed += relPaths.length;
7943
- }
7944
- const reuseAfter = index.hydrateReuseStats();
7945
- const reused = reuseAfter.rowsReused - reuseBefore.rowsReused;
7946
- const allocated = reuseAfter.rowsAllocated - reuseBefore.rowsAllocated;
7947
- logger.info("recorder: hydrated a device from one recordings root", {
7948
- tags: { deviceId },
7949
- meta: {
7950
- root,
7951
- entries: entries.length,
7952
- indexed,
7953
- reused,
7954
- allocated,
7955
- locationIds: [...pathsByLocation.keys()],
7956
- walkMs: walkedMs - startedMs,
7957
- ms: Date.now() - startedMs
7958
- }
7959
- });
7960
- }
8019
+ async function renderClip(renderDeps, input) {
8020
+ const out = await renderFootage(renderDeps, input, "mp4", (playlistPath, outPath) => [
8021
+ "-y",
8022
+ "-i",
8023
+ playlistPath,
8024
+ "-vf",
8025
+ `scale=${input.maxWidth}:-2:flags=lanczos`,
8026
+ "-c:v",
8027
+ "libx264",
8028
+ "-preset",
8029
+ "veryfast",
8030
+ "-crf",
8031
+ "28",
8032
+ "-profile:v",
8033
+ "main",
8034
+ "-pix_fmt",
8035
+ "yuv420p",
8036
+ "-movflags",
8037
+ "+faststart",
8038
+ "-an",
8039
+ "-f",
8040
+ "mp4",
8041
+ outPath
8042
+ ]);
8043
+ return {
8044
+ clipBase64: out.bytes.toString("base64"),
8045
+ mime: "video/mp4",
8046
+ fromMs: out.fromMs,
8047
+ toMs: out.toMs,
8048
+ bytes: out.bytes.byteLength
8049
+ };
7961
8050
  }
7962
- /** Entries classified between yields in {@link hydrateDeviceFromStorage}. */
7963
- var HYDRATE_CHUNK = 2e4;
7964
- var macrotask$2 = () => new Promise((resolve) => {
7965
- setImmediate(resolve);
7966
- });
7967
- /** `<deviceId>/<profile>/YYYY/MM/DD/HH` for an hour bucket (paths are UTC). */
7968
- function hourDirRelPath(deviceId, profile, hourStartMs) {
7969
- const d = new Date(hourStartMs);
7970
- const p2 = (n) => String(n).padStart(2, "0");
7971
- return `${deviceId}/${profile}/${d.getUTCFullYear()}/${p2(d.getUTCMonth() + 1)}/${p2(d.getUTCDate())}/${p2(d.getUTCHours())}`;
8051
+ /** The shared playlist → ffmpeg → bytes pipeline. */
8052
+ async function renderFootage(renderDeps, input, ext, buildArgs) {
8053
+ const now = renderDeps.now ?? Date.now;
8054
+ const fromMs = input.aroundMs - input.preRollSec * 1e3;
8055
+ const toMs = input.aroundMs + input.postRollSec * 1e3;
8056
+ if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || toMs <= fromMs) throw new Error(`invalid ${ext} window [${fromMs}, ${toMs}) for device ${input.deviceId} — aroundMs=${input.aroundMs} preRollSec=${input.preRollSec} postRollSec=${input.postRollSec}`);
8057
+ const renderId = `${ext}-${now()}-${Math.floor(Math.random() * 1e6)}`;
8058
+ const availableEnd = latestFinalizedEnd(renderDeps, input.deviceId);
8059
+ if (availableEnd === null || availableEnd < toMs) throw new Error(`${FOOTAGE_NOT_READY}: [${fromMs}, ${toMs}) for device ${input.deviceId} — finalized footage ends at ${availableEnd ?? "never"}`);
8060
+ const window = {
8061
+ fromMs,
8062
+ toMs
8063
+ };
8064
+ const playlist = await writeExportSourcePlaylist({
8065
+ deps: renderDeps.deps,
8066
+ exportsDir: renderDeps.exportsDir,
8067
+ exportId: renderId,
8068
+ deviceId: input.deviceId,
8069
+ profile: "low",
8070
+ fromMs: window.fromMs,
8071
+ toMs: window.toMs
8072
+ });
8073
+ if (playlist === null) throw new Error(`no footage covers [${window.fromMs}, ${window.toMs}) for device ${input.deviceId}`);
8074
+ const outPath = path.join(path.dirname(playlist.path), `${renderId}.${ext}`);
8075
+ try {
8076
+ await runFfmpeg(renderDeps, buildArgs(playlist.path, outPath), ext);
8077
+ const bytes = await promises.readFile(outPath);
8078
+ if (bytes.byteLength === 0) throw new Error(`${ext} render produced an empty file`);
8079
+ return {
8080
+ bytes,
8081
+ fromMs: window.fromMs,
8082
+ toMs: window.toMs
8083
+ };
8084
+ } finally {
8085
+ await promises.rm(playlist.path, { force: true }).catch(() => {});
8086
+ await promises.rm(playlist.concatPath, { force: true }).catch(() => {});
8087
+ await promises.rm(outPath, { force: true }).catch(() => {});
8088
+ }
7972
8089
  }
7973
- var HOUR_MS$4 = 36e5;
7974
8090
  /**
7975
- * Hydrate ONLY the hours a window spans, instead of walking the archive.
7976
- *
7977
- * `hydrateDeviceFromStorage` is a recursive `readdir` over the whole device
7978
- * subtree — ">1M files and takes minutes" per its own comment. Until it lands,
7979
- * every read of past footage sees an empty index, which the server reports as
7980
- * "no footage": measured after a deploy, a camera-day held 7 segments and grew
7981
- * only at the rate of NEW writes for ~9 minutes.
7982
- *
7983
- * The path convention makes the window addressable directly: one SHALLOW
7984
- * `readdir` per (profile, hour), ~450 entries each. A day is ~72 of them. The
7985
- * background full walk still runs, for the consumers that need the whole
7986
- * archive (footprint, eviction domains, retention).
7987
- *
7988
- * Hours already walked are skipped, so a drag across an hour costs nothing the
7989
- * second time.
7990
- *
7991
- * ## The ledger is the first stop for a CLOSED hour (D287)
7992
- *
7993
- * Boot no longer replays the archive into the index, so a ledger-known hour
7994
- * arrives here `unknown` and pays ONE point read of its hour row instead of a
7995
- * `readdir` — measured at ~650 ms per hour directory on the live share under
7996
- * load, against the array the scrub feeder is competing for. Hours the ledger
7997
- * does not know, and every hour if the read fails, fall through to the walk
7998
- * exactly as before: the failure direction is toward the disk, never toward an
7999
- * hour that renders empty because nobody read it.
8000
- *
8001
- * **The live hour is never served from the ledger.** It is the one hour whose
8002
- * durable row is knowably behind the disk: a segment finalized after the last
8003
- * persist and before a crash is on the array and not in the row. Boot used to
8004
- * mark every ledger hour `hydrated`, which made that tail invisible to
8005
- * playback and the timeline FOREVER, not merely under-reported. Excluding the
8006
- * live hour costs exactly one hour-directory read per process — the walk marks
8007
- * it, so the second reader pays nothing — and it is what turns a lost tail
8008
- * back into a recoverable one.
8091
+ * The end of the newest FINALIZED `low` segment, or null when none exists.
8009
8092
  *
8010
- * A CLOSED hour already walked is skipped, which is what makes that exclusion
8011
- * affordable: without it, every window touching the live hour would re-walk
8012
- * all of its hours. The past is immutable, so re-reading a closed hour
8013
- * directory can only ever return what the index already holds; eviction
8014
- * removes rows through the index directly.
8015
- */
8016
- async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations, logger, ledger) {
8017
- if (index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
8018
- const liveHour = Math.floor(Date.now() / HOUR_MS$4) * HOUR_MS$4;
8019
- if (ledger !== void 0) {
8020
- const startedMs = Date.now();
8021
- const hours = await ledger.detailForWindow(deviceId, fromMs, toMs);
8022
- let segments = 0;
8023
- let served = 0;
8024
- for (const hour of hours) {
8025
- if (hour.hourStartMs >= liveHour) continue;
8026
- index.hydrateHour(deviceId, hour.locationId, hour.hourStartMs, hour.paths);
8027
- segments += hour.paths.length;
8028
- served += 1;
8029
- }
8030
- if (served > 0) logger.info("recorder: hydrated a window from the hour ledger", {
8031
- tags: { deviceId },
8032
- meta: {
8033
- hours: served,
8034
- segments,
8035
- ms: Date.now() - startedMs
8093
+ * One scalar off the tail of the live sorted view — it used to COPY the
8094
+ * device's whole `low` archive to fold a maximum out of it, per render.
8095
+ */
8096
+ function latestFinalizedEnd(renderDeps, deviceId) {
8097
+ return renderDeps.deps.index.latestEnd(deviceId, "low");
8098
+ }
8099
+ /**
8100
+ * Run one ffmpeg and GUARANTEE the child is gone before the promise settles.
8101
+ *
8102
+ * The previous shape leaked on two paths: a `spawn` error (`ENOENT`, a bad
8103
+ * path) rejected while a process could still be alive, and a `close` with a
8104
+ * non-zero code rejected without ever reaping stdio. These renders fire on
8105
+ * every notification with a gif or clip, so a leak here is a slow accumulation
8106
+ * of ffmpeg processes on the recording node — the kind that shows up days later
8107
+ * as "the hub got slow" (operator, 2026-07-30).
8108
+ *
8109
+ * `settle` is idempotent and always kills first: killing an already-exited pid
8110
+ * throws ESRCH, which is swallowed, so the cost of being wrong is nothing while
8111
+ * the cost of NOT killing is a zombie per render.
8112
+ */
8113
+ function runFfmpeg(renderDeps, args, ext) {
8114
+ return new Promise((resolve, reject) => {
8115
+ const proc = renderDeps.spawn(renderDeps.ffmpegPath ?? "ffmpeg", args);
8116
+ let done = false;
8117
+ let timer = null;
8118
+ const settle = (err) => {
8119
+ if (done) return;
8120
+ done = true;
8121
+ if (timer !== null) {
8122
+ clearTimeout(timer);
8123
+ timer = null;
8036
8124
  }
8125
+ if (proc.exitCode === null && proc.signalCode === null) try {
8126
+ proc.kill("SIGKILL");
8127
+ } catch {}
8128
+ try {
8129
+ proc.stderr?.removeAllListeners();
8130
+ proc.stdout?.destroy();
8131
+ proc.stderr?.destroy();
8132
+ } catch {}
8133
+ if (err === null) resolve();
8134
+ else reject(err);
8135
+ };
8136
+ timer = setTimeout(() => settle(/* @__PURE__ */ new Error(`${ext} render timed out`)), RENDER_TIMEOUT_MS);
8137
+ let stderrTail = "";
8138
+ proc.stderr?.on("data", (c) => {
8139
+ stderrTail = (stderrTail + c.toString()).slice(-400);
8037
8140
  });
8038
- if (index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
8039
- }
8040
- for (const [root, aliases] of aliasesByRoot(locations)) {
8041
- const deviceDir = path.join(root, String(deviceId));
8042
- let profiles;
8043
- try {
8044
- profiles = (await promises.readdir(deviceDir)).filter((e) => e === "high" || e === "mid" || e === "low");
8045
- } catch (err) {
8046
- if (err.code !== "ENOENT") logger.warn("recorder: hydrateWindow profile listing failed", {
8047
- tags: { deviceId },
8048
- meta: {
8049
- locationIds: aliases.map((location) => location.id),
8050
- error: errMsg(err)
8051
- }
8052
- });
8053
- continue;
8054
- }
8055
- const first = Math.floor(fromMs / HOUR_MS$4) * HOUR_MS$4;
8056
- const reuseBefore = index.hydrateReuseStats();
8057
- let hoursRead = 0;
8058
- let repaired = 0;
8059
- for (let hour = first; hour < toMs; hour += HOUR_MS$4) {
8060
- if (hour < liveHour && index.hydrationOf(deviceId, hour, hour + HOUR_MS$4) === "hydrated") continue;
8061
- for (const profile of profiles) {
8062
- const rel = hourDirRelPath(deviceId, profile, hour);
8063
- let files;
8064
- try {
8065
- files = await promises.readdir(path.join(root, rel));
8066
- } catch {
8067
- continue;
8068
- }
8069
- hoursRead += 1;
8070
- const locationId = locationForHydratedProfile(aliases, profile).id;
8071
- const relPaths = files.filter((f) => f.endsWith(".m4s")).map((f) => `${rel}/${f}`);
8072
- index.hydrateHour(deviceId, locationId, hour, relPaths);
8073
- if (ledger !== void 0 && relPaths.length > 0 && hour + HOUR_MS$4 <= Date.now() && !ledger.knowsHour(deviceId, profile, locationId, hour)) {
8074
- repaired += 1;
8075
- await ledger.adoptRebuiltHour(deviceId, profile, locationId, hour, relPaths);
8076
- }
8077
- }
8078
- }
8079
- index.markHydrated(deviceId, fromMs, toMs);
8080
- if (hoursRead === 0) continue;
8081
- const reuseAfter = index.hydrateReuseStats();
8082
- const reused = reuseAfter.rowsReused - reuseBefore.rowsReused;
8083
- const allocated = reuseAfter.rowsAllocated - reuseBefore.rowsAllocated;
8084
- logger.info("recorder: hydrated a window from one recordings root", {
8085
- tags: { deviceId },
8086
- meta: {
8087
- root,
8088
- hoursRead,
8089
- repaired,
8090
- reused,
8091
- allocated
8092
- }
8141
+ proc.on("error", (err) => settle(err instanceof Error ? err : new Error(String(err))));
8142
+ proc.on("close", (code) => {
8143
+ settle(code === 0 ? null : /* @__PURE__ */ new Error(`ffmpeg ${ext} render exited ${code}: ${stderrTail}`));
8093
8144
  });
8094
- }
8145
+ });
8095
8146
  }
8096
8147
  //#endregion
8097
8148
  //#region src/durable/durable-ledger.ts
@@ -10432,47 +10483,17 @@ Object.freeze({
10432
10483
  },
10433
10484
  "setDeviceConfig": { "config": { "__nested__": { "bands": [] } } }
10434
10485
  },
10435
- "storage": { "upsertLocation": {
10436
- "isDefault": false,
10437
- "isSystem": false
10438
- } },
10486
+ "storage": { "upsertLocation": { "isSystem": false } },
10439
10487
  "storage-provider": {
10440
- "beginDownload": { "location": { "__nested__": {
10441
- "isDefault": false,
10442
- "isSystem": false
10443
- } } },
10444
- "beginUpload": { "location": { "__nested__": {
10445
- "isDefault": false,
10446
- "isSystem": false
10447
- } } },
10448
- "delete": { "location": { "__nested__": {
10449
- "isDefault": false,
10450
- "isSystem": false
10451
- } } },
10452
- "exists": { "location": { "__nested__": {
10453
- "isDefault": false,
10454
- "isSystem": false
10455
- } } },
10456
- "getAvailableSpace": { "location": { "__nested__": {
10457
- "isDefault": false,
10458
- "isSystem": false
10459
- } } },
10460
- "list": { "location": { "__nested__": {
10461
- "isDefault": false,
10462
- "isSystem": false
10463
- } } },
10464
- "read": { "location": { "__nested__": {
10465
- "isDefault": false,
10466
- "isSystem": false
10467
- } } },
10468
- "resolve": { "location": { "__nested__": {
10469
- "isDefault": false,
10470
- "isSystem": false
10471
- } } },
10472
- "write": { "location": { "__nested__": {
10473
- "isDefault": false,
10474
- "isSystem": false
10475
- } } }
10488
+ "beginDownload": { "location": { "__nested__": { "isSystem": false } } },
10489
+ "beginUpload": { "location": { "__nested__": { "isSystem": false } } },
10490
+ "delete": { "location": { "__nested__": { "isSystem": false } } },
10491
+ "exists": { "location": { "__nested__": { "isSystem": false } } },
10492
+ "getAvailableSpace": { "location": { "__nested__": { "isSystem": false } } },
10493
+ "list": { "location": { "__nested__": { "isSystem": false } } },
10494
+ "read": { "location": { "__nested__": { "isSystem": false } } },
10495
+ "resolve": { "location": { "__nested__": { "isSystem": false } } },
10496
+ "write": { "location": { "__nested__": { "isSystem": false } } }
10476
10497
  },
10477
10498
  "stream-broker": {
10478
10499
  "produceEventMedia": {
@@ -10546,6 +10567,7 @@ Object.freeze({
10546
10567
  "listTargetKinds",
10547
10568
  "listTargets"
10548
10569
  ],
10570
+ "storage-occupancy": ["getOccupancy"],
10549
10571
  "storage-provider": ["list"],
10550
10572
  "turn-provider": ["getTurnServers"],
10551
10573
  "user-passkeys": ["listPasskeys"]
@@ -10659,13 +10681,12 @@ var LedgerWalkReconciler = class {
10659
10681
  const apply = request.apply === true;
10660
10682
  const location = this.deps.locations().find((candidate) => candidate.id === request.locationId);
10661
10683
  if (location === void 0) return this.refusedReport(request.locationId, apply, "location-unknown");
10662
- if ((location.enabled ?? true) && !location.readOnly) {
10684
+ if (locationMayBeWritten(location)) {
10663
10685
  this.deps.logger.warn("recorder: ledger walk REFUSED — the source is still a write target, so a directory listing is a lower bound, not the truth", { meta: {
10664
10686
  locationId: location.id,
10665
10687
  root: location.root,
10666
- enabled: location.enabled ?? true,
10667
- readOnly: location.readOnly,
10668
- remedy: "set the location readOnly (or disable it) and re-run; a LIVE location is reconciled by the mover, under its bound (D318)"
10688
+ mode: location.mode,
10689
+ remedy: "switch the location to readonly / drain / disabled and re-run; a LIVE location is reconciled by the mover, under its bound (D318)"
10669
10690
  } });
10670
10691
  return this.refusedReport(location.id, apply, "source-writable");
10671
10692
  }
@@ -11019,6 +11040,91 @@ async function readLocationCapacity(root, statfs) {
11019
11040
  };
11020
11041
  }
11021
11042
  }
11043
+ /**
11044
+ * Coalesces location-change doorbells into refreshes, and converges on its own
11045
+ * whether or not any doorbell ever arrives.
11046
+ */
11047
+ var LocationRefresher = class {
11048
+ deps;
11049
+ timer = null;
11050
+ running = false;
11051
+ /** A doorbell rang while a refresh was in flight — run exactly one more. */
11052
+ rearm = false;
11053
+ /** When the last refresh STARTED. `null` ⇒ none since this object was made. */
11054
+ lastStartedAt = null;
11055
+ disposed = false;
11056
+ constructor(deps) {
11057
+ this.deps = deps;
11058
+ }
11059
+ get now() {
11060
+ return this.deps.now?.() ?? Date.now();
11061
+ }
11062
+ get debounceMs() {
11063
+ return this.deps.debounceMs ?? 2e3;
11064
+ }
11065
+ get backstopMs() {
11066
+ return this.deps.backstopMs ?? 3e5;
11067
+ }
11068
+ /**
11069
+ * The doorbell. Never awaits the refresh: this runs on the event bus, and a
11070
+ * subscriber that blocks on I/O holds up every other consumer of the bus.
11071
+ */
11072
+ ring(locationId, reason) {
11073
+ if (this.disposed) return;
11074
+ this.deps.logger.info("recorder: the storage location set changed — re-resolving where recordings may be written", { meta: {
11075
+ locationId,
11076
+ reason,
11077
+ coalesceMs: this.debounceMs
11078
+ } });
11079
+ if (this.timer !== null) return;
11080
+ this.timer = setTimeout(() => {
11081
+ this.timer = null;
11082
+ this.run("storage location change");
11083
+ }, this.debounceMs);
11084
+ }
11085
+ /**
11086
+ * The backstop, called from a pass that already runs on a timer. Refreshes
11087
+ * only when nothing has refreshed for {@link LOCATION_BACKSTOP_MS} — a
11088
+ * doorbell that already produced one resets the clock, so the two paths
11089
+ * never both pay.
11090
+ */
11091
+ async convergeIfDue() {
11092
+ if (this.disposed) return;
11093
+ const last = this.lastStartedAt;
11094
+ if (last !== null && this.now - last < this.backstopMs) return;
11095
+ await this.run("periodic convergence");
11096
+ }
11097
+ /** Stop the pending doorbell. Nothing in flight is cancelled — it is idempotent. */
11098
+ dispose() {
11099
+ this.disposed = true;
11100
+ if (this.timer !== null) {
11101
+ clearTimeout(this.timer);
11102
+ this.timer = null;
11103
+ }
11104
+ }
11105
+ async run(trigger) {
11106
+ if (this.running) {
11107
+ this.rearm = true;
11108
+ return;
11109
+ }
11110
+ this.running = true;
11111
+ this.lastStartedAt = this.now;
11112
+ try {
11113
+ await this.deps.refresh();
11114
+ } catch (err) {
11115
+ this.deps.logger.warn("recorder: could not re-resolve storage locations — the previously resolved set stands", { meta: {
11116
+ trigger,
11117
+ error: errMsg(err)
11118
+ } });
11119
+ } finally {
11120
+ this.running = false;
11121
+ }
11122
+ if (this.rearm && !this.disposed) {
11123
+ this.rearm = false;
11124
+ await this.run("change during refresh");
11125
+ }
11126
+ }
11127
+ };
11022
11128
  //#endregion
11023
11129
  //#region src/recorder/addon/ops-log-store.ts
11024
11130
  /**
@@ -11173,6 +11279,72 @@ function createRecordingOpsLogSink(deps) {
11173
11279
  };
11174
11280
  }
11175
11281
  //#endregion
11282
+ //#region src/recorder/addon/pending-writer-moves.ts
11283
+ /**
11284
+ * The gap between the PLAN and the DISK, for as long as it stands.
11285
+ *
11286
+ * Placement applies at writer-attach boundaries only: the `.rec-tmp` staging
11287
+ * dir lives UNDER the chosen root so the finalize relocate is a
11288
+ * same-filesystem rename, and repointing a live writer would turn every
11289
+ * segment into the EXDEV copy+unlink fallback — 2× disk writes on the hot path
11290
+ * (the F1 regression). So a plan reaches a camera when it next attaches.
11291
+ *
11292
+ * For a camera on a schedule that is the next band edge, minutes away. For a
11293
+ * CONTINUOUSLY recording camera there is no next attach: its writer outlives
11294
+ * every tick, and its assignment can be re-planned onto a new disk forever
11295
+ * without one byte moving. That is the state this module makes visible (D387).
11296
+ * The alternative — detaching live writers to apply a plan — buys the move at
11297
+ * the cost of a recording gap on every affected camera at once, which is
11298
+ * exactly the wrong trade on the day an operator adds a disk to relieve
11299
+ * pressure.
11300
+ */
11301
+ /** Is `dir` inside `root` (or the root itself)? A prefix compare that cannot
11302
+ * match `/recordings-2` against `/recordings`. */
11303
+ function isUnder(dir, root) {
11304
+ const rel = path.relative(root, dir);
11305
+ return rel === "" || !rel.startsWith("..") && !path.isAbsolute(rel);
11306
+ }
11307
+ /** The location whose root contains `dir` — the DEEPEST one, so a location
11308
+ * nested inside another is attributed to the nested one. */
11309
+ function locationOf(dir, locations) {
11310
+ let best = null;
11311
+ for (const location of locations) {
11312
+ if (!isUnder(dir, location.root)) continue;
11313
+ if (best === null || location.root.length > best.root.length) best = location;
11314
+ }
11315
+ return best?.id ?? null;
11316
+ }
11317
+ /**
11318
+ * Every live writer the plan wants somewhere else.
11319
+ *
11320
+ * A writer whose key the plan says nothing about is NOT pending: the pass
11321
+ * plans a work SUBSET, and "not in this pass" must never read as "planned
11322
+ * elsewhere". A writer under no known root is reported with `writingTo: null`
11323
+ * rather than dropped — a directory outside every resolved location is its own
11324
+ * fault, and a silent skip would hide it.
11325
+ */
11326
+ function pendingWriterMoves(writers, planned, locations) {
11327
+ const out = [];
11328
+ for (const writer of writers) {
11329
+ const plannedFor = planned.get(`${String(writer.deviceId)}:${writer.profile}`);
11330
+ if (plannedFor === void 0) continue;
11331
+ const writingTo = locationOf(writer.outDir, locations);
11332
+ if (writingTo === plannedFor) continue;
11333
+ out.push({
11334
+ deviceId: writer.deviceId,
11335
+ profile: writer.profile,
11336
+ writingTo,
11337
+ plannedFor
11338
+ });
11339
+ }
11340
+ return out.toSorted((a, b) => a.deviceId - b.deviceId || a.profile.localeCompare(b.profile));
11341
+ }
11342
+ /** A stable signature of a pending set, so the report is latched on WHAT is
11343
+ * pending rather than repeated every 30 s tick. */
11344
+ function pendingMovesSignature(moves) {
11345
+ return moves.map((m) => `${String(m.deviceId)}:${m.profile}>${m.plannedFor}`).join(",");
11346
+ }
11347
+ //#endregion
11176
11348
  //#region src/recorder/addon/periodic-pass.ts
11177
11349
  function startPeriodicPass(deps) {
11178
11350
  const now = () => deps.now?.() ?? Date.now();
@@ -11295,6 +11467,30 @@ function poolFor(demand, candidates) {
11295
11467
  function poolKey(pool) {
11296
11468
  return pool.map((c) => c.locationId).toSorted().join("|");
11297
11469
  }
11470
+ /**
11471
+ * What one demand with NO measured rate weighs: the median of the rates that
11472
+ * WERE measured, and one unit when nothing in the pool was measured at all.
11473
+ *
11474
+ * Never zero, and that is the whole point (D387). `dailyBytes` is 0 for every
11475
+ * camera the recorder's RAM index has not accounted for — after D287 that is
11476
+ * most of a fleet at any moment — and a zero-byte demand is invisible to every
11477
+ * balance calculation here. Phase C reaches for the SMALLEST demand on an
11478
+ * overloaded location, which is one of those; moving 0 bytes moves neither side
11479
+ * towards its target, so the strict-improvement check fails and the rebalance
11480
+ * `break`s on its first iteration. A single measured camera in the pool was
11481
+ * enough to make every unmeasured one weightless and wedge the whole pass — on
11482
+ * a live hub that left 19 assignments pinned to an 89%-full disk with an empty
11483
+ * 4 TB one next to it, silently, on every 30 s tick.
11484
+ *
11485
+ * The median (rather than the mean) so one 24/7 4K camera does not decide what
11486
+ * an unknown camera is assumed to cost.
11487
+ */
11488
+ function weightOfUnmeasured(demands) {
11489
+ const measured = demands.map((d) => d.dailyBytes).filter((bytes) => bytes > 0).toSorted((a, b) => a - b);
11490
+ if (measured.length === 0) return 1;
11491
+ const mid = Math.floor(measured.length / 2);
11492
+ return measured.length % 2 === 1 ? measured[mid] : (measured[mid - 1] + measured[mid]) / 2;
11493
+ }
11298
11494
  /** Normalised weight per location: headroom share, or an even split when no
11299
11495
  * location reports any headroom (nothing is known yet — never divide by 0). */
11300
11496
  function weightsFor(pool) {
@@ -11344,8 +11540,8 @@ function planPlacement(input) {
11344
11540
  });
11345
11541
  }
11346
11542
  for (const { pool, demands } of pools.values()) {
11347
- const anyRate = demands.some((d) => d.dailyBytes > 0);
11348
- const bytesOf = (d) => anyRate ? d.dailyBytes : 1;
11543
+ const unmeasured = weightOfUnmeasured(demands);
11544
+ const bytesOf = (d) => d.dailyBytes > 0 ? d.dailyBytes : unmeasured;
11349
11545
  const totalBytes = demands.reduce((sum, d) => sum + bytesOf(d), 0);
11350
11546
  const weights = weightsFor(pool);
11351
11547
  const slots = pool.map((c) => ({
@@ -11490,19 +11686,16 @@ var RecordingPlacementBlobSchema = object({
11490
11686
  /** locationId → the volume id last verified at that location's root. */
11491
11687
  volumeIds: record(string(), string()).default({}),
11492
11688
  /**
11493
- * Recordings class (`recordings` / `recordingsLow`) → the location
11494
- * `resolvePlacement` chose for it when these assignments were last planned.
11495
- *
11496
- * ABSENT means "nobody has recorded an authority yet", and that is never read
11497
- * as a repoint: shipping this field must not invalidate assignments that were
11498
- * made under an authority nothing wrote down. The first pass stamps it and
11499
- * invalidates nothing; every later change of it is a repoint.
11689
+ * DEAD KEY (D383) — the class authority a pass planned against, back when a
11690
+ * class had a default distinct from its pool. Declared so an existing blob
11691
+ * round-trips unchanged and so its removal is a decision on the record rather
11692
+ * than a silent strip. Never read, never written.
11500
11693
  */
11501
11694
  defaultLocations: record(string(), string()).default({}),
11502
11695
  /** When the blob was last written (operator-facing debugging only). */
11503
11696
  updatedAt: number().optional()
11504
11697
  });
11505
- /** Cold-start value: nothing assigned, nothing claimed, no authority recorded. */
11698
+ /** Cold-start value: nothing assigned, nothing claimed. */
11506
11699
  var EMPTY_PLACEMENT = {
11507
11700
  assignments: {},
11508
11701
  pins: {},
@@ -11551,31 +11744,27 @@ async function replaceAssignments(state, base, assignments) {
11551
11744
  });
11552
11745
  }
11553
11746
  /**
11554
- * The assignment record a pass leaves behind: the stored one, with the repoint
11555
- * applied, then the plan merged over it. **Nothing is ever removed** — a pass
11556
- * that plans a subset cannot delete a key it did not mention.
11747
+ * The assignment record a pass leaves behind: the stored one with the plan
11748
+ * merged over it. **Nothing is ever removed** — a pass that plans a subset
11749
+ * cannot delete a key it did not mention.
11557
11750
  *
11558
- * PURE, and shared by the write and the divergence report — a report computed
11751
+ * PURE, and shared by the write and the placement census — a report computed
11559
11752
  * from a second reading of the same rule is a report that can describe a record
11560
11753
  * the write did not produce.
11561
11754
  */
11562
11755
  function applyPlacementPass(base, pass) {
11563
11756
  const next = new Map(Object.entries(base));
11564
- for (const [key, locationId] of pass.repointed) next.set(key, locationId);
11565
11757
  for (const [key, locationId] of pass.planned) next.set(key, locationId);
11566
11758
  return next;
11567
11759
  }
11568
- /** Persist one work-scoped pass: apply the repoint, merge its plan, and stamp
11569
- * the authority the plan was resolved against. One write. */
11760
+ /** Persist one work-scoped pass: merge its plan over the stored record. One
11761
+ * write. */
11570
11762
  async function writePlacementPass(state, base, pass) {
11571
11763
  const assignments = {};
11572
11764
  for (const [key, locationId] of applyPlacementPass(base.assignments, pass)) assignments[key] = locationId;
11573
- const defaultLocations = {};
11574
- for (const [type, locationId] of pass.defaultLocations) defaultLocations[type] = locationId;
11575
11765
  await state.set({
11576
11766
  ...base,
11577
11767
  assignments,
11578
- defaultLocations,
11579
11768
  updatedAt: Date.now()
11580
11769
  });
11581
11770
  }
@@ -11824,14 +12013,6 @@ var NO_PLAN = {
11824
12013
  assignments: /* @__PURE__ */ new Map(),
11825
12014
  changes: []
11826
12015
  };
11827
- /** Is the authority already stamped, exactly? A pass that would change the
11828
- * stamp always writes, even when it moved nothing: an unstamped authority is
11829
- * an undetectable repoint. */
11830
- function sameStamp(stamped, authority) {
11831
- if (stamped.size !== authority.size) return false;
11832
- for (const [type, locationId] of authority) if (stamped.get(type) !== locationId) return false;
11833
- return true;
11834
- }
11835
12016
  /** Operator pins win over the planner for high/mid of the pinned camera. */
11836
12017
  function overlayPins(planned, pins) {
11837
12018
  const next = new Map(planned);
@@ -11859,14 +12040,11 @@ var PlacementService = class {
11859
12040
  /** The fair tie-break the PURE planner defers to (D37). Advisory state, and
11860
12041
  * consulted only for a tie the capacity signal cannot separate. */
11861
12042
  rotation = new PlacementRotation();
11862
- /** The divergent set last announced, as a stable signature. `null` ⇒ nothing
11863
- * announced yet, which is why an all-aligned fleet says nothing at boot. */
11864
- divergenceSignature = null;
11865
- /** When the standing divergence was last announced. */
11866
- divergenceReportedAt = null;
11867
- /** Repoint episodes already announced (`type:from->to`). Cleared the moment a
11868
- * pass sees no repoint at all — the next one is a NEW episode. */
11869
- repointAnnounced = /* @__PURE__ */ new Set();
12043
+ /** The census last announced, as a stable signature. `null` ⇒ nothing
12044
+ * announced yet. */
12045
+ censusSignature = null;
12046
+ /** When the census was last announced. */
12047
+ censusReportedAt = null;
11870
12048
  constructor(deps) {
11871
12049
  this.deps = deps;
11872
12050
  }
@@ -11901,15 +12079,14 @@ var PlacementService = class {
11901
12079
  async place(deviceId, profile) {
11902
12080
  const pool = this.pool();
11903
12081
  this.reprobeAfterBackoff(pool);
11904
- const authority = authorityLocations(pool);
11905
- const resolved = await this.assignedLocation(deviceId, profile, pool, authority) ?? resolvePlacement(pool, profile);
12082
+ const resolved = await this.assignedLocation(deviceId, profile, pool) ?? resolvePlacement(await this.withHeadroom(pool), profile);
11906
12083
  const usable = await this.ensureUsable(resolved, pool, profile, deviceId);
11907
12084
  if (this.everyLocationUnusable(pool)) {
11908
12085
  this.reportEveryLocationUnusable(deviceId, profile, resolved, pool);
11909
12086
  return resolved;
11910
12087
  }
11911
12088
  this.allUnusableReported.delete(deviceId);
11912
- await this.recordDecision(deviceId, profile, usable, resolved.id === usable.id, authority.get(placementTypeForProfile(profile)) ?? null);
12089
+ await this.recordDecision(deviceId, profile, usable, resolved.id === usable.id);
11913
12090
  return usable;
11914
12091
  }
11915
12092
  /**
@@ -11993,35 +12170,30 @@ var PlacementService = class {
11993
12170
  * to it, so an unknown flag is the only thing that counts as opted in.
11994
12171
  */
11995
12172
  pool() {
11996
- const { locations, fellBackToDefault } = placementPool(this.deps.locations());
11997
- if (fellBackToDefault) this.deps.logger.warn("recorder placement: NO recordings location is enabled for writing — falling back to the type default. Enable a location in Storage, or recordings will keep landing on the default.", { meta: { locationIds: locations.map((l) => l.id) } });
12173
+ const { locations, fellBackToDisabled } = placementPool(this.deps.locations());
12174
+ if (fellBackToDisabled) this.deps.logger.warn("recorder placement: NO recordings location is enabled for writing — writing to a DISABLED location under protest. Enable one in Storage.", { meta: { locationIds: locations.map((l) => l.id) } });
11998
12175
  return locations;
11999
12176
  }
12000
12177
  /**
12001
12178
  * The stored assignment for one (camera, profile), when it is still valid.
12002
12179
  *
12003
- * Three things invalidate it, and the third is the one this used to miss:
12180
+ * Two things invalidate it, and under the operator's model (D383) there is
12181
+ * nothing else that can:
12004
12182
  *
12005
- * 1. the location is absent from the current pool (deleted, opted out);
12006
- * 2. the location is read-only (an operator drain);
12007
- * 3. **the class was REPOINTED under it** — the authority stamped in the
12008
- * blob is not the authority now, and the assignment names neither.
12183
+ * 1. the location is absent from the current POOL — deleted, or the
12184
+ * operator disabled it. The pool is the single write authority, so an
12185
+ * assignment naming a location outside it is not a divergence to report,
12186
+ * it is unrepresentable;
12187
+ * 2. the location is read-only (an operator drain).
12009
12188
  *
12010
- * (1) and (2) both mean "this location is not a write target any more", so
12011
- * they only ever caught a repoint that took the old location out of service.
12012
- * A repoint of a class the operator is MIGRATING does the opposite: the
12013
- * source must stay enabled and writable, because it holds the history. So the
12014
- * assignment survived every attach, indefinitely, and `resolvePlacement`
12015
- * returning the new default changed nothing — it is the fallback, not the
12016
- * authority. On 2026-08-29 that left two of thirteen cameras writing to the
12017
- * disk the fleet was migrating off, silently (D298).
12018
- *
12019
- * The invalidation is a TRANSITION, not a policy: it fires only while the
12020
- * stamped authority disagrees with the live one, which the next placement pass
12021
- * ends by stamping. After that the plan is the authority again and a
12022
- * deliberate spread is honoured — and reported (`reportDivergence`).
12189
+ * There used to be a third — "the class was REPOINTED under it", measured
12190
+ * against the class default stamped in the blob (D301). It existed only
12191
+ * because the default was a SECOND write authority layered over the pool and
12192
+ * the two could disagree. With the default deleted the disagreement cannot be
12193
+ * expressed: a location leaving the pool already invalidates every assignment
12194
+ * naming it, and the planner then moves those keys with `reason: 'gone'`.
12023
12195
  */
12024
- async assignedLocation(deviceId, profile, pool, authority) {
12196
+ async assignedLocation(deviceId, profile, pool) {
12025
12197
  let blob;
12026
12198
  try {
12027
12199
  blob = await readPlacementState(this.deps.state);
@@ -12043,23 +12215,28 @@ var PlacementService = class {
12043
12215
  }
12044
12216
  if (assignedId === void 0) return null;
12045
12217
  const location = pool.find((l) => l.id === assignedId && !l.readOnly) ?? null;
12046
- if (location === null) return null;
12047
- const type = placementTypeForProfile(profile);
12048
- const target = authority.get(type);
12049
- const stamped = blob.defaultLocations[type];
12050
- if (target === void 0 || stamped === void 0) return location;
12051
- if (stamped === target || location.id === target) return location;
12052
- this.deps.logger.warn("recorder placement: stored assignment INVALIDATED — this recordings class was repointed and the assignment still names the location it was repointed away from", {
12218
+ if (location === null) this.deps.logger.info("recorder placement: stored assignment INVALIDATED — its location is not a write target any more (disabled, read-only, or gone). Resolving fresh from the pool.", {
12053
12219
  tags: { deviceId },
12054
12220
  meta: {
12055
12221
  profile,
12056
- assignedLocationId: location.id,
12057
- repointedFrom: stamped,
12058
- repointedTo: target,
12059
- locationType: type
12222
+ assignedLocationId: assignedId,
12223
+ poolLocationIds: pool.filter((l) => !l.readOnly).map((l) => l.id)
12060
12224
  }
12061
12225
  });
12062
- return null;
12226
+ return location;
12227
+ }
12228
+ /** The pool, with each member's measured headroom attached. One capacity read
12229
+ * per location; used only on the bootstrap path, never per segment. */
12230
+ async withHeadroom(pool) {
12231
+ const candidates = await this.candidatesFor(pool);
12232
+ const byId = new Map(candidates.map((c) => [c.locationId, c.headroomBytes]));
12233
+ return pool.map((location) => {
12234
+ const headroomBytes = byId.get(location.id);
12235
+ return headroomBytes === void 0 ? location : {
12236
+ ...location,
12237
+ headroomBytes
12238
+ };
12239
+ });
12063
12240
  }
12064
12241
  /**
12065
12242
  * Re-plan the (camera, profile) pairs in `work` over the current locations.
@@ -12081,7 +12258,6 @@ var PlacementService = class {
12081
12258
  */
12082
12259
  async recompute(work) {
12083
12260
  const pool = this.pool();
12084
- const authority = authorityLocations(pool);
12085
12261
  const candidates = await this.candidatesFor(pool);
12086
12262
  const demands = work.map((item) => ({
12087
12263
  key: assignmentKey(item.deviceId, item.profile),
@@ -12098,17 +12274,7 @@ var PlacementService = class {
12098
12274
  } });
12099
12275
  return NO_PLAN;
12100
12276
  }
12101
- const stamped = new Map(Object.entries(blob.defaultLocations));
12102
- const repoints = this.detectRepoints(blob.assignments, stamped, authority, blob.pins);
12103
- if (repoints.length === 0) this.repointAnnounced.clear();
12104
- for (const repoint of repoints) this.announceRepoint(repoint);
12105
- const repointed = /* @__PURE__ */ new Map();
12106
- for (const repoint of repoints) for (const key of repoint.keys) repointed.set(key, repoint.to);
12107
- const current = applyPlacementPass(blob.assignments, {
12108
- repointed,
12109
- planned: /* @__PURE__ */ new Map(),
12110
- defaultLocations: authority
12111
- });
12277
+ const current = new Map(Object.entries(blob.assignments));
12112
12278
  const evacuate = new Set(candidates.filter((c) => c.headroomBytes <= 0).map((c) => c.locationId));
12113
12279
  for (const location of pool) if (location.readOnly) evacuate.add(location.id);
12114
12280
  const plan = planPlacement({
@@ -12118,13 +12284,9 @@ var PlacementService = class {
12118
12284
  evacuate,
12119
12285
  breakTie: (tied) => this.rotation.next(tied)
12120
12286
  });
12121
- const pass = {
12122
- repointed,
12123
- planned: overlayPins(plan.assignments, blob.pins),
12124
- defaultLocations: authority
12125
- };
12126
- this.reportDivergence(applyPlacementPass(blob.assignments, pass), authority);
12127
- if (plan.changes.length === 0 && repointed.size === 0 && sameStamp(stamped, authority)) return plan;
12287
+ const pass = { planned: overlayPins(plan.assignments, blob.pins) };
12288
+ this.reportCensus(applyPlacementPass(blob.assignments, pass), pool);
12289
+ if (plan.changes.length === 0) return plan;
12128
12290
  for (const change of plan.changes) {
12129
12291
  const deviceId = Number(change.key.split(":")[0]);
12130
12292
  this.deps.logger.info("recorder placement: plan moves a profile to another location", {
@@ -12148,129 +12310,60 @@ var PlacementService = class {
12148
12310
  return plan;
12149
12311
  }
12150
12312
  /**
12151
- * Which recordings classes were REPOINTED under the stored plan, and which of
12152
- * its keys that invalidates.
12153
- *
12154
- * PURE. A class is repointed when the blob stamped an authority for it and
12155
- * the authority now is a different location. A class with NO stamp is not a
12156
- * repoint — it is the first pass after this shipped, and inferring a repoint
12157
- * from an absent stamp would throw away every deliberate assignment on the
12158
- * first tick after a deploy.
12159
- */
12160
- detectRepoints(stored, stamped, authority, pins) {
12161
- const out = [];
12162
- for (const [type, to] of authority) {
12163
- const from = stamped.get(type);
12164
- if (from === void 0 || from === to) continue;
12165
- const keys = [];
12166
- for (const [key, locationId] of Object.entries(stored)) {
12167
- const [devicePart, profilePart] = key.split(":");
12168
- if (placementTypeForProfile(profilePart ?? "") !== type) continue;
12169
- if (locationId === to) continue;
12170
- if (type === "recordings" && devicePart !== void 0 && pins[devicePart] !== void 0) continue;
12171
- keys.push(key);
12172
- }
12173
- out.push({
12174
- type,
12175
- from,
12176
- to,
12177
- keys
12178
- });
12179
- }
12180
- return out;
12181
- }
12182
- /**
12183
- * One line PER CAMERA (a repoint is asked per-camera: "did 3839 move?"), plus
12184
- * one audit row per class. A repoint is a rare operator event, so the volume
12185
- * is one burst of fleet-size, not a stream.
12186
- *
12187
- * Announced ONCE per episode: the detection is a comparison against the
12188
- * stamp, so a write that fails leaves the stamp behind and the next tick sees
12189
- * the same repoint again. Without the latch, a settings door that accepts
12190
- * reads and refuses writes would repeat the whole burst every 30 s.
12191
- */
12192
- announceRepoint(repoint) {
12193
- const episode = `${repoint.type}:${repoint.from}->${repoint.to}`;
12194
- if (this.repointAnnounced.has(episode)) return;
12195
- this.repointAnnounced.add(episode);
12196
- for (const key of repoint.keys) {
12197
- const deviceId = Number(key.split(":")[0]);
12198
- this.deps.logger.warn("recorder placement: recordings class REPOINTED — moving this assignment onto the class`s new authority location", {
12199
- ...Number.isFinite(deviceId) ? { tags: { deviceId } } : {},
12200
- meta: {
12201
- key,
12202
- locationType: repoint.type,
12203
- repointedFrom: repoint.from,
12204
- repointedTo: repoint.to
12205
- }
12206
- });
12207
- }
12208
- this.deps.opsLog.append({
12209
- op: "relocate",
12210
- reason: "operator",
12211
- deviceId: null,
12212
- itemsAffected: repoint.keys.length,
12213
- bytesReclaimed: 0,
12214
- actor: "system",
12215
- detail: `placement: ${repoint.type} repointed ${repoint.from} → ${repoint.to}; ${repoint.keys.length.toString()} assignment(s) moved (${repoint.keys.join(", ")})`
12216
- });
12217
- }
12218
- /**
12219
- * Say which (camera, profile) the plan writes somewhere other than its class
12220
- * authority — per camera, WITH its denominator (D277).
12313
+ * WHERE THE FLEET WRITES — one line per (class, location), always with its
12314
+ * denominator (D277).
12221
12315
  *
12222
- * This is not an error path. The planner spreads a class across its pool on
12223
- * purpose, and a location with headroom is a legitimate target. What was
12224
- * wrong is that a deliberate spread and a leftover from a repoint nobody
12225
- * noticed produced the *same* absence of output: `recordDecision` audits only
12226
- * a CHANGE, so a camera that keeps using its old assignment writes no row and
12227
- * no line, forever. An operator read "migration done" while two cameras were
12228
- * still filling the disk they were migrating off.
12316
+ * This replaced the class-authority divergence report (D301) when the default
12317
+ * was deleted: with every enabled location of a class a legitimate target,
12318
+ * "this camera does not write to THE location" is not a fact any more, while
12319
+ * "recordings: 9 of 13 on recordings:default, 4 on recordings:hgst" is
12320
+ * exactly the question an operator asks after enabling a second disk. A pool
12321
+ * member holding ZERO assignments is listed too — an enabled disk nothing
12322
+ * lands on is the failure this whole model exists to make visible.
12229
12323
  *
12230
- * Fires on a CHANGE of the divergent set, and otherwise at most once per
12231
- * {@link PLACEMENT_DIVERGENCE_REPORT_MS}. An all-aligned fleet says nothing —
12232
- * until it stops being all-aligned, and again when it recovers.
12324
+ * Fires on a CHANGE of the distribution, and otherwise at most once per
12325
+ * {@link PLACEMENT_CENSUS_REPORT_MS}.
12233
12326
  */
12234
- reportDivergence(assignments, authority) {
12235
- const divergent = [];
12327
+ reportCensus(assignments, pool) {
12328
+ const counts = /* @__PURE__ */ new Map();
12329
+ const perClass = /* @__PURE__ */ new Map();
12330
+ for (const location of pool) if (!location.readOnly) counts.set(location.id, 0);
12236
12331
  for (const [key, locationId] of assignments) {
12237
- const profile = key.split(":")[1] ?? "";
12238
- const authorityLocationId = authority.get(placementTypeForProfile(profile));
12239
- if (authorityLocationId === void 0 || authorityLocationId === locationId) continue;
12240
- divergent.push({
12241
- key,
12242
- deviceId: Number(key.split(":")[0]),
12243
- profile,
12244
- locationId,
12245
- authorityLocationId
12246
- });
12332
+ counts.set(locationId, (counts.get(locationId) ?? 0) + 1);
12333
+ const type = placementTypeForProfile(key.split(":")[1] ?? "");
12334
+ perClass.set(type, (perClass.get(type) ?? 0) + 1);
12247
12335
  }
12248
- const signature = divergent.map((d) => `${d.key}=${d.locationId}`).toSorted().join(",");
12249
- const previous = this.divergenceSignature;
12336
+ const typeOf = new Map(pool.map((location) => [location.id, location.type]));
12337
+ const lines = [...counts.entries()].toSorted(([a], [b]) => a.localeCompare(b)).map(([locationId, count]) => {
12338
+ const type = typeOf.get(locationId) ?? locationId.split(":")[0] ?? locationId;
12339
+ return {
12340
+ locationId,
12341
+ type,
12342
+ assignments: count,
12343
+ ofClass: perClass.get(type) ?? 0
12344
+ };
12345
+ });
12346
+ const signature = lines.map((line) => `${line.locationId}=${String(line.assignments)}`).join(",");
12250
12347
  const now = this.now;
12251
- const due = this.divergenceReportedAt === null || now - this.divergenceReportedAt >= 36e5;
12252
- if (signature === previous && !due) return;
12253
- this.divergenceSignature = signature;
12254
- this.divergenceReportedAt = now;
12255
- if (divergent.length === 0) {
12256
- if (previous !== null && previous !== "") this.deps.logger.info("recorder placement: every assignment now writes to its class authority location", { meta: { assignments: assignments.size } });
12257
- return;
12348
+ const due = this.censusReportedAt === null || now - this.censusReportedAt >= 36e5;
12349
+ if (signature === this.censusSignature && !due) return;
12350
+ this.censusSignature = signature;
12351
+ this.censusReportedAt = now;
12352
+ if (lines.length === 0) return;
12353
+ this.deps.logger.info("recorder placement: census — which locations the fleet writes to", { meta: { locations: lines.map((line) => ({
12354
+ locationId: line.locationId,
12355
+ type: line.type,
12356
+ assignments: line.assignments,
12357
+ ofClass: line.ofClass
12358
+ })) } });
12359
+ for (const line of lines) {
12360
+ if (line.assignments > 0) continue;
12361
+ this.deps.logger.warn("recorder placement: this location is ENABLED but holds no assignment — nothing writes to it", { meta: {
12362
+ locationId: line.locationId,
12363
+ type: line.type,
12364
+ ofClass: line.ofClass
12365
+ } });
12258
12366
  }
12259
- this.deps.logger.warn("recorder placement: assignments write somewhere other than their class authority location", { meta: {
12260
- divergent: divergent.length,
12261
- assignments: assignments.size,
12262
- authority: Object.fromEntries(authority)
12263
- } });
12264
- for (const entry of divergent) this.deps.logger.warn("recorder placement: this profile does NOT write to its class authority location", {
12265
- ...Number.isFinite(entry.deviceId) ? { tags: { deviceId: entry.deviceId } } : {},
12266
- meta: {
12267
- profile: entry.profile,
12268
- locationId: entry.locationId,
12269
- authorityLocationId: entry.authorityLocationId,
12270
- divergent: divergent.length,
12271
- assignments: assignments.size
12272
- }
12273
- });
12274
12367
  }
12275
12368
  /** Bound one addon-store round trip. See {@link PLACEMENT_STORE_TIMEOUT_MS}. */
12276
12369
  bounded(promise, label) {
@@ -12360,7 +12453,7 @@ var PlacementService = class {
12360
12453
  }
12361
12454
  if (location.maxUsedGb !== void 0) {
12362
12455
  const used = await this.deps.usedOnLocation?.(location.id) ?? 0;
12363
- limits.push(location.maxUsedGb * 1e9 - used);
12456
+ limits.push(gbToBytes(location.maxUsedGb) - used);
12364
12457
  }
12365
12458
  out.push({
12366
12459
  locationId: location.id,
@@ -12451,13 +12544,13 @@ var PlacementService = class {
12451
12544
  * Persist the assignment and audit it — but only when it actually changed.
12452
12545
  * The steady state (every attach after the first) writes nothing.
12453
12546
  *
12454
- * The per-attach line carries `authorityLocationId` and `divergent` so a
12455
- * single camera's line answers "is 3839 writing where the class default
12456
- * says?" on its own. Without it, a camera pinned to a location the class was
12457
- * repointed away from logged `reason: 'resolved'` — byte-identical to a
12458
- * camera correctly on the current default.
12547
+ * The per-attach line carries the camera's location and the one it replaced,
12548
+ * tagged with `deviceId`, so "where does 3839 write, and did it move?" is
12549
+ * answerable per camera. Which locations the FLEET is spread over is the
12550
+ * census's job (D383) — there is no class authority for a single line to be
12551
+ * measured against any more.
12459
12552
  */
12460
- async recordDecision(deviceId, profile, location, verbatim, authorityLocationId) {
12553
+ async recordDecision(deviceId, profile, location, verbatim) {
12461
12554
  const key = assignmentKey(deviceId, profile);
12462
12555
  let previous;
12463
12556
  try {
@@ -12478,8 +12571,6 @@ var PlacementService = class {
12478
12571
  locationId: location.id,
12479
12572
  root: location.root,
12480
12573
  previousLocationId: previous ?? null,
12481
- authorityLocationId,
12482
- divergent: authorityLocationId !== null && authorityLocationId !== location.id,
12483
12574
  reason: verbatim ? "resolved" : "failover"
12484
12575
  }
12485
12576
  });
@@ -14450,6 +14541,26 @@ var RecordingController = class {
14450
14541
  this.wakeups.arm(deviceId, next.atMs, next.reason);
14451
14542
  }
14452
14543
  /**
14544
+ * Where every LIVE writer is writing right now — the directory ffmpeg was
14545
+ * spawned against, per (device, profile).
14546
+ *
14547
+ * The plan and the disk disagree for as long as a writer outlives the tick
14548
+ * that re-planned it: placement applies at ATTACH BOUNDARIES only (the
14549
+ * `.rec-tmp` staging dir lives under the chosen root so the finalize
14550
+ * relocate is a same-filesystem rename), so a continuously-recording camera
14551
+ * keeps its root until something detaches it. That is a legitimate state and
14552
+ * a silent one, which is why it is READABLE from here (D387).
14553
+ */
14554
+ activeWriterDirs() {
14555
+ const out = [];
14556
+ for (const [deviceId, recordings] of this.active) for (const r of recordings) out.push({
14557
+ deviceId,
14558
+ profile: r.profile,
14559
+ outDir: r.outDir
14560
+ });
14561
+ return out;
14562
+ }
14563
+ /**
14453
14564
  * Boot restore: seed the readiness-gated queue with every persisted device and
14454
14565
  * (re)evaluate each on every `stream-broker` ready transition. Also arms the
14455
14566
  * periodic passes. Idempotent — safe to call once after the index is hydrated.
@@ -15377,7 +15488,7 @@ var RecordingController = class {
15377
15488
  tag: `recorder:${deviceId}/${profile}`
15378
15489
  }, nodePin(this.deps.ownerNodeId)), ATTACH_RPC_TIMEOUT_MS, "getStreamWithCodec"));
15379
15490
  const streamAcquiredMs = this.now();
15380
- const rtspUrl = withRecordingIntent(source.url);
15491
+ const rtspUrl = withRecordingIntent(source.url, RECORDING_PRE_ROLL_REQUEST_MS);
15381
15492
  this.attachSeq++;
15382
15493
  const consumerTag = `recorder/${deviceId}/${profile}/${this.attachSeq}`;
15383
15494
  if (rtspUrl === source.url) this.deps.logger.info("recorder: dial carries no recording intent — no pre-roll", {
@@ -16835,6 +16946,21 @@ var RecorderV2Addon = class extends BaseAddon {
16835
16946
  * per episode, not one per 30 s tick. `null` ⇒ the last pass had work. */
16836
16947
  placementWorkGap = null;
16837
16948
  /**
16949
+ * When the recorder re-resolves its storage locations (D387): coalesced off
16950
+ * the `StorageLocationsChanged` doorbell, and converged on the placement
16951
+ * pass's own timer whether or not that doorbell ever arrives. Built on the
16952
+ * recording node only, with the rest of the write path.
16953
+ */
16954
+ locationRefresher = null;
16955
+ /** Unsubscribe handle for the `StorageLocationsChanged` subscription. */
16956
+ locationChangeUnsub = null;
16957
+ /**
16958
+ * How many live writers are recording somewhere other than their planned
16959
+ * location, latched so the line is one per episode rather than one per 30 s
16960
+ * tick. `null` ⇒ nothing pending (or nothing measured yet).
16961
+ */
16962
+ pendingWriterMoves = null;
16963
+ /**
16838
16964
  * The calendar rungs — which days, which hours, and a day's exact ranges.
16839
16965
  *
16840
16966
  * Hours the RAM index has already walked (or the live hour it is
@@ -17059,8 +17185,7 @@ var RecorderV2Addon = class extends BaseAddon {
17059
17185
  },
17060
17186
  archiveHourPaths: async (deviceId, profile, locationId, hourStartMs) => await this.segmentHours?.hourPaths(deviceId, profile, locationId, hourStartMs) ?? null,
17061
17187
  removeDir: async (locationId, relDir) => {
17062
- const loc = this.resolvedLocations.find((l) => l.id === locationId);
17063
- if (!loc) throw new Error(`recorder: unknown recordings location ${locationId}`);
17188
+ const loc = requireReadableLocation(this.resolvedLocations, locationId, "recorder");
17064
17189
  await promises.rm(path.join(loc.root, relDir), {
17065
17190
  recursive: true,
17066
17191
  force: true
@@ -17098,8 +17223,7 @@ var RecorderV2Addon = class extends BaseAddon {
17098
17223
  });
17099
17224
  },
17100
17225
  removeDirIfEmpty: async (locationId, relDir) => {
17101
- const loc = this.resolvedLocations.find((l) => l.id === locationId);
17102
- if (!loc) throw new Error(`recorder: unknown recordings location ${locationId}`);
17226
+ const loc = requireReadableLocation(this.resolvedLocations, locationId, "recorder");
17103
17227
  await promises.rmdir(path.join(loc.root, relDir));
17104
17228
  },
17105
17229
  logger: {
@@ -17312,7 +17436,10 @@ var RecorderV2Addon = class extends BaseAddon {
17312
17436
  logger: this.ctx.logger,
17313
17437
  store: this.segmentStore,
17314
17438
  resolveDomain: (locationId) => this.locationDomain(locationId),
17315
- isReadOnly: (locationId) => this.resolvedLocations.find((l) => l.id === locationId)?.readOnly === true,
17439
+ isEvictionFrozen: (locationId) => {
17440
+ const location = this.resolvedLocations.find((l) => l.id === locationId);
17441
+ return location !== void 0 && evictionPolicyForMode(location.mode) === "never";
17442
+ },
17316
17443
  deviceRegistry: async () => {
17317
17444
  try {
17318
17445
  const devices = await this.ctx.api.deviceManager.listAll.query({ projection: "slim" });
@@ -17492,6 +17619,10 @@ var RecorderV2Addon = class extends BaseAddon {
17492
17619
  capability: storageEvictableCapability,
17493
17620
  provider: evictableProvider
17494
17621
  },
17622
+ {
17623
+ capability: storageOccupancyCapability,
17624
+ provider: evictableProvider
17625
+ },
17495
17626
  {
17496
17627
  capability: recordingExportCapability,
17497
17628
  provider: exportProvider
@@ -17542,6 +17673,10 @@ var RecorderV2Addon = class extends BaseAddon {
17542
17673
  this.sensorTriggerUnsub = null;
17543
17674
  this.signalTriggerUnsub?.();
17544
17675
  this.signalTriggerUnsub = null;
17676
+ this.locationChangeUnsub?.();
17677
+ this.locationChangeUnsub = null;
17678
+ this.locationRefresher?.dispose();
17679
+ this.locationRefresher = null;
17545
17680
  await this.controller?.stop();
17546
17681
  this.controller = null;
17547
17682
  this.segmentStore = null;
@@ -17657,6 +17792,13 @@ var RecorderV2Addon = class extends BaseAddon {
17657
17792
  this.ctx.logger.warn("recorder: controller start failed", { meta: { error: errMsg(err) } });
17658
17793
  }
17659
17794
  this.recoverStagedOrphans(stagingBootMs);
17795
+ if (this.locationRefresher === null) this.locationRefresher = new LocationRefresher({
17796
+ logger: this.ctx.logger,
17797
+ refresh: () => this.refreshLocations()
17798
+ });
17799
+ if (this.locationChangeUnsub === null) this.locationChangeUnsub = this.ctx.eventBus.subscribe({ category: EventCategory.StorageLocationsChanged }, (event) => {
17800
+ this.locationRefresher?.ring(event.data.locationId, event.data.reason);
17801
+ });
17660
17802
  if (this.eventUnsub === null) this.eventUnsub = subscribeEventCapture({
17661
17803
  eventBus: this.ctx.eventBus,
17662
17804
  onMotion: (deviceId, atMs) => {
@@ -18063,6 +18205,7 @@ var RecorderV2Addon = class extends BaseAddon {
18063
18205
  */
18064
18206
  async recomputePlacement(deviceIds) {
18065
18207
  if (!this.placement) return;
18208
+ await (this.locationRefresher?.convergeIfDue() ?? Promise.resolve());
18066
18209
  const archive = this.segmentHours;
18067
18210
  if (!archive) {
18068
18211
  this.reportPlacementWorkGap("no segment-hour ledger on this node", deviceIds.length, 0);
@@ -18079,7 +18222,48 @@ var RecorderV2Addon = class extends BaseAddon {
18079
18222
  return;
18080
18223
  }
18081
18224
  this.placementWorkGap = null;
18082
- await this.placement.recompute(work);
18225
+ const plan = await this.placement.recompute(work);
18226
+ this.reportPendingWriterMoves(plan.assignments);
18227
+ }
18228
+ /**
18229
+ * Cameras whose plan says one disk and whose live writer says another —
18230
+ * REPORTED, per camera, for as long as it stands (D387).
18231
+ *
18232
+ * Placement applies at attach boundaries only, so a re-planned camera moves
18233
+ * when it next attaches. A camera on a schedule attaches again within
18234
+ * minutes; a CONTINUOUSLY recording one never does, and its plan can sit
18235
+ * unapplied forever. Repointing a live writer is not the fix — the
18236
+ * `.rec-tmp` staging dir lives under the chosen root precisely so the
18237
+ * finalize relocate is a same-filesystem rename — and detaching the fleet to
18238
+ * apply a plan costs a recording gap on every affected camera at once, on
18239
+ * the very day the operator added a disk to relieve pressure. So the honest
18240
+ * behaviour is to say it, name the cameras, and let the operator decide.
18241
+ *
18242
+ * Latched on WHAT is pending: a line per 30 s tick would be its own defect.
18243
+ */
18244
+ reportPendingWriterMoves(planned) {
18245
+ const controller = this.controller;
18246
+ if (!controller) return;
18247
+ const moves = pendingWriterMoves(controller.activeWriterDirs(), planned, this.resolvedLocations);
18248
+ const signature = pendingMovesSignature(moves);
18249
+ if (signature === this.pendingWriterMoves) return;
18250
+ this.pendingWriterMoves = signature;
18251
+ if (moves.length === 0) return;
18252
+ for (const move of moves) this.ctx.logger.warn("recorder placement: this camera is PLANNED for another location and its writer will not move until it next attaches", {
18253
+ tags: { deviceId: move.deviceId },
18254
+ meta: {
18255
+ profile: move.profile,
18256
+ writingTo: move.writingTo,
18257
+ plannedFor: move.plannedFor,
18258
+ hint: "a continuously-recording camera never re-attaches on its own — toggle its recording switch, or restart the recorder, to apply the plan (each costs one gap on that camera)"
18259
+ }
18260
+ });
18261
+ this.emitAlert({
18262
+ severity: "warning",
18263
+ title: "Recording placement waiting on a writer restart",
18264
+ message: `${String(moves.length)} recording profile(s) are planned for a different storage location and will keep writing to their current one until each camera next attaches. Continuously-recording cameras never re-attach on their own.`,
18265
+ locationId: moves[0]?.plannedFor ?? ""
18266
+ });
18083
18267
  }
18084
18268
  /**
18085
18269
  * A placement pass that plans NOTHING says so — once per episode.
@@ -18242,12 +18426,22 @@ var RecorderV2Addon = class extends BaseAddon {
18242
18426
  * still be shouted about in the log even when it is unreachable.
18243
18427
  */
18244
18428
  emitPlacementAlert(input) {
18245
- this.ctx.api.alerts.emit.mutate({
18246
- id: randomUUID(),
18247
- category: "storage",
18429
+ this.emitAlert({
18248
18430
  severity: "error",
18249
18431
  title: "Recordings storage location unusable",
18250
18432
  message: input.detail,
18433
+ locationId: input.locationId
18434
+ });
18435
+ }
18436
+ /** One storage-category alert. Advisory: the LOG line is the durable record,
18437
+ * so a failed emit is swallowed rather than raised. */
18438
+ emitAlert(input) {
18439
+ this.ctx.api.alerts.emit.mutate({
18440
+ id: randomUUID(),
18441
+ category: "storage",
18442
+ severity: input.severity,
18443
+ title: input.title,
18444
+ message: input.message,
18251
18445
  status: "active",
18252
18446
  read: false,
18253
18447
  createdAt: Date.now(),
@@ -18485,8 +18679,7 @@ var RecorderV2Addon = class extends BaseAddon {
18485
18679
  * manifest uses). The scrub packs read the SAME source segments as playback.
18486
18680
  */
18487
18681
  resolveSegmentAbsPath(row) {
18488
- const loc = this.resolvedLocations.find((l) => l.id === row.locationId);
18489
- if (loc === void 0) throw new Error(`recorder scrub: unknown recordings location ${row.locationId}`);
18682
+ const loc = requireReadableLocation(this.resolvedLocations, row.locationId, "recorder scrub");
18490
18683
  return path.join(loc.root, row.path);
18491
18684
  }
18492
18685
  /**