@camstack/system 1.2.154 → 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 (67) 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.d.ts +2 -0
  53. package/dist/index.js +14 -2
  54. package/dist/index.mjs +3 -3
  55. package/dist/kernel/heap-watch.d.ts +8 -0
  56. package/dist/kernel/socket-plane-report.d.ts +99 -0
  57. package/dist/kernel/transport/frame-codec.d.ts +25 -0
  58. package/dist/kernel/transport/index.d.ts +2 -0
  59. package/dist/kernel/transport/local-child-registry.d.ts +13 -0
  60. package/dist/kernel/transport/local-transport.d.ts +11 -0
  61. package/dist/kernel/transport/socket-channel.d.ts +23 -0
  62. package/dist/kernel/transport/socket-traffic.d.ts +118 -0
  63. package/dist/{manifest-python-deps-BILJm2mI.mjs → manifest-python-deps-CD14Pv0-.mjs} +360 -7
  64. package/dist/{manifest-python-deps-COeSr7el.js → manifest-python-deps-DVODn-qc.js} +431 -6
  65. package/dist/{retired-settings-keys-DBY6ebwV.js → retired-settings-keys-BfAzWvPC.js} +1 -1
  66. package/dist/{retired-settings-keys-Bq1Tb84I.mjs → retired-settings-keys-CV4QVdXj.mjs} +1 -1
  67. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import { St as storageMigrationCapability, Yt as parseJsonObject, b as StorageMigrationJobSchema, kt as BaseAddon, xt as storageCapability, y as StorageLocationTypeSchema, yt as settingsStoreCapability } from "../../dist-D_NyUV6Y.mjs";
1
+ import { St as storageMigrationCapability, Yt as parseJsonObject, b as StorageMigrationJobSchema, kt as BaseAddon, xt as storageCapability, y as StorageLocationTypeSchema, yt as settingsStoreCapability } from "../../dist-LvKus-mT.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import * as fs from "node:fs/promises";
4
4
  import * as path$1 from "node:path";
