@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
@@ -2,12 +2,12 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-DmCrC2yx.js");
6
- const require_node = require("../node-DCSTbt6C.js");
5
+ const require_dist = require("../dist-Bd875pfj.js");
6
+ const require_node = require("../node-dwhLC9Fh.js");
7
7
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
8
- const require_restream_intent = require("../restream-intent-Cv9x3jmu.js");
8
+ const require_restream_intent = require("../restream-intent-B1Difcnq.js");
9
9
  const require_event_loop_stall_monitor = require("../event-loop-stall-monitor-DaUBcb13.js");
10
- const require_prebuffer = require("../prebuffer-DqdeLSsk.js");
10
+ const require_retire_root_keys = require("../retire-root-keys-Csb8z2f3.js");
11
11
  let node_crypto = require("node:crypto");
12
12
  let node_fs = require("node:fs");
13
13
  let node_path = require("node:path");
@@ -1517,14 +1517,17 @@ var RecordingIndex = class {
1517
1517
  /**
1518
1518
  * The physical-device key of a location root: `realpath` of the resolved path,
1519
1519
  * or the resolved path string itself when realpath throws (root missing).
1520
+ *
1521
+ * ONE derivation, shared with the orchestrator's same-type root refusal (D389)
1522
+ * — two implementations of "is this the same disk" is two answers waiting to
1523
+ * disagree. This side deliberately DROPS the `resolved` flag: an unresolvable
1524
+ * root falls back to the lexical path, which can only over-partition (treat one
1525
+ * disk as two) and never merge two different disks, the safe failure mode for a
1526
+ * data-loss guard. The orchestrator needs the flag, because a refusal must not
1527
+ * be built on a read that failed.
1520
1528
  */
1521
1529
  function physicalRootKey(root, realpath = node_fs.realpathSync) {
1522
- const resolved = node_path.default.resolve(root);
1523
- try {
1524
- return realpath(resolved);
1525
- } catch {
1526
- return resolved;
1527
- }
1530
+ return require_node.physicalRootOf(root, realpath).key;
1528
1531
  }
1529
1532
  /**
1530
1533
  * The eviction DOMAIN of `locationId`: the set of location ids (INCLUDING
@@ -1539,211 +1542,578 @@ function resolveEvictionDomain(locations, locationId, realpath = node_fs.realpat
1539
1542
  for (const l of locations) if (physicalRootKey(l.root, realpath) === selfKey) domain.add(l.id);
1540
1543
  return domain;
1541
1544
  }
1542
- //#endregion
1543
- //#region src/recorder/relocate-engine.ts
1545
+ var RECORDING_LOCATION_TYPES = new Set(["recordings", "recordingsLow"]);
1544
1546
  /**
1545
- * Footage relocation engine (entity-routing spec, Phase 4) — the mover half
1546
- * of disk decommissioning: mark the old location read-only (Phase 2), then
1547
- * relocate its footage here, then delete the location.
1548
- *
1549
- * Mechanics, per HOUR (D291): throttled copy + size-verify of every file in the
1550
- * hour → re-point the DURABLE hour row → delete the sources → re-point the RAM
1551
- * index. The hour is the batch because the durable index is hour-granular, and
1552
- * the sources outlive the durable write on purpose: between the copy and the
1553
- * delete both locations hold the hour, so no crash can leave the index naming a
1554
- * location without the bytes. The copy is `copy-if-absent` (an equal-size
1555
- * destination is adopted, not re-copied), so cancel/crash/re-run RESUMES
1556
- * instead of restarting, and a cancel inside an hour leaves that hour WHOLLY
1557
- * readable at the source. Throttling is file-level pacing (segments are
1558
- * ~1–8 MB / ~4 s, so per-file granularity bounds the rate tightly enough) —
1559
- * the drain is a background chore that must never starve live writers.
1560
- *
1561
- * Runs INSIDE the recorder process — container uid, same-uid files, no host
1562
- * chown trap (see docs/history 2026-07-29).
1563
- *
1564
- * ── Phase 2 (D116, multi-location) ────────────────────────────────────────
1565
- * Two additions the OPERATOR-ARMED rebalance needs:
1566
- *
1567
- * - A move is scoped to one CAMERA (and optionally to specific profiles),
1568
- * not just to a whole disk. The rebalance's unit is a (camera, profile)
1569
- * pile, because that is the unit the placement plan assigns.
1570
- * - Concurrent starts QUEUE instead of being refused. The engine is still
1571
- * single-flight — two movers on one disk thrash both — but a rebalance
1572
- * enqueues one job per pile, and refusing the second turned a
1573
- * fifteen-camera rebalance into fifteen manual retries. Jobs run FIFO; a
1574
- * job cancelled while queued never runs, and a job that FAILS does not
1575
- * strand the queue behind it.
1547
+ * The recordings CLASS a profile belongs to. The one place this rule is
1548
+ * written: `resolvePlacement`, the planner's demand type and the placement
1549
+ * census must agree on it, and three copies of `profile === 'low' ? … : …` is
1550
+ * how they stop agreeing.
1576
1551
  */
1577
- var DEFAULT_THROTTLE_MBPS = 40;
1578
- /** The durable index's bucket, and therefore this mover's batch. */
1579
- var HOUR_MS$7 = 36e5;
1580
- /** Progress callback cadence (files) — cheap enough to fire often. */
1581
- var PROGRESS_EVERY_FILES = 25;
1582
- /** Free space a destination must keep when it declares no `config.minFreePercent`
1583
- * — the same default `roots.ts` documents for placement. */
1584
- var DEFAULT_MIN_FREE_PERCENT$1 = 10;
1585
- /** Prefix every destination-full error carries, so the coordinator can report
1586
- * "drain stalled: destination full" instead of a raw ENOSPC string. */
1587
- var DESTINATION_FULL_PREFIX = "destination full";
1552
+ function placementTypeForProfile(profile) {
1553
+ return profile === "low" ? "recordingsLow" : "recordings";
1554
+ }
1588
1555
  /**
1589
- * Ghost rows one job may FORGET, as a share of its own census — and the floor
1590
- * below which the share is not the bound.
1591
- *
1592
- * D148 is why this is bounded at all: the ledger forgets only on a trusted walk
1593
- * or an eviction, so that a transient read error cannot erase an operator's
1594
- * index of real footage. A per-path ENOENT on a path derived from the row is
1595
- * strong evidence about THAT path (D296) and weak evidence about a mount: a
1596
- * root that vanished answers ENOENT for every path it is asked about, and looks
1597
- * exactly like a location that is genuinely empty. The bound is what makes the
1598
- * difference survivable — past it the mover keeps moving, keeps skipping, and
1599
- * REPORTS, so an operator gets a reconcile to act on instead of an archive that
1600
- * quietly emptied itself.
1556
+ * Narrow a resolved location list to the ones placement may write to: the
1557
+ * enabled ones — ALL of them, together. A location the operator has not opted
1558
+ * in (`enabled === false`) is still READ, age-swept and drained — it is simply
1559
+ * never a write target, exactly like a read-only one.
1601
1560
  *
1602
- * 17.5 % of `recordingsLow:default` was ghosts on 2026-08-30 (11 074 of
1603
- * 63 346), so a quarter admits a real reconcile of that size in one pass while
1604
- * refusing a whole-location erase.
1561
+ * When no enabled location is writable EVERY location serves instead, loudly.
1562
+ * The orchestrator refuses to leave a recordings class without an enabled
1563
+ * location, so this is reachable only through a hand-edited store — and a
1564
+ * recorder that writes nowhere is worse than one that writes under protest.
1605
1565
  */
1606
- var GHOST_FORGET_SHARE = .25;
1607
- function snapshot(j) {
1566
+ function placementPool(locations) {
1567
+ const writable = locations.filter((l) => locationMayBeWritten(l));
1568
+ if (writable.length > 0) return {
1569
+ locations: writable,
1570
+ fellBackToDisabled: false
1571
+ };
1608
1572
  return {
1609
- jobId: j.jobId,
1610
- state: j.state,
1611
- fromLocationId: j.fromLocationId,
1612
- toLocationId: j.toLocationId,
1613
- deviceId: j.deviceId,
1614
- entities: [...j.entities],
1615
- filesMoved: j.filesMoved,
1616
- bytesMoved: j.bytesMoved,
1617
- filesTotal: j.filesTotal,
1618
- rowsReconciled: j.rowsReconciled,
1619
- rowsForgotten: j.rowsForgotten,
1620
- startedAt: j.startedAt,
1621
- finishedAt: j.finishedAt,
1622
- error: j.error
1573
+ locations: locations.filter((l) => locationMayBeRead(l)),
1574
+ fellBackToDisabled: true
1623
1575
  };
1624
1576
  }
1625
1577
  /**
1626
- * The row's source file is not on disk.
1578
+ * The state question, put to the ONE interpreter.
1627
1579
  *
1628
- * Carries `code: 'ENOENT'` because that is how every caller here already tells
1629
- * a ghost row from a real failure, and {@link copyVerified} now discovers the
1630
- * absence with a `stat` rather than by letting `copyFile` raise it.
1580
+ * The legacy mirrors are handed over too, so a location built by a caller that
1581
+ * has not moved to `recordingsLocationOfMode` yet is folded by the SAME rule
1582
+ * that folds an unmigrated persisted row (`mode` absent → `readOnly` → `enabled`
1583
+ * → active). There is still exactly one interpretation; the mirrors and this
1584
+ * argument disappear together next release.
1631
1585
  */
1632
- var RelocateSourceMissingError = class extends Error {
1633
- code = "ENOENT";
1634
- constructor(src) {
1635
- super(`relocate source missing: ${src}`);
1636
- this.name = "RelocateSourceMissingError";
1637
- }
1638
- };
1586
+ function stateOf(location) {
1587
+ return {
1588
+ ...location.mode !== void 0 ? { mode: location.mode } : {},
1589
+ ...location.enabled !== void 0 ? { enabled: location.enabled } : {},
1590
+ config: location.readOnly === true ? { readOnly: true } : {}
1591
+ };
1592
+ }
1593
+ /** The resolved state of a location, for a message or a log line. */
1594
+ function locationMode(location) {
1595
+ return require_dist.resolveLocationMode(stateOf(location));
1596
+ }
1597
+ /** May the recorder write here? THE policy, never a local re-derivation. */
1598
+ function locationMayBeWritten(location) {
1599
+ return require_dist.mayWriteToLocation(stateOf(location));
1600
+ }
1639
1601
  /**
1640
- * One file's COPY: copy-if-absent → verify the destination against the SOURCE'S
1641
- * OWN SIZE. The source is left in place.
1642
- *
1643
- * Split out of {@link moveFile} because the durable index re-point has to
1644
- * happen while BOTH locations still hold the bytes — see
1645
- * `SegmentHourLedger.repointSegments`.
1646
- *
1647
- * **`recordedBytes` is never the verify's reference.** It used to be, and it
1648
- * cannot be: `fs.copyFile` truncates and replaces, so the destination always
1649
- * equals the source, and comparing that result against a number the LEDGER
1650
- * remembers can only ever fail when the ledger is wrong. On 2026-08-30 that
1651
- * reported a byte-perfect copy as corruption and killed a 110 749-file drain
1652
- * on its 12 440th file:
1653
- *
1654
- * relocate verify failed: …/1787926410000-9966-274432.m4s
1655
- * is 783801B, expected 274432B
1656
- *
1657
- * Both files measured 783801 B on the hub. What is faithful is "the
1658
- * destination equals the source", and that is what is asserted here; the
1659
- * recorded number survives only as the reference for a destination whose
1660
- * source is already GONE, where there is nothing else left to compare against.
1602
+ * A resolved location, with every derived mirror written from the ONE state.
1603
+ * Every `RecordingsLocation` in the recorder comes from here.
1661
1604
  */
1662
- async function copyVerified(src, dst, recordedBytes) {
1663
- const dstBytes = await fileSize(dst);
1664
- const srcBytes = await fileSize(src);
1665
- if (dstBytes !== null && dstBytes === (srcBytes ?? recordedBytes)) return {
1666
- copied: 0,
1667
- srcBytes: dstBytes
1668
- };
1669
- if (srcBytes === null) throw new RelocateSourceMissingError(src);
1670
- await node_fs.promises.mkdir(node_path.default.dirname(dst), { recursive: true });
1671
- await node_fs.promises.copyFile(src, dst);
1672
- const after = await node_fs.promises.stat(dst);
1673
- if (after.size !== srcBytes) throw new Error(`relocate verify failed: ${dst} is ${after.size}B, expected ${srcBytes}B`);
1605
+ function recordingsLocationOfMode(base, mode) {
1674
1606
  return {
1675
- copied: srcBytes,
1676
- srcBytes
1607
+ ...base,
1608
+ mode,
1609
+ readOnly: !require_dist.modeMayWrite(mode),
1610
+ enabled: require_dist.modeMayWrite(mode)
1677
1611
  };
1678
1612
  }
1679
- /** A file's size, or `null` when it is not there. */
1680
- async function fileSize(p) {
1681
- try {
1682
- return (await node_fs.promises.stat(p)).size;
1683
- } catch {
1684
- return null;
1685
- }
1613
+ /** May the recorder read here? A `disabled` location may not — and that is an
1614
+ * operator CHOICE, reported as unavailable, never as an unknown location. */
1615
+ function locationMayBeRead(location) {
1616
+ return require_dist.mayReadLocation(stateOf(location));
1686
1617
  }
1687
- var RelocateEngine = class {
1688
- deps;
1689
- jobs = /* @__PURE__ */ new Map();
1690
- /** Job ids waiting for the mover, in enqueue order. */
1691
- queue = [];
1692
- /** Set while a job is executing — the single-flight latch. */
1693
- draining = false;
1694
- constructor(deps) {
1695
- this.deps = deps;
1696
- }
1697
- list() {
1698
- return [...this.jobs.values()].sort((a, b) => b.startedAt - a.startedAt).map(snapshot);
1618
+ /**
1619
+ * Route a recording profile to a storage location, for a (camera, profile) the
1620
+ * PLANNER has not placed yet — the first attach of a new camera, before the
1621
+ * 30 s pass runs.
1622
+ *
1623
+ * The rule, in order:
1624
+ * 1. the location's declared `type` — `low` prefers `recordingsLow`, every
1625
+ * other profile prefers `recordings`; when the preferred type has only
1626
+ * read-only instances, any writable recordings-type location serves;
1627
+ * 2. among those, the one with the most MEASURED headroom
1628
+ * (`min(free − minFree floor, maxUsedGb − used)`) — the same signal the
1629
+ * planner balances on, so the bootstrap pick and the plan agree instead of
1630
+ * the plan having to undo the pick at the next tick;
1631
+ * 3. ties, and a pool where nothing was measured, fall to the
1632
+ * lexicographically first id — deterministic, never "whichever came back
1633
+ * first from the cap".
1634
+ *
1635
+ * There is no preferred instance any more (D383): every enabled location of a
1636
+ * class is a write target, and which one a camera gets is a capacity question,
1637
+ * not a flag. Throws when NO writable location exists — recording cannot start,
1638
+ * and that must be loud.
1639
+ */
1640
+ function resolvePlacement(locations, profile) {
1641
+ if (locations.length === 0) throw new Error("recorder controller: no recordings storage locations resolved");
1642
+ const writable = locations.filter((l) => locationMayBeWritten(l));
1643
+ if (writable.length === 0) throw new Error("recorder controller: every recordings location is read-only — nowhere to write");
1644
+ const preferType = profile === "low" ? "recordingsLow" : "recordings";
1645
+ const preferred = writable.filter((l) => l.type === preferType);
1646
+ return emptiestOf(preferred.length > 0 ? preferred : writable);
1647
+ }
1648
+ /** The candidate with the most measured headroom; ties (and an unmeasured
1649
+ * pool, where every headroom is absent) by lexicographic id. */
1650
+ function emptiestOf(candidates) {
1651
+ return candidates.toSorted((a, b) => {
1652
+ const byHeadroom = (b.headroomBytes ?? 0) - (a.headroomBytes ?? 0);
1653
+ return byHeadroom !== 0 ? byHeadroom : a.id.localeCompare(b.id);
1654
+ })[0];
1655
+ }
1656
+ /**
1657
+ * Attribute a discovered segment on an aliased physical root by its profile,
1658
+ * not by whichever logical location happened to be hydrated last. Stock
1659
+ * `recordings` and `recordingsLow` deliberately share a root: high/mid belong
1660
+ * to `recordings`, while low belongs to `recordingsLow`.
1661
+ */
1662
+ function locationForHydratedProfile(aliases, profile) {
1663
+ const preferredType = profile === "low" ? "recordingsLow" : "recordings";
1664
+ const preferred = aliases.filter((location) => location.type === preferredType).toSorted((a, b) => a.id.localeCompare(b.id))[0];
1665
+ const fallback = aliases.toSorted((a, b) => a.id.localeCompare(b.id))[0];
1666
+ if (preferred) return preferred;
1667
+ if (fallback) return fallback;
1668
+ throw new Error("recorder: no location aliases to attribute hydrated profile");
1669
+ }
1670
+ function aliasesByRoot(locations) {
1671
+ const grouped = /* @__PURE__ */ new Map();
1672
+ for (const location of locations) {
1673
+ const aliases = grouped.get(location.root);
1674
+ if (aliases) aliases.push(location);
1675
+ else grouped.set(location.root, [location]);
1699
1676
  }
1700
- /**
1701
- * Cancel a running OR queued job. A running job stops after its current
1702
- * file; a queued job is terminated where it stands and never runs — which is
1703
- * why this returns true for both, and why the cancelled job still gets its
1704
- * `onFinished` audit row.
1705
- */
1706
- cancel(jobId) {
1707
- const job = this.jobs.get(jobId);
1708
- if (!job) return false;
1709
- if (job.state === "running") {
1710
- job.cancelRequested = true;
1711
- return true;
1712
- }
1713
- if (job.state !== "queued") return false;
1714
- job.cancelRequested = true;
1715
- job.state = "cancelled";
1716
- job.finishedAt = this.deps.now();
1717
- const at = this.queue.indexOf(jobId);
1718
- if (at >= 0) this.queue.splice(at, 1);
1719
- this.deps.onFinished?.(snapshot(job));
1720
- return true;
1677
+ return grouped;
1678
+ }
1679
+ /** One numeric key lifted out of a location's untyped `config` record, or
1680
+ * nothing at all when it is absent or not a finite number. Absent is a real
1681
+ * answer here: the caller falls back to a documented default, and a garbage
1682
+ * value must never become a capacity limit. */
1683
+ function numericConfig(config, key, field) {
1684
+ const raw = config[key];
1685
+ if (typeof raw !== "number" || !Number.isFinite(raw)) return {};
1686
+ return { [field]: raw };
1687
+ }
1688
+ /**
1689
+ * Resolve every recordings storage location (`recordings` + `recordingsLow`)
1690
+ * to `{ id, root }`. Returns an empty list when the storage cap is unreachable
1691
+ * or no recordings location exists — callers degrade rather than crash.
1692
+ */
1693
+ async function resolveRecordingsLocations(api, logger) {
1694
+ try {
1695
+ const recordings = (await api.storage.listLocations.query({})).filter((l) => RECORDING_LOCATION_TYPES.has(l.type));
1696
+ return Promise.all(recordings.map(async (l) => recordingsLocationOfMode({
1697
+ id: l.id,
1698
+ type: l.type,
1699
+ root: await api.storage.resolve.query({
1700
+ location: l.id,
1701
+ relativePath: ""
1702
+ }),
1703
+ ...numericConfig(l.config, "minFreePercent", "minFreePercent"),
1704
+ ...numericConfig(l.config, "maxUsedGb", "maxUsedGb")
1705
+ }, require_dist.resolveLocationMode(l))));
1706
+ } catch (err) {
1707
+ logger.warn("recorder: listLocations failed — no recordings locations resolved", { meta: { error: require_dist.errMsg(err) } });
1708
+ return [];
1721
1709
  }
1722
- /**
1723
- * Enqueue a relocation and return its job id. Throws only on an input the
1724
- * mover could never satisfy (unknown/identical/read-only locations) —
1725
- * validated SYNCHRONOUSLY so a caller arming a rebalance learns about a bad
1726
- * target before any job runs.
1727
- *
1728
- * Concurrency: the returned job may be `queued` rather than `running`. The
1729
- * engine still moves one job at a time.
1730
- */
1731
- start(input) {
1732
- const locs = this.deps.locations();
1733
- const from = locs.find((l) => l.id === input.fromLocationId);
1734
- const to = locs.find((l) => l.id === input.toLocationId);
1735
- if (!from) throw new Error(`unknown source location: ${input.fromLocationId}`);
1736
- if (!to) throw new Error(`unknown target location: ${input.toLocationId}`);
1737
- if (from.id === to.id) throw new Error("source and target locations are identical");
1738
- if (to.readOnly) throw new Error(`target location is read-only: ${to.id}`);
1739
- 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", {
1740
- ...input.deviceId !== void 0 ? { tags: { deviceId: input.deviceId } } : {},
1741
- meta: {
1742
- fromLocationId: from.id,
1743
- toLocationId: to.id,
1744
- footageClass: input.footageClass ?? null
1710
+ }
1711
+ /**
1712
+ * Hydrate the v2 `RecordingIndex` for one device from every recordings location
1713
+ * by recursively walking `<root>/<deviceId>/` for `*.m4s` segments and feeding
1714
+ * the location-relative paths (`<deviceId>/<profile>/Y/M/D/H/<file>.m4s`, which
1715
+ * `parseSegmentPath` understands) to `index.hydrateDevice`. A missing device dir
1716
+ * (no footage yet) and any per-location failure are warned + skipped
1717
+ * (best-effort), so one bad volume never blocks the rest of the hydrate.
1718
+ */
1719
+ async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger, options = {}) {
1720
+ const yieldBetween = options.yieldBetween ?? macrotask$3;
1721
+ const chunkSize = options.chunkSize ?? HYDRATE_CHUNK;
1722
+ for (const [root, aliases] of aliasesByRoot(locations)) {
1723
+ const deviceDir = node_path.default.join(root, String(deviceId));
1724
+ const startedMs = Date.now();
1725
+ let entries;
1726
+ try {
1727
+ entries = await node_fs.promises.readdir(deviceDir, { recursive: true });
1728
+ } catch (err) {
1729
+ if (err.code === "ENOENT") {
1730
+ for (const location of aliases) index.hydrateDevice(deviceId, location.id, []);
1731
+ continue;
1732
+ }
1733
+ logger.warn("recorder: hydrateDevice walk failed for location", {
1734
+ tags: { deviceId },
1735
+ meta: {
1736
+ locationIds: aliases.map((location) => location.id),
1737
+ error: require_dist.errMsg(err)
1738
+ }
1739
+ });
1740
+ continue;
1741
+ }
1742
+ const walkedMs = Date.now();
1743
+ const locationForProfile = /* @__PURE__ */ new Map();
1744
+ const pathsByLocation = /* @__PURE__ */ new Map();
1745
+ let handled = 0;
1746
+ for (const entry of entries) {
1747
+ if (!entry.endsWith(".m4s")) continue;
1748
+ const relative = `${deviceId}/${entry.split(node_path.default.sep).join("/")}`;
1749
+ const profile = relative.split("/")[1] ?? "";
1750
+ let locationId = locationForProfile.get(profile);
1751
+ if (locationId === void 0) {
1752
+ locationId = locationForHydratedProfile(aliases, profile).id;
1753
+ locationForProfile.set(profile, locationId);
1754
+ }
1755
+ const paths = pathsByLocation.get(locationId);
1756
+ if (paths) paths.push(relative);
1757
+ else pathsByLocation.set(locationId, [relative]);
1758
+ handled += 1;
1759
+ if (handled % chunkSize === 0) await yieldBetween();
1760
+ }
1761
+ for (const location of aliases) if (!pathsByLocation.has(location.id)) pathsByLocation.set(location.id, []);
1762
+ let indexed = 0;
1763
+ const reuseBefore = index.hydrateReuseStats();
1764
+ for (const [locationId, relPaths] of pathsByLocation) {
1765
+ index.hydrateDevice(deviceId, locationId, relPaths);
1766
+ indexed += relPaths.length;
1767
+ }
1768
+ const reuseAfter = index.hydrateReuseStats();
1769
+ const reused = reuseAfter.rowsReused - reuseBefore.rowsReused;
1770
+ const allocated = reuseAfter.rowsAllocated - reuseBefore.rowsAllocated;
1771
+ logger.info("recorder: hydrated a device from one recordings root", {
1772
+ tags: { deviceId },
1773
+ meta: {
1774
+ root,
1775
+ entries: entries.length,
1776
+ indexed,
1777
+ reused,
1778
+ allocated,
1779
+ locationIds: [...pathsByLocation.keys()],
1780
+ walkMs: walkedMs - startedMs,
1781
+ ms: Date.now() - startedMs
1782
+ }
1783
+ });
1784
+ }
1785
+ }
1786
+ /** Entries classified between yields in {@link hydrateDeviceFromStorage}. */
1787
+ var HYDRATE_CHUNK = 2e4;
1788
+ var macrotask$3 = () => new Promise((resolve) => {
1789
+ setImmediate(resolve);
1790
+ });
1791
+ /** `<deviceId>/<profile>/YYYY/MM/DD/HH` for an hour bucket (paths are UTC). */
1792
+ function hourDirRelPath(deviceId, profile, hourStartMs) {
1793
+ const d = new Date(hourStartMs);
1794
+ const p2 = (n) => String(n).padStart(2, "0");
1795
+ return `${deviceId}/${profile}/${d.getUTCFullYear()}/${p2(d.getUTCMonth() + 1)}/${p2(d.getUTCDate())}/${p2(d.getUTCHours())}`;
1796
+ }
1797
+ var HOUR_MS$7 = 36e5;
1798
+ /**
1799
+ * Hydrate ONLY the hours a window spans, instead of walking the archive.
1800
+ *
1801
+ * `hydrateDeviceFromStorage` is a recursive `readdir` over the whole device
1802
+ * subtree — ">1M files and takes minutes" per its own comment. Until it lands,
1803
+ * every read of past footage sees an empty index, which the server reports as
1804
+ * "no footage": measured after a deploy, a camera-day held 7 segments and grew
1805
+ * only at the rate of NEW writes for ~9 minutes.
1806
+ *
1807
+ * The path convention makes the window addressable directly: one SHALLOW
1808
+ * `readdir` per (profile, hour), ~450 entries each. A day is ~72 of them. The
1809
+ * background full walk still runs, for the consumers that need the whole
1810
+ * archive (footprint, eviction domains, retention).
1811
+ *
1812
+ * Hours already walked are skipped, so a drag across an hour costs nothing the
1813
+ * second time.
1814
+ *
1815
+ * ## The ledger is the first stop for a CLOSED hour (D287)
1816
+ *
1817
+ * Boot no longer replays the archive into the index, so a ledger-known hour
1818
+ * arrives here `unknown` and pays ONE point read of its hour row instead of a
1819
+ * `readdir` — measured at ~650 ms per hour directory on the live share under
1820
+ * load, against the array the scrub feeder is competing for. Hours the ledger
1821
+ * does not know, and every hour if the read fails, fall through to the walk
1822
+ * exactly as before: the failure direction is toward the disk, never toward an
1823
+ * hour that renders empty because nobody read it.
1824
+ *
1825
+ * **The live hour is never served from the ledger.** It is the one hour whose
1826
+ * durable row is knowably behind the disk: a segment finalized after the last
1827
+ * persist and before a crash is on the array and not in the row. Boot used to
1828
+ * mark every ledger hour `hydrated`, which made that tail invisible to
1829
+ * playback and the timeline FOREVER, not merely under-reported. Excluding the
1830
+ * live hour costs exactly one hour-directory read per process — the walk marks
1831
+ * it, so the second reader pays nothing — and it is what turns a lost tail
1832
+ * back into a recoverable one.
1833
+ *
1834
+ * A CLOSED hour already walked is skipped, which is what makes that exclusion
1835
+ * affordable: without it, every window touching the live hour would re-walk
1836
+ * all of its hours. The past is immutable, so re-reading a closed hour
1837
+ * directory can only ever return what the index already holds; eviction
1838
+ * removes rows through the index directly.
1839
+ */
1840
+ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations, logger, ledger) {
1841
+ if (index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
1842
+ const liveHour = Math.floor(Date.now() / HOUR_MS$7) * HOUR_MS$7;
1843
+ if (ledger !== void 0) {
1844
+ const startedMs = Date.now();
1845
+ const hours = await ledger.detailForWindow(deviceId, fromMs, toMs);
1846
+ let segments = 0;
1847
+ let served = 0;
1848
+ for (const hour of hours) {
1849
+ if (hour.hourStartMs >= liveHour) continue;
1850
+ index.hydrateHour(deviceId, hour.locationId, hour.hourStartMs, hour.paths);
1851
+ segments += hour.paths.length;
1852
+ served += 1;
1853
+ }
1854
+ if (served > 0) logger.info("recorder: hydrated a window from the hour ledger", {
1855
+ tags: { deviceId },
1856
+ meta: {
1857
+ hours: served,
1858
+ segments,
1859
+ ms: Date.now() - startedMs
1860
+ }
1861
+ });
1862
+ if (index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
1863
+ }
1864
+ for (const [root, aliases] of aliasesByRoot(locations)) {
1865
+ const deviceDir = node_path.default.join(root, String(deviceId));
1866
+ let profiles;
1867
+ try {
1868
+ profiles = (await node_fs.promises.readdir(deviceDir)).filter((e) => e === "high" || e === "mid" || e === "low");
1869
+ } catch (err) {
1870
+ if (err.code !== "ENOENT") logger.warn("recorder: hydrateWindow profile listing failed", {
1871
+ tags: { deviceId },
1872
+ meta: {
1873
+ locationIds: aliases.map((location) => location.id),
1874
+ error: require_dist.errMsg(err)
1875
+ }
1876
+ });
1877
+ continue;
1878
+ }
1879
+ const first = Math.floor(fromMs / HOUR_MS$7) * HOUR_MS$7;
1880
+ const reuseBefore = index.hydrateReuseStats();
1881
+ let hoursRead = 0;
1882
+ let repaired = 0;
1883
+ for (let hour = first; hour < toMs; hour += HOUR_MS$7) {
1884
+ if (hour < liveHour && index.hydrationOf(deviceId, hour, hour + HOUR_MS$7) === "hydrated") continue;
1885
+ for (const profile of profiles) {
1886
+ const rel = hourDirRelPath(deviceId, profile, hour);
1887
+ let files;
1888
+ try {
1889
+ files = await node_fs.promises.readdir(node_path.default.join(root, rel));
1890
+ } catch {
1891
+ continue;
1892
+ }
1893
+ hoursRead += 1;
1894
+ const locationId = locationForHydratedProfile(aliases, profile).id;
1895
+ const relPaths = files.filter((f) => f.endsWith(".m4s")).map((f) => `${rel}/${f}`);
1896
+ index.hydrateHour(deviceId, locationId, hour, relPaths);
1897
+ if (ledger !== void 0 && relPaths.length > 0 && hour + HOUR_MS$7 <= Date.now() && !ledger.knowsHour(deviceId, profile, locationId, hour)) {
1898
+ repaired += 1;
1899
+ await ledger.adoptRebuiltHour(deviceId, profile, locationId, hour, relPaths);
1900
+ }
1901
+ }
1902
+ }
1903
+ index.markHydrated(deviceId, fromMs, toMs);
1904
+ if (hoursRead === 0) continue;
1905
+ const reuseAfter = index.hydrateReuseStats();
1906
+ const reused = reuseAfter.rowsReused - reuseBefore.rowsReused;
1907
+ const allocated = reuseAfter.rowsAllocated - reuseBefore.rowsAllocated;
1908
+ logger.info("recorder: hydrated a window from one recordings root", {
1909
+ tags: { deviceId },
1910
+ meta: {
1911
+ root,
1912
+ hoursRead,
1913
+ repaired,
1914
+ reused,
1915
+ allocated
1745
1916
  }
1746
1917
  });
1918
+ }
1919
+ }
1920
+ //#endregion
1921
+ //#region src/recorder/relocate-engine.ts
1922
+ /**
1923
+ * Footage relocation engine (entity-routing spec, Phase 4) — the mover half
1924
+ * of disk decommissioning: mark the old location read-only (Phase 2), then
1925
+ * relocate its footage here, then delete the location.
1926
+ *
1927
+ * Mechanics, per HOUR (D291): throttled copy + size-verify of every file in the
1928
+ * hour → re-point the DURABLE hour row → delete the sources → re-point the RAM
1929
+ * index. The hour is the batch because the durable index is hour-granular, and
1930
+ * the sources outlive the durable write on purpose: between the copy and the
1931
+ * delete both locations hold the hour, so no crash can leave the index naming a
1932
+ * location without the bytes. The copy is `copy-if-absent` (an equal-size
1933
+ * destination is adopted, not re-copied), so cancel/crash/re-run RESUMES
1934
+ * instead of restarting, and a cancel inside an hour leaves that hour WHOLLY
1935
+ * readable at the source. Throttling is file-level pacing (segments are
1936
+ * ~1–8 MB / ~4 s, so per-file granularity bounds the rate tightly enough) —
1937
+ * the drain is a background chore that must never starve live writers.
1938
+ *
1939
+ * Runs INSIDE the recorder process — container uid, same-uid files, no host
1940
+ * chown trap (see docs/history 2026-07-29).
1941
+ *
1942
+ * ── Phase 2 (D116, multi-location) ────────────────────────────────────────
1943
+ * Two additions the OPERATOR-ARMED rebalance needs:
1944
+ *
1945
+ * - A move is scoped to one CAMERA (and optionally to specific profiles),
1946
+ * not just to a whole disk. The rebalance's unit is a (camera, profile)
1947
+ * pile, because that is the unit the placement plan assigns.
1948
+ * - Concurrent starts QUEUE instead of being refused. The engine is still
1949
+ * single-flight — two movers on one disk thrash both — but a rebalance
1950
+ * enqueues one job per pile, and refusing the second turned a
1951
+ * fifteen-camera rebalance into fifteen manual retries. Jobs run FIFO; a
1952
+ * job cancelled while queued never runs, and a job that FAILS does not
1953
+ * strand the queue behind it.
1954
+ */
1955
+ var DEFAULT_THROTTLE_MBPS = 40;
1956
+ /** The durable index's bucket, and therefore this mover's batch. */
1957
+ var HOUR_MS$6 = 36e5;
1958
+ /** Progress callback cadence (files) — cheap enough to fire often. */
1959
+ var PROGRESS_EVERY_FILES = 25;
1960
+ /** Free space a destination must keep when it declares no `config.minFreePercent`
1961
+ * — the same default `roots.ts` documents for placement. */
1962
+ var DEFAULT_MIN_FREE_PERCENT$1 = 10;
1963
+ /** Prefix every destination-full error carries, so the coordinator can report
1964
+ * "drain stalled: destination full" instead of a raw ENOSPC string. */
1965
+ var DESTINATION_FULL_PREFIX = "destination full";
1966
+ /**
1967
+ * Ghost rows one job may FORGET, as a share of its own census — and the floor
1968
+ * below which the share is not the bound.
1969
+ *
1970
+ * D148 is why this is bounded at all: the ledger forgets only on a trusted walk
1971
+ * or an eviction, so that a transient read error cannot erase an operator's
1972
+ * index of real footage. A per-path ENOENT on a path derived from the row is
1973
+ * strong evidence about THAT path (D296) and weak evidence about a mount: a
1974
+ * root that vanished answers ENOENT for every path it is asked about, and looks
1975
+ * exactly like a location that is genuinely empty. The bound is what makes the
1976
+ * difference survivable — past it the mover keeps moving, keeps skipping, and
1977
+ * REPORTS, so an operator gets a reconcile to act on instead of an archive that
1978
+ * quietly emptied itself.
1979
+ *
1980
+ * 17.5 % of `recordingsLow:default` was ghosts on 2026-08-30 (11 074 of
1981
+ * 63 346), so a quarter admits a real reconcile of that size in one pass while
1982
+ * refusing a whole-location erase.
1983
+ */
1984
+ var GHOST_FORGET_SHARE = .25;
1985
+ function snapshot(j) {
1986
+ return {
1987
+ jobId: j.jobId,
1988
+ state: j.state,
1989
+ fromLocationId: j.fromLocationId,
1990
+ toLocationId: j.toLocationId,
1991
+ deviceId: j.deviceId,
1992
+ entities: [...j.entities],
1993
+ filesMoved: j.filesMoved,
1994
+ bytesMoved: j.bytesMoved,
1995
+ filesTotal: j.filesTotal,
1996
+ rowsReconciled: j.rowsReconciled,
1997
+ rowsForgotten: j.rowsForgotten,
1998
+ startedAt: j.startedAt,
1999
+ finishedAt: j.finishedAt,
2000
+ error: j.error
2001
+ };
2002
+ }
2003
+ /**
2004
+ * The row's source file is not on disk.
2005
+ *
2006
+ * Carries `code: 'ENOENT'` because that is how every caller here already tells
2007
+ * a ghost row from a real failure, and {@link copyVerified} now discovers the
2008
+ * absence with a `stat` rather than by letting `copyFile` raise it.
2009
+ */
2010
+ var RelocateSourceMissingError = class extends Error {
2011
+ code = "ENOENT";
2012
+ constructor(src) {
2013
+ super(`relocate source missing: ${src}`);
2014
+ this.name = "RelocateSourceMissingError";
2015
+ }
2016
+ };
2017
+ /**
2018
+ * One file's COPY: copy-if-absent → verify the destination against the SOURCE'S
2019
+ * OWN SIZE. The source is left in place.
2020
+ *
2021
+ * Split out of {@link moveFile} because the durable index re-point has to
2022
+ * happen while BOTH locations still hold the bytes — see
2023
+ * `SegmentHourLedger.repointSegments`.
2024
+ *
2025
+ * **`recordedBytes` is never the verify's reference.** It used to be, and it
2026
+ * cannot be: `fs.copyFile` truncates and replaces, so the destination always
2027
+ * equals the source, and comparing that result against a number the LEDGER
2028
+ * remembers can only ever fail when the ledger is wrong. On 2026-08-30 that
2029
+ * reported a byte-perfect copy as corruption and killed a 110 749-file drain
2030
+ * on its 12 440th file:
2031
+ *
2032
+ * relocate verify failed: …/1787926410000-9966-274432.m4s
2033
+ * is 783801B, expected 274432B
2034
+ *
2035
+ * Both files measured 783801 B on the hub. What is faithful is "the
2036
+ * destination equals the source", and that is what is asserted here; the
2037
+ * recorded number survives only as the reference for a destination whose
2038
+ * source is already GONE, where there is nothing else left to compare against.
2039
+ */
2040
+ async function copyVerified(src, dst, recordedBytes) {
2041
+ const dstBytes = await fileSize(dst);
2042
+ const srcBytes = await fileSize(src);
2043
+ if (dstBytes !== null && dstBytes === (srcBytes ?? recordedBytes)) return {
2044
+ copied: 0,
2045
+ srcBytes: dstBytes
2046
+ };
2047
+ if (srcBytes === null) throw new RelocateSourceMissingError(src);
2048
+ await node_fs.promises.mkdir(node_path.default.dirname(dst), { recursive: true });
2049
+ await node_fs.promises.copyFile(src, dst);
2050
+ const after = await node_fs.promises.stat(dst);
2051
+ if (after.size !== srcBytes) throw new Error(`relocate verify failed: ${dst} is ${after.size}B, expected ${srcBytes}B`);
2052
+ return {
2053
+ copied: srcBytes,
2054
+ srcBytes
2055
+ };
2056
+ }
2057
+ /** A file's size, or `null` when it is not there. */
2058
+ async function fileSize(p) {
2059
+ try {
2060
+ return (await node_fs.promises.stat(p)).size;
2061
+ } catch {
2062
+ return null;
2063
+ }
2064
+ }
2065
+ var RelocateEngine = class {
2066
+ deps;
2067
+ jobs = /* @__PURE__ */ new Map();
2068
+ /** Job ids waiting for the mover, in enqueue order. */
2069
+ queue = [];
2070
+ /** Set while a job is executing — the single-flight latch. */
2071
+ draining = false;
2072
+ constructor(deps) {
2073
+ this.deps = deps;
2074
+ }
2075
+ list() {
2076
+ return [...this.jobs.values()].sort((a, b) => b.startedAt - a.startedAt).map(snapshot);
2077
+ }
2078
+ /**
2079
+ * Cancel a running OR queued job. A running job stops after its current
2080
+ * file; a queued job is terminated where it stands and never runs — which is
2081
+ * why this returns true for both, and why the cancelled job still gets its
2082
+ * `onFinished` audit row.
2083
+ */
2084
+ cancel(jobId) {
2085
+ const job = this.jobs.get(jobId);
2086
+ if (!job) return false;
2087
+ if (job.state === "running") {
2088
+ job.cancelRequested = true;
2089
+ return true;
2090
+ }
2091
+ if (job.state !== "queued") return false;
2092
+ job.cancelRequested = true;
2093
+ job.state = "cancelled";
2094
+ job.finishedAt = this.deps.now();
2095
+ const at = this.queue.indexOf(jobId);
2096
+ if (at >= 0) this.queue.splice(at, 1);
2097
+ this.deps.onFinished?.(snapshot(job));
2098
+ return true;
2099
+ }
2100
+ /**
2101
+ * Enqueue a relocation and return its job id. Throws only on an input the
2102
+ * mover could never satisfy (unknown/identical/read-only locations) —
2103
+ * validated SYNCHRONOUSLY so a caller arming a rebalance learns about a bad
2104
+ * target before any job runs.
2105
+ *
2106
+ * Concurrency: the returned job may be `queued` rather than `running`. The
2107
+ * engine still moves one job at a time.
2108
+ */
2109
+ start(input) {
2110
+ const locs = this.deps.locations();
2111
+ const from = locs.find((l) => l.id === input.fromLocationId);
2112
+ const to = locs.find((l) => l.id === input.toLocationId);
2113
+ if (!from) throw new Error(`unknown source location: ${input.fromLocationId}`);
2114
+ if (!to) throw new Error(`unknown target location: ${input.toLocationId}`);
2115
+ if (from.id === to.id) throw new Error("source and target locations are identical");
2116
+ if (!locationMayBeWritten(to)) throw new Error(`target location is read-only (mode: ${locationMode(to)}): ${to.id}`);
1747
2117
  const job = {
1748
2118
  jobId: this.deps.newId(),
1749
2119
  state: "queued",
@@ -2281,7 +2651,7 @@ function profilesOfClass(footageClass) {
2281
2651
  function groupByHour(rows) {
2282
2652
  const out = /* @__PURE__ */ new Map();
2283
2653
  for (const row of rows) {
2284
- const key = `${row.deviceId}|${row.profile}|${Math.floor(row.startMs / HOUR_MS$7)}`;
2654
+ const key = `${row.deviceId}|${row.profile}|${Math.floor(row.startMs / HOUR_MS$6)}`;
2285
2655
  const found = out.get(key);
2286
2656
  if (found) found.push(row);
2287
2657
  else out.set(key, [row]);
@@ -2470,7 +2840,7 @@ var SegmentStore = class SegmentStore {
2470
2840
  else byBucket.set(bucketDir, [r]);
2471
2841
  }
2472
2842
  const chunkSize = this.deps.evictChunkSize ?? SegmentStore.DEFAULT_EVICT_CHUNK;
2473
- const yieldBetween = this.deps.yieldBetween ?? macrotask$3;
2843
+ const yieldBetween = this.deps.yieldBetween ?? macrotask$2;
2474
2844
  let bucketsRemoved = 0;
2475
2845
  let handled = 0;
2476
2846
  for (const [bucketDir, bucketRows] of byBucket) {
@@ -2618,7 +2988,7 @@ var SegmentStore = class SegmentStore {
2618
2988
  return held.every((p) => doomed.has(p));
2619
2989
  }
2620
2990
  };
2621
- var macrotask$3 = () => new Promise((resolve) => {
2991
+ var macrotask$2 = () => new Promise((resolve) => {
2622
2992
  setImmediate(resolve);
2623
2993
  });
2624
2994
  //#endregion
@@ -3130,6 +3500,12 @@ function intersect(a, b) {
3130
3500
  * The cap's `locationId` is used directly as the storage `location` (identity); see Plan 2d
3131
3501
  * notes. Wiring this class to the cap + the SegmentStore happens in Plan 2e.
3132
3502
  */
3503
+ /**
3504
+ * One class, two cap surfaces: `storage-evictable` (what may be deleted under
3505
+ * pressure) and `storage-occupancy` (what is HELD, D388). They live together
3506
+ * because they read the same archive, and they are separate CAPS because they
3507
+ * are separate quantities — see `getOccupancy`.
3508
+ */
3133
3509
  var StorageEvictableProvider = class {
3134
3510
  deps;
3135
3511
  orphanLane;
@@ -3147,9 +3523,9 @@ var StorageEvictableProvider = class {
3147
3523
  * reported evictable nor selected as victims). */
3148
3524
  domainOf(locationId) {
3149
3525
  const domain = this.deps.resolveDomain?.(locationId) ?? new Set([locationId]);
3150
- if (!this.deps.isReadOnly) return domain;
3526
+ if (!this.deps.isEvictionFrozen) return domain;
3151
3527
  const writable = /* @__PURE__ */ new Set();
3152
- for (const id of domain) if (!this.deps.isReadOnly(id)) writable.add(id);
3528
+ for (const id of domain) if (!this.deps.isEvictionFrozen(id)) writable.add(id);
3153
3529
  return writable;
3154
3530
  }
3155
3531
  /**
@@ -3168,6 +3544,41 @@ var StorageEvictableProvider = class {
3168
3544
  }
3169
3545
  return { bytes: (await archive.accountingForLocations(this.domainOf(input.locationId))).bytes };
3170
3546
  }
3547
+ /**
3548
+ * The `storage-occupancy` answer (D388) — a DIFFERENT quantity from
3549
+ * {@link getEvictableUsage}, and the difference is the whole reason the cap
3550
+ * exists:
3551
+ *
3552
+ * - it is the location's OWN bytes, not its eviction domain's. Two aliases
3553
+ * of one root report their own halves instead of the same combined total
3554
+ * twice;
3555
+ * - it counts a location whose eviction policy is `never` (a `readonly` or
3556
+ * `disabled` disk), which `getEvictableUsage` deliberately reports as 0.
3557
+ * Those are the disks an operator retiring hardware is staring at.
3558
+ *
3559
+ * A location this recorder holds nothing on is OMITTED, never reported as
3560
+ * zero, and an archive it cannot measure yields NO reports at all — unknown
3561
+ * is the truth when nothing could be read.
3562
+ */
3563
+ async getOccupancy(input) {
3564
+ const archive = this.deps.archive();
3565
+ if (archive === null) {
3566
+ 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] } });
3567
+ return [];
3568
+ }
3569
+ const measuredAtMs = this.deps.now?.() ?? Date.now();
3570
+ const out = [];
3571
+ for (const locationId of input.locationIds) {
3572
+ const acct = await archive.accountingForLocations(new Set([locationId]));
3573
+ if (acct.bytes <= 0) continue;
3574
+ out.push({
3575
+ locationId,
3576
+ ownedBytes: acct.bytes,
3577
+ measuredAtMs
3578
+ });
3579
+ }
3580
+ return out;
3581
+ }
3171
3582
  async evict(input) {
3172
3583
  const archive = this.deps.archive();
3173
3584
  if (archive === null) {
@@ -3434,7 +3845,7 @@ async function profileHasFootage(root, deviceId, profile) {
3434
3845
  //#endregion
3435
3846
  //#region src/recorder/addon/calendar-index.ts
3436
3847
  var DAY_MS$3 = 864e5;
3437
- var HOUR_MS$6 = 36e5;
3848
+ var HOUR_MS$5 = 36e5;
3438
3849
  /**
3439
3850
  * UTC ms → the UTC epoch of LOCAL midnight of the day it falls in.
3440
3851
  *
@@ -3472,8 +3883,8 @@ var RANGE_MERGE_GAP_MS$1 = 5e3;
3472
3883
  * deploy gap). Unknown hours still fall through to one directory read.
3473
3884
  */
3474
3885
  function hourSegmentsFromIndex(index, deviceId, profile, hourStartMs) {
3475
- const hour = Math.floor(hourStartMs / HOUR_MS$6) * HOUR_MS$6;
3476
- const hourEnd = hour + HOUR_MS$6;
3886
+ const hour = Math.floor(hourStartMs / HOUR_MS$5) * HOUR_MS$5;
3887
+ const hourEnd = hour + HOUR_MS$5;
3477
3888
  if (index.hydrationOf(deviceId, hour, hourEnd) !== "hydrated") return null;
3478
3889
  const rows = [];
3479
3890
  for (const s of index.segmentsStartingIn(deviceId, profile, hour, hourEnd)) rows.push({
@@ -3630,7 +4041,7 @@ var CalendarIndex = class {
3630
4041
  }));
3631
4042
  const days = /* @__PURE__ */ new Set();
3632
4043
  for (const { day, hours } of perDay) for (const h of hours) {
3633
- const at = day + h * HOUR_MS$6;
4044
+ const at = day + h * HOUR_MS$5;
3634
4045
  if (at < fromMs || at >= toMs) continue;
3635
4046
  days.add(localDayStart(at, tzOffsetMinutes));
3636
4047
  }
@@ -3692,7 +4103,7 @@ var CalendarIndex = class {
3692
4103
  const day = utcDayOf(utcDayMs);
3693
4104
  for (let h = 0; h < 24; h++) {
3694
4105
  await this.awaitQuiet();
3695
- await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$6, true);
4106
+ await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$5, true);
3696
4107
  }
3697
4108
  }
3698
4109
  /**
@@ -3713,7 +4124,7 @@ var CalendarIndex = class {
3713
4124
  */
3714
4125
  async segmentsOfChecked(deviceId, profile, hourStartMs, background = false) {
3715
4126
  if (!background) this.lastForegroundAt = this.deps.now?.() ?? Date.now();
3716
- const hour = Math.floor(hourStartMs / HOUR_MS$6) * HOUR_MS$6;
4127
+ const hour = Math.floor(hourStartMs / HOUR_MS$5) * HOUR_MS$5;
3717
4128
  const fromIndex = this.deps.indexHour?.(deviceId, profile, hour);
3718
4129
  if (fromIndex !== null && fromIndex !== void 0) return {
3719
4130
  segments: fromIndex,
@@ -3721,7 +4132,7 @@ var CalendarIndex = class {
3721
4132
  };
3722
4133
  const key = `${deviceId}:${profile}:${hour}`;
3723
4134
  const now = this.deps.now?.() ?? Date.now();
3724
- const isCurrentHour = hour === Math.floor(now / HOUR_MS$6) * HOUR_MS$6;
4135
+ const isCurrentHour = hour === Math.floor(now / HOUR_MS$5) * HOUR_MS$5;
3725
4136
  const cached = this.hourSegs.get(key);
3726
4137
  if (cached !== void 0) {
3727
4138
  if (!isCurrentHour) return {
@@ -3785,7 +4196,7 @@ var CalendarIndex = class {
3785
4196
  */
3786
4197
  async dayRanges(deviceId, profile, utcDayMs) {
3787
4198
  const day = utcDayOf(utcDayMs);
3788
- 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);
4199
+ 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);
3789
4200
  }
3790
4201
  /**
3791
4202
  * Exact ranges of `[fromMs, toMs)`, scanning ONLY the hours the window
@@ -3799,9 +4210,9 @@ var CalendarIndex = class {
3799
4210
  */
3800
4211
  async rangesIn(deviceId, profile, fromMs, toMs) {
3801
4212
  if (toMs <= fromMs) return [];
3802
- const first = Math.floor(fromMs / HOUR_MS$6) * HOUR_MS$6;
4213
+ const first = Math.floor(fromMs / HOUR_MS$5) * HOUR_MS$5;
3803
4214
  const hours = [];
3804
- for (let hour = first; hour < toMs; hour += HOUR_MS$6) hours.push(hour);
4215
+ for (let hour = first; hour < toMs; hour += HOUR_MS$5) hours.push(hour);
3805
4216
  const perHour = await mapBounded(hours, READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour));
3806
4217
  const ranges = [];
3807
4218
  for (const r of mergeRanges(perHour.flat(), RANGE_MERGE_GAP_MS$1)) {
@@ -3950,7 +4361,7 @@ var DEFAULT_DEVICE_CONFIG = {
3950
4361
  };
3951
4362
  /** Build the handle. `declare()` MUST run once at boot before any read/write. */
3952
4363
  function recordingConfigsState(store, logger) {
3953
- return new require_prebuffer.RowMapStore({
4364
+ return new require_retire_root_keys.RowMapStore({
3954
4365
  spec: RECORDING_CONFIGS_SPEC,
3955
4366
  store,
3956
4367
  logger
@@ -4329,7 +4740,7 @@ function handleStoreCensusAction(census, logger, input) {
4329
4740
  }
4330
4741
  //#endregion
4331
4742
  //#region src/recorder/addon/directory-handler.ts
4332
- var HOUR_MS$5 = 36e5;
4743
+ var HOUR_MS$4 = 36e5;
4333
4744
  /** A window wider than this is refused: it is a client bug, and answering it
4334
4745
  * would walk the archive on the read path — the thing this replaces. */
4335
4746
  var MAX_WINDOW_MS = 32 * 864e5;
@@ -4376,9 +4787,9 @@ function withDirectoryRoute(deps, next) {
4376
4787
  return;
4377
4788
  }
4378
4789
  const rows = [];
4379
- const firstHour = Math.floor(fromMs / HOUR_MS$5) * HOUR_MS$5;
4790
+ const firstHour = Math.floor(fromMs / HOUR_MS$4) * HOUR_MS$4;
4380
4791
  let hydrated = true;
4381
- for (let hour = firstHour; hour < toMs; hour += HOUR_MS$5) {
4792
+ for (let hour = firstHour; hour < toMs; hour += HOUR_MS$4) {
4382
4793
  const { segments, complete } = await deps.source.segmentsOfChecked(deviceId, profile, hour);
4383
4794
  if (!complete) hydrated = false;
4384
4795
  for (const s of segments) {
@@ -4564,21 +4975,16 @@ async function wipeClassSubtrees(input) {
4564
4975
  }
4565
4976
  //#endregion
4566
4977
  //#region src/recorder/addon/location-discard-policy.ts
4567
- /**
4568
- * Server-side eligibility for discarding a frozen location's leftover
4569
- * class subtree. Same rules as the admin-ui card: frozen, known class,
4570
- * writable sibling of the same type. The last writable of a type is
4571
- * refused here even if the UI is bypassed.
4572
- */
4573
4978
  var DISCARD_SUBTREES = {
4574
4979
  eventMedia: ["events"],
4575
4980
  recordingsLow: ["low"],
4576
4981
  recordings: ["high", "mid"]
4577
4982
  };
4983
+ /** Frozen = the system will not remove this footage on its own: eviction
4984
+ * policy `never` (D385), i.e. `readonly` or `disabled`. A `drain` location is
4985
+ * NOT frozen — the ratchet is already removing it. */
4578
4986
  function locationIsFrozen(location) {
4579
- const enabled = location.enabled !== false;
4580
- const readOnly = location.config["readOnly"] === true;
4581
- return !enabled || readOnly;
4987
+ return require_dist.evictionPolicyOfLocation(location) === "never";
4582
4988
  }
4583
4989
  function discardEligible(location, siblings) {
4584
4990
  if (!(location.type in DISCARD_SUBTREES)) return false;
@@ -4586,9 +4992,7 @@ function discardEligible(location, siblings) {
4586
4992
  return siblings.some((s) => {
4587
4993
  if (s.id === location.id) return false;
4588
4994
  if (s.type !== location.type) return false;
4589
- const enabled = s.enabled !== false;
4590
- const readOnly = s.config["readOnly"] === true;
4591
- return enabled && !readOnly;
4995
+ return require_dist.mayWriteToLocation(s);
4592
4996
  });
4593
4997
  }
4594
4998
  function assertDiscardAllowed(location, all, localNodeId) {
@@ -4797,6 +5201,20 @@ function createFootageDiscardJob(deps) {
4797
5201
  };
4798
5202
  }
4799
5203
  //#endregion
5204
+ //#region src/recorder/addon/location-resolve.ts
5205
+ /**
5206
+ * A location this process may READ from.
5207
+ *
5208
+ * Throws with a message that names WHICH of the two it is — never one message
5209
+ * for both. The caller does not have to branch: the message is the product.
5210
+ */
5211
+ function requireReadableLocation(locations, locationId, context) {
5212
+ const location = locations.find((candidate) => candidate.id === locationId);
5213
+ if (location === void 0) throw new Error(`${context}: unknown location ${locationId}`);
5214
+ 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.`);
5215
+ return location;
5216
+ }
5217
+ //#endregion
4800
5218
  //#region src/recorder/addon/export-dense-map.ts
4801
5219
  /**
4802
5220
  * Shortest clip worth keeping, in ms.
@@ -5241,7 +5659,7 @@ var RECORDING_EXPORTS_SPEC = {
5241
5659
  };
5242
5660
  /** Build the handle. `declare()` MUST run once at boot before any read/write. */
5243
5661
  function recordingExportsState(store, logger) {
5244
- return new require_prebuffer.RowMapStore({
5662
+ return new require_retire_root_keys.RowMapStore({
5245
5663
  spec: RECORDING_EXPORTS_SPEC,
5246
5664
  store,
5247
5665
  logger
@@ -6784,8 +7202,7 @@ async function evictVictims(deps, deviceId, victims) {
6784
7202
  function resolveSegmentFile(deps, deviceId, profile, startMs) {
6785
7203
  const seg = deps.index.segmentStartingAt(deviceId, profile, startMs);
6786
7204
  if (!seg) throw new Error(`recording: no segment at start ${startMs} for ${deviceId}/${profile}`);
6787
- const loc = deps.locations().find((l) => l.id === seg.locationId);
6788
- if (!loc) throw new Error(`recording: unknown location ${seg.locationId}`);
7205
+ const loc = requireReadableLocation(deps.locations(), seg.locationId, "recording");
6789
7206
  return {
6790
7207
  seg,
6791
7208
  abs: node_path.default.join(loc.root, seg.path)
@@ -7058,7 +7475,7 @@ function buildRecordingProvider(deps) {
7058
7475
  }
7059
7476
  const locationUsages = await Promise.all([...byPhysical.values()].map(async (group) => {
7060
7477
  const first = group[0];
7061
- const cap = group.some((l) => l.enabled !== false) ? await deps.capacity(first.root) : {
7478
+ const cap = group.some((l) => locationMayBeWritten(l)) ? await deps.capacity(first.root) : {
7062
7479
  availableBytes: null,
7063
7480
  totalBytes: null
7064
7481
  };
@@ -7576,528 +7993,162 @@ var RENDER_TIMEOUT_MS = 3e4;
7576
7993
  */
7577
7994
  var FOOTAGE_NOT_READY = "footage-not-yet-finalized";
7578
7995
  /**
7579
- * GIF for the widest notifier reach (every backend that takes an image takes a
7580
- * gif); `fps` decimates and lanczos downscales.
7581
- */
7582
- async function renderGif(renderDeps, input) {
7583
- const out = await renderFootage(renderDeps, input, "gif", (playlistPath, outPath) => [
7584
- "-y",
7585
- "-i",
7586
- playlistPath,
7587
- "-vf",
7588
- `fps=${input.fps},scale=${input.maxWidth}:-2:flags=lanczos`,
7589
- "-loop",
7590
- "0",
7591
- "-f",
7592
- "gif",
7593
- outPath
7594
- ]);
7595
- return {
7596
- gifBase64: out.bytes.toString("base64"),
7597
- fromMs: out.fromMs,
7598
- toMs: out.toMs
7599
- };
7600
- }
7601
- /**
7602
- * MP4 clip — the "send a clip, not a gif" attachment. Re-encodes rather than
7603
- * stream-copying: a concat of segments whose encoder parameters differ (a
7604
- * profile switch, a broker redial) produces an mp4 no player will scrub, and a
7605
- * notification attachment that will not play is worse than none. `faststart`
7606
- * puts the moov up front so a chat client can play it without the whole file.
7607
- */
7608
- async function renderClip(renderDeps, input) {
7609
- const out = await renderFootage(renderDeps, input, "mp4", (playlistPath, outPath) => [
7610
- "-y",
7611
- "-i",
7612
- playlistPath,
7613
- "-vf",
7614
- `scale=${input.maxWidth}:-2:flags=lanczos`,
7615
- "-c:v",
7616
- "libx264",
7617
- "-preset",
7618
- "veryfast",
7619
- "-crf",
7620
- "28",
7621
- "-profile:v",
7622
- "main",
7623
- "-pix_fmt",
7624
- "yuv420p",
7625
- "-movflags",
7626
- "+faststart",
7627
- "-an",
7628
- "-f",
7629
- "mp4",
7630
- outPath
7631
- ]);
7632
- return {
7633
- clipBase64: out.bytes.toString("base64"),
7634
- mime: "video/mp4",
7635
- fromMs: out.fromMs,
7636
- toMs: out.toMs,
7637
- bytes: out.bytes.byteLength
7638
- };
7639
- }
7640
- /** The shared playlist → ffmpeg → bytes pipeline. */
7641
- async function renderFootage(renderDeps, input, ext, buildArgs) {
7642
- const now = renderDeps.now ?? Date.now;
7643
- const fromMs = input.aroundMs - input.preRollSec * 1e3;
7644
- const toMs = input.aroundMs + input.postRollSec * 1e3;
7645
- 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}`);
7646
- const renderId = `${ext}-${now()}-${Math.floor(Math.random() * 1e6)}`;
7647
- const availableEnd = latestFinalizedEnd(renderDeps, input.deviceId);
7648
- if (availableEnd === null || availableEnd < toMs) throw new Error(`${FOOTAGE_NOT_READY}: [${fromMs}, ${toMs}) for device ${input.deviceId} — finalized footage ends at ${availableEnd ?? "never"}`);
7649
- const window = {
7650
- fromMs,
7651
- toMs
7652
- };
7653
- const playlist = await writeExportSourcePlaylist({
7654
- deps: renderDeps.deps,
7655
- exportsDir: renderDeps.exportsDir,
7656
- exportId: renderId,
7657
- deviceId: input.deviceId,
7658
- profile: "low",
7659
- fromMs: window.fromMs,
7660
- toMs: window.toMs
7661
- });
7662
- if (playlist === null) throw new Error(`no footage covers [${window.fromMs}, ${window.toMs}) for device ${input.deviceId}`);
7663
- const outPath = node_path.default.join(node_path.default.dirname(playlist.path), `${renderId}.${ext}`);
7664
- try {
7665
- await runFfmpeg(renderDeps, buildArgs(playlist.path, outPath), ext);
7666
- const bytes = await node_fs.promises.readFile(outPath);
7667
- if (bytes.byteLength === 0) throw new Error(`${ext} render produced an empty file`);
7668
- return {
7669
- bytes,
7670
- fromMs: window.fromMs,
7671
- toMs: window.toMs
7672
- };
7673
- } finally {
7674
- await node_fs.promises.rm(playlist.path, { force: true }).catch(() => {});
7675
- await node_fs.promises.rm(playlist.concatPath, { force: true }).catch(() => {});
7676
- await node_fs.promises.rm(outPath, { force: true }).catch(() => {});
7677
- }
7678
- }
7679
- /**
7680
- * The end of the newest FINALIZED `low` segment, or null when none exists.
7681
- *
7682
- * One scalar off the tail of the live sorted view — it used to COPY the
7683
- * device's whole `low` archive to fold a maximum out of it, per render.
7684
- */
7685
- function latestFinalizedEnd(renderDeps, deviceId) {
7686
- return renderDeps.deps.index.latestEnd(deviceId, "low");
7687
- }
7688
- /**
7689
- * Run one ffmpeg and GUARANTEE the child is gone before the promise settles.
7690
- *
7691
- * The previous shape leaked on two paths: a `spawn` error (`ENOENT`, a bad
7692
- * path) rejected while a process could still be alive, and a `close` with a
7693
- * non-zero code rejected without ever reaping stdio. These renders fire on
7694
- * every notification with a gif or clip, so a leak here is a slow accumulation
7695
- * of ffmpeg processes on the recording node — the kind that shows up days later
7696
- * as "the hub got slow" (operator, 2026-07-30).
7697
- *
7698
- * `settle` is idempotent and always kills first: killing an already-exited pid
7699
- * throws ESRCH, which is swallowed, so the cost of being wrong is nothing while
7700
- * the cost of NOT killing is a zombie per render.
7701
- */
7702
- function runFfmpeg(renderDeps, args, ext) {
7703
- return new Promise((resolve, reject) => {
7704
- const proc = renderDeps.spawn(renderDeps.ffmpegPath ?? "ffmpeg", args);
7705
- let done = false;
7706
- let timer = null;
7707
- const settle = (err) => {
7708
- if (done) return;
7709
- done = true;
7710
- if (timer !== null) {
7711
- clearTimeout(timer);
7712
- timer = null;
7713
- }
7714
- if (proc.exitCode === null && proc.signalCode === null) try {
7715
- proc.kill("SIGKILL");
7716
- } catch {}
7717
- try {
7718
- proc.stderr?.removeAllListeners();
7719
- proc.stdout?.destroy();
7720
- proc.stderr?.destroy();
7721
- } catch {}
7722
- if (err === null) resolve();
7723
- else reject(err);
7724
- };
7725
- timer = setTimeout(() => settle(/* @__PURE__ */ new Error(`${ext} render timed out`)), RENDER_TIMEOUT_MS);
7726
- let stderrTail = "";
7727
- proc.stderr?.on("data", (c) => {
7728
- stderrTail = (stderrTail + c.toString()).slice(-400);
7729
- });
7730
- proc.on("error", (err) => settle(err instanceof Error ? err : new Error(String(err))));
7731
- proc.on("close", (code) => {
7732
- settle(code === 0 ? null : /* @__PURE__ */ new Error(`ffmpeg ${ext} render exited ${code}: ${stderrTail}`));
7733
- });
7734
- });
7735
- }
7736
- //#endregion
7737
- //#region src/recorder/addon/roots.ts
7738
- /**
7739
- * Storage-root resolution + boot hydrate for the recorder addon.
7740
- *
7741
- * Recordings live in the managed `recordings` / `recordingsLow` storage
7742
- * locations (operator-configurable, multi-disk-ready), resolved through the
7743
- * `storage` cap. The v2 `RecordingIndex` is hydrated by walking each location
7744
- * root's `<deviceId>/` subtree on disk — segment paths encode start/dur/bytes,
7745
- * so the in-memory index can never drift from disk. (We walk the filesystem
7746
- * directly instead of `storage.list` because that cap lists only a directory's
7747
- * IMMEDIATE children, not the `<deviceId>/<profile>/Y/M/D/H/*.m4s` tree — using
7748
- * it dropped the entire index to ~empty on every restart/rescan; the recorder
7749
- * already does local fs I/O for stat/move on these same roots.)
7750
- */
7751
- /** The two recordings location types the recorder addon writes/reads. */
7752
- var PLACEMENT_LOCATION_TYPES = ["recordings", "recordingsLow"];
7753
- var RECORDING_LOCATION_TYPES = new Set(PLACEMENT_LOCATION_TYPES);
7754
- /**
7755
- * The recordings CLASS a profile belongs to. The one place this rule is
7756
- * written: `resolvePlacement`, the planner's demand type and the repoint check
7757
- * must agree on it, and three copies of `profile === 'low' ? … : …` is how they
7758
- * stop agreeing.
7759
- */
7760
- function placementTypeForProfile(profile) {
7761
- return profile === "low" ? "recordingsLow" : "recordings";
7762
- }
7763
- /** The profile that routes to each class — the probe `authorityLocations` asks
7764
- * `resolvePlacement` with, so the answer is derived from the same function the
7765
- * unassigned path uses rather than from a second reading of `isDefault`. */
7766
- var CLASS_PROBE_PROFILE = {
7767
- recordings: "high",
7768
- recordingsLow: "low"
7769
- };
7770
- /**
7771
- * Where each recordings class writes when NOTHING has planned it — the location
7772
- * `resolvePlacement` picks, which is the type default unless the default is
7773
- * gone, read-only, or absent from the pool.
7774
- *
7775
- * This is the AUTHORITY a stored assignment is measured against (D298). A
7776
- * repoint (`storage.setDefaultLocations`, an operator making another disk the
7777
- * default, a default deleted) changes exactly this map, and a stored assignment
7778
- * that survives that change is a plan that outlived the pool it was planned
7779
- * for.
7780
- *
7781
- * A class with nothing writable is ABSENT from the map, not guessed at: `place`
7782
- * already throws loudly on that path, and an authority nobody can compute must
7783
- * invalidate nothing.
7784
- */
7785
- function authorityLocations(pool) {
7786
- const out = /* @__PURE__ */ new Map();
7787
- for (const type of PLACEMENT_LOCATION_TYPES) try {
7788
- out.set(type, resolvePlacement(pool, CLASS_PROBE_PROFILE[type]).id);
7789
- } catch {}
7790
- return out;
7791
- }
7792
- /**
7793
- * Narrow a resolved location list to the ones placement may write to: the
7794
- * operator-enabled ones. A location the operator has not opted in
7795
- * (`enabled === false`) is still READ, age-swept and drained — it is simply
7796
- * never a write target, exactly like a read-only one.
7797
- *
7798
- * When no enabled location is writable the type DEFAULTS serve instead, loudly:
7799
- * the default of a type is guaranteed enabled by the orchestrator, so this can
7800
- * only happen through a hand-edited store — and a recorder that writes nowhere
7801
- * is worse than one that writes to the default and complains.
7802
- */
7803
- function placementPool(locations) {
7804
- const enabled = locations.filter((l) => l.enabled !== false);
7805
- if (enabled.some((l) => !l.readOnly)) return {
7806
- locations: enabled,
7807
- fellBackToDefault: false
7808
- };
7809
- const defaults = locations.filter((l) => l.isDefault === true);
7810
- if (defaults.some((l) => !l.readOnly)) return {
7811
- locations: defaults,
7812
- fellBackToDefault: true
7813
- };
7814
- return {
7815
- locations,
7816
- fellBackToDefault: true
7817
- };
7818
- }
7819
- /**
7820
- * Route a recording profile to its storage location by the location's declared
7821
- * `type`: the `low` profile prefers the `recordingsLow` location, every other
7822
- * profile the `recordings` location. READ-ONLY locations are never write
7823
- * targets (the drain model): when the preferred type has only read-only
7824
- * instances, any writable recordings-type location serves. Throws when NO
7825
- * writable location exists — recording cannot start, and that must be loud.
7826
- */
7827
- function resolvePlacement(locations, profile) {
7828
- if (locations.length === 0) throw new Error("recorder controller: no recordings storage locations resolved");
7829
- const writable = locations.filter((l) => !l.readOnly);
7830
- if (writable.length === 0) throw new Error("recorder controller: every recordings location is read-only — nowhere to write");
7831
- const preferType = profile === "low" ? "recordingsLow" : "recordings";
7832
- const preferred = writable.filter((l) => l.type === preferType);
7833
- return preferred.find((l) => l.isDefault) ?? preferred[0] ?? writable.find((l) => l.isDefault) ?? writable[0];
7834
- }
7835
- /**
7836
- * Attribute a discovered segment on an aliased physical root by its profile,
7837
- * not by whichever logical location happened to be hydrated last. Stock
7838
- * `recordings` and `recordingsLow` deliberately share a root: high/mid belong
7839
- * to `recordings`, while low belongs to `recordingsLow`.
7840
- */
7841
- function locationForHydratedProfile(aliases, profile) {
7842
- const preferredType = profile === "low" ? "recordingsLow" : "recordings";
7843
- const preferred = aliases.filter((location) => location.type === preferredType).toSorted((a, b) => a.id.localeCompare(b.id))[0];
7844
- const fallback = aliases.toSorted((a, b) => a.id.localeCompare(b.id))[0];
7845
- if (preferred) return preferred;
7846
- if (fallback) return fallback;
7847
- throw new Error("recorder: no location aliases to attribute hydrated profile");
7848
- }
7849
- function aliasesByRoot(locations) {
7850
- const grouped = /* @__PURE__ */ new Map();
7851
- for (const location of locations) {
7852
- const aliases = grouped.get(location.root);
7853
- if (aliases) aliases.push(location);
7854
- else grouped.set(location.root, [location]);
7855
- }
7856
- return grouped;
7857
- }
7858
- /** One numeric key lifted out of a location's untyped `config` record, or
7859
- * nothing at all when it is absent or not a finite number. Absent is a real
7860
- * answer here: the caller falls back to a documented default, and a garbage
7861
- * value must never become a capacity limit. */
7862
- function numericConfig(config, key, field) {
7863
- const raw = config[key];
7864
- if (typeof raw !== "number" || !Number.isFinite(raw)) return {};
7865
- return { [field]: raw };
7866
- }
7867
- /**
7868
- * Resolve every recordings storage location (`recordings` + `recordingsLow`)
7869
- * to `{ id, root }`. Returns an empty list when the storage cap is unreachable
7870
- * or no recordings location exists — callers degrade rather than crash.
7996
+ * GIF for the widest notifier reach (every backend that takes an image takes a
7997
+ * gif); `fps` decimates and lanczos downscales.
7871
7998
  */
7872
- async function resolveRecordingsLocations(api, logger) {
7873
- try {
7874
- const recordings = (await api.storage.listLocations.query({})).filter((l) => RECORDING_LOCATION_TYPES.has(l.type));
7875
- return Promise.all(recordings.map(async (l) => ({
7876
- id: l.id,
7877
- type: l.type,
7878
- root: await api.storage.resolve.query({
7879
- location: l.id,
7880
- relativePath: ""
7881
- }),
7882
- readOnly: l.config["readOnly"] === true,
7883
- isDefault: l.isDefault,
7884
- ...l.enabled === false ? { enabled: false } : {},
7885
- ...numericConfig(l.config, "minFreePercent", "minFreePercent"),
7886
- ...numericConfig(l.config, "maxUsedGb", "maxUsedGb")
7887
- })));
7888
- } catch (err) {
7889
- logger.warn("recorder: listLocations failed — no recordings locations resolved", { meta: { error: require_dist.errMsg(err) } });
7890
- return [];
7891
- }
7999
+ async function renderGif(renderDeps, input) {
8000
+ const out = await renderFootage(renderDeps, input, "gif", (playlistPath, outPath) => [
8001
+ "-y",
8002
+ "-i",
8003
+ playlistPath,
8004
+ "-vf",
8005
+ `fps=${input.fps},scale=${input.maxWidth}:-2:flags=lanczos`,
8006
+ "-loop",
8007
+ "0",
8008
+ "-f",
8009
+ "gif",
8010
+ outPath
8011
+ ]);
8012
+ return {
8013
+ gifBase64: out.bytes.toString("base64"),
8014
+ fromMs: out.fromMs,
8015
+ toMs: out.toMs
8016
+ };
7892
8017
  }
7893
8018
  /**
7894
- * Hydrate the v2 `RecordingIndex` for one device from every recordings location
7895
- * by recursively walking `<root>/<deviceId>/` for `*.m4s` segments and feeding
7896
- * the location-relative paths (`<deviceId>/<profile>/Y/M/D/H/<file>.m4s`, which
7897
- * `parseSegmentPath` understands) to `index.hydrateDevice`. A missing device dir
7898
- * (no footage yet) and any per-location failure are warned + skipped
7899
- * (best-effort), so one bad volume never blocks the rest of the hydrate.
8019
+ * MP4 clip — the "send a clip, not a gif" attachment. Re-encodes rather than
8020
+ * stream-copying: a concat of segments whose encoder parameters differ (a
8021
+ * profile switch, a broker redial) produces an mp4 no player will scrub, and a
8022
+ * notification attachment that will not play is worse than none. `faststart`
8023
+ * puts the moov up front so a chat client can play it without the whole file.
7900
8024
  */
7901
- async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger, options = {}) {
7902
- const yieldBetween = options.yieldBetween ?? macrotask$2;
7903
- const chunkSize = options.chunkSize ?? HYDRATE_CHUNK;
7904
- for (const [root, aliases] of aliasesByRoot(locations)) {
7905
- const deviceDir = node_path.default.join(root, String(deviceId));
7906
- const startedMs = Date.now();
7907
- let entries;
7908
- try {
7909
- entries = await node_fs.promises.readdir(deviceDir, { recursive: true });
7910
- } catch (err) {
7911
- if (err.code === "ENOENT") {
7912
- for (const location of aliases) index.hydrateDevice(deviceId, location.id, []);
7913
- continue;
7914
- }
7915
- logger.warn("recorder: hydrateDevice walk failed for location", {
7916
- tags: { deviceId },
7917
- meta: {
7918
- locationIds: aliases.map((location) => location.id),
7919
- error: require_dist.errMsg(err)
7920
- }
7921
- });
7922
- continue;
7923
- }
7924
- const walkedMs = Date.now();
7925
- const locationForProfile = /* @__PURE__ */ new Map();
7926
- const pathsByLocation = /* @__PURE__ */ new Map();
7927
- let handled = 0;
7928
- for (const entry of entries) {
7929
- if (!entry.endsWith(".m4s")) continue;
7930
- const relative = `${deviceId}/${entry.split(node_path.default.sep).join("/")}`;
7931
- const profile = relative.split("/")[1] ?? "";
7932
- let locationId = locationForProfile.get(profile);
7933
- if (locationId === void 0) {
7934
- locationId = locationForHydratedProfile(aliases, profile).id;
7935
- locationForProfile.set(profile, locationId);
7936
- }
7937
- const paths = pathsByLocation.get(locationId);
7938
- if (paths) paths.push(relative);
7939
- else pathsByLocation.set(locationId, [relative]);
7940
- handled += 1;
7941
- if (handled % chunkSize === 0) await yieldBetween();
7942
- }
7943
- for (const location of aliases) if (!pathsByLocation.has(location.id)) pathsByLocation.set(location.id, []);
7944
- let indexed = 0;
7945
- const reuseBefore = index.hydrateReuseStats();
7946
- for (const [locationId, relPaths] of pathsByLocation) {
7947
- index.hydrateDevice(deviceId, locationId, relPaths);
7948
- indexed += relPaths.length;
7949
- }
7950
- const reuseAfter = index.hydrateReuseStats();
7951
- const reused = reuseAfter.rowsReused - reuseBefore.rowsReused;
7952
- const allocated = reuseAfter.rowsAllocated - reuseBefore.rowsAllocated;
7953
- logger.info("recorder: hydrated a device from one recordings root", {
7954
- tags: { deviceId },
7955
- meta: {
7956
- root,
7957
- entries: entries.length,
7958
- indexed,
7959
- reused,
7960
- allocated,
7961
- locationIds: [...pathsByLocation.keys()],
7962
- walkMs: walkedMs - startedMs,
7963
- ms: Date.now() - startedMs
7964
- }
7965
- });
7966
- }
8025
+ async function renderClip(renderDeps, input) {
8026
+ const out = await renderFootage(renderDeps, input, "mp4", (playlistPath, outPath) => [
8027
+ "-y",
8028
+ "-i",
8029
+ playlistPath,
8030
+ "-vf",
8031
+ `scale=${input.maxWidth}:-2:flags=lanczos`,
8032
+ "-c:v",
8033
+ "libx264",
8034
+ "-preset",
8035
+ "veryfast",
8036
+ "-crf",
8037
+ "28",
8038
+ "-profile:v",
8039
+ "main",
8040
+ "-pix_fmt",
8041
+ "yuv420p",
8042
+ "-movflags",
8043
+ "+faststart",
8044
+ "-an",
8045
+ "-f",
8046
+ "mp4",
8047
+ outPath
8048
+ ]);
8049
+ return {
8050
+ clipBase64: out.bytes.toString("base64"),
8051
+ mime: "video/mp4",
8052
+ fromMs: out.fromMs,
8053
+ toMs: out.toMs,
8054
+ bytes: out.bytes.byteLength
8055
+ };
7967
8056
  }
7968
- /** Entries classified between yields in {@link hydrateDeviceFromStorage}. */
7969
- var HYDRATE_CHUNK = 2e4;
7970
- var macrotask$2 = () => new Promise((resolve) => {
7971
- setImmediate(resolve);
7972
- });
7973
- /** `<deviceId>/<profile>/YYYY/MM/DD/HH` for an hour bucket (paths are UTC). */
7974
- function hourDirRelPath(deviceId, profile, hourStartMs) {
7975
- const d = new Date(hourStartMs);
7976
- const p2 = (n) => String(n).padStart(2, "0");
7977
- return `${deviceId}/${profile}/${d.getUTCFullYear()}/${p2(d.getUTCMonth() + 1)}/${p2(d.getUTCDate())}/${p2(d.getUTCHours())}`;
8057
+ /** The shared playlist → ffmpeg → bytes pipeline. */
8058
+ async function renderFootage(renderDeps, input, ext, buildArgs) {
8059
+ const now = renderDeps.now ?? Date.now;
8060
+ const fromMs = input.aroundMs - input.preRollSec * 1e3;
8061
+ const toMs = input.aroundMs + input.postRollSec * 1e3;
8062
+ 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}`);
8063
+ const renderId = `${ext}-${now()}-${Math.floor(Math.random() * 1e6)}`;
8064
+ const availableEnd = latestFinalizedEnd(renderDeps, input.deviceId);
8065
+ if (availableEnd === null || availableEnd < toMs) throw new Error(`${FOOTAGE_NOT_READY}: [${fromMs}, ${toMs}) for device ${input.deviceId} — finalized footage ends at ${availableEnd ?? "never"}`);
8066
+ const window = {
8067
+ fromMs,
8068
+ toMs
8069
+ };
8070
+ const playlist = await writeExportSourcePlaylist({
8071
+ deps: renderDeps.deps,
8072
+ exportsDir: renderDeps.exportsDir,
8073
+ exportId: renderId,
8074
+ deviceId: input.deviceId,
8075
+ profile: "low",
8076
+ fromMs: window.fromMs,
8077
+ toMs: window.toMs
8078
+ });
8079
+ if (playlist === null) throw new Error(`no footage covers [${window.fromMs}, ${window.toMs}) for device ${input.deviceId}`);
8080
+ const outPath = node_path.default.join(node_path.default.dirname(playlist.path), `${renderId}.${ext}`);
8081
+ try {
8082
+ await runFfmpeg(renderDeps, buildArgs(playlist.path, outPath), ext);
8083
+ const bytes = await node_fs.promises.readFile(outPath);
8084
+ if (bytes.byteLength === 0) throw new Error(`${ext} render produced an empty file`);
8085
+ return {
8086
+ bytes,
8087
+ fromMs: window.fromMs,
8088
+ toMs: window.toMs
8089
+ };
8090
+ } finally {
8091
+ await node_fs.promises.rm(playlist.path, { force: true }).catch(() => {});
8092
+ await node_fs.promises.rm(playlist.concatPath, { force: true }).catch(() => {});
8093
+ await node_fs.promises.rm(outPath, { force: true }).catch(() => {});
8094
+ }
7978
8095
  }
7979
- var HOUR_MS$4 = 36e5;
7980
8096
  /**
7981
- * Hydrate ONLY the hours a window spans, instead of walking the archive.
7982
- *
7983
- * `hydrateDeviceFromStorage` is a recursive `readdir` over the whole device
7984
- * subtree — ">1M files and takes minutes" per its own comment. Until it lands,
7985
- * every read of past footage sees an empty index, which the server reports as
7986
- * "no footage": measured after a deploy, a camera-day held 7 segments and grew
7987
- * only at the rate of NEW writes for ~9 minutes.
7988
- *
7989
- * The path convention makes the window addressable directly: one SHALLOW
7990
- * `readdir` per (profile, hour), ~450 entries each. A day is ~72 of them. The
7991
- * background full walk still runs, for the consumers that need the whole
7992
- * archive (footprint, eviction domains, retention).
7993
- *
7994
- * Hours already walked are skipped, so a drag across an hour costs nothing the
7995
- * second time.
7996
- *
7997
- * ## The ledger is the first stop for a CLOSED hour (D287)
7998
- *
7999
- * Boot no longer replays the archive into the index, so a ledger-known hour
8000
- * arrives here `unknown` and pays ONE point read of its hour row instead of a
8001
- * `readdir` — measured at ~650 ms per hour directory on the live share under
8002
- * load, against the array the scrub feeder is competing for. Hours the ledger
8003
- * does not know, and every hour if the read fails, fall through to the walk
8004
- * exactly as before: the failure direction is toward the disk, never toward an
8005
- * hour that renders empty because nobody read it.
8006
- *
8007
- * **The live hour is never served from the ledger.** It is the one hour whose
8008
- * durable row is knowably behind the disk: a segment finalized after the last
8009
- * persist and before a crash is on the array and not in the row. Boot used to
8010
- * mark every ledger hour `hydrated`, which made that tail invisible to
8011
- * playback and the timeline FOREVER, not merely under-reported. Excluding the
8012
- * live hour costs exactly one hour-directory read per process — the walk marks
8013
- * it, so the second reader pays nothing — and it is what turns a lost tail
8014
- * back into a recoverable one.
8097
+ * The end of the newest FINALIZED `low` segment, or null when none exists.
8015
8098
  *
8016
- * A CLOSED hour already walked is skipped, which is what makes that exclusion
8017
- * affordable: without it, every window touching the live hour would re-walk
8018
- * all of its hours. The past is immutable, so re-reading a closed hour
8019
- * directory can only ever return what the index already holds; eviction
8020
- * removes rows through the index directly.
8021
- */
8022
- async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations, logger, ledger) {
8023
- if (index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
8024
- const liveHour = Math.floor(Date.now() / HOUR_MS$4) * HOUR_MS$4;
8025
- if (ledger !== void 0) {
8026
- const startedMs = Date.now();
8027
- const hours = await ledger.detailForWindow(deviceId, fromMs, toMs);
8028
- let segments = 0;
8029
- let served = 0;
8030
- for (const hour of hours) {
8031
- if (hour.hourStartMs >= liveHour) continue;
8032
- index.hydrateHour(deviceId, hour.locationId, hour.hourStartMs, hour.paths);
8033
- segments += hour.paths.length;
8034
- served += 1;
8035
- }
8036
- if (served > 0) logger.info("recorder: hydrated a window from the hour ledger", {
8037
- tags: { deviceId },
8038
- meta: {
8039
- hours: served,
8040
- segments,
8041
- ms: Date.now() - startedMs
8099
+ * One scalar off the tail of the live sorted view — it used to COPY the
8100
+ * device's whole `low` archive to fold a maximum out of it, per render.
8101
+ */
8102
+ function latestFinalizedEnd(renderDeps, deviceId) {
8103
+ return renderDeps.deps.index.latestEnd(deviceId, "low");
8104
+ }
8105
+ /**
8106
+ * Run one ffmpeg and GUARANTEE the child is gone before the promise settles.
8107
+ *
8108
+ * The previous shape leaked on two paths: a `spawn` error (`ENOENT`, a bad
8109
+ * path) rejected while a process could still be alive, and a `close` with a
8110
+ * non-zero code rejected without ever reaping stdio. These renders fire on
8111
+ * every notification with a gif or clip, so a leak here is a slow accumulation
8112
+ * of ffmpeg processes on the recording node — the kind that shows up days later
8113
+ * as "the hub got slow" (operator, 2026-07-30).
8114
+ *
8115
+ * `settle` is idempotent and always kills first: killing an already-exited pid
8116
+ * throws ESRCH, which is swallowed, so the cost of being wrong is nothing while
8117
+ * the cost of NOT killing is a zombie per render.
8118
+ */
8119
+ function runFfmpeg(renderDeps, args, ext) {
8120
+ return new Promise((resolve, reject) => {
8121
+ const proc = renderDeps.spawn(renderDeps.ffmpegPath ?? "ffmpeg", args);
8122
+ let done = false;
8123
+ let timer = null;
8124
+ const settle = (err) => {
8125
+ if (done) return;
8126
+ done = true;
8127
+ if (timer !== null) {
8128
+ clearTimeout(timer);
8129
+ timer = null;
8042
8130
  }
8131
+ if (proc.exitCode === null && proc.signalCode === null) try {
8132
+ proc.kill("SIGKILL");
8133
+ } catch {}
8134
+ try {
8135
+ proc.stderr?.removeAllListeners();
8136
+ proc.stdout?.destroy();
8137
+ proc.stderr?.destroy();
8138
+ } catch {}
8139
+ if (err === null) resolve();
8140
+ else reject(err);
8141
+ };
8142
+ timer = setTimeout(() => settle(/* @__PURE__ */ new Error(`${ext} render timed out`)), RENDER_TIMEOUT_MS);
8143
+ let stderrTail = "";
8144
+ proc.stderr?.on("data", (c) => {
8145
+ stderrTail = (stderrTail + c.toString()).slice(-400);
8043
8146
  });
8044
- if (index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
8045
- }
8046
- for (const [root, aliases] of aliasesByRoot(locations)) {
8047
- const deviceDir = node_path.default.join(root, String(deviceId));
8048
- let profiles;
8049
- try {
8050
- profiles = (await node_fs.promises.readdir(deviceDir)).filter((e) => e === "high" || e === "mid" || e === "low");
8051
- } catch (err) {
8052
- if (err.code !== "ENOENT") logger.warn("recorder: hydrateWindow profile listing failed", {
8053
- tags: { deviceId },
8054
- meta: {
8055
- locationIds: aliases.map((location) => location.id),
8056
- error: require_dist.errMsg(err)
8057
- }
8058
- });
8059
- continue;
8060
- }
8061
- const first = Math.floor(fromMs / HOUR_MS$4) * HOUR_MS$4;
8062
- const reuseBefore = index.hydrateReuseStats();
8063
- let hoursRead = 0;
8064
- let repaired = 0;
8065
- for (let hour = first; hour < toMs; hour += HOUR_MS$4) {
8066
- if (hour < liveHour && index.hydrationOf(deviceId, hour, hour + HOUR_MS$4) === "hydrated") continue;
8067
- for (const profile of profiles) {
8068
- const rel = hourDirRelPath(deviceId, profile, hour);
8069
- let files;
8070
- try {
8071
- files = await node_fs.promises.readdir(node_path.default.join(root, rel));
8072
- } catch {
8073
- continue;
8074
- }
8075
- hoursRead += 1;
8076
- const locationId = locationForHydratedProfile(aliases, profile).id;
8077
- const relPaths = files.filter((f) => f.endsWith(".m4s")).map((f) => `${rel}/${f}`);
8078
- index.hydrateHour(deviceId, locationId, hour, relPaths);
8079
- if (ledger !== void 0 && relPaths.length > 0 && hour + HOUR_MS$4 <= Date.now() && !ledger.knowsHour(deviceId, profile, locationId, hour)) {
8080
- repaired += 1;
8081
- await ledger.adoptRebuiltHour(deviceId, profile, locationId, hour, relPaths);
8082
- }
8083
- }
8084
- }
8085
- index.markHydrated(deviceId, fromMs, toMs);
8086
- if (hoursRead === 0) continue;
8087
- const reuseAfter = index.hydrateReuseStats();
8088
- const reused = reuseAfter.rowsReused - reuseBefore.rowsReused;
8089
- const allocated = reuseAfter.rowsAllocated - reuseBefore.rowsAllocated;
8090
- logger.info("recorder: hydrated a window from one recordings root", {
8091
- tags: { deviceId },
8092
- meta: {
8093
- root,
8094
- hoursRead,
8095
- repaired,
8096
- reused,
8097
- allocated
8098
- }
8147
+ proc.on("error", (err) => settle(err instanceof Error ? err : new Error(String(err))));
8148
+ proc.on("close", (code) => {
8149
+ settle(code === 0 ? null : /* @__PURE__ */ new Error(`ffmpeg ${ext} render exited ${code}: ${stderrTail}`));
8099
8150
  });
8100
- }
8151
+ });
8101
8152
  }
8102
8153
  //#endregion
8103
8154
  //#region src/durable/durable-ledger.ts
@@ -10438,47 +10489,17 @@ Object.freeze({
10438
10489
  },
10439
10490
  "setDeviceConfig": { "config": { "__nested__": { "bands": [] } } }
10440
10491
  },
10441
- "storage": { "upsertLocation": {
10442
- "isDefault": false,
10443
- "isSystem": false
10444
- } },
10492
+ "storage": { "upsertLocation": { "isSystem": false } },
10445
10493
  "storage-provider": {
10446
- "beginDownload": { "location": { "__nested__": {
10447
- "isDefault": false,
10448
- "isSystem": false
10449
- } } },
10450
- "beginUpload": { "location": { "__nested__": {
10451
- "isDefault": false,
10452
- "isSystem": false
10453
- } } },
10454
- "delete": { "location": { "__nested__": {
10455
- "isDefault": false,
10456
- "isSystem": false
10457
- } } },
10458
- "exists": { "location": { "__nested__": {
10459
- "isDefault": false,
10460
- "isSystem": false
10461
- } } },
10462
- "getAvailableSpace": { "location": { "__nested__": {
10463
- "isDefault": false,
10464
- "isSystem": false
10465
- } } },
10466
- "list": { "location": { "__nested__": {
10467
- "isDefault": false,
10468
- "isSystem": false
10469
- } } },
10470
- "read": { "location": { "__nested__": {
10471
- "isDefault": false,
10472
- "isSystem": false
10473
- } } },
10474
- "resolve": { "location": { "__nested__": {
10475
- "isDefault": false,
10476
- "isSystem": false
10477
- } } },
10478
- "write": { "location": { "__nested__": {
10479
- "isDefault": false,
10480
- "isSystem": false
10481
- } } }
10494
+ "beginDownload": { "location": { "__nested__": { "isSystem": false } } },
10495
+ "beginUpload": { "location": { "__nested__": { "isSystem": false } } },
10496
+ "delete": { "location": { "__nested__": { "isSystem": false } } },
10497
+ "exists": { "location": { "__nested__": { "isSystem": false } } },
10498
+ "getAvailableSpace": { "location": { "__nested__": { "isSystem": false } } },
10499
+ "list": { "location": { "__nested__": { "isSystem": false } } },
10500
+ "read": { "location": { "__nested__": { "isSystem": false } } },
10501
+ "resolve": { "location": { "__nested__": { "isSystem": false } } },
10502
+ "write": { "location": { "__nested__": { "isSystem": false } } }
10482
10503
  },
10483
10504
  "stream-broker": {
10484
10505
  "produceEventMedia": {
@@ -10552,6 +10573,7 @@ Object.freeze({
10552
10573
  "listTargetKinds",
10553
10574
  "listTargets"
10554
10575
  ],
10576
+ "storage-occupancy": ["getOccupancy"],
10555
10577
  "storage-provider": ["list"],
10556
10578
  "turn-provider": ["getTurnServers"],
10557
10579
  "user-passkeys": ["listPasskeys"]
@@ -10665,13 +10687,12 @@ var LedgerWalkReconciler = class {
10665
10687
  const apply = request.apply === true;
10666
10688
  const location = this.deps.locations().find((candidate) => candidate.id === request.locationId);
10667
10689
  if (location === void 0) return this.refusedReport(request.locationId, apply, "location-unknown");
10668
- if ((location.enabled ?? true) && !location.readOnly) {
10690
+ if (locationMayBeWritten(location)) {
10669
10691
  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: {
10670
10692
  locationId: location.id,
10671
10693
  root: location.root,
10672
- enabled: location.enabled ?? true,
10673
- readOnly: location.readOnly,
10674
- remedy: "set the location readOnly (or disable it) and re-run; a LIVE location is reconciled by the mover, under its bound (D318)"
10694
+ mode: location.mode,
10695
+ remedy: "switch the location to readonly / drain / disabled and re-run; a LIVE location is reconciled by the mover, under its bound (D318)"
10675
10696
  } });
10676
10697
  return this.refusedReport(location.id, apply, "source-writable");
10677
10698
  }
@@ -11025,6 +11046,91 @@ async function readLocationCapacity(root, statfs) {
11025
11046
  };
11026
11047
  }
11027
11048
  }
11049
+ /**
11050
+ * Coalesces location-change doorbells into refreshes, and converges on its own
11051
+ * whether or not any doorbell ever arrives.
11052
+ */
11053
+ var LocationRefresher = class {
11054
+ deps;
11055
+ timer = null;
11056
+ running = false;
11057
+ /** A doorbell rang while a refresh was in flight — run exactly one more. */
11058
+ rearm = false;
11059
+ /** When the last refresh STARTED. `null` ⇒ none since this object was made. */
11060
+ lastStartedAt = null;
11061
+ disposed = false;
11062
+ constructor(deps) {
11063
+ this.deps = deps;
11064
+ }
11065
+ get now() {
11066
+ return this.deps.now?.() ?? Date.now();
11067
+ }
11068
+ get debounceMs() {
11069
+ return this.deps.debounceMs ?? 2e3;
11070
+ }
11071
+ get backstopMs() {
11072
+ return this.deps.backstopMs ?? 3e5;
11073
+ }
11074
+ /**
11075
+ * The doorbell. Never awaits the refresh: this runs on the event bus, and a
11076
+ * subscriber that blocks on I/O holds up every other consumer of the bus.
11077
+ */
11078
+ ring(locationId, reason) {
11079
+ if (this.disposed) return;
11080
+ this.deps.logger.info("recorder: the storage location set changed — re-resolving where recordings may be written", { meta: {
11081
+ locationId,
11082
+ reason,
11083
+ coalesceMs: this.debounceMs
11084
+ } });
11085
+ if (this.timer !== null) return;
11086
+ this.timer = setTimeout(() => {
11087
+ this.timer = null;
11088
+ this.run("storage location change");
11089
+ }, this.debounceMs);
11090
+ }
11091
+ /**
11092
+ * The backstop, called from a pass that already runs on a timer. Refreshes
11093
+ * only when nothing has refreshed for {@link LOCATION_BACKSTOP_MS} — a
11094
+ * doorbell that already produced one resets the clock, so the two paths
11095
+ * never both pay.
11096
+ */
11097
+ async convergeIfDue() {
11098
+ if (this.disposed) return;
11099
+ const last = this.lastStartedAt;
11100
+ if (last !== null && this.now - last < this.backstopMs) return;
11101
+ await this.run("periodic convergence");
11102
+ }
11103
+ /** Stop the pending doorbell. Nothing in flight is cancelled — it is idempotent. */
11104
+ dispose() {
11105
+ this.disposed = true;
11106
+ if (this.timer !== null) {
11107
+ clearTimeout(this.timer);
11108
+ this.timer = null;
11109
+ }
11110
+ }
11111
+ async run(trigger) {
11112
+ if (this.running) {
11113
+ this.rearm = true;
11114
+ return;
11115
+ }
11116
+ this.running = true;
11117
+ this.lastStartedAt = this.now;
11118
+ try {
11119
+ await this.deps.refresh();
11120
+ } catch (err) {
11121
+ this.deps.logger.warn("recorder: could not re-resolve storage locations — the previously resolved set stands", { meta: {
11122
+ trigger,
11123
+ error: require_dist.errMsg(err)
11124
+ } });
11125
+ } finally {
11126
+ this.running = false;
11127
+ }
11128
+ if (this.rearm && !this.disposed) {
11129
+ this.rearm = false;
11130
+ await this.run("change during refresh");
11131
+ }
11132
+ }
11133
+ };
11028
11134
  //#endregion
11029
11135
  //#region src/recorder/addon/ops-log-store.ts
11030
11136
  /**
@@ -11084,7 +11190,7 @@ var RECORDING_OPS_LOG_SPEC = {
11084
11190
  };
11085
11191
  /** Build the handle. `declare()` MUST run once at boot before any read/write. */
11086
11192
  function recordingOpsLogState(store, logger) {
11087
- return new require_prebuffer.RowMapStore({
11193
+ return new require_retire_root_keys.RowMapStore({
11088
11194
  spec: RECORDING_OPS_LOG_SPEC,
11089
11195
  store,
11090
11196
  logger
@@ -11179,6 +11285,72 @@ function createRecordingOpsLogSink(deps) {
11179
11285
  };
11180
11286
  }
11181
11287
  //#endregion
11288
+ //#region src/recorder/addon/pending-writer-moves.ts
11289
+ /**
11290
+ * The gap between the PLAN and the DISK, for as long as it stands.
11291
+ *
11292
+ * Placement applies at writer-attach boundaries only: the `.rec-tmp` staging
11293
+ * dir lives UNDER the chosen root so the finalize relocate is a
11294
+ * same-filesystem rename, and repointing a live writer would turn every
11295
+ * segment into the EXDEV copy+unlink fallback — 2× disk writes on the hot path
11296
+ * (the F1 regression). So a plan reaches a camera when it next attaches.
11297
+ *
11298
+ * For a camera on a schedule that is the next band edge, minutes away. For a
11299
+ * CONTINUOUSLY recording camera there is no next attach: its writer outlives
11300
+ * every tick, and its assignment can be re-planned onto a new disk forever
11301
+ * without one byte moving. That is the state this module makes visible (D387).
11302
+ * The alternative — detaching live writers to apply a plan — buys the move at
11303
+ * the cost of a recording gap on every affected camera at once, which is
11304
+ * exactly the wrong trade on the day an operator adds a disk to relieve
11305
+ * pressure.
11306
+ */
11307
+ /** Is `dir` inside `root` (or the root itself)? A prefix compare that cannot
11308
+ * match `/recordings-2` against `/recordings`. */
11309
+ function isUnder(dir, root) {
11310
+ const rel = node_path.default.relative(root, dir);
11311
+ return rel === "" || !rel.startsWith("..") && !node_path.default.isAbsolute(rel);
11312
+ }
11313
+ /** The location whose root contains `dir` — the DEEPEST one, so a location
11314
+ * nested inside another is attributed to the nested one. */
11315
+ function locationOf(dir, locations) {
11316
+ let best = null;
11317
+ for (const location of locations) {
11318
+ if (!isUnder(dir, location.root)) continue;
11319
+ if (best === null || location.root.length > best.root.length) best = location;
11320
+ }
11321
+ return best?.id ?? null;
11322
+ }
11323
+ /**
11324
+ * Every live writer the plan wants somewhere else.
11325
+ *
11326
+ * A writer whose key the plan says nothing about is NOT pending: the pass
11327
+ * plans a work SUBSET, and "not in this pass" must never read as "planned
11328
+ * elsewhere". A writer under no known root is reported with `writingTo: null`
11329
+ * rather than dropped — a directory outside every resolved location is its own
11330
+ * fault, and a silent skip would hide it.
11331
+ */
11332
+ function pendingWriterMoves(writers, planned, locations) {
11333
+ const out = [];
11334
+ for (const writer of writers) {
11335
+ const plannedFor = planned.get(`${String(writer.deviceId)}:${writer.profile}`);
11336
+ if (plannedFor === void 0) continue;
11337
+ const writingTo = locationOf(writer.outDir, locations);
11338
+ if (writingTo === plannedFor) continue;
11339
+ out.push({
11340
+ deviceId: writer.deviceId,
11341
+ profile: writer.profile,
11342
+ writingTo,
11343
+ plannedFor
11344
+ });
11345
+ }
11346
+ return out.toSorted((a, b) => a.deviceId - b.deviceId || a.profile.localeCompare(b.profile));
11347
+ }
11348
+ /** A stable signature of a pending set, so the report is latched on WHAT is
11349
+ * pending rather than repeated every 30 s tick. */
11350
+ function pendingMovesSignature(moves) {
11351
+ return moves.map((m) => `${String(m.deviceId)}:${m.profile}>${m.plannedFor}`).join(",");
11352
+ }
11353
+ //#endregion
11182
11354
  //#region src/recorder/addon/periodic-pass.ts
11183
11355
  function startPeriodicPass(deps) {
11184
11356
  const now = () => deps.now?.() ?? Date.now();
@@ -11301,6 +11473,30 @@ function poolFor(demand, candidates) {
11301
11473
  function poolKey(pool) {
11302
11474
  return pool.map((c) => c.locationId).toSorted().join("|");
11303
11475
  }
11476
+ /**
11477
+ * What one demand with NO measured rate weighs: the median of the rates that
11478
+ * WERE measured, and one unit when nothing in the pool was measured at all.
11479
+ *
11480
+ * Never zero, and that is the whole point (D387). `dailyBytes` is 0 for every
11481
+ * camera the recorder's RAM index has not accounted for — after D287 that is
11482
+ * most of a fleet at any moment — and a zero-byte demand is invisible to every
11483
+ * balance calculation here. Phase C reaches for the SMALLEST demand on an
11484
+ * overloaded location, which is one of those; moving 0 bytes moves neither side
11485
+ * towards its target, so the strict-improvement check fails and the rebalance
11486
+ * `break`s on its first iteration. A single measured camera in the pool was
11487
+ * enough to make every unmeasured one weightless and wedge the whole pass — on
11488
+ * a live hub that left 19 assignments pinned to an 89%-full disk with an empty
11489
+ * 4 TB one next to it, silently, on every 30 s tick.
11490
+ *
11491
+ * The median (rather than the mean) so one 24/7 4K camera does not decide what
11492
+ * an unknown camera is assumed to cost.
11493
+ */
11494
+ function weightOfUnmeasured(demands) {
11495
+ const measured = demands.map((d) => d.dailyBytes).filter((bytes) => bytes > 0).toSorted((a, b) => a - b);
11496
+ if (measured.length === 0) return 1;
11497
+ const mid = Math.floor(measured.length / 2);
11498
+ return measured.length % 2 === 1 ? measured[mid] : (measured[mid - 1] + measured[mid]) / 2;
11499
+ }
11304
11500
  /** Normalised weight per location: headroom share, or an even split when no
11305
11501
  * location reports any headroom (nothing is known yet — never divide by 0). */
11306
11502
  function weightsFor(pool) {
@@ -11350,8 +11546,8 @@ function planPlacement(input) {
11350
11546
  });
11351
11547
  }
11352
11548
  for (const { pool, demands } of pools.values()) {
11353
- const anyRate = demands.some((d) => d.dailyBytes > 0);
11354
- const bytesOf = (d) => anyRate ? d.dailyBytes : 1;
11549
+ const unmeasured = weightOfUnmeasured(demands);
11550
+ const bytesOf = (d) => d.dailyBytes > 0 ? d.dailyBytes : unmeasured;
11355
11551
  const totalBytes = demands.reduce((sum, d) => sum + bytesOf(d), 0);
11356
11552
  const weights = weightsFor(pool);
11357
11553
  const slots = pool.map((c) => ({
@@ -11496,19 +11692,16 @@ var RecordingPlacementBlobSchema = require_dist.object({
11496
11692
  /** locationId → the volume id last verified at that location's root. */
11497
11693
  volumeIds: require_dist.record(require_dist.string(), require_dist.string()).default({}),
11498
11694
  /**
11499
- * Recordings class (`recordings` / `recordingsLow`) → the location
11500
- * `resolvePlacement` chose for it when these assignments were last planned.
11501
- *
11502
- * ABSENT means "nobody has recorded an authority yet", and that is never read
11503
- * as a repoint: shipping this field must not invalidate assignments that were
11504
- * made under an authority nothing wrote down. The first pass stamps it and
11505
- * invalidates nothing; every later change of it is a repoint.
11695
+ * DEAD KEY (D383) — the class authority a pass planned against, back when a
11696
+ * class had a default distinct from its pool. Declared so an existing blob
11697
+ * round-trips unchanged and so its removal is a decision on the record rather
11698
+ * than a silent strip. Never read, never written.
11506
11699
  */
11507
11700
  defaultLocations: require_dist.record(require_dist.string(), require_dist.string()).default({}),
11508
11701
  /** When the blob was last written (operator-facing debugging only). */
11509
11702
  updatedAt: require_dist.number().optional()
11510
11703
  });
11511
- /** Cold-start value: nothing assigned, nothing claimed, no authority recorded. */
11704
+ /** Cold-start value: nothing assigned, nothing claimed. */
11512
11705
  var EMPTY_PLACEMENT = {
11513
11706
  assignments: {},
11514
11707
  pins: {},
@@ -11557,31 +11750,27 @@ async function replaceAssignments(state, base, assignments) {
11557
11750
  });
11558
11751
  }
11559
11752
  /**
11560
- * The assignment record a pass leaves behind: the stored one, with the repoint
11561
- * applied, then the plan merged over it. **Nothing is ever removed** — a pass
11562
- * that plans a subset cannot delete a key it did not mention.
11753
+ * The assignment record a pass leaves behind: the stored one with the plan
11754
+ * merged over it. **Nothing is ever removed** — a pass that plans a subset
11755
+ * cannot delete a key it did not mention.
11563
11756
  *
11564
- * PURE, and shared by the write and the divergence report — a report computed
11757
+ * PURE, and shared by the write and the placement census — a report computed
11565
11758
  * from a second reading of the same rule is a report that can describe a record
11566
11759
  * the write did not produce.
11567
11760
  */
11568
11761
  function applyPlacementPass(base, pass) {
11569
11762
  const next = new Map(Object.entries(base));
11570
- for (const [key, locationId] of pass.repointed) next.set(key, locationId);
11571
11763
  for (const [key, locationId] of pass.planned) next.set(key, locationId);
11572
11764
  return next;
11573
11765
  }
11574
- /** Persist one work-scoped pass: apply the repoint, merge its plan, and stamp
11575
- * the authority the plan was resolved against. One write. */
11766
+ /** Persist one work-scoped pass: merge its plan over the stored record. One
11767
+ * write. */
11576
11768
  async function writePlacementPass(state, base, pass) {
11577
11769
  const assignments = {};
11578
11770
  for (const [key, locationId] of applyPlacementPass(base.assignments, pass)) assignments[key] = locationId;
11579
- const defaultLocations = {};
11580
- for (const [type, locationId] of pass.defaultLocations) defaultLocations[type] = locationId;
11581
11771
  await state.set({
11582
11772
  ...base,
11583
11773
  assignments,
11584
- defaultLocations,
11585
11774
  updatedAt: Date.now()
11586
11775
  });
11587
11776
  }
@@ -11830,14 +12019,6 @@ var NO_PLAN = {
11830
12019
  assignments: /* @__PURE__ */ new Map(),
11831
12020
  changes: []
11832
12021
  };
11833
- /** Is the authority already stamped, exactly? A pass that would change the
11834
- * stamp always writes, even when it moved nothing: an unstamped authority is
11835
- * an undetectable repoint. */
11836
- function sameStamp(stamped, authority) {
11837
- if (stamped.size !== authority.size) return false;
11838
- for (const [type, locationId] of authority) if (stamped.get(type) !== locationId) return false;
11839
- return true;
11840
- }
11841
12022
  /** Operator pins win over the planner for high/mid of the pinned camera. */
11842
12023
  function overlayPins(planned, pins) {
11843
12024
  const next = new Map(planned);
@@ -11865,14 +12046,11 @@ var PlacementService = class {
11865
12046
  /** The fair tie-break the PURE planner defers to (D37). Advisory state, and
11866
12047
  * consulted only for a tie the capacity signal cannot separate. */
11867
12048
  rotation = new PlacementRotation();
11868
- /** The divergent set last announced, as a stable signature. `null` ⇒ nothing
11869
- * announced yet, which is why an all-aligned fleet says nothing at boot. */
11870
- divergenceSignature = null;
11871
- /** When the standing divergence was last announced. */
11872
- divergenceReportedAt = null;
11873
- /** Repoint episodes already announced (`type:from->to`). Cleared the moment a
11874
- * pass sees no repoint at all — the next one is a NEW episode. */
11875
- repointAnnounced = /* @__PURE__ */ new Set();
12049
+ /** The census last announced, as a stable signature. `null` ⇒ nothing
12050
+ * announced yet. */
12051
+ censusSignature = null;
12052
+ /** When the census was last announced. */
12053
+ censusReportedAt = null;
11876
12054
  constructor(deps) {
11877
12055
  this.deps = deps;
11878
12056
  }
@@ -11907,15 +12085,14 @@ var PlacementService = class {
11907
12085
  async place(deviceId, profile) {
11908
12086
  const pool = this.pool();
11909
12087
  this.reprobeAfterBackoff(pool);
11910
- const authority = authorityLocations(pool);
11911
- const resolved = await this.assignedLocation(deviceId, profile, pool, authority) ?? resolvePlacement(pool, profile);
12088
+ const resolved = await this.assignedLocation(deviceId, profile, pool) ?? resolvePlacement(await this.withHeadroom(pool), profile);
11912
12089
  const usable = await this.ensureUsable(resolved, pool, profile, deviceId);
11913
12090
  if (this.everyLocationUnusable(pool)) {
11914
12091
  this.reportEveryLocationUnusable(deviceId, profile, resolved, pool);
11915
12092
  return resolved;
11916
12093
  }
11917
12094
  this.allUnusableReported.delete(deviceId);
11918
- await this.recordDecision(deviceId, profile, usable, resolved.id === usable.id, authority.get(placementTypeForProfile(profile)) ?? null);
12095
+ await this.recordDecision(deviceId, profile, usable, resolved.id === usable.id);
11919
12096
  return usable;
11920
12097
  }
11921
12098
  /**
@@ -11999,35 +12176,30 @@ var PlacementService = class {
11999
12176
  * to it, so an unknown flag is the only thing that counts as opted in.
12000
12177
  */
12001
12178
  pool() {
12002
- const { locations, fellBackToDefault } = placementPool(this.deps.locations());
12003
- 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) } });
12179
+ const { locations, fellBackToDisabled } = placementPool(this.deps.locations());
12180
+ 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) } });
12004
12181
  return locations;
12005
12182
  }
12006
12183
  /**
12007
12184
  * The stored assignment for one (camera, profile), when it is still valid.
12008
12185
  *
12009
- * Three things invalidate it, and the third is the one this used to miss:
12186
+ * Two things invalidate it, and under the operator's model (D383) there is
12187
+ * nothing else that can:
12010
12188
  *
12011
- * 1. the location is absent from the current pool (deleted, opted out);
12012
- * 2. the location is read-only (an operator drain);
12013
- * 3. **the class was REPOINTED under it** — the authority stamped in the
12014
- * blob is not the authority now, and the assignment names neither.
12189
+ * 1. the location is absent from the current POOL — deleted, or the
12190
+ * operator disabled it. The pool is the single write authority, so an
12191
+ * assignment naming a location outside it is not a divergence to report,
12192
+ * it is unrepresentable;
12193
+ * 2. the location is read-only (an operator drain).
12015
12194
  *
12016
- * (1) and (2) both mean "this location is not a write target any more", so
12017
- * they only ever caught a repoint that took the old location out of service.
12018
- * A repoint of a class the operator is MIGRATING does the opposite: the
12019
- * source must stay enabled and writable, because it holds the history. So the
12020
- * assignment survived every attach, indefinitely, and `resolvePlacement`
12021
- * returning the new default changed nothing — it is the fallback, not the
12022
- * authority. On 2026-08-29 that left two of thirteen cameras writing to the
12023
- * disk the fleet was migrating off, silently (D298).
12024
- *
12025
- * The invalidation is a TRANSITION, not a policy: it fires only while the
12026
- * stamped authority disagrees with the live one, which the next placement pass
12027
- * ends by stamping. After that the plan is the authority again and a
12028
- * deliberate spread is honoured — and reported (`reportDivergence`).
12195
+ * There used to be a third — "the class was REPOINTED under it", measured
12196
+ * against the class default stamped in the blob (D301). It existed only
12197
+ * because the default was a SECOND write authority layered over the pool and
12198
+ * the two could disagree. With the default deleted the disagreement cannot be
12199
+ * expressed: a location leaving the pool already invalidates every assignment
12200
+ * naming it, and the planner then moves those keys with `reason: 'gone'`.
12029
12201
  */
12030
- async assignedLocation(deviceId, profile, pool, authority) {
12202
+ async assignedLocation(deviceId, profile, pool) {
12031
12203
  let blob;
12032
12204
  try {
12033
12205
  blob = await readPlacementState(this.deps.state);
@@ -12049,23 +12221,28 @@ var PlacementService = class {
12049
12221
  }
12050
12222
  if (assignedId === void 0) return null;
12051
12223
  const location = pool.find((l) => l.id === assignedId && !l.readOnly) ?? null;
12052
- if (location === null) return null;
12053
- const type = placementTypeForProfile(profile);
12054
- const target = authority.get(type);
12055
- const stamped = blob.defaultLocations[type];
12056
- if (target === void 0 || stamped === void 0) return location;
12057
- if (stamped === target || location.id === target) return location;
12058
- 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", {
12224
+ 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.", {
12059
12225
  tags: { deviceId },
12060
12226
  meta: {
12061
12227
  profile,
12062
- assignedLocationId: location.id,
12063
- repointedFrom: stamped,
12064
- repointedTo: target,
12065
- locationType: type
12228
+ assignedLocationId: assignedId,
12229
+ poolLocationIds: pool.filter((l) => !l.readOnly).map((l) => l.id)
12066
12230
  }
12067
12231
  });
12068
- return null;
12232
+ return location;
12233
+ }
12234
+ /** The pool, with each member's measured headroom attached. One capacity read
12235
+ * per location; used only on the bootstrap path, never per segment. */
12236
+ async withHeadroom(pool) {
12237
+ const candidates = await this.candidatesFor(pool);
12238
+ const byId = new Map(candidates.map((c) => [c.locationId, c.headroomBytes]));
12239
+ return pool.map((location) => {
12240
+ const headroomBytes = byId.get(location.id);
12241
+ return headroomBytes === void 0 ? location : {
12242
+ ...location,
12243
+ headroomBytes
12244
+ };
12245
+ });
12069
12246
  }
12070
12247
  /**
12071
12248
  * Re-plan the (camera, profile) pairs in `work` over the current locations.
@@ -12087,7 +12264,6 @@ var PlacementService = class {
12087
12264
  */
12088
12265
  async recompute(work) {
12089
12266
  const pool = this.pool();
12090
- const authority = authorityLocations(pool);
12091
12267
  const candidates = await this.candidatesFor(pool);
12092
12268
  const demands = work.map((item) => ({
12093
12269
  key: assignmentKey(item.deviceId, item.profile),
@@ -12104,17 +12280,7 @@ var PlacementService = class {
12104
12280
  } });
12105
12281
  return NO_PLAN;
12106
12282
  }
12107
- const stamped = new Map(Object.entries(blob.defaultLocations));
12108
- const repoints = this.detectRepoints(blob.assignments, stamped, authority, blob.pins);
12109
- if (repoints.length === 0) this.repointAnnounced.clear();
12110
- for (const repoint of repoints) this.announceRepoint(repoint);
12111
- const repointed = /* @__PURE__ */ new Map();
12112
- for (const repoint of repoints) for (const key of repoint.keys) repointed.set(key, repoint.to);
12113
- const current = applyPlacementPass(blob.assignments, {
12114
- repointed,
12115
- planned: /* @__PURE__ */ new Map(),
12116
- defaultLocations: authority
12117
- });
12283
+ const current = new Map(Object.entries(blob.assignments));
12118
12284
  const evacuate = new Set(candidates.filter((c) => c.headroomBytes <= 0).map((c) => c.locationId));
12119
12285
  for (const location of pool) if (location.readOnly) evacuate.add(location.id);
12120
12286
  const plan = planPlacement({
@@ -12124,13 +12290,9 @@ var PlacementService = class {
12124
12290
  evacuate,
12125
12291
  breakTie: (tied) => this.rotation.next(tied)
12126
12292
  });
12127
- const pass = {
12128
- repointed,
12129
- planned: overlayPins(plan.assignments, blob.pins),
12130
- defaultLocations: authority
12131
- };
12132
- this.reportDivergence(applyPlacementPass(blob.assignments, pass), authority);
12133
- if (plan.changes.length === 0 && repointed.size === 0 && sameStamp(stamped, authority)) return plan;
12293
+ const pass = { planned: overlayPins(plan.assignments, blob.pins) };
12294
+ this.reportCensus(applyPlacementPass(blob.assignments, pass), pool);
12295
+ if (plan.changes.length === 0) return plan;
12134
12296
  for (const change of plan.changes) {
12135
12297
  const deviceId = Number(change.key.split(":")[0]);
12136
12298
  this.deps.logger.info("recorder placement: plan moves a profile to another location", {
@@ -12154,129 +12316,60 @@ var PlacementService = class {
12154
12316
  return plan;
12155
12317
  }
12156
12318
  /**
12157
- * Which recordings classes were REPOINTED under the stored plan, and which of
12158
- * its keys that invalidates.
12159
- *
12160
- * PURE. A class is repointed when the blob stamped an authority for it and
12161
- * the authority now is a different location. A class with NO stamp is not a
12162
- * repoint — it is the first pass after this shipped, and inferring a repoint
12163
- * from an absent stamp would throw away every deliberate assignment on the
12164
- * first tick after a deploy.
12165
- */
12166
- detectRepoints(stored, stamped, authority, pins) {
12167
- const out = [];
12168
- for (const [type, to] of authority) {
12169
- const from = stamped.get(type);
12170
- if (from === void 0 || from === to) continue;
12171
- const keys = [];
12172
- for (const [key, locationId] of Object.entries(stored)) {
12173
- const [devicePart, profilePart] = key.split(":");
12174
- if (placementTypeForProfile(profilePart ?? "") !== type) continue;
12175
- if (locationId === to) continue;
12176
- if (type === "recordings" && devicePart !== void 0 && pins[devicePart] !== void 0) continue;
12177
- keys.push(key);
12178
- }
12179
- out.push({
12180
- type,
12181
- from,
12182
- to,
12183
- keys
12184
- });
12185
- }
12186
- return out;
12187
- }
12188
- /**
12189
- * One line PER CAMERA (a repoint is asked per-camera: "did 3839 move?"), plus
12190
- * one audit row per class. A repoint is a rare operator event, so the volume
12191
- * is one burst of fleet-size, not a stream.
12192
- *
12193
- * Announced ONCE per episode: the detection is a comparison against the
12194
- * stamp, so a write that fails leaves the stamp behind and the next tick sees
12195
- * the same repoint again. Without the latch, a settings door that accepts
12196
- * reads and refuses writes would repeat the whole burst every 30 s.
12197
- */
12198
- announceRepoint(repoint) {
12199
- const episode = `${repoint.type}:${repoint.from}->${repoint.to}`;
12200
- if (this.repointAnnounced.has(episode)) return;
12201
- this.repointAnnounced.add(episode);
12202
- for (const key of repoint.keys) {
12203
- const deviceId = Number(key.split(":")[0]);
12204
- this.deps.logger.warn("recorder placement: recordings class REPOINTED — moving this assignment onto the class`s new authority location", {
12205
- ...Number.isFinite(deviceId) ? { tags: { deviceId } } : {},
12206
- meta: {
12207
- key,
12208
- locationType: repoint.type,
12209
- repointedFrom: repoint.from,
12210
- repointedTo: repoint.to
12211
- }
12212
- });
12213
- }
12214
- this.deps.opsLog.append({
12215
- op: "relocate",
12216
- reason: "operator",
12217
- deviceId: null,
12218
- itemsAffected: repoint.keys.length,
12219
- bytesReclaimed: 0,
12220
- actor: "system",
12221
- detail: `placement: ${repoint.type} repointed ${repoint.from} → ${repoint.to}; ${repoint.keys.length.toString()} assignment(s) moved (${repoint.keys.join(", ")})`
12222
- });
12223
- }
12224
- /**
12225
- * Say which (camera, profile) the plan writes somewhere other than its class
12226
- * authority — per camera, WITH its denominator (D277).
12319
+ * WHERE THE FLEET WRITES — one line per (class, location), always with its
12320
+ * denominator (D277).
12227
12321
  *
12228
- * This is not an error path. The planner spreads a class across its pool on
12229
- * purpose, and a location with headroom is a legitimate target. What was
12230
- * wrong is that a deliberate spread and a leftover from a repoint nobody
12231
- * noticed produced the *same* absence of output: `recordDecision` audits only
12232
- * a CHANGE, so a camera that keeps using its old assignment writes no row and
12233
- * no line, forever. An operator read "migration done" while two cameras were
12234
- * still filling the disk they were migrating off.
12322
+ * This replaced the class-authority divergence report (D301) when the default
12323
+ * was deleted: with every enabled location of a class a legitimate target,
12324
+ * "this camera does not write to THE location" is not a fact any more, while
12325
+ * "recordings: 9 of 13 on recordings:default, 4 on recordings:hgst" is
12326
+ * exactly the question an operator asks after enabling a second disk. A pool
12327
+ * member holding ZERO assignments is listed too — an enabled disk nothing
12328
+ * lands on is the failure this whole model exists to make visible.
12235
12329
  *
12236
- * Fires on a CHANGE of the divergent set, and otherwise at most once per
12237
- * {@link PLACEMENT_DIVERGENCE_REPORT_MS}. An all-aligned fleet says nothing —
12238
- * until it stops being all-aligned, and again when it recovers.
12330
+ * Fires on a CHANGE of the distribution, and otherwise at most once per
12331
+ * {@link PLACEMENT_CENSUS_REPORT_MS}.
12239
12332
  */
12240
- reportDivergence(assignments, authority) {
12241
- const divergent = [];
12333
+ reportCensus(assignments, pool) {
12334
+ const counts = /* @__PURE__ */ new Map();
12335
+ const perClass = /* @__PURE__ */ new Map();
12336
+ for (const location of pool) if (!location.readOnly) counts.set(location.id, 0);
12242
12337
  for (const [key, locationId] of assignments) {
12243
- const profile = key.split(":")[1] ?? "";
12244
- const authorityLocationId = authority.get(placementTypeForProfile(profile));
12245
- if (authorityLocationId === void 0 || authorityLocationId === locationId) continue;
12246
- divergent.push({
12247
- key,
12248
- deviceId: Number(key.split(":")[0]),
12249
- profile,
12250
- locationId,
12251
- authorityLocationId
12252
- });
12338
+ counts.set(locationId, (counts.get(locationId) ?? 0) + 1);
12339
+ const type = placementTypeForProfile(key.split(":")[1] ?? "");
12340
+ perClass.set(type, (perClass.get(type) ?? 0) + 1);
12253
12341
  }
12254
- const signature = divergent.map((d) => `${d.key}=${d.locationId}`).toSorted().join(",");
12255
- const previous = this.divergenceSignature;
12342
+ const typeOf = new Map(pool.map((location) => [location.id, location.type]));
12343
+ const lines = [...counts.entries()].toSorted(([a], [b]) => a.localeCompare(b)).map(([locationId, count]) => {
12344
+ const type = typeOf.get(locationId) ?? locationId.split(":")[0] ?? locationId;
12345
+ return {
12346
+ locationId,
12347
+ type,
12348
+ assignments: count,
12349
+ ofClass: perClass.get(type) ?? 0
12350
+ };
12351
+ });
12352
+ const signature = lines.map((line) => `${line.locationId}=${String(line.assignments)}`).join(",");
12256
12353
  const now = this.now;
12257
- const due = this.divergenceReportedAt === null || now - this.divergenceReportedAt >= 36e5;
12258
- if (signature === previous && !due) return;
12259
- this.divergenceSignature = signature;
12260
- this.divergenceReportedAt = now;
12261
- if (divergent.length === 0) {
12262
- if (previous !== null && previous !== "") this.deps.logger.info("recorder placement: every assignment now writes to its class authority location", { meta: { assignments: assignments.size } });
12263
- return;
12354
+ const due = this.censusReportedAt === null || now - this.censusReportedAt >= 36e5;
12355
+ if (signature === this.censusSignature && !due) return;
12356
+ this.censusSignature = signature;
12357
+ this.censusReportedAt = now;
12358
+ if (lines.length === 0) return;
12359
+ this.deps.logger.info("recorder placement: census — which locations the fleet writes to", { meta: { locations: lines.map((line) => ({
12360
+ locationId: line.locationId,
12361
+ type: line.type,
12362
+ assignments: line.assignments,
12363
+ ofClass: line.ofClass
12364
+ })) } });
12365
+ for (const line of lines) {
12366
+ if (line.assignments > 0) continue;
12367
+ this.deps.logger.warn("recorder placement: this location is ENABLED but holds no assignment — nothing writes to it", { meta: {
12368
+ locationId: line.locationId,
12369
+ type: line.type,
12370
+ ofClass: line.ofClass
12371
+ } });
12264
12372
  }
12265
- this.deps.logger.warn("recorder placement: assignments write somewhere other than their class authority location", { meta: {
12266
- divergent: divergent.length,
12267
- assignments: assignments.size,
12268
- authority: Object.fromEntries(authority)
12269
- } });
12270
- for (const entry of divergent) this.deps.logger.warn("recorder placement: this profile does NOT write to its class authority location", {
12271
- ...Number.isFinite(entry.deviceId) ? { tags: { deviceId: entry.deviceId } } : {},
12272
- meta: {
12273
- profile: entry.profile,
12274
- locationId: entry.locationId,
12275
- authorityLocationId: entry.authorityLocationId,
12276
- divergent: divergent.length,
12277
- assignments: assignments.size
12278
- }
12279
- });
12280
12373
  }
12281
12374
  /** Bound one addon-store round trip. See {@link PLACEMENT_STORE_TIMEOUT_MS}. */
12282
12375
  bounded(promise, label) {
@@ -12366,7 +12459,7 @@ var PlacementService = class {
12366
12459
  }
12367
12460
  if (location.maxUsedGb !== void 0) {
12368
12461
  const used = await this.deps.usedOnLocation?.(location.id) ?? 0;
12369
- limits.push(location.maxUsedGb * 1e9 - used);
12462
+ limits.push(require_dist.gbToBytes(location.maxUsedGb) - used);
12370
12463
  }
12371
12464
  out.push({
12372
12465
  locationId: location.id,
@@ -12457,13 +12550,13 @@ var PlacementService = class {
12457
12550
  * Persist the assignment and audit it — but only when it actually changed.
12458
12551
  * The steady state (every attach after the first) writes nothing.
12459
12552
  *
12460
- * The per-attach line carries `authorityLocationId` and `divergent` so a
12461
- * single camera's line answers "is 3839 writing where the class default
12462
- * says?" on its own. Without it, a camera pinned to a location the class was
12463
- * repointed away from logged `reason: 'resolved'` — byte-identical to a
12464
- * camera correctly on the current default.
12553
+ * The per-attach line carries the camera's location and the one it replaced,
12554
+ * tagged with `deviceId`, so "where does 3839 write, and did it move?" is
12555
+ * answerable per camera. Which locations the FLEET is spread over is the
12556
+ * census's job (D383) — there is no class authority for a single line to be
12557
+ * measured against any more.
12465
12558
  */
12466
- async recordDecision(deviceId, profile, location, verbatim, authorityLocationId) {
12559
+ async recordDecision(deviceId, profile, location, verbatim) {
12467
12560
  const key = assignmentKey(deviceId, profile);
12468
12561
  let previous;
12469
12562
  try {
@@ -12484,8 +12577,6 @@ var PlacementService = class {
12484
12577
  locationId: location.id,
12485
12578
  root: location.root,
12486
12579
  previousLocationId: previous ?? null,
12487
- authorityLocationId,
12488
- divergent: authorityLocationId !== null && authorityLocationId !== location.id,
12489
12580
  reason: verbatim ? "resolved" : "failover"
12490
12581
  }
12491
12582
  });
@@ -12613,7 +12704,7 @@ function effectiveBands(config) {
12613
12704
  */
12614
12705
  function resolveBandBufferMs(band) {
12615
12706
  return {
12616
- preMs: require_prebuffer.PREBUFFER_MAX_MS,
12707
+ preMs: require_restream_intent.PREBUFFER_MAX_MS,
12617
12708
  postMs: (band.postBufferSec ?? require_dist.DEFAULT_EVENTS_BAND_BUFFER_SEC.postBufferSec) * 1e3
12618
12709
  };
12619
12710
  }
@@ -14456,6 +14547,26 @@ var RecordingController = class {
14456
14547
  this.wakeups.arm(deviceId, next.atMs, next.reason);
14457
14548
  }
14458
14549
  /**
14550
+ * Where every LIVE writer is writing right now — the directory ffmpeg was
14551
+ * spawned against, per (device, profile).
14552
+ *
14553
+ * The plan and the disk disagree for as long as a writer outlives the tick
14554
+ * that re-planned it: placement applies at ATTACH BOUNDARIES only (the
14555
+ * `.rec-tmp` staging dir lives under the chosen root so the finalize
14556
+ * relocate is a same-filesystem rename), so a continuously-recording camera
14557
+ * keeps its root until something detaches it. That is a legitimate state and
14558
+ * a silent one, which is why it is READABLE from here (D387).
14559
+ */
14560
+ activeWriterDirs() {
14561
+ const out = [];
14562
+ for (const [deviceId, recordings] of this.active) for (const r of recordings) out.push({
14563
+ deviceId,
14564
+ profile: r.profile,
14565
+ outDir: r.outDir
14566
+ });
14567
+ return out;
14568
+ }
14569
+ /**
14459
14570
  * Boot restore: seed the readiness-gated queue with every persisted device and
14460
14571
  * (re)evaluate each on every `stream-broker` ready transition. Also arms the
14461
14572
  * periodic passes. Idempotent — safe to call once after the index is hydrated.
@@ -15383,7 +15494,7 @@ var RecordingController = class {
15383
15494
  tag: `recorder:${deviceId}/${profile}`
15384
15495
  }, require_dist.nodePin(this.deps.ownerNodeId)), ATTACH_RPC_TIMEOUT_MS, "getStreamWithCodec"));
15385
15496
  const streamAcquiredMs = this.now();
15386
- const rtspUrl = require_restream_intent.withRecordingIntent(source.url);
15497
+ const rtspUrl = require_restream_intent.withRecordingIntent(source.url, require_restream_intent.RECORDING_PRE_ROLL_REQUEST_MS);
15387
15498
  this.attachSeq++;
15388
15499
  const consumerTag = `recorder/${deviceId}/${profile}/${this.attachSeq}`;
15389
15500
  if (rtspUrl === source.url) this.deps.logger.info("recorder: dial carries no recording intent — no pre-roll", {
@@ -16841,6 +16952,21 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
16841
16952
  * per episode, not one per 30 s tick. `null` ⇒ the last pass had work. */
16842
16953
  placementWorkGap = null;
16843
16954
  /**
16955
+ * When the recorder re-resolves its storage locations (D387): coalesced off
16956
+ * the `StorageLocationsChanged` doorbell, and converged on the placement
16957
+ * pass's own timer whether or not that doorbell ever arrives. Built on the
16958
+ * recording node only, with the rest of the write path.
16959
+ */
16960
+ locationRefresher = null;
16961
+ /** Unsubscribe handle for the `StorageLocationsChanged` subscription. */
16962
+ locationChangeUnsub = null;
16963
+ /**
16964
+ * How many live writers are recording somewhere other than their planned
16965
+ * location, latched so the line is one per episode rather than one per 30 s
16966
+ * tick. `null` ⇒ nothing pending (or nothing measured yet).
16967
+ */
16968
+ pendingWriterMoves = null;
16969
+ /**
16844
16970
  * The calendar rungs — which days, which hours, and a day's exact ranges.
16845
16971
  *
16846
16972
  * Hours the RAM index has already walked (or the live hour it is
@@ -17065,8 +17191,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
17065
17191
  },
17066
17192
  archiveHourPaths: async (deviceId, profile, locationId, hourStartMs) => await this.segmentHours?.hourPaths(deviceId, profile, locationId, hourStartMs) ?? null,
17067
17193
  removeDir: async (locationId, relDir) => {
17068
- const loc = this.resolvedLocations.find((l) => l.id === locationId);
17069
- if (!loc) throw new Error(`recorder: unknown recordings location ${locationId}`);
17194
+ const loc = requireReadableLocation(this.resolvedLocations, locationId, "recorder");
17070
17195
  await node_fs.promises.rm(node_path.default.join(loc.root, relDir), {
17071
17196
  recursive: true,
17072
17197
  force: true
@@ -17104,8 +17229,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
17104
17229
  });
17105
17230
  },
17106
17231
  removeDirIfEmpty: async (locationId, relDir) => {
17107
- const loc = this.resolvedLocations.find((l) => l.id === locationId);
17108
- if (!loc) throw new Error(`recorder: unknown recordings location ${locationId}`);
17232
+ const loc = requireReadableLocation(this.resolvedLocations, locationId, "recorder");
17109
17233
  await node_fs.promises.rmdir(node_path.default.join(loc.root, relDir));
17110
17234
  },
17111
17235
  logger: {
@@ -17318,7 +17442,10 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
17318
17442
  logger: this.ctx.logger,
17319
17443
  store: this.segmentStore,
17320
17444
  resolveDomain: (locationId) => this.locationDomain(locationId),
17321
- isReadOnly: (locationId) => this.resolvedLocations.find((l) => l.id === locationId)?.readOnly === true,
17445
+ isEvictionFrozen: (locationId) => {
17446
+ const location = this.resolvedLocations.find((l) => l.id === locationId);
17447
+ return location !== void 0 && require_dist.evictionPolicyForMode(location.mode) === "never";
17448
+ },
17322
17449
  deviceRegistry: async () => {
17323
17450
  try {
17324
17451
  const devices = await this.ctx.api.deviceManager.listAll.query({ projection: "slim" });
@@ -17498,6 +17625,10 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
17498
17625
  capability: require_dist.storageEvictableCapability,
17499
17626
  provider: evictableProvider
17500
17627
  },
17628
+ {
17629
+ capability: require_dist.storageOccupancyCapability,
17630
+ provider: evictableProvider
17631
+ },
17501
17632
  {
17502
17633
  capability: require_dist.recordingExportCapability,
17503
17634
  provider: exportProvider
@@ -17548,6 +17679,10 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
17548
17679
  this.sensorTriggerUnsub = null;
17549
17680
  this.signalTriggerUnsub?.();
17550
17681
  this.signalTriggerUnsub = null;
17682
+ this.locationChangeUnsub?.();
17683
+ this.locationChangeUnsub = null;
17684
+ this.locationRefresher?.dispose();
17685
+ this.locationRefresher = null;
17551
17686
  await this.controller?.stop();
17552
17687
  this.controller = null;
17553
17688
  this.segmentStore = null;
@@ -17663,6 +17798,13 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
17663
17798
  this.ctx.logger.warn("recorder: controller start failed", { meta: { error: require_dist.errMsg(err) } });
17664
17799
  }
17665
17800
  this.recoverStagedOrphans(stagingBootMs);
17801
+ if (this.locationRefresher === null) this.locationRefresher = new LocationRefresher({
17802
+ logger: this.ctx.logger,
17803
+ refresh: () => this.refreshLocations()
17804
+ });
17805
+ if (this.locationChangeUnsub === null) this.locationChangeUnsub = this.ctx.eventBus.subscribe({ category: require_dist.EventCategory.StorageLocationsChanged }, (event) => {
17806
+ this.locationRefresher?.ring(event.data.locationId, event.data.reason);
17807
+ });
17666
17808
  if (this.eventUnsub === null) this.eventUnsub = subscribeEventCapture({
17667
17809
  eventBus: this.ctx.eventBus,
17668
17810
  onMotion: (deviceId, atMs) => {
@@ -18069,6 +18211,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
18069
18211
  */
18070
18212
  async recomputePlacement(deviceIds) {
18071
18213
  if (!this.placement) return;
18214
+ await (this.locationRefresher?.convergeIfDue() ?? Promise.resolve());
18072
18215
  const archive = this.segmentHours;
18073
18216
  if (!archive) {
18074
18217
  this.reportPlacementWorkGap("no segment-hour ledger on this node", deviceIds.length, 0);
@@ -18085,7 +18228,48 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
18085
18228
  return;
18086
18229
  }
18087
18230
  this.placementWorkGap = null;
18088
- await this.placement.recompute(work);
18231
+ const plan = await this.placement.recompute(work);
18232
+ this.reportPendingWriterMoves(plan.assignments);
18233
+ }
18234
+ /**
18235
+ * Cameras whose plan says one disk and whose live writer says another —
18236
+ * REPORTED, per camera, for as long as it stands (D387).
18237
+ *
18238
+ * Placement applies at attach boundaries only, so a re-planned camera moves
18239
+ * when it next attaches. A camera on a schedule attaches again within
18240
+ * minutes; a CONTINUOUSLY recording one never does, and its plan can sit
18241
+ * unapplied forever. Repointing a live writer is not the fix — the
18242
+ * `.rec-tmp` staging dir lives under the chosen root precisely so the
18243
+ * finalize relocate is a same-filesystem rename — and detaching the fleet to
18244
+ * apply a plan costs a recording gap on every affected camera at once, on
18245
+ * the very day the operator added a disk to relieve pressure. So the honest
18246
+ * behaviour is to say it, name the cameras, and let the operator decide.
18247
+ *
18248
+ * Latched on WHAT is pending: a line per 30 s tick would be its own defect.
18249
+ */
18250
+ reportPendingWriterMoves(planned) {
18251
+ const controller = this.controller;
18252
+ if (!controller) return;
18253
+ const moves = pendingWriterMoves(controller.activeWriterDirs(), planned, this.resolvedLocations);
18254
+ const signature = pendingMovesSignature(moves);
18255
+ if (signature === this.pendingWriterMoves) return;
18256
+ this.pendingWriterMoves = signature;
18257
+ if (moves.length === 0) return;
18258
+ 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", {
18259
+ tags: { deviceId: move.deviceId },
18260
+ meta: {
18261
+ profile: move.profile,
18262
+ writingTo: move.writingTo,
18263
+ plannedFor: move.plannedFor,
18264
+ 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)"
18265
+ }
18266
+ });
18267
+ this.emitAlert({
18268
+ severity: "warning",
18269
+ title: "Recording placement waiting on a writer restart",
18270
+ 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.`,
18271
+ locationId: moves[0]?.plannedFor ?? ""
18272
+ });
18089
18273
  }
18090
18274
  /**
18091
18275
  * A placement pass that plans NOTHING says so — once per episode.
@@ -18248,12 +18432,22 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
18248
18432
  * still be shouted about in the log even when it is unreachable.
18249
18433
  */
18250
18434
  emitPlacementAlert(input) {
18251
- this.ctx.api.alerts.emit.mutate({
18252
- id: (0, node_crypto.randomUUID)(),
18253
- category: "storage",
18435
+ this.emitAlert({
18254
18436
  severity: "error",
18255
18437
  title: "Recordings storage location unusable",
18256
18438
  message: input.detail,
18439
+ locationId: input.locationId
18440
+ });
18441
+ }
18442
+ /** One storage-category alert. Advisory: the LOG line is the durable record,
18443
+ * so a failed emit is swallowed rather than raised. */
18444
+ emitAlert(input) {
18445
+ this.ctx.api.alerts.emit.mutate({
18446
+ id: (0, node_crypto.randomUUID)(),
18447
+ category: "storage",
18448
+ severity: input.severity,
18449
+ title: input.title,
18450
+ message: input.message,
18257
18451
  status: "active",
18258
18452
  read: false,
18259
18453
  createdAt: Date.now(),
@@ -18410,7 +18604,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
18410
18604
  */
18411
18605
  async purgeFlattenedSettingsKeys() {
18412
18606
  try {
18413
- await require_prebuffer.retireRootKeys({
18607
+ await require_retire_root_keys.retireRootKeys({
18414
18608
  store: this.ctx.api.settingsStore,
18415
18609
  addonId: RECORDER_ADDON_ID,
18416
18610
  logger: this.ctx.logger,
@@ -18491,8 +18685,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
18491
18685
  * manifest uses). The scrub packs read the SAME source segments as playback.
18492
18686
  */
18493
18687
  resolveSegmentAbsPath(row) {
18494
- const loc = this.resolvedLocations.find((l) => l.id === row.locationId);
18495
- if (loc === void 0) throw new Error(`recorder scrub: unknown recordings location ${row.locationId}`);
18688
+ const loc = requireReadableLocation(this.resolvedLocations, row.locationId, "recorder scrub");
18496
18689
  return node_path.default.join(loc.root, row.path);
18497
18690
  }
18498
18691
  /**