@camstack/system 1.2.155 → 1.2.156

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 (58) 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/storage-migration-coordinator.d.ts +101 -1
  42. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.d.ts +24 -0
  43. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +348 -39
  44. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +348 -39
  45. package/dist/builtins/storage-orchestrator/storage-orchestrator.service.d.ts +37 -1
  46. package/dist/builtins/system-config/system-config.addon.js +1 -1
  47. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  48. package/dist/builtins/winston-logging/index.js +1 -1
  49. package/dist/builtins/winston-logging/index.mjs +1 -1
  50. package/dist/{dist-BPlfW-CG.js → dist-CDgIzo82.js} +169 -12
  51. package/dist/{dist-D_NyUV6Y.mjs → dist-LvKus-mT.mjs} +169 -12
  52. package/dist/index.js +2 -2
  53. package/dist/index.mjs +2 -2
  54. package/dist/{manifest-python-deps-BiWtkFGY.mjs → manifest-python-deps-CD14Pv0-.mjs} +1 -1
  55. package/dist/{manifest-python-deps-w48vOhGR.js → manifest-python-deps-DVODn-qc.js} +1 -1
  56. package/dist/{retired-settings-keys-DBY6ebwV.js → retired-settings-keys-BfAzWvPC.js} +1 -1
  57. package/dist/{retired-settings-keys-Bq1Tb84I.mjs → retired-settings-keys-CV4QVdXj.mjs} +1 -1
  58. package/package.json +1 -1
@@ -3,7 +3,7 @@ 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-BPlfW-CG.js");
6
+ const require_dist = require("../../dist-CDgIzo82.js");
7
7
  let node_crypto = require("node:crypto");
8
8
  let node_fs_promises = require("node:fs/promises");
9
9
  node_fs_promises = require_chunk.__toESM(node_fs_promises);