@@ -229,8 +229,41 @@ function mapRowToLocation(row) {
229
229
  var STORAGE_CLASSES = [
230
230
  "recordings",
231
231
  "recordingsLow",
232
- "eventMedia"
232
+ "eventMedia",
233
+ "backups",
234
+ "galleryMedia"
233
235
  ];
236
+ /** The classes footage/media movers actually own. Everything else is planned,
237
+ * reported and refused rather than silently accepted. */
238
+ var MOVER_CLASSES = [
239
+ "recordings",
240
+ "recordingsLow",
241
+ "eventMedia",
242
+ "galleryMedia"
243
+ ];
244
+ /**
245
+ * Classes that can never span two locations at once.
246
+ *
247
+ * `galleryMedia` is `cardinality: 'single'` and `upsertLocation` refuses a
248
+ * second instance of a single type, so the "both locations are first-class for
249
+ * reads while the past drains" transient the non-blocking mode rests on is not
250
+ * merely undesirable there — it is not constructible. Its move is therefore
251
+ * stop-the-world, always. That is acceptable only because the enrolled gallery
252
+ * is a few KB per sample, and it is said here rather than discovered.
253
+ */
254
+ var BLOCKING_ONLY_CLASSES = ["galleryMedia"];
255
+ /**
256
+ * Location types whose volume `galleryMedia` must NEVER land on.
257
+ *
258
+ * Verbatim from its own declaration: *"Deliberately NOT on the recordings
259
+ * volume: these images survive every retention sweep, so they must also survive
260
+ * a footage wipe, a quota eviction and a recordings disk swap."* A planner that
261
+ * accepted `galleryMedia` → the recordings volume would let an operator destroy
262
+ * the enrolment gallery on the next disk swap, months later, with no visible
263
+ * link back to this decision.
264
+ */
265
+ var GALLERY_FORBIDDEN_NEIGHBOUR_TYPES = ["recordings", "recordingsLow"];
266
+ var MOVE_LANES = ["media", "footage"];
234
267
  var PARTICIPANTS = [
235
268
  "pipeline",
236
269
  "recorder",
@@ -255,7 +288,9 @@ var StorageMigrationCoordinator = class {
255
288
  this.deps = deps;
256
289
  }
257
290
  async plan(input) {
291
+ const mode = input.mode ?? "blocking";
258
292
  const moves = [];
293
+ const findings = [];
259
294
  for (const storageClass of STORAGE_CLASSES) {
260
295
  const targetId = input.destinations[storageClass];
261
296
  if (targetId === void 0) continue;
@@ -265,6 +300,9 @@ var StorageMigrationCoordinator = class {
265
300
  if (!target) throw new Error(`Storage location "${targetId}" not found`);
266
301
  if (target.type !== storageClass) throw new Error(`Storage location "${targetId}" is type "${target.type}", expected "${storageClass}"`);
267
302
  if (source.id === target.id) throw new Error(`Storage location "${targetId}" is already the "${storageClass}" default`);
303
+ 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.`);
304
+ 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".`);
305
+ await this.refuseGalleryOntoRecordingsVolume(storageClass, targetId);
268
306
  moves.push({
269
307
  storageClass,
270
308
  fromLocationId: source.id,
@@ -273,17 +311,79 @@ var StorageMigrationCoordinator = class {
273
311
  state: null,
274
312
  error: null
275
313
  });
314
+ if (BLOCKING_ONLY_CLASSES.includes(storageClass)) findings.push({
315
+ code: "blockingOnly",
316
+ storageClass,
317
+ 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.`
318
+ });
319
+ findings.push(...await this.deviceFindings(storageClass, source.id, target.id));
276
320
  }
277
321
  if (moves.length === 0) throw new Error("select at least one storage class");
322
+ if (moves.some((move) => move.storageClass === "eventMedia")) {
323
+ const unstamped = await this.deps.participants.analytics.countUnstamped();
324
+ if (unstamped.total > 0) {
325
+ 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"}" }).`);
326
+ findings.push({
327
+ code: "unstampedEventMediaRows",
328
+ storageClass: "eventMedia",
329
+ 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.`
330
+ });
331
+ }
332
+ }
278
333
  return {
279
334
  destinations: input.destinations,
335
+ mode,
280
336
  moves: moves.map(({ storageClass, fromLocationId, toLocationId }) => ({
281
337
  storageClass,
282
338
  fromLocationId,
283
339
  toLocationId
284
- }))
340
+ })),
341
+ findings
285
342
  };
286
343
  }
344
+ /**
345
+ * HARD refusal: `galleryMedia` onto a volume a recordings class lives on.
346
+ *
347
+ * This is the one same-device case that is a refusal rather than a finding,
348
+ * because its consequence is silent and arrives months later — the gallery is
349
+ * wiped by the next footage wipe, quota eviction or recordings disk swap, and
350
+ * nothing at that moment points back at this migration.
351
+ *
352
+ * It can only refuse what it can SEE. `deviceKeyOf` is a realpath, so it
353
+ * catches a symlink, a bind mount and a literally-shared directory — and
354
+ * misses two distinct directories on one filesystem, and any location pinned
355
+ * to another node. Those come back `null`/distinct and produce a
356
+ * `deviceIdentityUnknown` finding instead.
357
+ */
358
+ async refuseGalleryOntoRecordingsVolume(storageClass, targetId) {
359
+ if (storageClass !== "galleryMedia") return;
360
+ const targetKey = await this.deviceKey(targetId);
361
+ if (targetKey === null) return;
362
+ for (const type of GALLERY_FORBIDDEN_NEIGHBOUR_TYPES) for (const neighbour of this.deps.locations.listLocations({ type })) {
363
+ if (await this.deviceKey(neighbour.id) !== targetKey) continue;
364
+ 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.`);
365
+ }
366
+ }
367
+ /** Same-device observations that are reported, not refused. */
368
+ async deviceFindings(storageClass, sourceId, targetId) {
369
+ if (this.deps.deviceKeyOf === void 0) return [];
370
+ const [sourceKey, targetKey] = await Promise.all([this.deviceKey(sourceId), this.deviceKey(targetId)]);
371
+ if (sourceKey === null || targetKey === null) return [{
372
+ code: "deviceIdentityUnknown",
373
+ storageClass,
374
+ 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.`
375
+ }];
376
+ if (sourceKey !== targetKey) return [];
377
+ return [{
378
+ code: "sharesDeviceWithSource",
379
+ storageClass,
380
+ 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.`
381
+ }];
382
+ }
383
+ async deviceKey(locationId) {
384
+ if (this.deps.deviceKeyOf === void 0) return null;
385
+ return this.deps.deviceKeyOf(locationId);
386
+ }
287
387
  async start(input) {
288
388
  if (this.startReserved) throw new Error("storage migration is already active");
289
389
  this.startReserved = true;
@@ -300,6 +400,7 @@ var StorageMigrationCoordinator = class {
300
400
  const job = {
301
401
  jobId: this.deps.newId(),
302
402
  phase: "planning",
403
+ mode: plan.mode,
303
404
  destinations: input.destinations,
304
405
  throttleMbps: input.throttleMbps ?? 40,
305
406
  moves: plan.moves.map((move) => ({
@@ -333,7 +434,8 @@ var StorageMigrationCoordinator = class {
333
434
  }
334
435
  async cancel(jobId) {
335
436
  const job = await this.status(jobId);
336
- if (!job || isTerminal(job) || job.repointed) return false;
437
+ const cutoverInFlight = job !== null && job.repointed && (job.phase === "refreshing" || job.phase === "resuming");
438
+ if (!job || isTerminal(job) || cutoverInFlight) return false;
337
439
  job.cancelRequested = true;
338
440
  await this.persist(job);
339
441
  await Promise.all(job.moves.filter((move) => move.moverJobId !== null).map((move) => this.cancelMove(move)));
@@ -364,7 +466,7 @@ var StorageMigrationCoordinator = class {
364
466
  job.pauseLeaseId ??= this.deps.newId();
365
467
  if (job.repointed && job.phase === "resuming") job.phase = "refreshing";
366
468
  await this.persist(job);
367
- try {
469
+ if (!runsUnleased(job)) try {
368
470
  await this.pauseParticipants(job, true);
369
471
  } catch (err) {
370
472
  job.error = err instanceof Error ? err.message : String(err);
@@ -376,14 +478,34 @@ var StorageMigrationCoordinator = class {
376
478
  this.runPromise;
377
479
  }
378
480
  }
481
+ /**
482
+ * The state machine. Both modes run the SAME phase handlers; only the order
483
+ * differs, which is the whole of the non-blocking design — no second mover,
484
+ * no parallel code path, nothing that can drift between the two.
485
+ *
486
+ * blocking: planning → pausing → moving → verifying → repointing →
487
+ * refreshing → resuming → done
488
+ * nonBlocking: planning → sealing → pausing → repointing → refreshing →
489
+ * resuming → draining → verifying → done
490
+ *
491
+ * Written as a straight-line sequence of `if (phase === X)` blocks so boot
492
+ * recovery re-enters at whatever phase the durable record says, exactly as
493
+ * before.
494
+ */
379
495
  async run(job) {
496
+ const nonBlocking = job.mode === "nonBlocking";
380
497
  try {
381
- if (job.phase === "planning") await this.setPhase(job, "pausing");
498
+ if (job.phase === "planning") await this.setPhase(job, nonBlocking ? "sealing" : "pausing");
499
+ if (job.phase === "sealing") {
500
+ await this.sealEventMedia(job);
501
+ if (job.cancelRequested) return this.finishCancelled(job);
502
+ await this.setPhase(job, "pausing");
503
+ }
382
504
  if (job.phase === "pausing") {
383
505
  job.pauseLeaseId ??= job.jobId;
384
506
  await this.pauseParticipants(job);
385
507
  if (job.cancelRequested) return this.finishCancelled(job);
386
- await this.setPhase(job, "moving");
508
+ await this.setPhase(job, nonBlocking ? "repointing" : "moving");
387
509
  }
388
510
  if (job.phase === "moving") {
389
511
  await this.startOrResumeMoves(job);
@@ -391,7 +513,7 @@ var StorageMigrationCoordinator = class {
391
513
  if (job.cancelRequested) return this.finishCancelled(job);
392
514
  await this.setPhase(job, "verifying");
393
515
  }
394
- if (job.phase === "verifying") {
516
+ if (job.phase === "verifying" && !nonBlocking) {
395
517
  await this.verifyMoves(job);
396
518
  if (job.cancelRequested) return this.finishCancelled(job);
397
519
  await this.setPhase(job, "repointing");
@@ -404,17 +526,28 @@ var StorageMigrationCoordinator = class {
404
526
  }
405
527
  if (job.phase === "refreshing") {
406
528
  const leaseId = requireLease(job);
407
- if (job.moves.some((move) => move.storageClass !== "eventMedia")) await this.deps.participants.recorder.refresh(leaseId);
408
- if (job.moves.some((move) => move.storageClass === "eventMedia")) await this.deps.participants.analytics.refresh(leaseId);
529
+ if (job.moves.some((move) => laneOf(move.storageClass) === "footage")) await this.deps.participants.recorder.refresh(leaseId);
530
+ if (job.moves.some((move) => laneOf(move.storageClass) === "media")) await this.deps.participants.analytics.refresh(leaseId);
409
531
  await this.setPhase(job, "resuming");
410
532
  }
411
533
  if (job.phase === "resuming") {
412
534
  await this.resumeParticipants(job);
535
+ await this.setPhase(job, nonBlocking ? "draining" : "done");
536
+ }
537
+ if (job.phase === "draining") {
538
+ await this.startOrResumeMoves(job);
539
+ await this.waitForMoves(job);
540
+ if (job.cancelRequested) return this.finishCancelled(job);
541
+ await this.setPhase(job, "verifying");
542
+ }
543
+ if (job.phase === "verifying" && nonBlocking) {
544
+ await this.verifyMoves(job);
545
+ if (job.cancelRequested) return this.finishCancelled(job);
413
546
  await this.setPhase(job, "done");
414
547
  }
415
548
  } catch (err) {
416
549
  job.error = err instanceof Error ? err.message : String(err);
417
- if (job.repointed) {
550
+ if (job.repointed && job.pausedParticipants.length > 0) {
418
551
  job.phase = "refreshing";
419
552
  job.finishedAt = null;
420
553
  await this.persist(job);
@@ -462,21 +595,28 @@ var StorageMigrationCoordinator = class {
462
595
  } catch {}
463
596
  }
464
597
  }
598
+ /**
599
+ * Start whatever is startable, honouring the ONE constraint the engines
600
+ * impose: each is single-flight.
601
+ *
602
+ * Two lanes — the recorder's footage engine and post-analysis's media engine
603
+ * — run in parallel with each other and strictly FIFO within themselves.
604
+ * `galleryMedia` shares the media lane with `eventMedia` because it shares
605
+ * the same `MediaRelocateEngine`, which refuses a second running job; starting
606
+ * both would fail the second one instantly.
607
+ */
465
608
  async startOrResumeMoves(job) {
466
609
  if (job.cancelRequested) return;
467
- const mediaMove = job.moves.find((move) => move.storageClass === "eventMedia");
468
- if (mediaMove?.moverJobId === null) {
469
- mediaMove.moverJobId = await this.startMove(job, mediaMove);
470
- await this.persist(job);
471
- }
472
- const footageMoves = job.moves.filter((move) => move.storageClass !== "eventMedia");
473
- for (let index = 0; index < footageMoves.length; index++) {
474
- const move = footageMoves[index];
475
- if (move.moverJobId !== null) continue;
476
- if (!footageMoves.slice(0, index).every((predecessor) => predecessor.state === "done")) return;
477
- move.moverJobId = await this.startMove(job, move);
478
- await this.persist(job);
479
- return;
610
+ for (const lane of MOVE_LANES) {
611
+ const laneMoves = job.moves.filter((move) => laneOf(move.storageClass) === lane);
612
+ for (let index = 0; index < laneMoves.length; index++) {
613
+ const move = laneMoves[index];
614
+ if (move === void 0 || move.moverJobId !== null) continue;
615
+ if (!laneMoves.slice(0, index).every((predecessor) => predecessor.state === "done")) break;
616
+ move.moverJobId = await this.startMove(job, move);
617
+ await this.persist(job);
618
+ break;
619
+ }
480
620
  }
481
621
  }
482
622
  async waitForMoves(job) {
@@ -487,7 +627,7 @@ var StorageMigrationCoordinator = class {
487
627
  let complete = true;
488
628
  for (const [index, move] of job.moves.entries()) {
489
629
  if (job.cancelRequested && move.moverJobId === null) continue;
490
- if (move.storageClass !== "eventMedia" && job.moves.slice(0, index).filter((predecessor) => predecessor.storageClass !== "eventMedia").some((predecessor) => predecessor.state !== "done")) {
630
+ if (job.moves.slice(0, index).filter((predecessor) => laneOf(predecessor.storageClass) === laneOf(move.storageClass)).some((predecessor) => predecessor.state !== "done")) {
491
631
  complete = false;
492
632
  continue;
493
633
  }
@@ -521,30 +661,92 @@ var StorageMigrationCoordinator = class {
521
661
  await sleep(pollMs);
522
662
  }
523
663
  }
664
+ /**
665
+ * Nail every unstamped `eventMedia`/retrain row to the location its bytes are
666
+ * ALREADY on, before anything repoints. Runs with every writer up — it moves
667
+ * no bytes at all.
668
+ *
669
+ * The gate is the RE-COUNT, not the seal job's terminal state: a seal that
670
+ * failed some rows still finishes, and "finished" is not "there are none
671
+ * left". A non-zero count here fails the job while nothing has been paused
672
+ * and nothing has been repointed, which is the cheapest possible place to
673
+ * discover it.
674
+ *
675
+ * The seal's own mover job id is deliberately NOT durable. It is idempotent
676
+ * and cheap, so a coordinator restart mid-seal simply re-runs the whole
677
+ * phase — and the count that follows is what actually decides, so a lost job
678
+ * id cannot make the gate pass.
679
+ */
680
+ async sealEventMedia(job) {
681
+ const move = job.moves.find((candidate) => candidate.storageClass === "eventMedia");
682
+ if (move === void 0) return;
683
+ if ((await this.deps.participants.analytics.countUnstamped()).total > 0) {
684
+ const started = await this.deps.participants.analytics.startDrain({
685
+ toLocationId: move.fromLocationId,
686
+ mode: "seal"
687
+ });
688
+ await this.waitForSeal(job, started.jobId);
689
+ if (job.cancelRequested) return;
690
+ }
691
+ const after = await this.deps.participants.analytics.countUnstamped();
692
+ 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.`);
693
+ }
694
+ async waitForSeal(job, moverJobId) {
695
+ const sleep = this.deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
696
+ const pollMs = this.deps.pollMs ?? 250;
697
+ for (;;) {
698
+ if (job.cancelRequested) {
699
+ await this.deps.participants.analytics.cancelMove(moverJobId);
700
+ return;
701
+ }
702
+ const status = await this.deps.participants.analytics.getMove(moverJobId);
703
+ if (status === null || status.state === "done" || status.state === "cancelled") return;
704
+ if (status.state === "failed") throw new Error(status.error ?? "event-media seal failed");
705
+ await sleep(pollMs);
706
+ }
707
+ }
524
708
  async verifyMoves(job) {
525
709
  for (const move of job.moves) if (move.state !== "done") throw new Error(`${move.storageClass} move did not complete verification`);
526
710
  }
711
+ /**
712
+ * Arm one class's mover.
713
+ *
714
+ * `draining` runs after every writer has been resumed, so there is no lease
715
+ * left to present — it goes through the participants' UNLEASED entry points
716
+ * (`recording.relocateFootage` / `pipelineAnalytics.relocateMedia`), which
717
+ * are the same engines, not a second mover.
718
+ */
527
719
  async startMove(job, move) {
528
- if (move.storageClass === "eventMedia") return (await this.deps.participants.analytics.startMove({
529
- toLocationId: move.toLocationId,
530
- throttleMbps: job.throttleMbps,
531
- leaseId: requireLease(job)
532
- })).jobId;
533
- return (await this.deps.participants.recorder.startMove({
720
+ const unleased = runsUnleased(job);
721
+ if (laneOf(move.storageClass) === "media") {
722
+ const input = {
723
+ toLocationId: move.toLocationId,
724
+ throttleMbps: job.throttleMbps,
725
+ mode: move.storageClass === "galleryMedia" ? "gallery" : "move"
726
+ };
727
+ return (unleased ? await this.deps.participants.analytics.startDrain(input) : await this.deps.participants.analytics.startMove({
728
+ ...input,
729
+ leaseId: requireLease(job)
730
+ })).jobId;
731
+ }
732
+ const input = {
534
733
  fromLocationId: move.fromLocationId,
535
734
  toLocationId: move.toLocationId,
536
- footageClass: move.storageClass,
537
- throttleMbps: job.throttleMbps,
735
+ footageClass: move.storageClass === "recordingsLow" ? "recordingsLow" : "recordings",
736
+ throttleMbps: job.throttleMbps
737
+ };
738
+ return (unleased ? await this.deps.participants.recorder.startDrain(input) : await this.deps.participants.recorder.startMove({
739
+ ...input,
538
740
  leaseId: requireLease(job)
539
741
  })).jobId;
540
742
  }
541
743
  async moveStatus(move) {
542
744
  if (move.moverJobId === null) return null;
543
- return move.storageClass === "eventMedia" ? this.deps.participants.analytics.getMove(move.moverJobId) : this.deps.participants.recorder.getMove(move.moverJobId);
745
+ return laneOf(move.storageClass) === "media" ? this.deps.participants.analytics.getMove(move.moverJobId) : this.deps.participants.recorder.getMove(move.moverJobId);
544
746
  }
545
747
  async cancelMove(move) {
546
748
  if (move.moverJobId === null) return;
547
- if (move.storageClass === "eventMedia") await this.deps.participants.analytics.cancelMove(move.moverJobId);
749
+ if (laneOf(move.storageClass) === "media") await this.deps.participants.analytics.cancelMove(move.moverJobId);
548
750
  else await this.deps.participants.recorder.cancelMove(move.moverJobId);
549
751
  }
550
752
  participant(name) {
@@ -573,6 +775,27 @@ function requireLease(job) {
573
775
  function isTerminal(job) {
574
776
  return job.phase === "done" || job.phase === "failed" || job.phase === "cancelled";
575
777
  }
778
+ /**
779
+ * Which single-flight engine owns a class.
780
+ *
781
+ * `galleryMedia` is in the media lane not because it is event media — it
782
+ * explicitly is not (D197) — but because it is moved by the same
783
+ * `MediaRelocateEngine`, which refuses a second running job.
784
+ */
785
+ function laneOf(storageClass) {
786
+ return storageClass === "eventMedia" || storageClass === "galleryMedia" ? "media" : "footage";
787
+ }
788
+ /**
789
+ * True when this phase runs with every writer already back up, so no
790
+ * maintenance lease exists to present to a participant.
791
+ *
792
+ * `verifying` is in both camps: it precedes the repoint (paused) under
793
+ * `blocking` and follows the drain (running) under `nonBlocking`.
794
+ */
795
+ function runsUnleased(job) {
796
+ if (job.phase === "sealing" || job.phase === "draining") return true;
797
+ return job.phase === "verifying" && job.mode === "nonBlocking";
798
+ }
576
799
  //#endregion
577
800
  //#region src/kernel/storage-location-registry.ts
578
801
  /**
@@ -758,6 +981,14 @@ var StorageOrchestratorService = class {
758
981
  * early-boot path that predates the resolver wiring.
759
982
  */
760
983
  nodeLocalResolver = null;
984
+ /**
985
+ * Injected occupancy probe (see {@link LocationOccupancyProbe}). `null` until
986
+ * {@link setOccupancyProbe} runs, and a `null` probe answers `unknown` — so a
987
+ * service with no probe REFUSES an unforced delete rather than performing one
988
+ * it could not check. The check is the point; a check that defaults to
989
+ * "permit" is a paragraph, not a guard.
990
+ */
991
+ occupancyProbe = null;
761
992
  /** `localNodeId` with any forked-child `/addon` suffix stripped. */
762
993
  localNode;
763
994
  /**
@@ -856,6 +1087,15 @@ var StorageOrchestratorService = class {
856
1087
  this.nodeLocalResolver = resolver;
857
1088
  }
858
1089
  /**
1090
+ * Inject the "does this location still hold anything" probe read by
1091
+ * {@link deleteLocation}. Absent → every delete sees `unknown` and therefore
1092
+ * REFUSES without `force`, which is the safe direction for a service wired
1093
+ * without one.
1094
+ */
1095
+ setOccupancyProbe(probe) {
1096
+ this.occupancyProbe = probe;
1097
+ }
1098
+ /**
859
1099
  * The full set of addon-declared storage locations, as aggregated by
860
1100
  * the kernel and injected via {@link setRegistry}. Powers the admin-UI
861
1101
  * Data screen's per-declaration grouping. Empty until the registry is
@@ -1078,13 +1318,22 @@ var StorageOrchestratorService = class {
1078
1318
  * Persistence (Task 6) mirrors the delete asynchronously, with errors
1079
1319
  * routed to the logger — see `upsertLocation` for the rationale.
1080
1320
  */
1081
- deleteLocation(id) {
1321
+ async deleteLocation(id, options) {
1082
1322
  const loc = this.locations.get(id);
1083
1323
  if (!loc) throw new Error(`Storage location "${id}" not found`);
1084
1324
  if (loc.isSystem) throw new Error(`Storage location "${id}" is system-managed and cannot be deleted. Edit its config (path / providerId) instead.`);
1085
1325
  if (loc.isDefault) {
1086
1326
  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`);
1087
1327
  }
1328
+ const occupancy = await this.probeOccupancy(loc);
1329
+ if (occupancy !== "empty") {
1330
+ 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`);
1331
+ this.logger.warn("storage-orchestrator: FORCED delete of a location that was not verified empty", { meta: {
1332
+ id,
1333
+ type: loc.type,
1334
+ occupancy
1335
+ } });
1336
+ }
1088
1337
  this.locations.delete(id);
1089
1338
  if (this.locationStore) this.locationStore.delete(id).catch((err) => {
1090
1339
  this.logger.error("storage-orchestrator: delete persistence failed", { meta: {
@@ -1094,6 +1343,23 @@ var StorageOrchestratorService = class {
1094
1343
  });
1095
1344
  }
1096
1345
  /**
1346
+ * Run the injected occupancy probe, converting "no probe" and "the probe
1347
+ * threw" into `unknown` rather than into a permit.
1348
+ */
1349
+ async probeOccupancy(location) {
1350
+ const probe = this.occupancyProbe;
1351
+ if (probe === null) return "unknown";
1352
+ try {
1353
+ return await probe(location);
1354
+ } catch (err) {
1355
+ this.logger.warn("storage-orchestrator: occupancy probe failed", { meta: {
1356
+ id: location.id,
1357
+ error: err instanceof Error ? err.message : String(err)
1358
+ } });
1359
+ return "unknown";
1360
+ }
1361
+ }
1362
+ /**
1097
1363
  * Remove system-seeded locations whose type is no longer declared by any
1098
1364
  * addon (stale defaults from a removed location type). Operator-added
1099
1365
  * (non-system) locations of an undeclared type are KEPT but warned — the
@@ -1530,6 +1796,7 @@ var StorageOrchestratorAddon = class extends BaseAddon {
1530
1796
  });
1531
1797
  this.service = service;
1532
1798
  service.setNodeLocalResolver((providerId) => this.nodeLocalByProvider.get(providerId));
1799
+ service.setOccupancyProbe((location) => this.locationOccupancy(location));
1533
1800
  await service.initialize();
1534
1801
  const provider = {
1535
1802
  listLocations: async ({ type }) => {
@@ -1542,8 +1809,8 @@ var StorageOrchestratorAddon = class extends BaseAddon {
1542
1809
  getDefaultLocation: async ({ type }) => service.getDefaultLocation(type),
1543
1810
  listLocationDeclarations: async () => service.listDeclarations(),
1544
1811
  upsertLocation: async (input) => service.upsertLocation(input),
1545
- deleteLocation: async ({ id }) => {
1546
- service.deleteLocation(id);
1812
+ deleteLocation: async ({ id, force }) => {
1813
+ await service.deleteLocation(id, { force: force === true });
1547
1814
  },
1548
1815
  testLocation: async ({ id }) => {
1549
1816
  const loc = service.getLocationById(id);
@@ -1706,6 +1973,7 @@ var StorageOrchestratorAddon = class extends BaseAddon {
1706
1973
  await this.ctx.api.recording.resumeForStorageMigration.mutate({ leaseId });
1707
1974
  },
1708
1975
  startMove: (input) => this.ctx.api.recording.startStorageMigrationMove.mutate(input),
1976
+ startDrain: (input) => this.ctx.api.recording.relocateFootage.mutate(input),
1709
1977
  getMove: (jobId) => this.ctx.api.recording.getStorageMigrationMoveStatus.query({ jobId }),
1710
1978
  cancelMove: async (jobId) => (await this.ctx.api.recording.cancelStorageMigrationMove.mutate({ jobId })).cancelled,
1711
1979
  refresh: async (leaseId) => {
@@ -1720,6 +1988,8 @@ var StorageOrchestratorAddon = class extends BaseAddon {
1720
1988
  await this.ctx.api.pipelineAnalytics.resumeForStorageMigration.mutate({ leaseId });
1721
1989
  },
1722
1990
  startMove: (input) => this.ctx.api.pipelineAnalytics.startStorageMigrationMove.mutate(input),
1991
+ startDrain: (input) => this.ctx.api.pipelineAnalytics.relocateMedia.mutate(input),
1992
+ countUnstamped: () => this.ctx.api.pipelineAnalytics.countUnstampedEventMedia.query({}),
1723
1993
  getMove: (jobId) => this.ctx.api.pipelineAnalytics.getStorageMigrationMoveStatus.query({ jobId }),
1724
1994
  cancelMove: async (jobId) => (await this.ctx.api.pipelineAnalytics.cancelStorageMigrationMove.mutate({ jobId })).cancelled,
1725
1995
  refresh: async (leaseId) => {
@@ -1728,7 +1998,8 @@ var StorageOrchestratorAddon = class extends BaseAddon {
1728
1998
  }
1729
1999
  },
1730
2000
  now: () => Date.now(),
1731
- newId: () => randomUUID()
2001
+ newId: () => randomUUID(),
2002
+ deviceKeyOf: (locationId) => this.locationDeviceKey(locationId)
1732
2003
  });
1733
2004
  this.migration = migration;
1734
2005
  const migrationProvider = {
@@ -1941,6 +2212,44 @@ var StorageOrchestratorAddon = class extends BaseAddon {
1941
2212
  return path$1.resolve(bp);
1942
2213
  }
1943
2214
  }
2215
+ /**
2216
+ * Does this location still hold anything? A SHALLOW `readdir` of its
2217
+ * `basePath` — one syscall, never a walk. `/recordings` holds one entry per
2218
+ * camera, so this is cheap on a volume with millions of segments, and a
2219
+ * `find`-style walk of a saturated recordings disk is precisely what must
2220
+ * not happen here.
2221
+ *
2222
+ * **What it cannot see, stated rather than papered over:**
2223
+ *
2224
+ * - A location on ANOTHER node, or behind a remote provider (S3 / SFTP /
2225
+ * WebDAV): its bytes are not on this filesystem and this probe answers
2226
+ * `unknown`, never `empty`.
2227
+ * - A location with no `basePath` — same answer.
2228
+ * - The difference between "holds live footage" and "holds one stray
2229
+ * lock-file". It measures BYTES ON DISK, not durable rows, so it refuses
2230
+ * a location holding orphan files that no index names. That direction is
2231
+ * the safe one, and `force` is the escape hatch for it.
2232
+ *
2233
+ * A missing root (`ENOENT`) is `empty`, not `unknown`: an unmounted disk and
2234
+ * a deleted directory are indistinguishable here, and if the record's own
2235
+ * path does not exist there is nothing this delete can strand. Every OTHER
2236
+ * errno — `EACCES`, `EIO`, a stale NFS handle — is `unknown` and refuses.
2237
+ */
2238
+ async locationOccupancy(location) {
2239
+ if ((location.nodeId === void 0 || location.nodeId === "" ? HUB_NODE_ID : location.nodeId) !== (this.service?.getLocalNodeId() ?? HUB_NODE_ID)) return "unknown";
2240
+ const basePath = this.locationBasePath(location.id);
2241
+ if (basePath === null) return "unknown";
2242
+ try {
2243
+ return (await fs.readdir(basePath)).length > 0 ? "occupied" : "empty";
2244
+ } catch (err) {
2245
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") return "empty";
2246
+ this.ctx.logger.warn("storage-orchestrator: could not read a location root to check it", { meta: {
2247
+ id: location.id,
2248
+ error: err instanceof Error ? err.message : String(err)
2249
+ } });
2250
+ return "unknown";
2251
+ }
2252
+ }
1944
2253
  /** Free capacity (%) on a location's volume via `statfs`; 100 (guard inert) when unstattable. */
1945
2254
  async locationFreePercent(locationId) {
1946
2255
  const bp = this.locationBasePath(locationId);
@@ -37,6 +37,22 @@ export type StorageProviderLookup = () => readonly IStorageProviderImpl[];
37
37
  * remote), the safe default for the early-boot / in-memory path.
38
38
  */
39
39
  export type NodeLocalResolver = (providerId: string) => boolean | undefined;
40
+ /**
41
+ * Whether a location still holds anything — the only question `deleteLocation`
42
+ * is allowed to destroy a record on the answer to.
43
+ *
44
+ * - `'occupied'` — something is there. Deleting the record now strands it.
45
+ * - `'empty'` — the location was reached and holds nothing.
46
+ * - `'unknown'` — it could not be reached at all: a remote provider, another
47
+ * node's disk, an unmounted root. NOT "empty". A read that
48
+ * fails must never authorise a destruction (D49).
49
+ */
50
+ export type LocationOccupancy = 'occupied' | 'empty' | 'unknown';
51
+ export type LocationOccupancyProbe = (location: StorageLocation) => Promise<LocationOccupancy>;
52
+ export interface DeleteLocationOptions {
53
+ /** Override an `occupied` or `unknown` verdict. See the cap docblock. */
54
+ readonly force?: boolean;
55
+ }
40
56
  /**
41
57
  * Where a single dispatch must execute: which `storage-provider`, on which
42
58
  * node. `providerId` alone is NOT a routing key — every node's filesystem
@@ -89,6 +105,14 @@ export declare class StorageOrchestratorService {
89
105
  * early-boot path that predates the resolver wiring.
90
106
  */
91
107
  private nodeLocalResolver;
108
+ /**
109
+ * Injected occupancy probe (see {@link LocationOccupancyProbe}). `null` until
110
+ * {@link setOccupancyProbe} runs, and a `null` probe answers `unknown` — so a
111
+ * service with no probe REFUSES an unforced delete rather than performing one
112
+ * it could not check. The check is the point; a check that defaults to
113
+ * "permit" is a paragraph, not a guard.
114
+ */
115
+ private occupancyProbe;
92
116
  /** `localNodeId` with any forked-child `/addon` suffix stripped. */
93
117
  private readonly localNode;
94
118
  /**
@@ -166,6 +190,13 @@ export declare class StorageOrchestratorService {
166
190
  * changes. Powers the node-local upsert guard and the boot backfill.
167
191
  */
168
192
  setNodeLocalResolver(resolver: NodeLocalResolver | null): void;
193
+ /**
194
+ * Inject the "does this location still hold anything" probe read by
195
+ * {@link deleteLocation}. Absent → every delete sees `unknown` and therefore
196
+ * REFUSES without `force`, which is the safe direction for a service wired
197
+ * without one.
198
+ */
199
+ setOccupancyProbe(probe: LocationOccupancyProbe | null): void;
169
200
  /**
170
201
  * The full set of addon-declared storage locations, as aggregated by
171
202
  * the kernel and injected via {@link setRegistry}. Powers the admin-UI
@@ -231,7 +262,12 @@ export declare class StorageOrchestratorService {
231
262
  * Persistence (Task 6) mirrors the delete asynchronously, with errors
232
263
  * routed to the logger — see `upsertLocation` for the rationale.
233
264
  */
234
- deleteLocation(id: string): void;
265
+ deleteLocation(id: string, options?: DeleteLocationOptions): Promise<void>;
266
+ /**
267
+ * Run the injected occupancy probe, converting "no probe" and "the probe
268
+ * threw" into `unknown` rather than into a permit.
269
+ */
270
+ private probeOccupancy;
235
271
  /**
236
272
  * Remove system-seeded locations whose type is no longer declared by any
237
273
  * addon (stale defaults from a removed location type). Operator-added