@camstack/system 1.2.165 → 1.2.166

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 (60) hide show
  1. package/dist/addon-runner.js +1 -1
  2. package/dist/addon-runner.mjs +1 -1
  3. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  6. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  7. package/dist/builtins/alerts/alerts.addon.js +1 -1
  8. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  9. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  10. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  11. package/dist/builtins/console-logging/index.js +1 -1
  12. package/dist/builtins/console-logging/index.mjs +1 -1
  13. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  14. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  15. package/dist/builtins/device-manager/device-manager.addon.js +2 -2
  16. package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
  17. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  18. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  19. package/dist/builtins/hub-forwarder/index.js +1 -1
  20. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  21. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  22. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  23. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  24. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  25. package/dist/builtins/local-network/local-network.addon.js +1 -1
  26. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  27. package/dist/builtins/loki-logging/index.js +1 -1
  28. package/dist/builtins/loki-logging/index.mjs +1 -1
  29. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  30. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  31. package/dist/builtins/platform-probe/index.js +1 -1
  32. package/dist/builtins/platform-probe/index.mjs +1 -1
  33. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  34. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  35. package/dist/builtins/snapshot/index.js +1 -1
  36. package/dist/builtins/snapshot/index.mjs +1 -1
  37. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  38. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  39. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  40. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  41. package/dist/builtins/storage-orchestrator/location-occupancy.d.ts +19 -0
  42. package/dist/builtins/storage-orchestrator/storage-cleanup-coordinator.d.ts +68 -0
  43. package/dist/builtins/storage-orchestrator/storage-migration-coordinator.d.ts +14 -0
  44. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.d.ts +13 -8
  45. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +441 -100
  46. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +443 -100
  47. package/dist/builtins/storage-orchestrator/storage-orchestrator.service.d.ts +11 -13
  48. package/dist/builtins/system-config/system-config.addon.js +1 -1
  49. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  50. package/dist/builtins/winston-logging/index.js +1 -1
  51. package/dist/builtins/winston-logging/index.mjs +1 -1
  52. package/dist/{dist-BhE8zNfY.js → dist-CFTKQGym.js} +185 -7
  53. package/dist/{dist-BpsHt11f.mjs → dist-Dw9cm1NS.mjs} +180 -8
  54. package/dist/index.js +2 -2
  55. package/dist/index.mjs +2 -2
  56. package/dist/{manifest-system-deps-uflHvHDB.js → manifest-system-deps-BIxY1Y5e.js} +1 -1
  57. package/dist/{manifest-system-deps-D2GebQTp.mjs → manifest-system-deps-DTz_zNQf.mjs} +1 -1
  58. package/dist/{retired-settings-keys-CqDbI-vK.js → retired-settings-keys-dehyu2N3.js} +1 -1
  59. package/dist/{retired-settings-keys-DLwieFXR.mjs → retired-settings-keys-diW1NmyP.mjs} +1 -1
  60. package/package.json +1 -1
@@ -3,7 +3,8 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-BhE8zNfY.js");
6
+ const require_dist = require("../../dist-CFTKQGym.js");
7
+ let zod = require("zod");
7
8
  let node_crypto = require("node:crypto");
8
9
  let node_fs_promises = require("node:fs/promises");
9
10
  node_fs_promises = require_chunk.__toESM(node_fs_promises);