@@ -236,8 +236,41 @@ function mapRowToLocation(row) {
236
236
  var STORAGE_CLASSES = [
237
237
  "recordings",
238
238
  "recordingsLow",
239
- "eventMedia"
239
+ "eventMedia",
240
+ "backups",
241
+ "galleryMedia"
240
242
  ];
243
+ /** The classes footage/media movers actually own. Everything else is planned,
244
+ * reported and refused rather than silently accepted. */
245
+ var MOVER_CLASSES = [
246
+ "recordings",
247
+ "recordingsLow",
248
+ "eventMedia",
249
+ "galleryMedia"
250
+ ];
251
+ /**
252
+ * Classes that can never span two locations at once.
253
+ *
254
+ * `galleryMedia` is `cardinality: 'single'` and `upsertLocation` refuses a
255
+ * second instance of a single type, so the "both locations are first-class for
256
+ * reads while the past drains" transient the non-blocking mode rests on is not
257
+ * merely undesirable there — it is not constructible. Its move is therefore
258
+ * stop-the-world, always. That is acceptable only because the enrolled gallery
259
+ * is a few KB per sample, and it is said here rather than discovered.
260
+ */
261
+ var BLOCKING_ONLY_CLASSES = ["galleryMedia"];
262
+ /**
263
+ * Location types whose volume `galleryMedia` must NEVER land on.
264
+ *
265
+ * Verbatim from its own declaration: *"Deliberately NOT on the recordings
266
+ * volume: these images survive every retention sweep, so they must also survive
267
+ * a footage wipe, a quota eviction and a recordings disk swap."* A planner that
268
+ * accepted `galleryMedia` → the recordings volume would let an operator destroy
269
+ * the enrolment gallery on the next disk swap, months later, with no visible
270
+ * link back to this decision.
271
+ */
272
+ var GALLERY_FORBIDDEN_NEIGHBOUR_TYPES = ["recordings", "recordingsLow"];
273
+ var MOVE_LANES = ["media", "footage"];
241
274
  var PARTICIPANTS = [
242
275
  "pipeline",
243
276
  "recorder",
@@ -262,7 +295,9 @@ var StorageMigrationCoordinator = class {
262
295
  this.deps = deps;
263
296
  }
264
297
  async plan(input) {
298
+ const mode = input.mode ?? "blocking";
265
299
  const moves = [];
300
+ const findings = [];
266
301
  for (const storageClass of STORAGE_CLASSES) {
267
302
  const targetId = input.destinations[storageClass];
268
303
  if (targetId === void 0) continue;
@@ -272,6 +307,9 @@ var StorageMigrationCoordinator = class {
272
307
  if (!target) throw new Error(`Storage location "${targetId}" not found`);
273
308
  if (target.type !== storageClass) throw new Error(`Storage location "${targetId}" is type "${target.type}", expected "${storageClass}"`);
274
309
  if (source.id === target.id) throw new Error(`Storage location "${targetId}" is already the "${storageClass}" default`);
310
+ 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.`);
311
+ if (mode === "nonBlocking" && BLOCKING_ONLY_CLASSES.includes(storageClass)) throw new Error(`"${storageClass}" is a single-cardinality storage class: it can never exist at two locations at once, so it cannot be repointed first and drained behind. Use mode "blocking".`);
312
+ await this.refuseGalleryOntoRecordingsVolume(storageClass, targetId);
275
313
  moves.push({
276
314
  storageClass,
277
315
  fromLocationId: source.id,
@@ -280,17 +318,79 @@ var StorageMigrationCoordinator = class {
280
318
  state: null,
281
319
  error: null
282
320
  });
321
+ if (BLOCKING_ONLY_CLASSES.includes(storageClass)) findings.push({
322
+ code: "blockingOnly",
323
+ storageClass,
324
+ message: `"${storageClass}" is single-cardinality: this move is stop-the-world for its whole duration, in every mode. It is a few KB per enrolled sample, which is why that is acceptable.`
325
+ });
326
+ findings.push(...await this.deviceFindings(storageClass, source.id, target.id));
283
327
  }
284
328
  if (moves.length === 0) throw new Error("select at least one storage class");
329
+ if (moves.some((move) => move.storageClass === "eventMedia")) {
330
+ const unstamped = await this.deps.participants.analytics.countUnstamped();
331
+ if (unstamped.total > 0) {
332
+ if (mode === "nonBlocking") throw new Error(`${unstamped.total.toString()} event-media row(s) still carry no locationId (${unstamped.media.toString()} media, ${unstamped.retrainFrames.toString()} retrain frames). A non-blocking cutover would silently orphan them. Run the seal first: pipelineAnalytics.relocateMedia({ mode: "seal", toLocationId: "${this.deps.locations.getDefaultLocation("eventMedia")?.id ?? "eventMedia"}" }).`);
333
+ findings.push({
334
+ code: "unstampedEventMediaRows",
335
+ storageClass: "eventMedia",
336
+ message: `${unstamped.total.toString()} event-media row(s) carry no locationId. This blocking migration stamps them as it moves them, but a non-blocking one would be refused until a seal pass drives the count to zero.`
337
+ });
338
+ }
339
+ }
285
340
  return {
286
341
  destinations: input.destinations,
342
+ mode,
287
343
  moves: moves.map(({ storageClass, fromLocationId, toLocationId }) => ({
288
344
  storageClass,
289
345
  fromLocationId,
290
346
  toLocationId
291
- }))
347
+ })),
348
+ findings
292
349
  };
293
350
  }
351
+ /**
352
+ * HARD refusal: `galleryMedia` onto a volume a recordings class lives on.
353
+ *
354
+ * This is the one same-device case that is a refusal rather than a finding,
355
+ * because its consequence is silent and arrives months later — the gallery is
356
+ * wiped by the next footage wipe, quota eviction or recordings disk swap, and
357
+ * nothing at that moment points back at this migration.
358
+ *
359
+ * It can only refuse what it can SEE. `deviceKeyOf` is a realpath, so it
360
+ * catches a symlink, a bind mount and a literally-shared directory — and
361
+ * misses two distinct directories on one filesystem, and any location pinned
362
+ * to another node. Those come back `null`/distinct and produce a
363
+ * `deviceIdentityUnknown` finding instead.
364
+ */
365
+ async refuseGalleryOntoRecordingsVolume(storageClass, targetId) {
366
+ if (storageClass !== "galleryMedia") return;
367
+ const targetKey = await this.deviceKey(targetId);
368
+ if (targetKey === null) return;
369
+ for (const type of GALLERY_FORBIDDEN_NEIGHBOUR_TYPES) for (const neighbour of this.deps.locations.listLocations({ type })) {
370
+ if (await this.deviceKey(neighbour.id) !== targetKey) continue;
371
+ throw new Error(`Storage location "${targetId}" shares a volume with "${neighbour.id}" (${type}). galleryMedia is deliberately NOT on the recordings volume: enrolment images survive every retention sweep, so they must also survive a footage wipe, a quota eviction and a recordings disk swap. Choose a destination on another volume.`);
372
+ }
373
+ }
374
+ /** Same-device observations that are reported, not refused. */
375
+ async deviceFindings(storageClass, sourceId, targetId) {
376
+ if (this.deps.deviceKeyOf === void 0) return [];
377
+ const [sourceKey, targetKey] = await Promise.all([this.deviceKey(sourceId), this.deviceKey(targetId)]);
378
+ if (sourceKey === null || targetKey === null) return [{
379
+ code: "deviceIdentityUnknown",
380
+ storageClass,
381
+ message: `Could not establish whether "${sourceId}" and "${targetId}" are on the same device (a location with no basePath, or pinned to another node — the realpath would have to be taken there). Note also that this check is PATH identity, not st_dev: two distinct directories on one filesystem are never detected as one device.`
382
+ }];
383
+ if (sourceKey !== targetKey) return [];
384
+ return [{
385
+ code: "sharesDeviceWithSource",
386
+ storageClass,
387
+ message: `"${targetId}" resolves to the same path as "${sourceId}". The move will re-stamp rows rather than copy bytes, and it buys no redundancy — both "locations" are one disk.`
388
+ }];
389
+ }
390
+ async deviceKey(locationId) {
391
+ if (this.deps.deviceKeyOf === void 0) return null;
392
+ return this.deps.deviceKeyOf(locationId);
393
+ }
294
394
  async start(input) {
295
395
  if (this.startReserved) throw new Error("storage migration is already active");
296
396
  this.startReserved = true;
@@ -307,6 +407,7 @@ var StorageMigrationCoordinator = class {
307
407
  const job = {
308
408
  jobId: this.deps.newId(),
309
409
  phase: "planning",
410
+ mode: plan.mode,
310
411
  destinations: input.destinations,
311
412
  throttleMbps: input.throttleMbps ?? 40,
312
413
  moves: plan.moves.map((move) => ({
@@ -340,7 +441,8 @@ var StorageMigrationCoordinator = class {
340
441
  }
341
442
  async cancel(jobId) {
342
443
  const job = await this.status(jobId);
343
- if (!job || isTerminal(job) || job.repointed) return false;
444
+ const cutoverInFlight = job !== null && job.repointed && (job.phase === "refreshing" || job.phase === "resuming");
445
+ if (!job || isTerminal(job) || cutoverInFlight) return false;
344
446
  job.cancelRequested = true;
345
447
  await this.persist(job);
346
448
  await Promise.all(job.moves.filter((move) => move.moverJobId !== null).map((move) => this.cancelMove(move)));
@@ -371,7 +473,7 @@ var StorageMigrationCoordinator = class {
371
473
  job.pauseLeaseId ??= this.deps.newId();
372
474
  if (job.repointed && job.phase === "resuming") job.phase = "refreshing";
373
475
  await this.persist(job);
374
- try {
476
+ if (!runsUnleased(job)) try {
375
477
  await this.pauseParticipants(job, true);
376
478
  } catch (err) {
377
479
  job.error = err instanceof Error ? err.message : String(err);
@@ -383,14 +485,34 @@ var StorageMigrationCoordinator = class {
383
485
  this.runPromise;
384
486
  }
385
487
  }
488
+ /**
489
+ * The state machine. Both modes run the SAME phase handlers; only the order
490
+ * differs, which is the whole of the non-blocking design — no second mover,
491
+ * no parallel code path, nothing that can drift between the two.
492
+ *
493
+ * blocking: planning → pausing → moving → verifying → repointing →
494
+ * refreshing → resuming → done
495
+ * nonBlocking: planning → sealing → pausing → repointing → refreshing →
496
+ * resuming → draining → verifying → done
497
+ *
498
+ * Written as a straight-line sequence of `if (phase === X)` blocks so boot
499
+ * recovery re-enters at whatever phase the durable record says, exactly as
500
+ * before.
501
+ */
386
502
  async run(job) {
503
+ const nonBlocking = job.mode === "nonBlocking";
387
504
  try {
388
- if (job.phase === "planning") await this.setPhase(job, "pausing");
505
+ if (job.phase === "planning") await this.setPhase(job, nonBlocking ? "sealing" : "pausing");
506
+ if (job.phase === "sealing") {
507
+ await this.sealEventMedia(job);
508
+ if (job.cancelRequested) return this.finishCancelled(job);
509
+ await this.setPhase(job, "pausing");
510
+ }
389
511
  if (job.phase === "pausing") {
390
512
  job.pauseLeaseId ??= job.jobId;
391
513
  await this.pauseParticipants(job);
392
514
  if (job.cancelRequested) return this.finishCancelled(job);
393
- await this.setPhase(job, "moving");
515
+ await this.setPhase(job, nonBlocking ? "repointing" : "moving");
394
516
  }
395
517
  if (job.phase === "moving") {
396
518
  await this.startOrResumeMoves(job);
@@ -398,7 +520,7 @@ var StorageMigrationCoordinator = class {
398
520
  if (job.cancelRequested) return this.finishCancelled(job);
399
521
  await this.setPhase(job, "verifying");
400
522
  }
401
- if (job.phase === "verifying") {
523
+ if (job.phase === "verifying" && !nonBlocking) {
402
524
  await this.verifyMoves(job);
403
525
  if (job.cancelRequested) return this.finishCancelled(job);
404
526
  await this.setPhase(job, "repointing");
@@ -411,17 +533,28 @@ var StorageMigrationCoordinator = class {
411
533
  }
412
534
  if (job.phase === "refreshing") {
413
535
  const leaseId = requireLease(job);
414
- if (job.moves.some((move) => move.storageClass !== "eventMedia")) await this.deps.participants.recorder.refresh(leaseId);
415
- if (job.moves.some((move) => move.storageClass === "eventMedia")) await this.deps.participants.analytics.refresh(leaseId);
536
+ if (job.moves.some((move) => laneOf(move.storageClass) === "footage")) await this.deps.participants.recorder.refresh(leaseId);
537
+ if (job.moves.some((move) => laneOf(move.storageClass) === "media")) await this.deps.participants.analytics.refresh(leaseId);
416
538
  await this.setPhase(job, "resuming");
417
539
  }
418
540
  if (job.phase === "resuming") {
419
541
  await this.resumeParticipants(job);
542
+ await this.setPhase(job, nonBlocking ? "draining" : "done");
543
+ }
544
+ if (job.phase === "draining") {
545
+ await this.startOrResumeMoves(job);
546
+ await this.waitForMoves(job);
547
+ if (job.cancelRequested) return this.finishCancelled(job);
548
+ await this.setPhase(job, "verifying");
549
+ }
550
+ if (job.phase === "verifying" && nonBlocking) {
551
+ await this.verifyMoves(job);
552
+ if (job.cancelRequested) return this.finishCancelled(job);
420
553
  await this.setPhase(job, "done");
421
554
  }
422
555
  } catch (err) {
423
556
  job.error = err instanceof Error ? err.message : String(err);
424
- if (job.repointed) {
557
+ if (job.repointed && job.pausedParticipants.length > 0) {
425
558
  job.phase = "refreshing";
426
559
  job.finishedAt = null;
427
560
  await this.persist(job);
@@ -469,21 +602,28 @@ var StorageMigrationCoordinator = class {
469
602
  } catch {}
470
603
  }
471
604
  }
605
+ /**
606
+ * Start whatever is startable, honouring the ONE constraint the engines
607
+ * impose: each is single-flight.
608
+ *
609
+ * Two lanes — the recorder's footage engine and post-analysis's media engine
610
+ * — run in parallel with each other and strictly FIFO within themselves.
611
+ * `galleryMedia` shares the media lane with `eventMedia` because it shares
612
+ * the same `MediaRelocateEngine`, which refuses a second running job; starting
613
+ * both would fail the second one instantly.
614
+ */
472
615
  async startOrResumeMoves(job) {
473
616
  if (job.cancelRequested) return;
474
- const mediaMove = job.moves.find((move) => move.storageClass === "eventMedia");
475
- if (mediaMove?.moverJobId === null) {
476
- mediaMove.moverJobId = await this.startMove(job, mediaMove);
477
- await this.persist(job);
478
- }
479
- const footageMoves = job.moves.filter((move) => move.storageClass !== "eventMedia");
480
- for (let index = 0; index < footageMoves.length; index++) {
481
- const move = footageMoves[index];
482
- if (move.moverJobId !== null) continue;
483
- if (!footageMoves.slice(0, index).every((predecessor) => predecessor.state === "done")) return;
484
- move.moverJobId = await this.startMove(job, move);
485
- await this.persist(job);
486
- return;
617
+ for (const lane of MOVE_LANES) {
618
+ const laneMoves = job.moves.filter((move) => laneOf(move.storageClass) === lane);
619
+ for (let index = 0; index < laneMoves.length; index++) {
620
+ const move = laneMoves[index];
621
+ if (move === void 0 || move.moverJobId !== null) continue;
622
+ if (!laneMoves.slice(0, index).every((predecessor) => predecessor.state === "done")) break;
623
+ move.moverJobId = await this.startMove(job, move);
624
+ await this.persist(job);
625
+ break;
626
+ }
487
627
  }
488
628
  }
489
629
  async waitForMoves(job) {
@@ -494,7 +634,7 @@ var StorageMigrationCoordinator = class {
494
634
  let complete = true;
495
635
  for (const [index, move] of job.moves.entries()) {
496
636
  if (job.cancelRequested && move.moverJobId === null) continue;
497
- if (move.storageClass !== "eventMedia" && job.moves.slice(0, index).filter((predecessor) => predecessor.storageClass !== "eventMedia").some((predecessor) => predecessor.state !== "done")) {
637
+ if (job.moves.slice(0, index).filter((predecessor) => laneOf(predecessor.storageClass) === laneOf(move.storageClass)).some((predecessor) => predecessor.state !== "done")) {
498
638
  complete = false;
499
639
  continue;
500
640
  }
@@ -528,30 +668,92 @@ var StorageMigrationCoordinator = class {
528
668
  await sleep(pollMs);
529
669
  }
530
670
  }
671
+ /**
672
+ * Nail every unstamped `eventMedia`/retrain row to the location its bytes are
673
+ * ALREADY on, before anything repoints. Runs with every writer up — it moves
674
+ * no bytes at all.
675
+ *
676
+ * The gate is the RE-COUNT, not the seal job's terminal state: a seal that
677
+ * failed some rows still finishes, and "finished" is not "there are none
678
+ * left". A non-zero count here fails the job while nothing has been paused
679
+ * and nothing has been repointed, which is the cheapest possible place to
680
+ * discover it.
681
+ *
682
+ * The seal's own mover job id is deliberately NOT durable. It is idempotent
683
+ * and cheap, so a coordinator restart mid-seal simply re-runs the whole
684
+ * phase — and the count that follows is what actually decides, so a lost job
685
+ * id cannot make the gate pass.
686
+ */
687
+ async sealEventMedia(job) {
688
+ const move = job.moves.find((candidate) => candidate.storageClass === "eventMedia");
689
+ if (move === void 0) return;
690
+ if ((await this.deps.participants.analytics.countUnstamped()).total > 0) {
691
+ const started = await this.deps.participants.analytics.startDrain({
692
+ toLocationId: move.fromLocationId,
693
+ mode: "seal"
694
+ });
695
+ await this.waitForSeal(job, started.jobId);
696
+ if (job.cancelRequested) return;
697
+ }
698
+ const after = await this.deps.participants.analytics.countUnstamped();
699
+ if (after.total > 0) throw new Error(`event-media seal left ${after.total.toString()} row(s) without a locationId (${after.media.toString()} media, ${after.retrainFrames.toString()} retrain frames). A non-blocking cutover would silently orphan them; nothing has been paused or repointed.`);
700
+ }
701
+ async waitForSeal(job, moverJobId) {
702
+ const sleep = this.deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
703
+ const pollMs = this.deps.pollMs ?? 250;
704
+ for (;;) {
705
+ if (job.cancelRequested) {
706
+ await this.deps.participants.analytics.cancelMove(moverJobId);
707
+ return;
708
+ }
709
+ const status = await this.deps.participants.analytics.getMove(moverJobId);
710
+ if (status === null || status.state === "done" || status.state === "cancelled") return;
711
+ if (status.state === "failed") throw new Error(status.error ?? "event-media seal failed");
712
+ await sleep(pollMs);
713
+ }
714
+ }
531
715
  async verifyMoves(job) {
532
716
  for (const move of job.moves) if (move.state !== "done") throw new Error(`${move.storageClass} move did not complete verification`);
533
717
  }
718
+ /**
719
+ * Arm one class's mover.
720
+ *
721
+ * `draining` runs after every writer has been resumed, so there is no lease
722
+ * left to present — it goes through the participants' UNLEASED entry points
723
+ * (`recording.relocateFootage` / `pipelineAnalytics.relocateMedia`), which
724
+ * are the same engines, not a second mover.
725
+ */
534
726
  async startMove(job, move) {
535
- if (move.storageClass === "eventMedia") return (await this.deps.participants.analytics.startMove({
536
- toLocationId: move.toLocationId,
537
- throttleMbps: job.throttleMbps,
538
- leaseId: requireLease(job)
539
- })).jobId;
540
- return (await this.deps.participants.recorder.startMove({
727
+ const unleased = runsUnleased(job);
728
+ if (laneOf(move.storageClass) === "media") {
729
+ const input = {
730
+ toLocationId: move.toLocationId,
731
+ throttleMbps: job.throttleMbps,
732
+ mode: move.storageClass === "galleryMedia" ? "gallery" : "move"
733
+ };
734
+ return (unleased ? await this.deps.participants.analytics.startDrain(input) : await this.deps.participants.analytics.startMove({
735
+ ...input,
736
+ leaseId: requireLease(job)
737
+ })).jobId;
738
+ }
739
+ const input = {
541
740
  fromLocationId: move.fromLocationId,
542
741
  toLocationId: move.toLocationId,
543
- footageClass: move.storageClass,
544
- throttleMbps: job.throttleMbps,
742
+ footageClass: move.storageClass === "recordingsLow" ? "recordingsLow" : "recordings",
743
+ throttleMbps: job.throttleMbps
744
+ };
745
+ return (unleased ? await this.deps.participants.recorder.startDrain(input) : await this.deps.participants.recorder.startMove({
746
+ ...input,
545
747
  leaseId: requireLease(job)
546
748
  })).jobId;
547
749
  }
548
750
  async moveStatus(move) {
549
751
  if (move.moverJobId === null) return null;
550
- return move.storageClass === "eventMedia" ? this.deps.participants.analytics.getMove(move.moverJobId) : this.deps.participants.recorder.getMove(move.moverJobId);
752
+ return laneOf(move.storageClass) === "media" ? this.deps.participants.analytics.getMove(move.moverJobId) : this.deps.participants.recorder.getMove(move.moverJobId);
551
753
  }
552
754
  async cancelMove(move) {
553
755
  if (move.moverJobId === null) return;
554
- if (move.storageClass === "eventMedia") await this.deps.participants.analytics.cancelMove(move.moverJobId);
756
+ if (laneOf(move.storageClass) === "media") await this.deps.participants.analytics.cancelMove(move.moverJobId);
555
757
  else await this.deps.participants.recorder.cancelMove(move.moverJobId);
556
758
  }
557
759
  participant(name) {
@@ -580,6 +782,27 @@ function requireLease(job) {
580
782
  function isTerminal(job) {
581
783
  return job.phase === "done" || job.phase === "failed" || job.phase === "cancelled";
582
784
  }
785
+ /**
786
+ * Which single-flight engine owns a class.
787
+ *
788
+ * `galleryMedia` is in the media lane not because it is event media — it
789
+ * explicitly is not (D197) — but because it is moved by the same
790
+ * `MediaRelocateEngine`, which refuses a second running job.
791
+ */
792
+ function laneOf(storageClass) {
793
+ return storageClass === "eventMedia" || storageClass === "galleryMedia" ? "media" : "footage";
794
+ }
795
+ /**
796
+ * True when this phase runs with every writer already back up, so no
797
+ * maintenance lease exists to present to a participant.
798
+ *
799
+ * `verifying` is in both camps: it precedes the repoint (paused) under
800
+ * `blocking` and follows the drain (running) under `nonBlocking`.
801
+ */
802
+ function runsUnleased(job) {
803
+ if (job.phase === "sealing" || job.phase === "draining") return true;
804
+ return job.phase === "verifying" && job.mode === "nonBlocking";
805
+ }
583
806
  //#endregion
584
807
  //#region src/kernel/storage-location-registry.ts
585
808
  /**
@@ -765,6 +988,14 @@ var StorageOrchestratorService = class {
765
988
  * early-boot path that predates the resolver wiring.
766
989
  */
767
990
  nodeLocalResolver = null;
991
+ /**
992
+ * Injected occupancy probe (see {@link LocationOccupancyProbe}). `null` until
993
+ * {@link setOccupancyProbe} runs, and a `null` probe answers `unknown` — so a
994
+ * service with no probe REFUSES an unforced delete rather than performing one
995
+ * it could not check. The check is the point; a check that defaults to
996
+ * "permit" is a paragraph, not a guard.
997
+ */
998
+ occupancyProbe = null;
768
999
  /** `localNodeId` with any forked-child `/addon` suffix stripped. */
769
1000
  localNode;
770
1001
  /**
@@ -863,6 +1094,15 @@ var StorageOrchestratorService = class {
863
1094
  this.nodeLocalResolver = resolver;
864
1095
  }
865
1096
  /**
1097
+ * Inject the "does this location still hold anything" probe read by
1098
+ * {@link deleteLocation}. Absent → every delete sees `unknown` and therefore
1099
+ * REFUSES without `force`, which is the safe direction for a service wired
1100
+ * without one.
1101
+ */
1102
+ setOccupancyProbe(probe) {
1103
+ this.occupancyProbe = probe;
1104
+ }
1105
+ /**
866
1106
  * The full set of addon-declared storage locations, as aggregated by
867
1107
  * the kernel and injected via {@link setRegistry}. Powers the admin-UI
868
1108
  * Data screen's per-declaration grouping. Empty until the registry is
@@ -1085,13 +1325,22 @@ var StorageOrchestratorService = class {
1085
1325
  * Persistence (Task 6) mirrors the delete asynchronously, with errors
1086
1326
  * routed to the logger — see `upsertLocation` for the rationale.
1087
1327
  */
1088
- deleteLocation(id) {
1328
+ async deleteLocation(id, options) {
1089
1329
  const loc = this.locations.get(id);
1090
1330
  if (!loc) throw new Error(`Storage location "${id}" not found`);
1091
1331
  if (loc.isSystem) throw new Error(`Storage location "${id}" is system-managed and cannot be deleted. Edit its config (path / providerId) instead.`);
1092
1332
  if (loc.isDefault) {
1093
1333
  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`);
1094
1334
  }
1335
+ const occupancy = await this.probeOccupancy(loc);
1336
+ if (occupancy !== "empty") {
1337
+ 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`);
1338
+ this.logger.warn("storage-orchestrator: FORCED delete of a location that was not verified empty", { meta: {
1339
+ id,
1340
+ type: loc.type,
1341
+ occupancy
1342
+ } });
1343
+ }
1095
1344
  this.locations.delete(id);
1096
1345
  if (this.locationStore) this.locationStore.delete(id).catch((err) => {
1097
1346
  this.logger.error("storage-orchestrator: delete persistence failed", { meta: {
@@ -1101,6 +1350,23 @@ var StorageOrchestratorService = class {
1101
1350
  });
1102
1351
  }
1103
1352
  /**
1353
+ * Run the injected occupancy probe, converting "no probe" and "the probe
1354
+ * threw" into `unknown` rather than into a permit.
1355
+ */
1356
+ async probeOccupancy(location) {
1357
+ const probe = this.occupancyProbe;
1358
+ if (probe === null) return "unknown";
1359
+ try {
1360
+ return await probe(location);
1361
+ } catch (err) {
1362
+ this.logger.warn("storage-orchestrator: occupancy probe failed", { meta: {
1363
+ id: location.id,
1364
+ error: err instanceof Error ? err.message : String(err)
1365
+ } });
1366
+ return "unknown";
1367
+ }
1368
+ }
1369
+ /**
1104
1370
  * Remove system-seeded locations whose type is no longer declared by any
1105
1371
  * addon (stale defaults from a removed location type). Operator-added
1106
1372
  * (non-system) locations of an undeclared type are KEPT but warned — the
@@ -1537,6 +1803,7 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
1537
1803
  });
1538
1804
  this.service = service;
1539
1805
  service.setNodeLocalResolver((providerId) => this.nodeLocalByProvider.get(providerId));
1806
+ service.setOccupancyProbe((location) => this.locationOccupancy(location));
1540
1807
  await service.initialize();
1541
1808
  const provider = {
1542
1809
  listLocations: async ({ type }) => {
@@ -1549,8 +1816,8 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
1549
1816
  getDefaultLocation: async ({ type }) => service.getDefaultLocation(type),
1550
1817
  listLocationDeclarations: async () => service.listDeclarations(),
1551
1818
  upsertLocation: async (input) => service.upsertLocation(input),
1552
- deleteLocation: async ({ id }) => {
1553
- service.deleteLocation(id);
1819
+ deleteLocation: async ({ id, force }) => {
1820
+ await service.deleteLocation(id, { force: force === true });
1554
1821
  },
1555
1822
  testLocation: async ({ id }) => {
1556
1823
  const loc = service.getLocationById(id);
@@ -1713,6 +1980,7 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
1713
1980
  await this.ctx.api.recording.resumeForStorageMigration.mutate({ leaseId });
1714
1981
  },
1715
1982
  startMove: (input) => this.ctx.api.recording.startStorageMigrationMove.mutate(input),
1983
+ startDrain: (input) => this.ctx.api.recording.relocateFootage.mutate(input),
1716
1984
  getMove: (jobId) => this.ctx.api.recording.getStorageMigrationMoveStatus.query({ jobId }),
1717
1985
  cancelMove: async (jobId) => (await this.ctx.api.recording.cancelStorageMigrationMove.mutate({ jobId })).cancelled,
1718
1986
  refresh: async (leaseId) => {
@@ -1727,6 +1995,8 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
1727
1995
  await this.ctx.api.pipelineAnalytics.resumeForStorageMigration.mutate({ leaseId });
1728
1996
  },
1729
1997
  startMove: (input) => this.ctx.api.pipelineAnalytics.startStorageMigrationMove.mutate(input),
1998
+ startDrain: (input) => this.ctx.api.pipelineAnalytics.relocateMedia.mutate(input),
1999
+ countUnstamped: () => this.ctx.api.pipelineAnalytics.countUnstampedEventMedia.query({}),
1730
2000
  getMove: (jobId) => this.ctx.api.pipelineAnalytics.getStorageMigrationMoveStatus.query({ jobId }),
1731
2001
  cancelMove: async (jobId) => (await this.ctx.api.pipelineAnalytics.cancelStorageMigrationMove.mutate({ jobId })).cancelled,
1732
2002
  refresh: async (leaseId) => {
@@ -1735,7 +2005,8 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
1735
2005
  }
1736
2006
  },
1737
2007
  now: () => Date.now(),
1738
- newId: () => (0, node_crypto.randomUUID)()
2008
+ newId: () => (0, node_crypto.randomUUID)(),
2009
+ deviceKeyOf: (locationId) => this.locationDeviceKey(locationId)
1739
2010
  });
1740
2011
  this.migration = migration;
1741
2012
  const migrationProvider = {
@@ -1948,6 +2219,44 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
1948
2219
  return node_path.resolve(bp);
1949
2220
  }
1950
2221
  }
2222
+ /**
2223
+ * Does this location still hold anything? A SHALLOW `readdir` of its
2224
+ * `basePath` — one syscall, never a walk. `/recordings` holds one entry per
2225
+ * camera, so this is cheap on a volume with millions of segments, and a
2226
+ * `find`-style walk of a saturated recordings disk is precisely what must
2227
+ * not happen here.
2228
+ *
2229
+ * **What it cannot see, stated rather than papered over:**
2230
+ *
2231
+ * - A location on ANOTHER node, or behind a remote provider (S3 / SFTP /
2232
+ * WebDAV): its bytes are not on this filesystem and this probe answers
2233
+ * `unknown`, never `empty`.
2234
+ * - A location with no `basePath` — same answer.
2235
+ * - The difference between "holds live footage" and "holds one stray
2236
+ * lock-file". It measures BYTES ON DISK, not durable rows, so it refuses
2237
+ * a location holding orphan files that no index names. That direction is
2238
+ * the safe one, and `force` is the escape hatch for it.
2239
+ *
2240
+ * A missing root (`ENOENT`) is `empty`, not `unknown`: an unmounted disk and
2241
+ * a deleted directory are indistinguishable here, and if the record's own
2242
+ * path does not exist there is nothing this delete can strand. Every OTHER
2243
+ * errno — `EACCES`, `EIO`, a stale NFS handle — is `unknown` and refuses.
2244
+ */
2245
+ async locationOccupancy(location) {
2246
+ if ((location.nodeId === void 0 || location.nodeId === "" ? HUB_NODE_ID : location.nodeId) !== (this.service?.getLocalNodeId() ?? HUB_NODE_ID)) return "unknown";
2247
+ const basePath = this.locationBasePath(location.id);
2248
+ if (basePath === null) return "unknown";
2249
+ try {
2250
+ return (await node_fs_promises.readdir(basePath)).length > 0 ? "occupied" : "empty";
2251
+ } catch (err) {
2252
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") return "empty";
2253
+ this.ctx.logger.warn("storage-orchestrator: could not read a location root to check it", { meta: {
2254
+ id: location.id,
2255
+ error: err instanceof Error ? err.message : String(err)
2256
+ } });
2257
+ return "unknown";
2258
+ }
2259
+ }
1951
2260
  /** Free capacity (%) on a location's volume via `statfs`; 100 (guard inert) when unstattable. */
1952
2261
  async locationFreePercent(locationId) {
1953
2262
  const bp = this.locationBasePath(locationId);