@camstack/system 1.2.201 → 1.2.203

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 (62) hide show
  1. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  2. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  3. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/alerts/alerts.addon.js +1 -1
  6. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  7. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.d.ts +12 -0
  8. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +37 -3
  9. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +37 -3
  10. package/dist/builtins/console-logging/index.js +1 -1
  11. package/dist/builtins/console-logging/index.mjs +1 -1
  12. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  13. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  14. package/dist/builtins/device-manager/device-manager.addon.js +2 -2
  15. package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
  16. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  17. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  18. package/dist/builtins/hub-forwarder/index.js +1 -1
  19. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  20. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  21. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  22. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  23. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  24. package/dist/builtins/local-network/local-network.addon.js +1 -1
  25. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  26. package/dist/builtins/loki-logging/index.js +1 -1
  27. package/dist/builtins/loki-logging/index.mjs +1 -1
  28. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  29. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  30. package/dist/builtins/platform-probe/index.js +1 -1
  31. package/dist/builtins/platform-probe/index.mjs +1 -1
  32. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  33. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  34. package/dist/builtins/snapshot/index.js +1 -1
  35. package/dist/builtins/snapshot/index.mjs +1 -1
  36. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  37. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  38. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  39. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  40. package/dist/builtins/storage-orchestrator/drain-ratchet.d.ts +104 -0
  41. package/dist/builtins/storage-orchestrator/drain-state-store.d.ts +13 -0
  42. package/dist/builtins/storage-orchestrator/location-occupancy-slice.d.ts +47 -0
  43. package/dist/builtins/storage-orchestrator/location-protection.d.ts +20 -0
  44. package/dist/builtins/storage-orchestrator/location-root-collision.d.ts +44 -0
  45. package/dist/builtins/storage-orchestrator/location-store.d.ts +12 -3
  46. package/dist/builtins/storage-orchestrator/storage-migration-coordinator.d.ts +9 -9
  47. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.d.ts +47 -0
  48. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +865 -157
  49. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +865 -157
  50. package/dist/builtins/storage-orchestrator/storage-orchestrator.service.d.ts +75 -31
  51. package/dist/builtins/storage-orchestrator/storage-pressure-manager.d.ts +45 -0
  52. package/dist/builtins/system-config/system-config.addon.js +1 -1
  53. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  54. package/dist/builtins/winston-logging/index.js +1 -1
  55. package/dist/builtins/winston-logging/index.mjs +1 -1
  56. package/dist/{dist-CcV3k-hD.js → dist-BFvWv_bz.js} +933 -546
  57. package/dist/{dist-BmJNnb_s.mjs → dist-DcMUcdeq.mjs} +862 -547
  58. package/dist/index.js +1 -1
  59. package/dist/index.mjs +1 -1
  60. package/dist/{retired-settings-keys-nLVKlhe-.mjs → retired-settings-keys-CJfgnUSb.mjs} +1 -1
  61. package/dist/{retired-settings-keys-Bd8fuwO1.js → retired-settings-keys-Dd7K3K3r.js} +1 -1
  62. package/package.json +1 -1
@@ -3,10 +3,11 @@ 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-CcV3k-hD.js");
6
+ const require_dist = require("../../dist-BFvWv_bz.js");
7
7
  const require_hub_cap_forward = require("../../hub-cap-forward-DmHNjbB0.js");
8
8
  let zod = require("zod");
9
9
  let node_crypto = require("node:crypto");
10
+ let _camstack_types_node = require("@camstack/types/node");
10
11
  let node_fs_promises = require("node:fs/promises");
11
12
  node_fs_promises = require_chunk.__toESM(node_fs_promises);
12
13
  let node_path = require("node:path");
@@ -43,6 +44,17 @@ var NODE_ID_CONFIG_KEY = "__nodeId";
43
44
  */
44
45
  var ENABLED_CONFIG_KEY = "__enabled";
45
46
  /**
47
+ * Reserved key under which the location's typed STATE ({@link
48
+ * StorageLocationMode}, D385) is persisted — same channel, same reason, as
49
+ * {@link NODE_ID_CONFIG_KEY} and {@link ENABLED_CONFIG_KEY}.
50
+ *
51
+ * ABSENT means UNSTAMPED, not `active`: the orchestrator's boot migration folds
52
+ * the legacy `enabled` / `config.readOnly` pair into a mode and writes it once.
53
+ * Reading absence as a state here would do that fold on every read forever,
54
+ * which is the thing D385 removes.
55
+ */
56
+ var MODE_CONFIG_KEY = "__mode";
57
+ /**
46
58
  * `config` is declared `JSON`, so the store hands the surface an object and
47
59
  * gets an object back. There is deliberately NO `node_id` column — `nodeId`
48
60
  * rides inside the blob under a reserved key (see {@link NODE_ID_CONFIG_KEY}),
@@ -187,13 +199,14 @@ function mapLocationToRow(loc) {
187
199
  const storedConfig = { ...loc.config };
188
200
  if (loc.nodeId !== void 0) storedConfig[NODE_ID_CONFIG_KEY] = loc.nodeId;
189
201
  if (loc.enabled !== void 0) storedConfig[ENABLED_CONFIG_KEY] = loc.enabled;
202
+ if (loc.mode !== void 0) storedConfig[MODE_CONFIG_KEY] = loc.mode;
190
203
  return {
191
204
  id: loc.id,
192
205
  type: loc.type,
193
206
  display_name: loc.displayName,
194
207
  provider_id: loc.providerId,
195
208
  config: storedConfig,
196
- is_default: loc.isDefault ? 1 : 0,
209
+ is_default: 0,
197
210
  is_system: loc.isSystem ? 1 : 0,
198
211
  created_at: loc.createdAt,
199
212
  updated_at: loc.updatedAt
@@ -216,9 +229,12 @@ function mapRowToLocation(row) {
216
229
  const nodeId = typeof nodeIdRaw === "string" && nodeIdRaw.length > 0 ? nodeIdRaw : void 0;
217
230
  const enabledRaw = storedConfig[ENABLED_CONFIG_KEY];
218
231
  const enabled = typeof enabledRaw === "boolean" ? enabledRaw : void 0;
232
+ const parsedMode = require_dist.StorageLocationModeSchema.safeParse(storedConfig[MODE_CONFIG_KEY]);
233
+ const mode = parsedMode.success ? parsedMode.data : void 0;
219
234
  const config = { ...storedConfig };
220
235
  delete config[NODE_ID_CONFIG_KEY];
221
236
  delete config[ENABLED_CONFIG_KEY];
237
+ delete config[MODE_CONFIG_KEY];
222
238
  return {
223
239
  id,
224
240
  type,
@@ -227,7 +243,8 @@ function mapRowToLocation(row) {
227
243
  config,
228
244
  ...nodeId !== void 0 ? { nodeId } : {},
229
245
  ...enabled !== void 0 ? { enabled } : {},
230
- isDefault: row["is_default"] === 1 || row["is_default"] === true,
246
+ ...mode !== void 0 ? { mode } : {},
247
+ ...require_dist.LegacyStorageLocationDefaultSchema.parse({ isDefault: row["is_default"] === 1 || row["is_default"] === true }),
231
248
  isSystem: row["is_system"] === 1 || row["is_system"] === true,
232
249
  createdAt: Number(row["created_at"] ?? 0),
233
250
  updatedAt: Number(row["updated_at"] ?? 0)
@@ -295,16 +312,18 @@ var StorageMigrationCoordinator = class {
295
312
  const targetId = input.destinations[storageClass];
296
313
  if (targetId === void 0) continue;
297
314
  const namedSourceId = input.sources?.[storageClass];
298
- const defaultLoc = this.deps.locations.getDefaultLocation(storageClass);
299
- const source = namedSourceId ? this.deps.locations.getLocationById(namedSourceId) : defaultLoc;
300
- if (!source) throw new Error(namedSourceId ? `Storage location "${namedSourceId}" not found` : `No default storage location for "${storageClass}"`);
315
+ const writeSet = this.deps.locations.writeTargets(storageClass);
316
+ const implicitSources = writeSet.filter((loc) => loc.id !== targetId);
317
+ if (!namedSourceId && implicitSources.length > 1) throw new Error(`"${storageClass}" writes to ${String(implicitSources.length)} locations (${implicitSources.map((loc) => loc.id).join(", ")}) — name the one to cut over explicitly with \`sources\`.`);
318
+ const source = namedSourceId ? this.deps.locations.getLocationById(namedSourceId) : implicitSources[0];
319
+ if (!source) throw new Error(namedSourceId ? `Storage location "${namedSourceId}" not found` : `Storage location "${targetId}" is already the only write target for "${storageClass}" — there is nothing to cut over. Use storageMigration.drain to move what is left behind.`);
301
320
  if (source.type !== storageClass) throw new Error(`Storage location "${source.id}" is type "${source.type}", expected "${storageClass}"`);
302
321
  const target = this.deps.locations.getLocationById(targetId);
303
322
  if (!target) throw new Error(`Storage location "${targetId}" not found`);
304
323
  if (target.type !== storageClass) throw new Error(`Storage location "${targetId}" is type "${target.type}", expected "${storageClass}"`);
305
- 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`);
306
- const freezeSource = defaultLoc === null || source.id !== defaultLoc.id;
307
- 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.`);
324
+ if (source.id === target.id) throw new Error(namedSourceId ? `Source and destination are the same location ("${targetId}")` : `Storage location "${targetId}" is already the only write target for "${storageClass}"`);
325
+ const freezeSource = !writeSet.some((loc) => loc.id === source.id);
326
+ if (!MOVER_CLASSES.includes(storageClass)) throw new Error(`No mover owns "${storageClass}" — the migration can move its write set but cannot move its bytes. Move it by hand, then enable the destination (and disable the source) with upsertLocation.`);
308
327
  moves.push({
309
328
  storageClass,
310
329
  fromLocationId: source.id,
@@ -321,7 +340,7 @@ var StorageMigrationCoordinator = class {
321
340
  if (moves.some((move) => move.storageClass === "eventMedia")) {
322
341
  const unstamped = await this.deps.participants.analytics.countUnstamped();
323
342
  if (unstamped === null) {
324
- if (mode === "nonBlocking") throw new Error("could not determine whether any event-media row still carries no locationId. A non-blocking cutover repoints the default and would silently orphan every such row, so an unmeasured collection is refused exactly like a non-empty one. Retry, or run the blocking mode, which stamps rows as it moves them.");
343
+ if (mode === "nonBlocking") throw new Error("could not determine whether any event-media row still carries no locationId. A non-blocking cutover moves the write set and would silently orphan every such row, so an unmeasured collection is refused exactly like a non-empty one. Retry, or run the blocking mode, which stamps rows as it moves them.");
325
344
  findings.push({
326
345
  code: "unstampedEventMediaRows",
327
346
  storageClass: "eventMedia",
@@ -330,7 +349,7 @@ var StorageMigrationCoordinator = class {
330
349
  } else if (unstamped.anyPresent) {
331
350
  const scale = unstamped.total === null ? "an unknown number of" : unstamped.total.toString();
332
351
  const perLane = `${describeUnstamped(unstamped.media)} media, ${describeUnstamped(unstamped.retrainFrames)} retrain frames`;
333
- if (mode === "nonBlocking") throw new Error(`${scale} event-media row(s) still carry no locationId (${perLane}). A non-blocking cutover would silently orphan them. Run the seal first: pipelineAnalytics.relocateMedia({ mode: "seal", toLocationId: "${this.deps.locations.getDefaultLocation("eventMedia")?.id ?? "eventMedia"}" }).`);
352
+ if (mode === "nonBlocking") throw new Error(`${scale} event-media row(s) still carry no locationId (${perLane}). A non-blocking cutover would silently orphan them. Run the seal first: pipelineAnalytics.relocateMedia({ mode: "seal", toLocationId: "${this.deps.locations.writeTargets("eventMedia")[0]?.id ?? "eventMedia"}" }).`);
334
353
  findings.push({
335
354
  code: "unstampedEventMediaRows",
336
355
  storageClass: "eventMedia",
@@ -469,7 +488,7 @@ var StorageMigrationCoordinator = class {
469
488
  * remaining" action.
470
489
  *
471
490
  * A class appears here only when something is (or might be) left on a
472
- * location that is not its default. An empty result therefore means exactly
491
+ * location outside its write set. An empty result therefore means exactly
473
492
  * "there is nothing to drain", which is what lets the UI offer the action
474
493
  * only when it is true, and what lets {@link drain} refuse rather than start
475
494
  * a job that would move nothing and report `done` — the failure mode D295
@@ -483,11 +502,13 @@ var StorageMigrationCoordinator = class {
483
502
  const out = [];
484
503
  for (const storageClass of STORAGE_CLASSES) {
485
504
  if (!MOVER_CLASSES.includes(storageClass)) continue;
486
- const target = this.deps.locations.getDefaultLocation(storageClass);
505
+ const writeSet = this.deps.locations.writeTargets(storageClass);
506
+ const target = writeSet[0];
487
507
  if (!target) continue;
508
+ const inWriteSet = new Set(writeSet.map((loc) => loc.id));
488
509
  if (laneOf(storageClass) === "media") {
489
510
  for (const source of this.deps.locations.listLocations({ type: storageClass })) {
490
- if (source.id === target.id) continue;
511
+ if (inWriteSet.has(source.id)) continue;
491
512
  const count = await unanswerable(this.deps.participants.analytics.residue({
492
513
  toLocationId: target.id,
493
514
  fromLocationId: source.id,
@@ -505,7 +526,7 @@ var StorageMigrationCoordinator = class {
505
526
  continue;
506
527
  }
507
528
  for (const source of this.deps.locations.listLocations({ type: storageClass })) {
508
- if (source.id === target.id) continue;
529
+ if (inWriteSet.has(source.id)) continue;
509
530
  const census = await unanswerable(this.deps.participants.recorder.residue({
510
531
  fromLocationId: source.id,
511
532
  footageClass: storageClass === "recordingsLow" ? "recordingsLow" : "recordings"
@@ -523,12 +544,12 @@ var StorageMigrationCoordinator = class {
523
544
  return out;
524
545
  }
525
546
  /**
526
- * Run the DRAIN half alone, against classes whose default has already moved.
547
+ * Run the DRAIN half alone, against classes whose write set has already moved.
527
548
  *
528
549
  * ## Why this is a second verb rather than a looser `start`
529
550
  *
530
- * `start` refuses a destination that is already the class's default
531
- * (`"recordingsLow:ssd" is already the "recordingsLow" default`). That refusal
551
+ * `start` refuses a destination that is already the class's only write target
552
+ * (`"recordingsLow:ssd" is already the only write target for "recordingsLow"`). That refusal
532
553
  * is correct and it is load-bearing: there is genuinely nothing left to
533
554
  * repoint, and an operator must never be able to re-repoint a migrated class
534
555
  * by accident. Making `start` idempotent — "an already-repointed class
@@ -537,7 +558,7 @@ var StorageMigrationCoordinator = class {
537
558
  * operator reads ("pauses the writers…") would be a lie half the time.
538
559
  *
539
560
  * `drain` instead cannot repoint AT ALL: it never touches
540
- * `setDefaultLocations`, and its job starts in `draining` with `repointed`
561
+ * `setWriteTargets`, and its job starts in `draining` with `repointed`
541
562
  * already true, so the `repointing` / `refreshing` / `resuming` blocks of
542
563
  * {@link run} are behind it and unreachable. The two verbs are disjoint, and
543
564
  * `start`'s refusal keeps meaning exactly what it meant.
@@ -547,7 +568,7 @@ var StorageMigrationCoordinator = class {
547
568
  * The finished job is the audit of what happened; re-opening it destroys
548
569
  * that. And a drain is needed in cases where no migration job ever existed
549
570
  * (a mover armed by hand, footage stranded on a location an operator added
550
- * and then un-defaulted). One job = one operation, and the work list comes
571
+ * and then disabled again). One job = one operation, and the work list comes
551
572
  * from {@link residue} — the archive — not from what a previous job believed.
552
573
  */