@@ -292,17 +293,22 @@ var StorageMigrationCoordinator = class {
292
293
  for (const storageClass of STORAGE_CLASSES) {
293
294
  const targetId = input.destinations[storageClass];
294
295
  if (targetId === void 0) continue;
295
- const source = this.deps.locations.getDefaultLocation(storageClass);
296
- if (!source) throw new Error(`No default storage location for "${storageClass}"`);
296
+ const namedSourceId = input.sources?.[storageClass];
297
+ const defaultLoc = this.deps.locations.getDefaultLocation(storageClass);
298
+ const source = namedSourceId ? this.deps.locations.getLocationById(namedSourceId) : defaultLoc;
299
+ if (!source) throw new Error(namedSourceId ? `Storage location "${namedSourceId}" not found` : `No default storage location for "${storageClass}"`);
300
+ if (source.type !== storageClass) throw new Error(`Storage location "${source.id}" is type "${source.type}", expected "${storageClass}"`);
297
301
  const target = this.deps.locations.getLocationById(targetId);
298
302
  if (!target) throw new Error(`Storage location "${targetId}" not found`);
299
303
  if (target.type !== storageClass) throw new Error(`Storage location "${targetId}" is type "${target.type}", expected "${storageClass}"`);
300
- if (source.id === target.id) throw new Error(`Storage location "${targetId}" is already the "${storageClass}" default`);
304
+ if (source.id === target.id) throw new Error(namedSourceId ? `Source and destination are the same location ("${targetId}")` : `Storage location "${targetId}" is already the "${storageClass}" default`);
305
+ const freezeSource = defaultLoc !== void 0 && source.id !== defaultLoc.id;
301
306
  if (!MOVER_CLASSES.includes(storageClass)) throw new Error(`No mover owns "${storageClass}" — the migration can repoint its default but cannot move its bytes. Move it by hand, then repoint the default with upsertLocation.`);
302
307
  moves.push({
303
308
  storageClass,
304
309
  fromLocationId: source.id,
305
310
  toLocationId: target.id,
311
+ freezeSource,
306
312
  moverJobId: null,
307
313
  state: null,
308
314
  error: null,
@@ -333,11 +339,13 @@ var StorageMigrationCoordinator = class {
333
339
  }
334
340
  return {
335
341
  destinations: input.destinations,
342
+ sources: input.sources,
336
343
  mode,
337
- moves: moves.map(({ storageClass, fromLocationId, toLocationId }) => ({
344
+ moves: moves.map(({ storageClass, fromLocationId, toLocationId, freezeSource }) => ({
338
345
  storageClass,
339
346
  fromLocationId,
340
- toLocationId
347
+ toLocationId,
348
+ freezeSource
341
349
  })),
342
350
  findings
343
351
  };
@@ -367,12 +375,12 @@ var StorageMigrationCoordinator = class {
367
375
  this.startReserved = true;
368
376
  try {
369
377
  const existing = await this.status();
370
- if (existing && isTerminal(existing) && existing.pausedParticipants.length > 0) {
378
+ if (existing && isTerminal$1(existing) && existing.pausedParticipants.length > 0) {
371
379
  await this.releaseAfterTerminal(existing);
372
380
  await this.persist(existing);
373
381
  if (existing.pausedParticipants.length > 0) throw new Error(`storage migration ${existing.jobId} still holds maintenance leases`);
374
382
  }
375
- if (existing && !isTerminal(existing)) throw new Error(`storage migration is already active (${existing.jobId})`);
383
+ if (existing && !isTerminal$1(existing)) throw new Error(`storage migration is already active (${existing.jobId})`);
376
384
  const plan = await this.plan(input);
377
385
  const now = this.deps.now();
378
386
  const job = {
@@ -380,6 +388,7 @@ var StorageMigrationCoordinator = class {
380
388
  phase: "planning",
381
389
  mode: plan.mode,
382
390
  destinations: input.destinations,
391
+ sources: input.sources,
383
392
  throttleMbps: input.throttleMbps ?? 40,
384
393
  moves: plan.moves.map((move) => ({
385
394
  ...move,
@@ -411,10 +420,15 @@ var StorageMigrationCoordinator = class {
411
420
  if (jobId !== void 0 && job?.jobId !== jobId) return null;
412
421
  return job;
413
422
  }
423
+ /** Finished jobs, newest first, excluding the one `status` currently shows. */
424
+ async history() {
425
+ const current = await this.status();
426
+ return (await this.deps.history?.get() ?? []).filter((job) => job.jobId !== current?.jobId);
427
+ }
414
428
  async cancel(jobId) {
415
429
  const job = await this.status(jobId);
416
430
  const cutoverInFlight = job !== null && job.repointed && (job.phase === "refreshing" || job.phase === "resuming");
417
- if (!job || isTerminal(job) || cutoverInFlight) return false;
431
+ if (!job || isTerminal$1(job) || cutoverInFlight) return false;
418
432
  job.cancelRequested = true;
419
433
  await this.persist(job);
420
434
  await Promise.all(job.moves.filter((move) => move.moverJobId !== null).map((move) => this.cancelMove(move)));
@@ -471,18 +485,22 @@ var StorageMigrationCoordinator = class {
471
485
  const target = this.deps.locations.getDefaultLocation(storageClass);
472
486
  if (!target) continue;
473
487
  if (laneOf(storageClass) === "media") {
474
- const count = await unanswerable(this.deps.participants.analytics.residue({
475
- toLocationId: target.id,
476
- mode: "move"
477
- }));
478
- if (count !== null && count.rows === 0) continue;
479
- out.push({
480
- storageClass,
481
- fromLocationId: "*",
482
- toLocationId: target.id,
483
- items: count?.rows ?? null,
484
- bytes: null
485
- });
488
+ for (const source of this.deps.locations.listLocations({ type: storageClass })) {
489
+ if (source.id === target.id) continue;
490
+ const count = await unanswerable(this.deps.participants.analytics.residue({
491
+ toLocationId: target.id,
492
+ fromLocationId: source.id,
493
+ mode: "move"
494
+ }));
495
+ if (count !== null && count.rows === 0) continue;
496
+ out.push({
497
+ storageClass,
498
+ fromLocationId: source.id,
499
+ toLocationId: target.id,
500
+ items: count?.rows ?? null,
501
+ bytes: null
502
+ });
503
+ }
486
504
  continue;
487
505
  }
488
506
  for (const source of this.deps.locations.listLocations({ type: storageClass })) {
@@ -536,12 +554,12 @@ var StorageMigrationCoordinator = class {
536
554
  this.startReserved = true;
537
555
  try {
538
556
  const existing = await this.status();
539
- if (existing && isTerminal(existing) && existing.pausedParticipants.length > 0) {
557
+ if (existing && isTerminal$1(existing) && existing.pausedParticipants.length > 0) {
540
558
  await this.releaseAfterTerminal(existing);
541
559
  await this.persist(existing);
542
560
  if (existing.pausedParticipants.length > 0) throw new Error(`storage migration ${existing.jobId} still holds maintenance leases`);
543
561
  }
544
- if (existing && !isTerminal(existing)) throw new Error(`storage migration is already active (${existing.jobId})`);
562
+ if (existing && !isTerminal$1(existing)) throw new Error(`storage migration is already active (${existing.jobId})`);
545
563
  for (const storageClass of input.classes) {
546
564
  if (MOVER_CLASSES.includes(storageClass)) continue;
547
565
  throw new Error(`No mover owns "${storageClass}" — there is nothing that can drain it. Move it by hand.`);
@@ -606,7 +624,7 @@ var StorageMigrationCoordinator = class {
606
624
  await this.persist(job);
607
625
  return;
608
626
  }
609
- if (isTerminal(job)) {
627
+ if (isTerminal$1(job)) {
610
628
  if (job.pausedParticipants.length > 0) {
611
629
  await this.releaseAfterTerminal(job);
612
630
  await this.persist(job);
@@ -667,11 +685,12 @@ var StorageMigrationCoordinator = class {
667
685
  if (job.phase === "verifying" && !nonBlocking) {
668
686
  await this.verifyMoves(job);
669
687
  if (job.cancelRequested) return this.finishCancelled(job);
688
+ await this.freezeDrainedSources(job);
670
689
  await this.setPhase(job, "repointing");
671
690
  }
672
691
  if (job.phase === "repointing") {
673
- const targets = new Map(job.moves.map((move) => [move.storageClass, move.toLocationId]));
674
- await this.deps.locations.setDefaultLocations(targets);
692
+ const targets = new Map(job.moves.filter((move) => move.freezeSource !== true).map((move) => [move.storageClass, move.toLocationId]));
693
+ if (targets.size > 0) await this.deps.locations.setDefaultLocations(targets);
675
694
  job.repointed = true;
676
695
  await this.setPhase(job, "refreshing");
677
696
  }
@@ -694,6 +713,7 @@ var StorageMigrationCoordinator = class {
694
713
  if (job.phase === "verifying" && nonBlocking) {
695
714
  await this.verifyMoves(job);
696
715
  if (job.cancelRequested) return this.finishCancelled(job);
716
+ await this.freezeDrainedSources(job);
697
717
  await this.setPhase(job, "done");
698
718
  }
699
719
  } catch (err) {
@@ -709,7 +729,7 @@ var StorageMigrationCoordinator = class {
709
729
  await this.releaseAfterTerminal(job);
710
730
  await this.persist(job);
711
731
  } finally {
712
- if (isTerminal(job)) {
732
+ if (isTerminal$1(job)) {
713
733
  await this.releaseAfterTerminal(job);
714
734
  this.active = job;
715
735
  }
@@ -869,6 +889,24 @@ var StorageMigrationCoordinator = class {
869
889
  for (const move of job.moves) if (move.state !== "done") throw new Error(`${move.storageClass} move did not complete verification`);
870
890
  }
871
891
  /**
892
+ * After a from→to copy whose source was NOT the class default: stop new
893
+ * writes to `from`. The default is unchanged (that is the whole point of
894
+ * naming a non-default source). A location that is already disabled is
895
+ * left alone.
896
+ */
897
+ freezeDrainedSources(job) {
898
+ for (const move of job.moves) {
899
+ if (move.freezeSource !== true) continue;
900
+ const loc = this.deps.locations.getLocationById(move.fromLocationId);
901
+ if (!loc || loc.enabled === false) continue;
902
+ const { createdAt: _c, updatedAt: _u, capacity: _cap, ...rest } = loc;
903
+ this.deps.locations.upsertLocation({
904
+ ...rest,
905
+ enabled: false
906
+ });
907
+ }
908
+ }
909
+ /**
872
910
  * Arm one class's mover.
873
911
  *
874
912
  * `draining` runs after every writer has been resumed, so there is no lease
@@ -881,6 +919,7 @@ var StorageMigrationCoordinator = class {
881
919
  if (laneOf(move.storageClass) === "media") {
882
920
  const input = {
883
921
  toLocationId: move.toLocationId,
922
+ fromLocationId: move.fromLocationId,
884
923
  throttleMbps: job.throttleMbps,
885
924
  mode: "move"
886
925
  };
@@ -914,7 +953,7 @@ var StorageMigrationCoordinator = class {
914
953
  }
915
954
  async setPhase(job, phase) {
916
955
  job.phase = phase;
917
- if (isTerminal(job)) job.finishedAt = this.deps.now();
956
+ if (isTerminal$1(job)) job.finishedAt = this.deps.now();
918
957
  await this.persist(job);
919
958
  }
920
959
  async finishCancelled(job) {
@@ -926,6 +965,9 @@ var StorageMigrationCoordinator = class {
926
965
  async persist(job) {
927
966
  job.updatedAt = this.deps.now();
928
967
  await this.deps.state.set(job);
968
+ if (!isTerminal$1(job) || this.deps.history === void 0) return;
969
+ const prev = [...await this.deps.history.get() ?? []];
970
+ await this.deps.history.set([job, ...prev.filter((row) => row.jobId !== job.jobId)].slice(0, HISTORY_CAP));
929
971
  }
930
972
  };
931
973
  /**
@@ -951,9 +993,10 @@ function requireLease(job) {
951
993
  if (job.pauseLeaseId === null) throw new Error("storage migration has no maintenance lease");
952
994
  return job.pauseLeaseId;
953
995
  }
954
- function isTerminal(job) {
996
+ function isTerminal$1(job) {
955
997
  return job.phase === "done" || job.phase === "failed" || job.phase === "cancelled";
956
998
  }
999
+ var HISTORY_CAP = 20;
957
1000
  /**
958
1001
  * Which single-flight engine owns a class.
959
1002
  */
@@ -1075,6 +1118,78 @@ function canProbeOccupancyLocally(locality) {
1075
1118
  return locality === true;
1076
1119
  }
1077
1120
  //#endregion
1121
+ //#region src/builtins/storage-orchestrator/location-occupancy.ts
1122
+ /**
1123
+ * Delete-time occupancy of a location's on-disk root.
1124
+ *
1125
+ * A recordings disk may host several classes at one `basePath`
1126
+ * (`<camera>/high|mid`, `<camera>/low`, `<camera>/events`). Occupancy for
1127
+ * a class is therefore the class subtree, not the shared root — otherwise a
1128
+ * drained `eventMedia:default` still looks full because high footage is
1129
+ * writing next to it (observed 2026-09-01).
1130
+ *
1131
+ * The walk is shallow: one `readdir` of the root (one dirent per camera)
1132
+ * plus `opendir`+first-entry of each named subtree. No recursive find of
1133
+ * segments — that is the walk that saturates a USB disk.
1134
+ */
1135
+ /**
1136
+ * Class → directories under `<root>/<camera>/`. Same map as the discard
1137
+ * wipe (`DISCARD_SUBTREES` in the recorder / pipeline-analytics addons).
1138
+ * Types not listed keep the legacy "any non-dot entry at the root" rule
1139
+ * (backups, addon-data, …).
1140
+ */
1141
+ var STORAGE_CLASS_SUBTREES = {
1142
+ eventMedia: ["events"],
1143
+ recordingsLow: ["low"],
1144
+ recordings: ["high", "mid"]
1145
+ };
1146
+ function isDotEntry(name) {
1147
+ return name.startsWith(".");
1148
+ }
1149
+ /**
1150
+ * True when `dir` exists and contains at least one dirent. Missing → false.
1151
+ * Any other errno throws so the caller can answer `unknown` (D49).
1152
+ */
1153
+ async function dirHasAnyEntry(dir) {
1154
+ let handle;
1155
+ try {
1156
+ handle = await (0, node_fs_promises.opendir)(dir);
1157
+ } catch (err) {
1158
+ if (err.code === "ENOENT") return false;
1159
+ throw err;
1160
+ }
1161
+ try {
1162
+ return await handle.read() !== null;
1163
+ } finally {
1164
+ await handle.close();
1165
+ }
1166
+ }
1167
+ /**
1168
+ * Occupancy of `basePath` for a location of `type`.
1169
+ *
1170
+ * - missing root → `empty` (nothing this delete can strand)
1171
+ * - class-mapped type → occupied iff any `<camera>/<subtree>` has an entry
1172
+ * - other types → occupied iff the root has a non-dot entry
1173
+ */
1174
+ async function probeLocalDirectoryOccupancy(input) {
1175
+ let names;
1176
+ try {
1177
+ names = await (0, node_fs_promises.readdir)(input.basePath);
1178
+ } catch (err) {
1179
+ if (err.code === "ENOENT") return "empty";
1180
+ return "unknown";
1181
+ }
1182
+ const visible = names.filter((name) => !isDotEntry(name));
1183
+ const subtrees = STORAGE_CLASS_SUBTREES[input.type];
1184
+ if (subtrees === void 0) return visible.length > 0 ? "occupied" : "empty";
1185
+ try {
1186
+ for (const camera of visible) for (const subtree of subtrees) if (await dirHasAnyEntry(node_path.default.join(input.basePath, camera, subtree))) return "occupied";
1187
+ } catch {
1188
+ return "unknown";
1189
+ }
1190
+ return "empty";
1191
+ }
1192
+ //#endregion
1078
1193
  //#region src/builtins/storage-orchestrator/data-store-dispatch.ts
1079
1194
  /**
1080
1195
  * Pick the engine to serve a call.
@@ -1253,6 +1368,173 @@ async function collectProviderInfos(providers, onError) {
1253
1368
  return out;
1254
1369
  }
1255
1370
  //#endregion
1371
+ //#region src/builtins/storage-orchestrator/storage-cleanup-coordinator.ts
1372
+ var FOOTAGE_TYPES = new Set(["recordings", "recordingsLow"]);
1373
+ function isTerminal(job) {
1374
+ return job.phase === "done" || job.phase === "failed" || job.phase === "cancelled";
1375
+ }
1376
+ function footageFrozen(location) {
1377
+ return location.enabled === false || location.config["readOnly"] === true;
1378
+ }
1379
+ /**
1380
+ * One operator-facing cleanup of leftover analytics rows/blobs, optional
1381
+ * debug media, and ghost ledger rows on frozen footage locations.
1382
+ *
1383
+ * Each addon call either returns immediately (start+poll) or is a bounded
1384
+ * ledger walk. The HTTP caller never waits on the full pass.
1385
+ */
1386
+ var StorageCleanupCoordinator = class {
1387
+ deps;
1388
+ active = null;
1389
+ startReserved = false;
1390
+ constructor(deps) {
1391
+ this.deps = deps;
1392
+ }
1393
+ async start(input) {
1394
+ if (this.startReserved) throw new Error("storage cleanup is already active");
1395
+ this.startReserved = true;
1396
+ try {
1397
+ const existing = await this.status();
1398
+ if (existing && !isTerminal(existing)) throw new Error(`storage cleanup is already active (${existing.jobId})`);
1399
+ const now = this.deps.now();
1400
+ const job = {
1401
+ jobId: this.deps.newId(),
1402
+ phase: "orphans",
1403
+ includeDebugMedia: input.includeDebugMedia === true,
1404
+ orphansReclaimed: 0,
1405
+ orphanBytesReclaimed: 0,
1406
+ debugMediaReclaimed: 0,
1407
+ debugMediaBytesReclaimed: 0,
1408
+ ghostsForgotten: 0,
1409
+ ghostBytesForgotten: 0,
1410
+ detail: "Starting orphan reclaim",
1411
+ cancelRequested: false,
1412
+ startedAt: now,
1413
+ updatedAt: now,
1414
+ finishedAt: null,
1415
+ error: null
1416
+ };
1417
+ await this.persist(job);
1418
+ this.active = job;
1419
+ this.run(job);
1420
+ return job.jobId;
1421
+ } finally {
1422
+ this.startReserved = false;
1423
+ }
1424
+ }
1425
+ async status(jobId) {
1426
+ const job = this.active ?? await this.deps.state.get();
1427
+ if (jobId !== void 0 && job?.jobId !== jobId) return null;
1428
+ return job;
1429
+ }
1430
+ async cancel(jobId) {
1431
+ const job = await this.status(jobId);
1432
+ if (!job || isTerminal(job)) return false;
1433
+ job.cancelRequested = true;
1434
+ job.detail = "Cancel requested — finishing the current step";
1435
+ await this.persist(job);
1436
+ return true;
1437
+ }
1438
+ async run(job) {
1439
+ try {
1440
+ await this.reclaimOrphans(job);
1441
+ if (await this.stopped(job)) return;
1442
+ if (job.includeDebugMedia) {
1443
+ await this.setPhase(job, "debug-media", "Reclaiming debug media");
1444
+ await this.reclaimDebugMedia(job);
1445
+ if (await this.stopped(job)) return;
1446
+ }
1447
+ await this.setPhase(job, "ghost-ledger", "Forgetting ghost footage rows");
1448
+ await this.forgetGhosts(job);
1449
+ if (await this.stopped(job)) return;
1450
+ await this.finish(job, "done", null);
1451
+ } catch (err) {
1452
+ const message = err instanceof Error ? err.message : String(err);
1453
+ await this.finish(job, "failed", message);
1454
+ }
1455
+ }
1456
+ async reclaimOrphans(job) {
1457
+ let restart = true;
1458
+ for (;;) {
1459
+ if (await this.stopped(job)) return;
1460
+ const start = await this.deps.participants.startOrphan(restart);
1461
+ restart = false;
1462
+ if (!start.started && !start.alreadyRunning) throw new Error("orphan reclaim did not start");
1463
+ const status = await this.waitUntilIdle(() => this.deps.participants.orphanStatus(), job, (s) => `Orphan reclaim: ${s.totalReclaimed} rows / ${String(s.totalBytesReclaimed)} bytes`);
1464
+ job.orphansReclaimed += status.totalReclaimed;
1465
+ job.orphanBytesReclaimed += status.totalBytesReclaimed;
1466
+ job.detail = `Orphans: ${job.orphansReclaimed} rows, ${String(job.orphanBytesReclaimed)} bytes`;
1467
+ await this.persist(job);
1468
+ if (status.error) throw new Error(status.error);
1469
+ if (status.complete === true) return;
1470
+ }
1471
+ }
1472
+ async reclaimDebugMedia(job) {
1473
+ const start = await this.deps.participants.startDebugMedia();
1474
+ if (!start.started && !start.alreadyRunning) throw new Error("debug-media reclaim did not start");
1475
+ const status = await this.waitUntilIdle(() => this.deps.participants.debugMediaStatus(), job, (s) => `Debug media: ${s.totalReclaimed} files`);
1476
+ job.debugMediaReclaimed = status.totalReclaimed;
1477
+ job.debugMediaBytesReclaimed = status.totalBytesReclaimed;
1478
+ await this.persist(job);
1479
+ if (status.error) throw new Error(status.error);
1480
+ }
1481
+ async forgetGhosts(job) {
1482
+ const frozen = this.deps.locations.listLocations().filter((l) => FOOTAGE_TYPES.has(l.type) && footageFrozen(l));
1483
+ if (frozen.length === 0) {
1484
+ job.detail = "No frozen footage locations";
1485
+ await this.persist(job);
1486
+ return;
1487
+ }
1488
+ for (const loc of frozen) {
1489
+ if (await this.stopped(job)) return;
1490
+ job.detail = `Ghost ledger: ${loc.id}`;
1491
+ await this.persist(job);
1492
+ const report = await this.deps.participants.walkLedger({
1493
+ locationId: loc.id,
1494
+ apply: true
1495
+ });
1496
+ job.ghostsForgotten += report.forgottenSegments;
1497
+ job.ghostBytesForgotten += report.forgottenBytes;
1498
+ await this.persist(job);
1499
+ }
1500
+ }
1501
+ async waitUntilIdle(read, job, detail) {
1502
+ const pollMs = this.deps.pollMs ?? 2e3;
1503
+ const sleep = this.deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1504
+ for (;;) {
1505
+ const status = await read();
1506
+ job.detail = detail(status);
1507
+ await this.persist(job);
1508
+ if (!status.running) return status;
1509
+ await sleep(pollMs);
1510
+ }
1511
+ }
1512
+ async stopped(job) {
1513
+ if ((await this.status(job.jobId))?.cancelRequested === true) {
1514
+ await this.finish(job, "cancelled", null);
1515
+ return true;
1516
+ }
1517
+ return false;
1518
+ }
1519
+ async setPhase(job, phase, detail) {
1520
+ job.phase = phase;
1521
+ job.detail = detail;
1522
+ await this.persist(job);
1523
+ }
1524
+ async finish(job, phase, error) {
1525
+ job.phase = phase;
1526
+ job.error = error;
1527
+ job.finishedAt = this.deps.now();
1528
+ job.detail = phase === "done" ? "Cleanup finished" : job.detail;
1529
+ await this.persist(job);
1530
+ }
1531
+ async persist(job) {
1532
+ job.updatedAt = this.deps.now();
1533
+ this.active = job;
1534
+ await this.deps.state.set(job);
1535
+ }
1536
+ };
1537
+ //#endregion
1256
1538
  //#region src/builtins/storage-orchestrator/storage-orchestrator.service.ts
1257
1539
  /**
1258
1540
  * Routing core for the `storage-orchestrator` builtin.
@@ -1375,7 +1657,7 @@ var StorageOrchestratorService = class {
1375
1657
  * Reconciles both directions so operator edits survive a reboot:
1376
1658
  * 1. hydrate — DB rows win over any early in-memory seed (restores
1377
1659
  * operator config like `minFreePercent` that the pre-store boot
1378
- * can't see), with the same `isSystem` upgrade as {@link initialize};
1660
+ * can't see);
1379
1661
  * 2. backfill — in-memory locations the DB doesn't have yet (the
1380
1662
  * pre-store seed defaults on a fresh install) are persisted, so the
1381
1663
  * store becomes the durable source of truth from here on.
@@ -1397,14 +1679,7 @@ var StorageOrchestratorService = class {
1397
1679
  const rows = await store.loadAll();
1398
1680
  this.locationStore = store;
1399
1681
  const dbIds = new Set(rows.map((r) => r.id));
1400
- for (const loc of rows) {
1401
- const upgraded = !loc.isSystem && loc.id === `${loc.type}:default` ? {
1402
- ...loc,
1403
- isSystem: true
1404
- } : loc;
1405
- this.locations.set(upgraded.id, upgraded);
1406
- if (upgraded !== loc) store.upsert(upgraded).catch(() => {});
1407
- }
1682
+ for (const loc of rows) this.locations.set(loc.id, loc);
1408
1683
  const backfill = [...this.locations.values()].filter((l) => !dbIds.has(l.id));
1409
1684
  for (const loc of backfill) store.upsert(loc).catch((err) => {
1410
1685
  this.logger.warn("storage-orchestrator: attachStore backfill persist failed", { meta: {
@@ -1496,25 +1771,8 @@ var StorageOrchestratorService = class {
1496
1771
  async initialize() {
1497
1772
  if (!this.locationStore) return;
1498
1773
  const rows = await this.locationStore.loadAll();
1499
- let upgraded = 0;
1500
- for (const loc of rows) if (!loc.isSystem && loc.id === `${loc.type}:default`) {
1501
- const upgradedLoc = {
1502
- ...loc,
1503
- isSystem: true
1504
- };
1505
- this.locations.set(upgradedLoc.id, upgradedLoc);
1506
- this.locationStore.upsert(upgradedLoc).catch((err) => {
1507
- this.logger.warn("storage-orchestrator: isSystem upgrade persist failed", { meta: {
1508
- id: upgradedLoc.id,
1509
- error: err instanceof Error ? err.message : String(err)
1510
- } });
1511
- });
1512
- upgraded++;
1513
- } else this.locations.set(loc.id, loc);
1514
- this.logger.info("storage-orchestrator: hydrated locations from store", { meta: {
1515
- loaded: this.locations.size,
1516
- isSystemUpgraded: upgraded
1517
- } });
1774
+ for (const loc of rows) this.locations.set(loc.id, loc);
1775
+ this.logger.info("storage-orchestrator: hydrated locations from store", { meta: { loaded: this.locations.size } });
1518
1776
  this.reportDisabledDefaults();
1519
1777
  }
1520
1778
  /**
@@ -1699,10 +1957,6 @@ var StorageOrchestratorService = class {
1699
1957
  ...input,
1700
1958
  nodeId: "hub"
1701
1959
  };
1702
- if (existing?.isSystem === true) input = {
1703
- ...input,
1704
- isSystem: true
1705
- };
1706
1960
  input = {
1707
1961
  ...input,
1708
1962
  ...resolveEnabled(input, existing, this.hasAnyLocationOfType(input.type))
@@ -1749,21 +2003,22 @@ var StorageOrchestratorService = class {
1749
2003
  return next;
1750
2004
  }
1751
2005
  /**
1752
- * Remove a location. Refuses to remove the default of a type unless a
1753
- * sibling default exists (defensive `upsertLocation` already ensures
1754
- * at most one default per type, but the logic guards against future
1755
- * bypass paths e.g. SQLite migration that imports two defaults).
1756
- *
1757
- * Persistence (Task 6) mirrors the delete asynchronously, with errors
1758
- * routed to the logger — see `upsertLocation` for the rationale.
2006
+ * Remove a location. Refuses the last location of a type and the last
2007
+ * enabled location of a type. `isSystem` is informational — a frozen
2008
+ * recordings `:default` is deletable once a sibling is live. Deleting
2009
+ * the flagged default promotes an enabled sibling. Occupancy still
2010
+ * refuses unless `force` is set; force never overrides uniqueness /
2011
+ * last-enabled.
1759
2012
  */
1760
2013
  async deleteLocation(id, options) {
1761
2014
  const loc = this.locations.get(id);
1762
2015
  if (!loc) throw new Error(`Storage location "${id}" not found`);
1763
- if (loc.isSystem) throw new Error(`Storage location "${id}" is system-managed and cannot be deleted. Edit its config (path / providerId) instead.`);
1764
- if (loc.isDefault) {
1765
- if (![...this.locations.values()].find((l) => l.type === loc.type && l.id !== id && l.isDefault)) throw new Error(`Cannot delete default location "${id}" for type "${loc.type}" — promote another location to default first`);
1766
- }
2016
+ const ofType = [...this.locations.values()].filter((l) => l.type === loc.type);
2017
+ if (ofType.length <= 1) throw new Error(`Cannot delete "${id}" — it is the only location for type "${loc.type}"`);
2018
+ const enabledOfType = ofType.filter((l) => l.enabled !== false);
2019
+ if (loc.enabled !== false && enabledOfType.length <= 1) throw new Error(`Cannot delete "${id}" — it is the only enabled location for type "${loc.type}"`);
2020
+ const successor = loc.isDefault === true ? ofType.find((l) => l.id !== id && l.enabled !== false) : void 0;
2021
+ if (loc.isDefault === true && successor === void 0) throw new Error(`Cannot delete "${id}" — it is the only enabled location for type "${loc.type}"`);
1767
2022
  const occupancy = await this.probeOccupancy(loc);
1768
2023
  if (occupancy !== "empty") {
1769
2024
  if (options?.force !== true) throw new Error(occupancy === "occupied" ? `Storage location "${id}" still holds data — drain it first (storage migration / relocate), or pass force to delete the record anyway and strand what is on it` : `Storage location "${id}" could not be checked for remaining data (unreachable provider, another node, or an unmounted root) — pass force to delete the record anyway`);
@@ -1773,6 +2028,21 @@ var StorageOrchestratorService = class {
1773
2028
  occupancy
1774
2029
  } });
1775
2030
  }
2031
+ if (successor !== void 0 && successor.isDefault !== true) {
2032
+ const now = Date.now();
2033
+ const promoted = {
2034
+ ...successor,
2035
+ isDefault: true,
2036
+ updatedAt: now
2037
+ };
2038
+ this.locations.set(successor.id, promoted);
2039
+ if (this.locationStore) this.locationStore.upsert(promoted).catch((err) => {
2040
+ this.logger.error("storage-orchestrator: default promotion persist failed", { meta: {
2041
+ id: successor.id,
2042
+ error: err instanceof Error ? err.message : String(err)
2043
+ } });
2044
+ });
2045
+ }
1776
2046
  this.locations.delete(id);
1777
2047
  if (this.locationStore) this.locationStore.delete(id).catch((err) => {
1778
2048
  this.logger.error("storage-orchestrator: delete persistence failed", { meta: {
@@ -1799,15 +2069,14 @@ var StorageOrchestratorService = class {
1799
2069
  }
1800
2070
  }
1801
2071
  /**
1802
- * Remove system-seeded locations whose type is no longer declared by any
1803
- * addon (stale defaults from a removed location type). Operator-added
1804
- * (non-system) locations of an undeclared type are KEPT but warned — the
1805
- * operator owns them. Requires the registry to be set first.
2072
+ * Remove seed-shaped locations (`<type>:default`) whose type is no longer
2073
+ * declared by any addon (stale defaults from a removed location type).
2074
+ * Operator-added locations of an undeclared type are KEPT but warned.
1806
2075
  *
1807
2076
  * FAIL-SAFE: if the registry is EMPTY (no addon declared any location), we
1808
2077
  * refuse to prune anything. An empty registry almost always means
1809
2078
  * declarations failed to load (boot ordering, a stale install) — pruning
1810
- * "everything undeclared" in that state would wipe every system location
2079
+ * "everything undeclared" in that state would wipe every seeded location
1811
2080
  * (data/logs/recordings/…). Better to keep stale rows than destroy live ones.
1812
2081
  */
1813
2082
  pruneUndeclaredSystemLocations() {
@@ -1818,7 +2087,7 @@ var StorageOrchestratorService = class {
1818
2087
  }
1819
2088
  for (const [id, loc] of Array.from(this.locations)) {
1820
2089
  if (this.registry.cardinalityOf(loc.type) !== null) continue;
1821
- if (loc.isSystem) {
2090
+ if (loc.id === `${loc.type}:default`) {
1822
2091
  this.locations.delete(id);
1823
2092
  if (this.locationStore) this.locationStore.delete(id).catch((err) => {
1824
2093
  this.logger.error("storage-orchestrator: prune persistence failed", { meta: {
@@ -1826,7 +2095,7 @@ var StorageOrchestratorService = class {
1826
2095
  error: err instanceof Error ? err.message : String(err)
1827
2096
  } });
1828
2097
  });
1829
- this.logger.info("storage-orchestrator: pruned stale system location", { meta: {
2098
+ this.logger.info("storage-orchestrator: pruned stale seeded location", { meta: {
1830
2099
  id,
1831
2100
  type: loc.type
1832
2101
  } });
@@ -1986,7 +2255,7 @@ var StorageOrchestratorService = class {
1986
2255
  providerId: input.providerId,
1987
2256
  config: { basePath: base },
1988
2257
  isDefault: true,
1989
- isSystem: true
2258
+ isSystem: false
1990
2259
  });
1991
2260
  added++;
1992
2261
  }
@@ -2216,6 +2485,7 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
2216
2485
  pressureTimer = null;
2217
2486
  pressureSweepInFlight = false;
2218
2487
  migration = null;
2488
+ cleanup = null;
2219
2489
  /**
2220
2490
  * Cached `providerId → nodeLocal` snapshot (SP1). Backs the orchestrator's
2221
2491
  * synchronous `NodeLocalResolver` — `getProviderInfo()` is async, so we
@@ -2273,7 +2543,7 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
2273
2543
  const rows = type !== void 0 ? service.listLocations({ type }) : service.listLocations();
2274
2544
  return Promise.all(rows.map(async (loc) => ({
2275
2545
  ...this.redacted(loc),
2276
- capacity: await this.localCapacityOf(loc)
2546
+ capacity: loc.enabled === false ? null : await this.localCapacityOf(loc)
2277
2547
  })));
2278
2548
  },
2279
2549
  getDefaultLocation: async ({ type }) => {
@@ -2441,6 +2711,7 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
2441
2711
  const migration = new StorageMigrationCoordinator({
2442
2712
  locations: service,
2443
2713
  state: this.state("storage-migration", require_dist.StorageMigrationJobSchema.nullable(), null),
2714
+ history: this.state("storage-migration-history", zod.z.array(require_dist.StorageMigrationJobSchema), []),
2444
2715
  participants: {
2445
2716
  pipeline: {
2446
2717
  pause: async (leaseId) => {
@@ -2491,6 +2762,36 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
2491
2762
  deviceKeyOf: (locationId) => this.locationDeviceKey(locationId)
2492
2763
  });
2493
2764
  this.migration = migration;
2765
+ const cleanup = new StorageCleanupCoordinator({
2766
+ locations: service,
2767
+ state: this.state("storage-cleanup", require_dist.StorageCleanupJobSchema.nullable(), null),
2768
+ participants: {
2769
+ startOrphan: async (restart) => {
2770
+ return parseStarted(await this.ctx.api.addons.custom.mutate({
2771
+ addonId: "pipeline-analytics",
2772
+ action: "retention.orphanAudit",
2773
+ input: {
2774
+ mode: "reclaim",
2775
+ restart,
2776
+ maxRowsPerScope: 2e4
2777
+ }
2778
+ }));
2779
+ },
2780
+ orphanStatus: async () => {
2781
+ return parseOrphanStatus(await this.ctx.api.addons.custom.mutate({
2782
+ addonId: "pipeline-analytics",
2783
+ action: "retention.orphanAuditStatus",
2784
+ input: {}
2785
+ }));
2786
+ },
2787
+ startDebugMedia: () => this.ctx.api.pipelineAnalytics.reclaimDebugMedia.mutate({ mode: "reclaim" }),
2788
+ debugMediaStatus: () => this.ctx.api.pipelineAnalytics.getMediaReclaimStatus.query({}),
2789
+ walkLedger: (input) => this.ctx.api.recording.reconcileLedgerAgainstDisk.mutate(input)
2790
+ },
2791
+ now: () => Date.now(),
2792
+ newId: () => (0, node_crypto.randomUUID)()
2793
+ });
2794
+ this.cleanup = cleanup;
2494
2795
  const migrationProvider = {
2495
2796
  plan: (input) => migration.plan(input),
2496
2797
  start: async (input) => ({ jobId: await migration.start(input) }),
@@ -2498,7 +2799,11 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
2498
2799
  cancel: async ({ jobId }) => ({ cancelled: await migration.cancel(jobId) }),
2499
2800
  movers: () => migration.movers(),
2500
2801
  residue: () => migration.residue(),
2501
- drain: async (input) => ({ jobId: await migration.drain(input) })
2802
+ drain: async (input) => ({ jobId: await migration.drain(input) }),
2803
+ cleanupStart: async (input) => ({ jobId: await cleanup.start(input) }),
2804
+ cleanupStatus: ({ jobId }) => cleanup.status(jobId),
2805
+ cleanupCancel: async ({ jobId }) => ({ cancelled: await cleanup.cancel(jobId) }),
2806
+ history: () => migration.history()
2502
2807
  };
2503
2808
  await this.seedFromDeclarations();
2504
2809
  const eventBus = this.ctx.eventBus;
@@ -2741,11 +3046,14 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
2741
3046
  }
2742
3047
  }
2743
3048
  /**
2744
- * Does this location still hold anything? A SHALLOW `readdir` of its
2745
- * `basePath` — one syscall, never a walk. `/recordings` holds one entry per
2746
- * camera, so this is cheap on a volume with millions of segments, and a
2747
- * `find`-style walk of a saturated recordings disk is precisely what must
2748
- * not happen here.
3049
+ * Does this location still hold anything of ITS class?
3050
+ *
3051
+ * Shared roots (`/recordings` hosting high + low + events) are scored by
3052
+ * class subtree (`<camera>/high|mid`, `/low`, `/events`), not by a raw
3053
+ * `readdir` of the root — camera dirs and `.camstack-location` would
3054
+ * otherwise make every class look occupied. The walk stays shallow: one
3055
+ * dirent per camera, then first-entry of the named subtree. A `find` of
3056
+ * segments is precisely what must not happen here.
2749
3057
  *
2750
3058
  * **What it cannot see, stated rather than papered over:**
2751
3059
  *
@@ -2754,9 +3062,10 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
2754
3062
  * `unknown`, never `empty`.
2755
3063
  * - A location with no `basePath` — same answer.
2756
3064
  * - The difference between "holds live footage" and "holds one stray
2757
- * lock-file". It measures BYTES ON DISK, not durable rows, so it refuses
2758
- * a location holding orphan files that no index names. That direction is
2759
- * the safe one, and `force` is the escape hatch for it.
3065
+ * lock-file". It measures ON-DISK entries of this class, not durable
3066
+ * rows, so it refuses a location holding orphan files that no index
3067
+ * names. That direction is the safe one, and `force` is the escape
3068
+ * hatch for it.
2760
3069
  *
2761
3070
  * A missing root (`ENOENT`) is `empty`, not `unknown`: an unmounted disk and
2762
3071
  * a deleted directory are indistinguishable here, and if the record's own
@@ -2774,16 +3083,16 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
2774
3083
  if ((location.nodeId === void 0 || location.nodeId === "" ? HUB_NODE_ID : location.nodeId) !== (this.service?.getLocalNodeId() ?? HUB_NODE_ID)) return "unknown";
2775
3084
  const basePath = this.locationBasePath(location.id);
2776
3085
  if (basePath === null) return "unknown";
2777
- try {
2778
- return (await node_fs_promises.readdir(basePath)).length > 0 ? "occupied" : "empty";
2779
- } catch (err) {
2780
- if (err instanceof Error && "code" in err && err.code === "ENOENT") return "empty";
2781
- this.ctx.logger.warn("storage-orchestrator: could not read a location root to check it", { meta: {
2782
- id: location.id,
2783
- error: err instanceof Error ? err.message : String(err)
2784
- } });
2785
- return "unknown";
2786
- }
3086
+ const occupancy = await probeLocalDirectoryOccupancy({
3087
+ type: location.type,
3088
+ basePath
3089
+ });
3090
+ if (occupancy === "unknown") this.ctx.logger.warn("storage-orchestrator: could not read a location root to check it", { meta: {
3091
+ id: location.id,
3092
+ basePath,
3093
+ type: location.type
3094
+ } });
3095
+ return occupancy;
2787
3096
  }
2788
3097
  /** Free capacity (%) on a location's volume via `statfs`; 100 (guard inert) when unstattable. */
2789
3098
  async locationFreePercent(locationId) {
@@ -2859,6 +3168,38 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
2859
3168
  }
2860
3169
  }
2861
3170
  };
3171
+ function parseStarted(raw) {
3172
+ if (raw !== null && typeof raw === "object") {
3173
+ const o = raw;
3174
+ if (typeof o["started"] === "boolean" && typeof o["alreadyRunning"] === "boolean") return {
3175
+ started: o["started"],
3176
+ alreadyRunning: o["alreadyRunning"]
3177
+ };
3178
+ }
3179
+ return {
3180
+ started: false,
3181
+ alreadyRunning: false
3182
+ };
3183
+ }
3184
+ function parseOrphanStatus(raw) {
3185
+ if (raw !== null && typeof raw === "object") {
3186
+ const o = raw;
3187
+ return {
3188
+ running: o["running"] === true,
3189
+ complete: typeof o["complete"] === "boolean" ? o["complete"] : null,
3190
+ totalReclaimed: typeof o["totalReclaimed"] === "number" ? o["totalReclaimed"] : 0,
3191
+ totalBytesReclaimed: typeof o["totalBytesReclaimed"] === "number" ? o["totalBytesReclaimed"] : 0,
3192
+ error: typeof o["error"] === "string" ? o["error"] : null
3193
+ };
3194
+ }
3195
+ return {
3196
+ running: false,
3197
+ complete: true,
3198
+ totalReclaimed: 0,
3199
+ totalBytesReclaimed: 0,
3200
+ error: "orphan audit status unreadable"
3201
+ };
3202
+ }
2862
3203
  //#endregion
2863
3204
  exports.SqliteLocationStore = SqliteLocationStore;
2864
3205
  exports.StorageMigrationCoordinator = StorageMigrationCoordinator;