553
574
  async drain(input) {
@@ -570,9 +591,10 @@ var StorageMigrationCoordinator = class {
570
591
  const destinations = {};
571
592
  for (const storageClass of input.classes) {
572
593
  const remaining = residue.filter((entry) => entry.storageClass === storageClass);
573
- if (remaining.length === 0) throw new Error(`"${storageClass}" has nothing left outside its default location — there is nothing to drain. To CHANGE where it writes, use storageMigration.start with a new destination.`);
594
+ if (remaining.length === 0) throw new Error(`"${storageClass}" has nothing left outside its write set — there is nothing to drain. To CHANGE where it writes, use storageMigration.start with a new destination.`);
574
595
  for (const entry of remaining) {
575
- if (this.deps.locations.getLocationById(entry.toLocationId)?.enabled === false) throw new Error(`The "${storageClass}" drain destination "${entry.toLocationId}" is DISABLED — a disabled location takes no writes, and a drain is the largest write there is. Enable it on the Storage screen, or make an enabled location the "${storageClass}" default first.`);
596
+ const target = this.deps.locations.getLocationById(entry.toLocationId);
597
+ if (target !== void 0 && !require_dist.mayWriteToLocation(target)) throw new Error(`The "${storageClass}" drain destination "${entry.toLocationId}" is not a write target (mode: ${require_dist.resolveLocationMode(target)}) — it takes no writes, and a drain is the largest write there is. Set it active on the Storage screen first.`);
576
598
  moves.push({
577
599
  storageClass,
578
600
  fromLocationId: entry.fromLocationId,
@@ -690,8 +712,13 @@ var StorageMigrationCoordinator = class {
690
712
  await this.setPhase(job, "repointing");
691
713
  }
692
714
  if (job.phase === "repointing") {
693
- const targets = new Map(job.moves.filter((move) => move.freezeSource !== true).map((move) => [move.storageClass, move.toLocationId]));
694
- if (targets.size > 0) await this.deps.locations.setDefaultLocations(targets);
715
+ const targets = /* @__PURE__ */ new Map();
716
+ for (const move of job.moves) {
717
+ if (move.freezeSource === true) continue;
718
+ targets.set(move.toLocationId, true);
719
+ targets.set(move.fromLocationId, false);
720
+ }
721
+ if (targets.size > 0) await this.deps.locations.setWriteTargets(targets);
695
722
  job.repointed = true;
696
723
  await this.setPhase(job, "refreshing");
697
724
  }
@@ -890,21 +917,18 @@ var StorageMigrationCoordinator = class {
890
917
  for (const move of job.moves) if (move.state !== "done") throw new Error(`${move.storageClass} move did not complete verification`);
891
918
  }
892
919
  /**
893
- * After a from→to copy whose source was NOT the class default: stop new
894
- * writes to `from`. The default is unchanged (that is the whole point of
895
- * naming a non-default source). A location that is already disabled is
920
+ * After a from→to copy whose source was NOT a write target: stop new writes
921
+ * to `from`. The rest of the write set is unchanged (that is the whole point
922
+ * of naming a source outside it). A location that is already disabled is
896
923
  * left alone.
897
924
  */
898
925
  freezeDrainedSources(job) {
899
926
  for (const move of job.moves) {
900
927
  if (move.freezeSource !== true) continue;
901
928
  const loc = this.deps.locations.getLocationById(move.fromLocationId);
902
- if (!loc || loc.enabled === false) continue;
929
+ if (!loc || !require_dist.mayWriteToLocation(loc)) continue;
903
930
  const { createdAt: _c, updatedAt: _u, capacity: _cap, ...rest } = loc;
904
- this.deps.locations.upsertLocation({
905
- ...rest,
906
- enabled: false
907
- });
931
+ this.deps.locations.upsertLocation(require_dist.withLocationMode(rest, "readonly"));
908
932
  }
909
933
  }
910
934
  /**
@@ -1175,6 +1199,136 @@ function createDataStoreDispatch(getEngines, getRemote) {
1175
1199
  };
1176
1200
  }
1177
1201
  //#endregion
1202
+ //#region src/builtins/storage-orchestrator/drain-state-store.ts
1203
+ /**
1204
+ * @durable class=config owner=storage-orchestrator
1205
+ * write="one row per location the operator has switched to `drain`, rewritten on every pressure sweep (60 s) while it drains"
1206
+ * retention="the row is deleted the moment the location leaves `drain` mode. Bounded by the number of disks a human is retiring at once."
1207
+ */
1208
+ var STORAGE_DRAIN_STATE = "storage_drain_state";
1209
+ var STORAGE_DRAIN_STATE_COLUMNS = [
1210
+ {
1211
+ name: "location_id",
1212
+ type: "TEXT",
1213
+ primaryKey: true
1214
+ },
1215
+ {
1216
+ name: "started_at_ms",
1217
+ type: "INTEGER",
1218
+ notNull: true
1219
+ },
1220
+ {
1221
+ name: "start_bytes",
1222
+ type: "INTEGER",
1223
+ notNull: true
1224
+ },
1225
+ {
1226
+ name: "cap_bytes",
1227
+ type: "INTEGER",
1228
+ notNull: true
1229
+ },
1230
+ {
1231
+ name: "last_peer_bytes",
1232
+ type: "INTEGER"
1233
+ },
1234
+ {
1235
+ name: "last_sweep_ms",
1236
+ type: "INTEGER",
1237
+ notNull: true
1238
+ },
1239
+ {
1240
+ name: "observed_growth_bytes",
1241
+ type: "INTEGER",
1242
+ notNull: true,
1243
+ defaultValue: 0
1244
+ },
1245
+ {
1246
+ name: "observed_elapsed_ms",
1247
+ type: "INTEGER",
1248
+ notNull: true,
1249
+ defaultValue: 0
1250
+ }
1251
+ ];
1252
+ /** A finite number off a persisted row, or the documented fallback. A row
1253
+ * written by a hand-edit must never become a cap. */
1254
+ function num(raw, fallback) {
1255
+ return typeof raw === "number" && Number.isFinite(raw) ? raw : fallback;
1256
+ }
1257
+ function mapRow(row) {
1258
+ const locationId = String(row["location_id"] ?? "");
1259
+ if (!locationId) return null;
1260
+ const lastPeerRaw = row["last_peer_bytes"];
1261
+ return {
1262
+ locationId,
1263
+ startedAtMs: num(row["started_at_ms"], 0),
1264
+ startBytes: num(row["start_bytes"], 0),
1265
+ capBytes: num(row["cap_bytes"], 0),
1266
+ lastPeerBytes: typeof lastPeerRaw === "number" && Number.isFinite(lastPeerRaw) ? lastPeerRaw : null,
1267
+ lastSweepMs: num(row["last_sweep_ms"], 0),
1268
+ observedGrowthBytes: num(row["observed_growth_bytes"], 0),
1269
+ observedElapsedMs: num(row["observed_elapsed_ms"], 0)
1270
+ };
1271
+ }
1272
+ function mapState(state) {
1273
+ return {
1274
+ location_id: state.locationId,
1275
+ started_at_ms: state.startedAtMs,
1276
+ start_bytes: state.startBytes,
1277
+ cap_bytes: state.capBytes,
1278
+ last_peer_bytes: state.lastPeerBytes,
1279
+ last_sweep_ms: state.lastSweepMs,
1280
+ observed_growth_bytes: state.observedGrowthBytes,
1281
+ observed_elapsed_ms: state.observedElapsedMs
1282
+ };
1283
+ }
1284
+ /** {@link DrainStateStore} on the `settings-store` collection surface. */
1285
+ var SqliteDrainStateStore = class {
1286
+ store;
1287
+ declared = false;
1288
+ constructor(store) {
1289
+ this.store = store;
1290
+ }
1291
+ async ensureDeclared() {
1292
+ if (this.declared) return;
1293
+ await this.store.declareCollection({
1294
+ collection: STORAGE_DRAIN_STATE,
1295
+ columns: STORAGE_DRAIN_STATE_COLUMNS
1296
+ });
1297
+ this.declared = true;
1298
+ }
1299
+ async load() {
1300
+ await this.ensureDeclared();
1301
+ const rows = await this.store.query({ collection: STORAGE_DRAIN_STATE });
1302
+ const out = [];
1303
+ for (const row of rows) {
1304
+ const mapped = mapRow(row.data);
1305
+ if (mapped) out.push(mapped);
1306
+ }
1307
+ return out;
1308
+ }
1309
+ async save(state) {
1310
+ await this.ensureDeclared();
1311
+ await this.store.deleteWhere({
1312
+ collection: STORAGE_DRAIN_STATE,
1313
+ filter: { where: { location_id: state.locationId } }
1314
+ });
1315
+ await this.store.insert({
1316
+ collection: STORAGE_DRAIN_STATE,
1317
+ record: {
1318
+ id: state.locationId,
1319
+ data: mapState(state)
1320
+ }
1321
+ });
1322
+ }
1323
+ async clear(locationId) {
1324
+ await this.ensureDeclared();
1325
+ await this.store.deleteWhere({
1326
+ collection: STORAGE_DRAIN_STATE,
1327
+ filter: { where: { location_id: locationId } }
1328
+ });
1329
+ }
1330
+ };
1331
+ //#endregion
1178
1332
  //#region src/builtins/storage-orchestrator/hub-settings-forward.ts
1179
1333
  /**
1180
1334
  * The data door with the HUB behind it — an agent's `settings-store` provider.
@@ -1386,6 +1540,139 @@ async function probeLocalDirectoryOccupancy(input) {
1386
1540
  return "empty";
1387
1541
  }
1388
1542
  //#endregion
1543
+ //#region src/builtins/storage-orchestrator/location-occupancy-slice.ts
1544
+ /** One minute: the same order as the pressure sweep, and far longer than an
1545
+ * admin-UI repaint. */
1546
+ var DEFAULT_STALE_AFTER_MS = 6e4;
1547
+ var LocationOccupancySlice = class {
1548
+ deps;
1549
+ slice = /* @__PURE__ */ new Map();
1550
+ refreshing = null;
1551
+ lastRefreshMs = 0;
1552
+ constructor(deps) {
1553
+ this.deps = deps;
1554
+ }
1555
+ /**
1556
+ * What the last completed refresh saw for this location, or `undefined` when
1557
+ * nothing has reported for it. There is deliberately no fallback: unknown is
1558
+ * the truth about a location nobody owns bytes on.
1559
+ */
1560
+ ownedFor(locationId) {
1561
+ return this.slice.get(locationId);
1562
+ }
1563
+ /**
1564
+ * Serve-and-revalidate. Called by every read: it returns immediately, and
1565
+ * kicks a background refresh when the slice is older than the window. A read
1566
+ * never awaits, and never joins a refresh already in flight (D224).
1567
+ */
1568
+ serveAndRevalidate() {
1569
+ if (this.refreshing !== null) return;
1570
+ const staleAfter = this.deps.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
1571
+ if (this.lastRefreshMs !== 0 && this.deps.now() - this.lastRefreshMs < staleAfter) return;
1572
+ this.refresh();
1573
+ }
1574
+ /**
1575
+ * Ask every provider once and replace the slice.
1576
+ *
1577
+ * A provider that throws contributes NOTHING and is logged — it does not
1578
+ * contribute a zero, and it does not take the other providers' answers down
1579
+ * with it. A location that ends up with no contribution at all simply stays
1580
+ * out of the slice, which is what makes it read back as unknown.
1581
+ */
1582
+ async refresh() {
1583
+ if (this.refreshing !== null) return this.refreshing;
1584
+ this.refreshing = this.runRefresh().finally(() => {
1585
+ this.refreshing = null;
1586
+ });
1587
+ return this.refreshing;
1588
+ }
1589
+ async runRefresh() {
1590
+ const locationIds = [...this.deps.locationIds()];
1591
+ if (locationIds.length === 0) {
1592
+ this.slice = /* @__PURE__ */ new Map();
1593
+ this.lastRefreshMs = this.deps.now();
1594
+ return;
1595
+ }
1596
+ const merged = /* @__PURE__ */ new Map();
1597
+ for (const provider of this.deps.getProviders()) {
1598
+ let reports;
1599
+ try {
1600
+ reports = await provider.getOccupancy({ locationIds });
1601
+ } catch (err) {
1602
+ this.deps.logger.warn("storage-orchestrator: a storage-occupancy provider failed — its bytes are MISSING from this refresh, not counted as zero", { meta: { error: err instanceof Error ? err.message : String(err) } });
1603
+ continue;
1604
+ }
1605
+ for (const report of reports) {
1606
+ const previous = merged.get(report.locationId);
1607
+ merged.set(report.locationId, {
1608
+ bytes: (previous?.bytes ?? 0) + report.ownedBytes,
1609
+ measuredAtMs: previous === void 0 ? report.measuredAtMs : Math.min(previous.measuredAtMs, report.measuredAtMs)
1610
+ });
1611
+ }
1612
+ }
1613
+ this.slice = merged;
1614
+ this.lastRefreshMs = this.deps.now();
1615
+ }
1616
+ };
1617
+ //#endregion
1618
+ //#region src/builtins/storage-orchestrator/location-protection.ts
1619
+ /**
1620
+ * The protection criterion, in the two units an operator thinks in.
1621
+ *
1622
+ * The disk-pressure guard has always spoken ONE language — a free-space
1623
+ * PERCENTAGE. An operator does not: on a 20 TB array "10%" is 2 TB of headroom
1624
+ * nobody asked for, and on a 500 GB SSD it is 50 GB. So a location may instead
1625
+ * declare `config.minFreeGb`, and this module converts it to the percentage the
1626
+ * guard already speaks using the volume total the orchestrator can already
1627
+ * stat.
1628
+ *
1629
+ * `minFreeGb` WINS when both are present, and the redundancy is logged — two
1630
+ * knobs that disagree is the shape D62 forbids, and the one the operator set
1631
+ * most recently is not knowable here. `maxUsedGb` stays in the model but stops
1632
+ * being an operator knob: it is the internal lever the drain ratchet drives
1633
+ * (D386).
1634
+ *
1635
+ * Pure. Every input is a parameter.
1636
+ */
1637
+ /** A finite, non-negative number off the untyped config blob, or `null`.
1638
+ * Garbage must never become a capacity limit. */
1639
+ function positiveNumber(raw) {
1640
+ if (typeof raw !== "number" || !Number.isFinite(raw) || raw < 0) return null;
1641
+ return raw;
1642
+ }
1643
+ /**
1644
+ * The free-space percentage the guard enforces for one location.
1645
+ *
1646
+ * Order:
1647
+ * 1. `config.minFreeGb`, converted against the volume total — the operator's
1648
+ * criterion in the unit they set it in.
1649
+ * 2. `config.minFreePercent` — the legacy per-location override, and the
1650
+ * fallback when `minFreeGb` cannot be converted (no volume total: a
1651
+ * remote-node or unstattable location). Falling back is the right
1652
+ * direction — the alternative is a location with no guard at all.
1653
+ * 3. the provider's declared default.
1654
+ */
1655
+ function resolveMinFreePercent(config, providerDefaultPercent, capacity, notice) {
1656
+ const minFreeGb = positiveNumber(config["minFreeGb"]);
1657
+ const minFreePercent = positiveNumber(config["minFreePercent"]);
1658
+ const fallback = minFreePercent ?? providerDefaultPercent;
1659
+ if (minFreeGb === null) return fallback;
1660
+ if (minFreePercent !== null) notice("storage-orchestrator: location declares BOTH minFreeGb and minFreePercent — minFreeGb wins and minFreePercent is ignored. Remove one.", {
1661
+ minFreeGb,
1662
+ minFreePercent
1663
+ });
1664
+ const totalBytes = capacity?.totalBytes ?? 0;
1665
+ if (totalBytes <= 0) {
1666
+ notice("storage-orchestrator: minFreeGb cannot be converted — the volume total is unknown (remote node, or an unstattable path). Falling back to the percentage guard.", {
1667
+ minFreeGb,
1668
+ fallbackPercent: fallback
1669
+ });
1670
+ return fallback;
1671
+ }
1672
+ const percent = require_dist.gbToBytes(minFreeGb) / totalBytes * 100;
1673
+ return Math.min(100, percent);
1674
+ }
1675
+ //#endregion
1389
1676
  //#region src/builtins/storage-orchestrator/location-secrets.ts
1390
1677
  /**
1391
1678
  * The config keys the ORCHESTRATOR itself owns, as opposed to the ones a
@@ -1518,8 +1805,13 @@ var FOOTAGE_TYPES = new Set(["recordings", "recordingsLow"]);
1518
1805
  function isTerminal(job) {
1519
1806
  return job.phase === "done" || job.phase === "failed" || job.phase === "cancelled";
1520
1807
  }
1808
+ /**
1809
+ * A location whose footage the system will not remove on its own — eviction
1810
+ * policy `never` (D385): `readonly` and `disabled`. NOT `drain`, which is
1811
+ * shrinking on purpose and whose leftovers the ratchet is already removing.
1812
+ */
1521
1813
  function footageFrozen(location) {
1522
- return location.enabled === false || location.config["readOnly"] === true;
1814
+ return require_dist.evictionPolicyOfLocation(location) === "never";
1523
1815
  }
1524
1816
  /**
1525
1817
  * One operator-facing cleanup of leftover analytics rows/blobs, optional
@@ -1680,6 +1972,78 @@ var StorageCleanupCoordinator = class {
1680
1972
  }
1681
1973
  };
1682
1974
  //#endregion
1975
+ //#region src/builtins/storage-orchestrator/location-root-collision.ts
1976
+ function basePathOf(row) {
1977
+ const raw = row.config["basePath"];
1978
+ return typeof raw === "string" && raw.length > 0 ? raw : null;
1979
+ }
1980
+ /** Both rows on one node? Absent `nodeId` means the hub (the seed writes
1981
+ * node-blind rows), so the comparison normalises rather than skipping. */
1982
+ function sameNode(a, b) {
1983
+ return (a.nodeId ?? "hub") === (b.nodeId ?? "hub");
1984
+ }
1985
+ /**
1986
+ * Does `next` collide with anything already stored?
1987
+ *
1988
+ * `existing` is every other location; the row being EDITED is excluded by id,
1989
+ * so re-saving a location without moving its root never refuses itself.
1990
+ */
1991
+ function findRootCollision(next, existing, resolveRoot, storedSelf) {
1992
+ if (storedSelf !== void 0 && basePathOf(storedSelf) === basePathOf(next)) return { outcome: "clear" };
1993
+ const basePath = basePathOf(next);
1994
+ if (basePath === null) return { outcome: "clear" };
1995
+ const root = resolveRoot(basePath);
1996
+ if (!root.resolved) return {
1997
+ outcome: "unknown",
1998
+ unknown: {
1999
+ reason: "unresolvable",
2000
+ detail: `could not resolve "${basePath}" on this node`
2001
+ }
2002
+ };
2003
+ for (const other of existing) {
2004
+ if (other.id === next.id) continue;
2005
+ if (other.type !== next.type) continue;
2006
+ if (!sameNode(other, next)) continue;
2007
+ const otherPath = basePathOf(other);
2008
+ if (otherPath === null) continue;
2009
+ const otherRoot = resolveRoot(otherPath);
2010
+ if (!otherRoot.resolved) continue;
2011
+ if (root.key === otherRoot.key) return {
2012
+ outcome: "collides",
2013
+ collision: {
2014
+ otherId: other.id,
2015
+ type: next.type,
2016
+ sharedRoot: root.key,
2017
+ kind: "equal"
2018
+ }
2019
+ };
2020
+ if ((0, _camstack_types_node.containsOrEquals)(otherRoot.key, root.key)) return {
2021
+ outcome: "collides",
2022
+ collision: {
2023
+ otherId: other.id,
2024
+ type: next.type,
2025
+ sharedRoot: otherRoot.key,
2026
+ kind: "nested"
2027
+ }
2028
+ };
2029
+ if ((0, _camstack_types_node.containsOrEquals)(root.key, otherRoot.key)) return {
2030
+ outcome: "collides",
2031
+ collision: {
2032
+ otherId: other.id,
2033
+ type: next.type,
2034
+ sharedRoot: root.key,
2035
+ kind: "nested"
2036
+ }
2037
+ };
2038
+ }
2039
+ return { outcome: "clear" };
2040
+ }
2041
+ /** The operator-facing refusal. Names the location it collides with and the
2042
+ * shared root — a refusal that says only "conflict" makes the operator hunt. */
2043
+ function rootCollisionMessage(id, collision) {
2044
+ return `Cannot save "${id}": it is ${collision.kind === "equal" ? `the same directory as "${collision.otherId}"` : `nested with "${collision.otherId}" (one is inside the other's tree)`}, and both are type "${collision.type}". Two locations of one type on the same disk share a class subtree, so every per-location figure double-counts and a deletion under one is a deletion under the other. Shared root: ${collision.sharedRoot}. Two locations of DIFFERENT types may share a root — that is how recordings and recordingsLow are configured by default.`;
2045
+ }
2046
+ //#endregion
1683
2047
  //#region src/builtins/storage-orchestrator/storage-orchestrator.service.ts
1684
2048
  /**
1685
2049
  * Routing core for the `storage-orchestrator` builtin.
@@ -1837,33 +2201,77 @@ var StorageOrchestratorService = class {
1837
2201
  backfilled: backfill.length,
1838
2202
  total: this.locations.size
1839
2203
  } });
1840
- this.reportDisabledDefaults();
2204
+ this.migrateOntoTheWriteSet(rows);
1841
2205
  }
1842
2206
  /**
1843
- * Shout about a persisted row that is BOTH the type default and disabled.
2207
+ * ONE explicit migration onto the four-state model (D385), run once per boot
2208
+ * against the rows that came off disk. Idempotent: the second boot finds
2209
+ * nothing to do.
1844
2210
  *
1845
- * `upsertLocation` can now produce this (enabled is the write set; `isDefault`
1846
- * is store residue). Hydrate also writes rows straight into the map, so a
1847
- * hand-edited store or a row written by another build can too. It matters
1848
- * because every bare-type ref still resolves through {@link getDefaultLocation}
1849
- * until Stage 5 of the enabled-write-set design: on such a row
1850
- * `storage.write({ location: 'eventMedia' })` and the migration coordinator's
1851
- * drain destination both point at a disk the operator turned off.
2211
+ * Two steps, in order, and each one is a WRITE — the point is that "absent"
2212
+ * stops existing rather than being re-interpreted on every read forever:
1852
2213
  *
1853
- * Reported, never repaired: flipping an operator's flag back on at boot would
1854
- * be the system overruling the switch instead of obeying it. The coordinator
1855
- * refuses the drain (`storage-migration-coordinator.drain`); the bare-ref read
1856
- * path is deliberately left working, because narrowing a READ is a worse bug
1857
- * than the one this line reports.
2214
+ * 1. A row with no `mode` is stamped with the mode its legacy flags imply
2215
+ * (`legacyModeOf`): `config.readOnly === true` → `readonly` and the
2216
+ * config key is DELETED; `enabled === false` → `readonly` (the
2217
+ * CONSERVATIVE direction — a state change must never start deleting
2218
+ * footage on its own); everything else → `active`. `withLocationMode`
2219
+ * derives `enabled` from the mode, so the two can never disagree, and
2220
+ * this subsumes the D383 pass that stamped flagless rows `enabled: true`.
2221
+ * 2. A type left with NO write target — only reachable from a hand-edited
2222
+ * store or a row an older build wrote — gets one made `active`, chosen as
2223
+ * the row the RETIRED default flag named, else the `:default`-slugged
2224
+ * row, else the lexicographically first. Loudly: a class with no write
2225
+ * target records nowhere, and refusing to repair it here would leave the
2226
+ * hub silently unable to write with no operator action pending.
2227
+ *
2228
+ * An operator's explicit `readonly`/`drain`/`disabled` is never reversed by
2229
+ * step 1 — only step 2 touches it, and only when the alternative is a class
2230
+ * that cannot write at all.
2231
+ *
2232
+ * On the live hub every location is enabled with no `readOnly`, so step 1 is
2233
+ * a no-op that stamps `active` and nothing else changes.
1858
2234
  */
1859
- reportDisabledDefaults() {
2235
+ migrateOntoTheWriteSet(rows) {
2236
+ const legacyDefaultOfType = /* @__PURE__ */ new Map();
2237
+ for (const row of rows) if (row.isDefault === true) legacyDefaultOfType.set(row.type, row.id);
2238
+ const stamped = [];
1860
2239
  for (const loc of this.locations.values()) {
1861
- if (!loc.isDefault || loc.enabled !== false) continue;
1862
- this.logger.error("storage-orchestrator: the type DEFAULT is disabled — every bare-type ref for this type resolves to a location the operator turned off. Enable it, or make another location the default.", { meta: {
1863
- id: loc.id,
1864
- type: loc.type
2240
+ if (loc.mode !== void 0) continue;
2241
+ this.persistMigrated(require_dist.withLocationMode(loc, require_dist.legacyModeOf(loc) ?? "active"));
2242
+ stamped.push(loc.id);
2243
+ }
2244
+ const repaired = [];
2245
+ for (const type of new Set([...this.locations.values()].map((l) => l.type))) {
2246
+ const ofType = [...this.locations.values()].filter((l) => l.type === type);
2247
+ if (ofType.some((l) => require_dist.isLocationEnabled(l))) continue;
2248
+ const legacyDefault = legacyDefaultOfType.get(type);
2249
+ const pick = ofType.find((l) => l.id === legacyDefault) ?? ofType.find((l) => l.id === `${type}:default`) ?? ofType.toSorted((a, b) => a.id.localeCompare(b.id))[0];
2250
+ if (!pick) continue;
2251
+ this.persistMigrated(require_dist.withLocationMode(pick, "active"));
2252
+ repaired.push(pick.id);
2253
+ }
2254
+ if (stamped.length > 0 || repaired.length > 0) this.logger.info("storage-orchestrator: migrated locations onto the state model", { meta: {
2255
+ stamped,
2256
+ repaired
2257
+ } });
2258
+ for (const id of repaired) this.logger.warn("storage-orchestrator: a storage type had NO enabled location — re-enabled one so it can write. Every other location of that type stays read-only until you enable it.", { meta: { id } });
2259
+ }
2260
+ /** Apply one migrated row in memory and mirror it to the store. */
2261
+ persistMigrated(next) {
2262
+ const withStamp = {
2263
+ ...next,
2264
+ updatedAt: Date.now()
2265
+ };
2266
+ this.locations.set(withStamp.id, withStamp);
2267
+ const store = this.locationStore;
2268
+ if (!store) return;
2269
+ store.upsert(withStamp).catch((err) => {
2270
+ this.logger.error("storage-orchestrator: state-model migration persist failed", { meta: {
2271
+ id: withStamp.id,
2272
+ error: err instanceof Error ? err.message : String(err)
1865
2273
  } });
1866
- }
2274
+ });
1867
2275
  }
1868
2276
  /**
1869
2277
  * Inject the declaration-driven cardinality source. Called once by the
@@ -1918,7 +2326,7 @@ var StorageOrchestratorService = class {
1918
2326
  const rows = await this.locationStore.loadAll();
1919
2327
  for (const loc of rows) this.locations.set(loc.id, loc);
1920
2328
  this.logger.info("storage-orchestrator: hydrated locations from store", { meta: { loaded: this.locations.size } });
1921
- this.reportDisabledDefaults();
2329
+ this.migrateOntoTheWriteSet(rows);
1922
2330
  }
1923
2331
  /**
1924
2332
  * Boot backfill (SP1): stamp `nodeId` on every persisted node-local
@@ -1967,40 +2375,50 @@ var StorageOrchestratorService = class {
1967
2375
  const all = [...this.locations.values()];
1968
2376
  return filter?.type ? all.filter((l) => l.type === filter.type) : all;
1969
2377
  }
1970
- getDefaultLocation(type) {
1971
- for (const loc of this.locations.values()) if (loc.type === type && loc.isDefault) return loc;
1972
- return null;
2378
+ /**
2379
+ * The WRITE SET of a type: every enabled location of it, in stable id order.
2380
+ * This replaced `getDefaultLocation` (D383) — a type does not have one write
2381
+ * target any more, it has a pool, and every consumer that must pick one picks
2382
+ * from here by its own rule (the recorder by headroom, a drain by an operator
2383
+ * -named destination).
2384
+ */
2385
+ writeTargets(type) {
2386
+ return [...this.locations.values()].filter((loc) => loc.type === type && require_dist.isLocationEnabled(loc)).toSorted((a, b) => a.id.localeCompare(b.id));
1973
2387
  }
1974
2388
  /**
1975
- * Repoint several logical defaults as one in-memory commit. The migration
1976
- * coordinator persists its `repointing` phase before calling this method, so
1977
- * a process crash during the backing-store writes is recovered by replaying
1978
- * the same idempotent target set before anyone resumes writers.
2389
+ * Change the WRITE SET as one compensated commit: each named location is
2390
+ * enabled (it joins the pool) or disabled (it becomes read-only — still read,
2391
+ * still played back, still age-swept, still drained).
2392
+ *
2393
+ * This is the cutover verb, and its only caller is the migration coordinator,
2394
+ * which persists its `repointing` phase BEFORE calling so a crash mid-write
2395
+ * is recovered by replaying the same idempotent set before writers resume.
2396
+ *
2397
+ * It moves ONLY the locations it is given: a cutover from A to C on a class
2398
+ * that also writes to B leaves B in the pool, because the operator's pool is
2399
+ * the authority and a migration is not a licence to shrink it. The commit is
2400
+ * refused when it would leave a write-serving type with nothing enabled —
2401
+ * the same refusal `upsertLocation` applies, judged on the RESULT so an
2402
+ * enable and a disable in one commit are weighed together.
1979
2403
  */
1980
- async setDefaultLocations(targetByType) {
1981
- if (targetByType.size === 0) return;
2404
+ async setWriteTargets(enabledByLocationId) {
2405
+ if (enabledByLocationId.size === 0) return;
1982
2406
  const now = Date.now();
1983
2407
  const nextById = new Map(this.locations);
1984
2408
  const touched = /* @__PURE__ */ new Map();
1985
- for (const [type, targetId] of targetByType) {
1986
- const target = nextById.get(targetId);
1987
- if (!target) throw new Error(`Storage location "${targetId}" not found`);
1988
- if (target.type !== type) throw new Error(`Storage location "${targetId}" is type "${target.type}", expected "${type}"`);
1989
- for (const [id, location] of nextById) {
1990
- if (location.type !== type) continue;
1991
- const isDefault = id === targetId;
1992
- const enable = isDefault && location.enabled === false;
1993
- if (location.isDefault === isDefault && !enable) continue;
1994
- const updated = {
1995
- ...location,
1996
- isDefault,
1997
- ...enable ? { enabled: true } : {},
1998
- updatedAt: now
1999
- };
2000
- nextById.set(id, updated);
2001
- touched.set(id, updated);
2002
- }
2409
+ for (const [id, enabled] of enabledByLocationId) {
2410
+ const location = nextById.get(id);
2411
+ if (!location) throw new Error(`Storage location "${id}" not found`);
2412
+ if (require_dist.isLocationEnabled(location) === enabled) continue;
2413
+ const updated = require_dist.withLocationMode({
2414
+ ...location,
2415
+ updatedAt: now
2416
+ }, enabled ? "active" : "readonly");
2417
+ nextById.set(id, updated);
2418
+ touched.set(id, updated);
2003
2419
  }
2420
+ if (touched.size === 0) return;
2421
+ refuseTypeWithoutWriteTarget(touched.values(), nextById.values());
2004
2422
  if (this.locationStore) {
2005
2423
  const previous = /* @__PURE__ */ new Map();
2006
2424
  for (const id of touched.keys()) {
@@ -2020,13 +2438,10 @@ var StorageOrchestratorService = class {
2020
2438
  for (const [id, location] of nextById) this.locations.set(id, location);
2021
2439
  }
2022
2440
  /**
2023
- * Insert or update a location. If `isDefault: true`, atomically
2024
- * demotes any other default for the same type — operators always see
2025
- * exactly one default per type.
2441
+ * Insert or update a location.
2026
2442
  *
2027
- * When a persistence store is wired (Task 6), the new record AND any
2028
- * implicitly-demoted siblings are persisted before the in-memory map
2029
- * mutation returns. Persistence errors propagate to the caller.
2443
+ * When a persistence store is wired (Task 6), the record is mirrored to it;
2444
+ * the mirror is fire-and-forget and its errors reach the logger.
2030
2445
  */
2031
2446
  /**
2032
2447
  * Refuse a `(location kind, provider)` pair the kind cannot use.
@@ -2102,40 +2517,24 @@ var StorageOrchestratorService = class {
2102
2517
  ...input,
2103
2518
  nodeId: "hub"
2104
2519
  };
2105
- input = {
2106
- ...input,
2107
- ...resolveEnabled(input, existing, this.hasAnyLocationOfType(input.type))
2108
- };
2109
- const next = {
2520
+ this.refuseSameTypeRootCollision(input, existing);
2521
+ const next = require_dist.withLocationMode({
2110
2522
  ...input,
2111
2523
  createdAt: existing?.createdAt ?? now,
2112
2524
  updatedAt: now
2113
- };
2525
+ }, resolveUpsertMode(input, existing, this.hasAnyLocationOfType(input.type)));
2114
2526
  refuseLastWriteTargetDisabled(existing, next, this.locations.values());
2115
- const demoted = [];
2116
- if (next.isDefault) {
2117
- for (const [otherId, otherLoc] of this.locations) if (otherLoc.type === next.type && otherLoc.id !== next.id && otherLoc.isDefault) {
2118
- const updated = {
2119
- ...otherLoc,
2120
- isDefault: false,
2121
- updatedAt: now
2122
- };
2123
- this.locations.set(otherId, updated);
2124
- demoted.push(updated);
2125
- }
2126
- }
2127
2527
  this.locations.set(next.id, next);
2128
2528
  this.logger.debug("storage-orchestrator: upsertLocation", { meta: {
2129
2529
  id: next.id,
2130
2530
  type: next.type,
2131
2531
  providerId: next.providerId,
2132
- isDefault: next.isDefault
2532
+ mode: next.mode
2133
2533
  } });
2134
2534
  if (this.locationStore) {
2135
2535
  const store = this.locationStore;
2136
2536
  (async () => {
2137
2537
  try {
2138
- for (const d of demoted) await store.upsert(d);
2139
2538
  await store.upsert(next);
2140
2539
  } catch (err) {
2141
2540
  this.logger.error("storage-orchestrator: upsert persistence failed", { meta: {
@@ -2150,8 +2549,7 @@ var StorageOrchestratorService = class {
2150
2549
  /**
2151
2550
  * Remove a location. Refuses the last location of a type and the last
2152
2551
  * enabled location of a type. `isSystem` is informational — a frozen
2153
- * recordings `:default` is deletable once a sibling is live. Deleting
2154
- * the flagged default promotes an enabled sibling. Occupancy still
2552
+ * recordings `:default` is deletable once a sibling is live. Occupancy still
2155
2553
  * refuses unless `force` is set; force never overrides uniqueness /
2156
2554
  * last-enabled.
2157
2555
  */
@@ -2160,10 +2558,8 @@ var StorageOrchestratorService = class {
2160
2558
  if (!loc) throw new Error(`Storage location "${id}" not found`);
2161
2559
  const ofType = [...this.locations.values()].filter((l) => l.type === loc.type);
2162
2560
  if (ofType.length <= 1) throw new Error(`Cannot delete "${id}" — it is the only location for type "${loc.type}"`);
2163
- const enabledOfType = ofType.filter((l) => l.enabled !== false);
2164
- if (loc.enabled !== false && enabledOfType.length <= 1) throw new Error(`Cannot delete "${id}" — it is the only enabled location for type "${loc.type}"`);
2165
- const successor = loc.isDefault === true ? ofType.find((l) => l.id !== id && l.enabled !== false) : void 0;
2166
- if (loc.isDefault === true && successor === void 0) throw new Error(`Cannot delete "${id}" — it is the only enabled location for type "${loc.type}"`);
2561
+ const enabledOfType = ofType.filter((l) => require_dist.mayWriteToLocation(l));
2562
+ if (require_dist.mayWriteToLocation(loc) && enabledOfType.length <= 1) throw new Error(`Cannot delete "${id}" — it is the only enabled location for type "${loc.type}"`);
2167
2563
  const occupancy = await this.probeOccupancy(loc);
2168
2564
  if (occupancy !== "empty") {
2169
2565
  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`);
@@ -2173,21 +2569,6 @@ var StorageOrchestratorService = class {
2173
2569
  occupancy
2174
2570
  } });
2175
2571
  }
2176
- if (successor !== void 0 && successor.isDefault !== true) {
2177
- const now = Date.now();
2178
- const promoted = {
2179
- ...successor,
2180
- isDefault: true,
2181
- updatedAt: now
2182
- };
2183
- this.locations.set(successor.id, promoted);
2184
- if (this.locationStore) this.locationStore.upsert(promoted).catch((err) => {
2185
- this.logger.error("storage-orchestrator: default promotion persist failed", { meta: {
2186
- id: successor.id,
2187
- error: err instanceof Error ? err.message : String(err)
2188
- } });
2189
- });
2190
- }
2191
2572
  this.locations.delete(id);
2192
2573
  if (this.locationStore) this.locationStore.delete(id).catch((err) => {
2193
2574
  this.logger.error("storage-orchestrator: delete persistence failed", { meta: {
@@ -2266,9 +2647,17 @@ var StorageOrchestratorService = class {
2266
2647
  if (!loc) throw new Error(`Storage location "${ref}" not found`);
2267
2648
  return loc;
2268
2649
  }
2269
- const def = this.getDefaultLocation(ref);
2270
- if (!def) throw new Error(`No default storage location configured for type "${ref}" — register one or pass a fully-qualified id (\`<type>:<slug>\`)`);
2271
- return def;
2650
+ const ofType = [...this.locations.values()].filter((loc) => loc.type === ref);
2651
+ if (ofType.length === 1) return ofType[0];
2652
+ if (ofType.length === 0) throw new Error(`No storage location configured for type "${ref}" — register one or pass a fully-qualified id (\`<type>:<slug>\`)`);
2653
+ const slugged = ofType.find((loc) => loc.id === `${ref}:default`);
2654
+ if (!slugged) throw new Error(`Storage type "${ref}" has ${String(ofType.length)} locations (${ofType.map((loc) => loc.id).toSorted().join(", ")}) — a bare type ref is ambiguous, pass a fully-qualified id (\`<type>:<slug>\`)`);
2655
+ this.logger.warn("storage-orchestrator: DEPRECATED bare-type ref on a type with several locations — resolved to the legacy `:default`-slugged row. Pass a fully-qualified location id.", { meta: {
2656
+ ref,
2657
+ resolved: slugged.id,
2658
+ candidates: ofType.length
2659
+ } });
2660
+ return slugged;
2272
2661
  }
2273
2662
  /**
2274
2663
  * Find the storage-provider that backs a given location — the entry point
@@ -2372,9 +2761,11 @@ var StorageOrchestratorService = class {
2372
2761
  * declarations on a populated map is a no-op (each declared `id`
2373
2762
  * already resolves to an existing `<id>:default`).
2374
2763
  *
2375
- * Convention: the bare-type ref `'recordings'` resolves via
2376
- * `getDefaultLocation('recordings')` (walks `isDefault === true`); the
2377
- * actual stored `id` is `'recordings:default'` so it satisfies the
2764
+ * The seeded row is born ENABLED — a fresh install must be able to write the
2765
+ * moment it boots, and `enabled` is the only write switch there is (D383).
2766
+ * The `:default` suffix is a NAME kept for idempotency across existing
2767
+ * installs, not a flag: the stored `id` is `'recordings:default'` so it
2768
+ * satisfies the
2378
2769
  * `^[a-z][a-z0-9-]*:[a-z0-9-]+$` regex on `StorageLocationSchema.id`.
2379
2770
  *
2380
2771
  * `basePath` is the storage root (e.g. `<CAMSTACK_DATA>`). Each
@@ -2399,7 +2790,7 @@ var StorageOrchestratorService = class {
2399
2790
  displayName: d.displayName,
2400
2791
  providerId: input.providerId,
2401
2792
  config: { basePath: base },
2402
- isDefault: true,
2793
+ enabled: true,
2403
2794
  isSystem: false
2404
2795
  });
2405
2796
  added++;
@@ -2409,6 +2800,34 @@ var StorageOrchestratorService = class {
2409
2800
  basePath: input.basePath
2410
2801
  } });
2411
2802
  }
2803
+ /**
2804
+ * The same-type physical-root refusal (D389).
2805
+ *
2806
+ * An unresolvable root — another node's disk, a remote provider, a path that
2807
+ * does not exist yet — WARNS and lets the write through. Refusing on a read
2808
+ * that could not be taken blocks the operator on a doubt, and the recorder's
2809
+ * demotion still catches what slips past (D49's spirit).
2810
+ */
2811
+ refuseSameTypeRootCollision(input, existing) {
2812
+ const verdict = findRootCollision(input, this.locations.values(), this.resolveRoot, existing);
2813
+ if (verdict.outcome === "clear") return;
2814
+ if (verdict.outcome === "unknown") {
2815
+ this.logger.warn("storage-orchestrator: could not check this location for a same-type root collision — saving it anyway. A read that fails is not a refusal; the recorder demotes a same-root duplicate it later finds.", { meta: {
2816
+ id: input.id,
2817
+ type: input.type,
2818
+ ...verdict.unknown
2819
+ } });
2820
+ return;
2821
+ }
2822
+ throw new Error(rootCollisionMessage(input.id, verdict.collision));
2823
+ }
2824
+ /** Injectable so a test can drive symlinks and unresolvable paths without
2825
+ * touching a real filesystem. Production takes the shared derivation. */
2826
+ resolveRoot = (basePath) => (0, _camstack_types_node.physicalRootOf)(basePath);
2827
+ /** Test seam for {@link refuseSameTypeRootCollision}. */
2828
+ setRootResolver(resolveRoot) {
2829
+ this.resolveRoot = resolveRoot;
2830
+ }
2412
2831
  /** Internal: check whether any location of the given type exists. */
2413
2832
  hasAnyLocationOfType(type) {
2414
2833
  for (const loc of this.locations.values()) if (loc.type === type) return true;
@@ -2416,42 +2835,140 @@ var StorageOrchestratorService = class {
2416
2835
  }
2417
2836
  };
2418
2837
  /**
2419
- * Resolve `enabled` for one upsert — see the call site in `upsertLocation` for
2420
- * the ordered rules. Pure: every input it needs is a parameter.
2838
+ * Resolve the MODE one upsert lands on (D385). Pure: every input it needs is a
2839
+ * parameter. Ordered rules:
2840
+ *
2841
+ * 1. An explicit `mode` always wins — the operator named the state.
2842
+ * 2. An explicit `enabled` (the retired switch, still on the wire for one
2843
+ * release) means `active` / `readonly`. It can never select `drain` or
2844
+ * `disabled`: those are states only the new field can ask for, so an old
2845
+ * client cannot start erasing a disk or hide footage by accident.
2846
+ * 3. On an UPDATE, an omitted state preserves what is stored — a rename or a
2847
+ * path edit must never flip a live write target, and must never cancel a
2848
+ * drain in progress.
2849
+ * 4. On a CREATE, an omitted state is `readonly` — creating a disk must not
2850
+ * start writing to it — UNLESS it is the FIRST location of its type, where
2851
+ * there is nothing else that could serve.
2421
2852
  */
2422
- function resolveEnabled(input, existing, typeAlreadyHasLocation) {
2423
- const requested = input.enabled ?? existing?.enabled;
2424
- if (requested === void 0) {
2425
- if (existing) return {};
2426
- return { enabled: input.isDefault || !typeAlreadyHasLocation };
2853
+ function resolveUpsertMode(input, existing, typeAlreadyHasLocation) {
2854
+ if (input.mode !== void 0) {
2855
+ if (input.enabled === void 0 || input.enabled === require_dist.modeMayWrite(input.mode)) return input.mode;
2427
2856
  }
2428
- return { enabled: requested };
2857
+ if (input.enabled !== void 0) return input.enabled ? "active" : "readonly";
2858
+ if (existing) return require_dist.resolveLocationMode(existing);
2859
+ return typeAlreadyHasLocation ? "readonly" : "active";
2429
2860
  }
2430
2861
  /** Types that may have zero enabled locations. Backups with none is sad, not illegal. */
2431
2862
  var OPTIONAL_WRITE_TYPES = new Set(["backups"]);
2432
2863
  /**
2433
2864
  * Refuse an UPDATE that would leave a write-serving type with no enabled
2434
2865
  * location. Creates are exempt (an explicit `enabled: false` on the first
2435
- * location is already a named test). `isDefault` is not consulted — enabled
2436
- * is the write set.
2866
+ * location is already a named test). Enabled IS the write set.
2437
2867
  */
2438
2868
  function refuseLastWriteTargetDisabled(existing, next, locations) {
2439
2869
  if (OPTIONAL_WRITE_TYPES.has(next.type)) return;
2440
- if (next.enabled !== false) return;
2870
+ if (require_dist.mayWriteToLocation(next)) return;
2441
2871
  if (existing === void 0) return;
2442
- if (existing.enabled === false) return;
2872
+ if (!require_dist.mayWriteToLocation(existing)) return;
2443
2873
  for (const loc of locations) {
2444
2874
  if (loc.id === next.id || loc.type !== next.type) continue;
2445
- if (loc.enabled !== false) return;
2875
+ if (require_dist.mayWriteToLocation(loc)) return;
2446
2876
  }
2447
2877
  throw new Error(`Cannot disable "${next.id}": it is the last enabled location of type "${next.type}". Enable another location of this type first.`);
2448
2878
  }
2879
+ /**
2880
+ * The same refusal, judged on a whole COMMIT rather than one row: a
2881
+ * multi-location change (a cutover enables its destination and disables its
2882
+ * source in one go) must be weighed on its RESULT, or the disable half looks
2883
+ * like the last write target leaving while the enable half is already in the
2884
+ * same map.
2885
+ */
2886
+ function refuseTypeWithoutWriteTarget(touched, result) {
2887
+ const rows = [...result];
2888
+ for (const type of new Set([...touched].map((loc) => loc.type))) {
2889
+ if (OPTIONAL_WRITE_TYPES.has(type)) continue;
2890
+ const ofType = rows.filter((loc) => loc.type === type);
2891
+ if (ofType.length === 0) continue;
2892
+ if (ofType.some((loc) => require_dist.isLocationEnabled(loc))) continue;
2893
+ throw new Error(`Refusing the write-set change: it would leave type "${type}" with no enabled location. Enable one of ${ofType.map((loc) => loc.id).toSorted().join(", ")} first.`);
2894
+ }
2895
+ }
2896
+ //#endregion
2897
+ //#region src/builtins/storage-orchestrator/drain-ratchet.ts
2898
+ /** The state a drain starts from. No deletion happens on this sweep. */
2899
+ function beginDrain(observation) {
2900
+ return {
2901
+ locationId: observation.locationId,
2902
+ startedAtMs: observation.atMs,
2903
+ startBytes: observation.usedBytes,
2904
+ capBytes: observation.usedBytes,
2905
+ lastPeerBytes: observation.peerUsedBytes,
2906
+ lastSweepMs: observation.atMs,
2907
+ observedGrowthBytes: 0,
2908
+ observedElapsedMs: 0
2909
+ };
2910
+ }
2911
+ /**
2912
+ * Advance the ratchet by one sweep. `previous` is `null` on the first sweep of
2913
+ * a drain (and after a state the caller cleared), which only ARMS the ratchet:
2914
+ * the first growth can be measured no earlier than the second sweep, and a cap
2915
+ * derived from no measurement would be a rate the operator never asked for.
2916
+ */
2917
+ function stepDrain(previous, observation) {
2918
+ if (previous === null) return {
2919
+ state: beginDrain(observation),
2920
+ capBytes: null,
2921
+ drained: observation.usedBytes <= 0,
2922
+ growthBytes: null
2923
+ };
2924
+ const growthBytes = previous.lastPeerBytes !== null && observation.peerUsedBytes !== null ? observation.peerUsedBytes - previous.lastPeerBytes : null;
2925
+ const ceded = growthBytes !== null && growthBytes > 0 ? growthBytes : 0;
2926
+ const nextCap = Math.max(0, Math.min(previous.capBytes, observation.usedBytes) - ceded);
2927
+ const state = {
2928
+ ...previous,
2929
+ capBytes: nextCap,
2930
+ lastPeerBytes: observation.peerUsedBytes ?? previous.lastPeerBytes,
2931
+ lastSweepMs: observation.atMs,
2932
+ observedGrowthBytes: previous.observedGrowthBytes + ceded,
2933
+ observedElapsedMs: growthBytes === null ? previous.observedElapsedMs : previous.observedElapsedMs + Math.max(0, observation.atMs - previous.lastSweepMs)
2934
+ };
2935
+ if (observation.usedBytes <= 0) return {
2936
+ state: {
2937
+ ...state,
2938
+ capBytes: 0
2939
+ },
2940
+ capBytes: null,
2941
+ drained: true,
2942
+ growthBytes
2943
+ };
2944
+ return {
2945
+ state,
2946
+ capBytes: nextCap,
2947
+ drained: false,
2948
+ growthBytes
2949
+ };
2950
+ }
2951
+ /** Progress for a UI, derived from the persisted state and today's usage. */
2952
+ function drainProgressOf(state, usedBytes, nowMs) {
2953
+ const bytesRemaining = Math.max(0, usedBytes);
2954
+ const rateBytesPerMs = state.observedGrowthBytes > 0 && state.observedElapsedMs > 0 ? state.observedGrowthBytes / state.observedElapsedMs : null;
2955
+ return {
2956
+ locationId: state.locationId,
2957
+ startedAtMs: state.startedAtMs,
2958
+ startBytes: state.startBytes,
2959
+ bytesRemaining,
2960
+ drained: bytesRemaining <= 0,
2961
+ estimatedEmptyAtMs: rateBytesPerMs === null || bytesRemaining <= 0 ? null : nowMs + Math.ceil(bytesRemaining / rateBytesPerMs)
2962
+ };
2963
+ }
2449
2964
  //#endregion
2450
2965
  //#region src/builtins/storage-orchestrator/storage-pressure-manager.ts
2451
2966
  var DEFAULT_MAX_ROUNDS = 8;
2452
2967
  var DEFAULT_HYSTERESIS_PERCENT = 1;
2453
2968
  var StoragePressureManager = class {
2454
2969
  deps;
2970
+ /** Last sweep's drain progress, by location. Read-only surface for the UI. */
2971
+ drainProgress = /* @__PURE__ */ new Map();
2455
2972
  constructor(deps) {
2456
2973
  this.deps = deps;
2457
2974
  }
@@ -2493,6 +3010,73 @@ var StoragePressureManager = class {
2493
3010
  } });
2494
3011
  }
2495
3012
  }
3013
+ await this.sweepDrains();
3014
+ }
3015
+ /**
3016
+ * The drain pass (D386). A `drain` location is excluded from nothing — it is
3017
+ * given a monotonically descending effective usage cap and handed to the SAME
3018
+ * `relieveToUsageCap` machinery every other cap uses. There is no second
3019
+ * eviction path, and no operator-facing rate.
3020
+ */
3021
+ async sweepDrains() {
3022
+ const plan = await this.deps.getDrainPlan?.() ?? [];
3023
+ const store = this.deps.drainState;
3024
+ if (store === void 0) {
3025
+ if (plan.length > 0) this.deps.logger.warn("storage drain: locations are draining but no durable state store is wired — the ratchet stays inert rather than restarting from scratch every boot", { meta: { locationIds: plan.map((e) => e.locationId) } });
3026
+ this.drainProgress = /* @__PURE__ */ new Map();
3027
+ return;
3028
+ }
3029
+ const persisted = new Map((await store.load()).map((state) => [state.locationId, state]));
3030
+ const draining = new Set(plan.map((entry) => entry.locationId));
3031
+ for (const locationId of persisted.keys()) {
3032
+ if (draining.has(locationId)) continue;
3033
+ await store.clear(locationId);
3034
+ this.drainProgress.delete(locationId);
3035
+ this.deps.logger.info("storage drain: stopped — location is no longer draining", { meta: { locationId } });
3036
+ }
3037
+ const nowMs = Date.now();
3038
+ const progress = /* @__PURE__ */ new Map();
3039
+ for (const entry of plan) try {
3040
+ const usedBytes = await this.evictableUsageOf(entry.locationId);
3041
+ const peerUsedBytes = entry.activePeerLocationIds.length === 0 ? null : await this.evictableUsageOfAll(entry.activePeerLocationIds);
3042
+ const step = stepDrain(persisted.get(entry.locationId) ?? null, {
3043
+ locationId: entry.locationId,
3044
+ usedBytes,
3045
+ peerUsedBytes,
3046
+ atMs: nowMs
3047
+ });
3048
+ await store.save(step.state);
3049
+ this.deps.logger.info("storage drain: sweep", { meta: {
3050
+ locationId: entry.locationId,
3051
+ usedBytes,
3052
+ peerUsedBytes,
3053
+ growthBytes: step.growthBytes,
3054
+ capBytes: step.capBytes,
3055
+ drained: step.drained
3056
+ } });
3057
+ if (step.capBytes !== null) await this.relieveToUsageCap(entry.locationId, step.capBytes);
3058
+ progress.set(entry.locationId, drainProgressOf(step.state, await this.evictableUsageOf(entry.locationId), nowMs));
3059
+ } catch (err) {
3060
+ this.deps.logger.warn("storage drain: sweep failed for location", { meta: {
3061
+ locationId: entry.locationId,
3062
+ error: err instanceof Error ? err.message : String(err)
3063
+ } });
3064
+ }
3065
+ this.drainProgress = progress;
3066
+ }
3067
+ /** Progress for every draining location, as of the last sweep. The read a UI
3068
+ * asks for — it never triggers work of its own. */
3069
+ drainProgressSnapshot() {
3070
+ return [...this.drainProgress.values()];
3071
+ }
3072
+ /** Evictable bytes on one location, summed across every provider. */
3073
+ async evictableUsageOf(locationId) {
3074
+ const providers = this.deps.getEvictableProviders();
3075
+ return (await Promise.all(providers.map(async (p) => (await p.getEvictableUsage({ locationId })).bytes))).reduce((sum, bytes) => sum + bytes, 0);
3076
+ }
3077
+ /** Evictable bytes across a set of locations. */
3078
+ async evictableUsageOfAll(locationIds) {
3079
+ return (await Promise.all(locationIds.map((id) => this.evictableUsageOf(id)))).reduce((sum, bytes) => sum + bytes, 0);
2496
3080
  }
2497
3081
  /** Evict oldest-first until the location's evictable usage fits `maxBytes`. */
2498
3082
  async relieveToUsageCap(locationId, maxBytes) {
@@ -2610,6 +3194,7 @@ var StoragePressureManager = class {
2610
3194
  var STORAGE_PROVIDER_CAP_NAME = "storage-provider";
2611
3195
  var DATA_STORE_PROVIDER_CAP_NAME = "data-store-provider";
2612
3196
  var STORAGE_EVICTABLE_CAP_NAME = "storage-evictable";
3197
+ var STORAGE_OCCUPANCY_CAP_NAME = "storage-occupancy";
2613
3198
  /**
2614
3199
  * Single-node default: existing node-local locations live on the hub.
2615
3200
  * The boot backfill (SP1) stamps this on any node-local location seeded
@@ -2627,6 +3212,24 @@ var CAP_PROVIDER_REGISTERED_CATEGORY = "capability:provider-registered";
2627
3212
  var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
2628
3213
  service = null;
2629
3214
  pressureManager = null;
3215
+ /**
3216
+ * How much of each location CamStack itself holds (D388).
3217
+ *
3218
+ * Built on first use rather than in a field initializer: `this.ctx` does not
3219
+ * exist until `initialize()` runs, and a field initializer touching it throws
3220
+ * in the constructor.
3221
+ */
3222
+ occupancySlice = null;
3223
+ get occupancy() {
3224
+ if (this.occupancySlice) return this.occupancySlice;
3225
+ this.occupancySlice = new LocationOccupancySlice({
3226
+ logger: this.ctx.logger,
3227
+ getProviders: () => this.ctx.kernel.capabilityRegistry?.getCollection(STORAGE_OCCUPANCY_CAP_NAME) ?? [],
3228
+ locationIds: () => this.service?.listLocations().map((loc) => loc.id) ?? [],
3229
+ now: () => Date.now()
3230
+ });
3231
+ return this.occupancySlice;
3232
+ }
2630
3233
  pressureTimer = null;
2631
3234
  pressureSweepInFlight = false;
2632
3235
  migration = null;
@@ -2685,16 +3288,15 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
2685
3288
  const provider = {
2686
3289
  listLocations: async ({ type }) => {
2687
3290
  const rows = type !== void 0 ? service.listLocations({ type }) : service.listLocations();
3291
+ this.occupancy.serveAndRevalidate();
2688
3292
  return Promise.all(rows.map(async (loc) => ({
2689
3293
  ...this.redacted(loc),
2690
- capacity: loc.enabled === false ? null : await this.localCapacityOf(loc)
3294
+ ...this.occupancy.ownedFor(loc.id) !== void 0 ? { owned: this.occupancy.ownedFor(loc.id) } : {},
3295
+ capacity: require_dist.isLocationEnabled(loc) ? await this.localCapacityOf(loc) : null
2691
3296
  })));
2692
3297
  },
2693
- getDefaultLocation: async ({ type }) => {
2694
- const loc = service.getDefaultLocation(type);
2695
- return loc === null ? null : this.redacted(loc);
2696
- },
2697
3298
  listLocationDeclarations: async () => service.listDeclarations(),
3299
+ listDrainProgress: async () => this.drainProgress(),
2698
3300
  upsertLocation: async (input) => {
2699
3301
  const stored = service.getLocationById(input.id);
2700
3302
  const config = restoreRedactedSecrets(input.config, stored?.config, this.secretKeysFor(input.providerId, stored?.config ?? {}));
@@ -2702,10 +3304,13 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
2702
3304
  ...input,
2703
3305
  config
2704
3306
  });
3307
+ this.announceLocationsChanged(saved.id, saved.type, "upsert");
2705
3308
  return this.redacted(saved);
2706
3309
  },
2707
3310
  deleteLocation: async ({ id, force }) => {
3311
+ const doomed = service.getLocationById(id);
2708
3312
  await service.deleteLocation(id, { force: force === true });
3313
+ this.announceLocationsChanged(id, doomed?.type ?? "", "delete");
2709
3314
  },
2710
3315
  testLocation: async ({ id }) => {
2711
3316
  const loc = service.getLocationById(id);
@@ -2966,7 +3571,17 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
2966
3571
  getEvictableProviders: () => this.ctx.kernel.capabilityRegistry?.getCollection(STORAGE_EVICTABLE_CAP_NAME) ?? [],
2967
3572
  getLocationThresholds: () => this.resolveLocationThresholds(),
2968
3573
  getLocationMaxUsedBytes: () => this.resolveLocationMaxUsedBytes(),
2969
- getLocationDeviceKey: (id) => this.locationDeviceKey(id)
3574
+ getLocationDeviceKey: (id) => this.locationDeviceKey(id),
3575
+ getDrainPlan: () => Promise.resolve(this.resolveDrainPlan()),
3576
+ drainState: {
3577
+ load: async () => await this.resolveDrainStore()?.load() ?? [],
3578
+ save: async (state) => {
3579
+ await this.resolveDrainStore()?.save(state);
3580
+ },
3581
+ clear: async (locationId) => {
3582
+ await this.resolveDrainStore()?.clear(locationId);
3583
+ }
3584
+ }
2970
3585
  });
2971
3586
  this.pressureTimer = setInterval(() => {
2972
3587
  this.runPressureSweep();
@@ -3114,14 +3729,64 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
3114
3729
  const infos = await collectProviderInfos(this.storageProviders(), () => {});
3115
3730
  const byProvider = new Map(infos.map((i) => [i.providerId, i]));
3116
3731
  for (const loc of svc.listLocations()) {
3732
+ if (!require_dist.mayReadLocation(loc)) continue;
3117
3733
  const info = byProvider.get(loc.providerId);
3118
3734
  if (!info || !info.shouldSaveDiskSpace) continue;
3119
- const override = loc.config["minFreePercent"];
3120
- const threshold = typeof override === "number" && Number.isFinite(override) && override >= 0 ? override : info.minFreePercent ?? 0;
3735
+ const threshold = resolveMinFreePercent(loc.config, info.minFreePercent ?? 0, await this.localCapacityOf(loc), (message, meta) => this.logMinFreeRedundancyOnce(loc.id, message, meta));
3121
3736
  if (threshold > 0) out.set(loc.id, threshold);
3122
3737
  }
3123
3738
  return out;
3124
3739
  }
3740
+ /** Location ids that have already logged the `minFreeGb` / `minFreePercent`
3741
+ * redundancy. The pressure sweep runs on a timer; the notice is worth
3742
+ * exactly one line per location per process, not one per minute. */
3743
+ minFreeRedundancyLogged = /* @__PURE__ */ new Set();
3744
+ logMinFreeRedundancyOnce(locationId, message, meta) {
3745
+ if (this.minFreeRedundancyLogged.has(locationId)) return;
3746
+ this.minFreeRedundancyLogged.add(locationId);
3747
+ this.ctx.logger.warn(message, { meta: {
3748
+ locationId,
3749
+ ...meta
3750
+ } });
3751
+ }
3752
+ /**
3753
+ * The draining locations and the ACTIVE peers of their class whose growth
3754
+ * paces them (D386). Rebuilt every sweep from the live mode of every
3755
+ * location, so this is also what STOPS a drain: a location the operator
3756
+ * switched back simply stops appearing.
3757
+ */
3758
+ drainStore = null;
3759
+ /** The drain ratchet's durable home, once a data-store engine exists. Same
3760
+ * late-wiring window as the location table — on an agent it is never. */
3761
+ resolveDrainStore() {
3762
+ if (this.drainStore) return this.drainStore;
3763
+ const engine = this.ctx.kernel.capabilityRegistry?.getCollection(DATA_STORE_PROVIDER_CAP_NAME)[0];
3764
+ if (!engine) return null;
3765
+ this.drainStore = new SqliteDrainStateStore(engine);
3766
+ return this.drainStore;
3767
+ }
3768
+ drainPlanRefusalLogged = false;
3769
+ resolveDrainPlan() {
3770
+ const svc = this.service;
3771
+ if (!svc) return [];
3772
+ if (this.resolveDrainStore() === null) {
3773
+ const draining = svc.listLocations().filter((l) => require_dist.evictionPolicyOfLocation(l) === "drain");
3774
+ if (draining.length > 0 && !this.drainPlanRefusalLogged) {
3775
+ this.drainPlanRefusalLogged = true;
3776
+ this.ctx.logger.warn("storage-orchestrator: REFUSING to drain — this node has no data-store engine to persist the drain cap, and a cap recomputed at every boot would delete on a pace nobody chose", { meta: { locationIds: draining.map((l) => l.id) } });
3777
+ }
3778
+ return [];
3779
+ }
3780
+ const locations = svc.listLocations();
3781
+ return locations.filter((loc) => require_dist.evictionPolicyOfLocation(loc) === "drain").map((loc) => ({
3782
+ locationId: loc.id,
3783
+ activePeerLocationIds: locations.filter((peer) => peer.type === loc.type && peer.id !== loc.id && require_dist.mayWriteToLocation(peer)).map((peer) => peer.id)
3784
+ }));
3785
+ }
3786
+ /** Drain progress for every draining location, as of the last sweep. */
3787
+ drainProgress() {
3788
+ return this.pressureManager?.drainProgressSnapshot() ?? [];
3789
+ }
3125
3790
  /**
3126
3791
  * What the provider's own schema declared secret, or `undefined` when this
3127
3792
  * node has never heard from that provider. The distinction is the whole
@@ -3134,6 +3799,48 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
3134
3799
  secretKeysFor(providerId, config) {
3135
3800
  return secretKeysForConfig(config, this.declaredSecretKeys(providerId));
3136
3801
  }
3802
+ /**
3803
+ * Ring the doorbell: the SET of storage locations changed (D387).
3804
+ *
3805
+ * A consumer that resolves locations at boot had no way to learn that the
3806
+ * operator added, edited or removed one — the recorder kept writing to the
3807
+ * disk set it resolved hours ago, and an operator who added a second
3808
+ * recordings disk in the admin UI got nothing until something unrelated
3809
+ * happened to re-resolve.
3810
+ *
3811
+ * Telemetry, and deliberately so (D8/D11): it carries no row, it is allowed
3812
+ * to be dropped, and every consumer re-reads `listLocations` for itself and
3813
+ * converges on its own periodic path anyway. A bulk edit rings it once per
3814
+ * row — coalescing is the consumer's job, not this one's.
3815
+ *
3816
+ * Never throws into the mutation that produced it: the location IS saved,
3817
+ * and a bus that will not take a telemetry event must not turn that into a
3818
+ * failed upsert.
3819
+ */
3820
+ announceLocationsChanged(locationId, locationType, reason) {
3821
+ try {
3822
+ this.ctx.eventBus.emit({
3823
+ id: (0, node_crypto.randomUUID)(),
3824
+ timestamp: /* @__PURE__ */ new Date(),
3825
+ source: {
3826
+ type: "addon",
3827
+ id: this.ctx.id
3828
+ },
3829
+ category: require_dist.EventCategory.StorageLocationsChanged,
3830
+ data: {
3831
+ locationId,
3832
+ locationType,
3833
+ reason
3834
+ }
3835
+ });
3836
+ } catch (err) {
3837
+ this.ctx.logger.warn("storage-orchestrator: could not announce the location change — consumers will converge on their own periodic path", { meta: {
3838
+ locationId,
3839
+ reason,
3840
+ error: err instanceof Error ? err.message : String(err)
3841
+ } });
3842
+ }
3843
+ }
3137
3844
  /** A location with its provider's declared secrets replaced by the sentinel. */
3138
3845
  redacted(location) {
3139
3846
  return redactLocation(location, this.secretKeysFor(location.providerId, location.config));
@@ -3192,8 +3899,9 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
3192
3899
  const svc = this.service;
3193
3900
  if (!svc) return out;
3194
3901
  for (const loc of svc.listLocations()) {
3902
+ if (!require_dist.mayReadLocation(loc)) continue;
3195
3903
  const raw = loc.config["maxUsedGb"];
3196
- if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) out.set(loc.id, Math.floor(raw * 1024 ** 3));
3904
+ if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) out.set(loc.id, require_dist.gbToBytes(raw));
3197
3905
  }
3198
3906
  return out;
3199
3907
  }