@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
@@ -1,7 +1,8 @@
1
- import { C as StorageCleanupJobSchema, Et as settingsStoreCapability, I as collectSecretConfigKeys, It as BaseAddon, Ot as storageCapability, T as StorageMigrationJobSchema, h as REDACTED_SECRET, kt as storageMigrationCapability, nn as parseJsonObject, w as StorageLocationTypeSchema } from "../../dist-BmJNnb_s.mjs";
1
+ import { $ as evictionPolicyOfLocation, At as resolveLocationMode, D as StorageMigrationJobSchema, E as StorageLocationTypeSchema, It as settingsStoreCapability, Jt as BaseAddon, Kt as withLocationMode, R as collectSecretConfigKeys, Rt as storageCapability, T as StorageLocationModeSchema, bn as EventCategory, ft as legacyModeOf, g as REDACTED_SECRET, m as LegacyStorageLocationDefaultSchema, mn as parseJsonObject, ot as isLocationEnabled, rt as gbToBytes, vt as mayReadLocation, w as StorageCleanupJobSchema, xt as modeMayWrite, yt as mayWriteToLocation, zt as storageMigrationCapability } from "../../dist-DcMUcdeq.mjs";
2
2
  import { t as HUB_CAP_FWD_ACTION } from "../../hub-cap-forward-BdT320dq.mjs";
3
3
  import { z } from "zod";
4
4
  import { randomUUID } from "node:crypto";
5
+ import { containsOrEquals, physicalRootOf } from "@camstack/types/node";
5
6
  import * as fs from "node:fs/promises";
6
7
  import { opendir, readdir } from "node:fs/promises";
7
8
  import * as path$1 from "node:path";
@@ -38,6 +39,17 @@ var NODE_ID_CONFIG_KEY = "__nodeId";
38
39
  */
39
40
  var ENABLED_CONFIG_KEY = "__enabled";
40
41
  /**
42
+ * Reserved key under which the location's typed STATE ({@link
43
+ * StorageLocationMode}, D385) is persisted — same channel, same reason, as
44
+ * {@link NODE_ID_CONFIG_KEY} and {@link ENABLED_CONFIG_KEY}.
45
+ *
46
+ * ABSENT means UNSTAMPED, not `active`: the orchestrator's boot migration folds
47
+ * the legacy `enabled` / `config.readOnly` pair into a mode and writes it once.
48
+ * Reading absence as a state here would do that fold on every read forever,
49
+ * which is the thing D385 removes.
50
+ */
51
+ var MODE_CONFIG_KEY = "__mode";
52
+ /**
41
53
  * `config` is declared `JSON`, so the store hands the surface an object and
42
54
  * gets an object back. There is deliberately NO `node_id` column — `nodeId`
43
55
  * rides inside the blob under a reserved key (see {@link NODE_ID_CONFIG_KEY}),
@@ -182,13 +194,14 @@ function mapLocationToRow(loc) {
182
194
  const storedConfig = { ...loc.config };
183
195
  if (loc.nodeId !== void 0) storedConfig[NODE_ID_CONFIG_KEY] = loc.nodeId;
184
196
  if (loc.enabled !== void 0) storedConfig[ENABLED_CONFIG_KEY] = loc.enabled;
197
+ if (loc.mode !== void 0) storedConfig[MODE_CONFIG_KEY] = loc.mode;
185
198
  return {
186
199
  id: loc.id,
187
200
  type: loc.type,
188
201
  display_name: loc.displayName,
189
202
  provider_id: loc.providerId,
190
203
  config: storedConfig,
191
- is_default: loc.isDefault ? 1 : 0,
204
+ is_default: 0,
192
205
  is_system: loc.isSystem ? 1 : 0,
193
206
  created_at: loc.createdAt,
194
207
  updated_at: loc.updatedAt
@@ -211,9 +224,12 @@ function mapRowToLocation(row) {
211
224
  const nodeId = typeof nodeIdRaw === "string" && nodeIdRaw.length > 0 ? nodeIdRaw : void 0;
212
225
  const enabledRaw = storedConfig[ENABLED_CONFIG_KEY];
213
226
  const enabled = typeof enabledRaw === "boolean" ? enabledRaw : void 0;
227
+ const parsedMode = StorageLocationModeSchema.safeParse(storedConfig[MODE_CONFIG_KEY]);
228
+ const mode = parsedMode.success ? parsedMode.data : void 0;
214
229
  const config = { ...storedConfig };
215
230
  delete config[NODE_ID_CONFIG_KEY];
216
231
  delete config[ENABLED_CONFIG_KEY];
232
+ delete config[MODE_CONFIG_KEY];
217
233
  return {
218
234
  id,
219
235
  type,
@@ -222,7 +238,8 @@ function mapRowToLocation(row) {
222
238
  config,
223
239
  ...nodeId !== void 0 ? { nodeId } : {},
224
240
  ...enabled !== void 0 ? { enabled } : {},
225
- isDefault: row["is_default"] === 1 || row["is_default"] === true,
241
+ ...mode !== void 0 ? { mode } : {},
242
+ ...LegacyStorageLocationDefaultSchema.parse({ isDefault: row["is_default"] === 1 || row["is_default"] === true }),
226
243
  isSystem: row["is_system"] === 1 || row["is_system"] === true,
227
244
  createdAt: Number(row["created_at"] ?? 0),
228
245
  updatedAt: Number(row["updated_at"] ?? 0)
@@ -290,16 +307,18 @@ var StorageMigrationCoordinator = class {
290
307
  const targetId = input.destinations[storageClass];
291
308
  if (targetId === void 0) continue;
292
309
  const namedSourceId = input.sources?.[storageClass];
293
- const defaultLoc = this.deps.locations.getDefaultLocation(storageClass);
294
- const source = namedSourceId ? this.deps.locations.getLocationById(namedSourceId) : defaultLoc;
295
- if (!source) throw new Error(namedSourceId ? `Storage location "${namedSourceId}" not found` : `No default storage location for "${storageClass}"`);
310
+ const writeSet = this.deps.locations.writeTargets(storageClass);
311
+ const implicitSources = writeSet.filter((loc) => loc.id !== targetId);
312
+ 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\`.`);
313
+ const source = namedSourceId ? this.deps.locations.getLocationById(namedSourceId) : implicitSources[0];
314
+ 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.`);
296
315
  if (source.type !== storageClass) throw new Error(`Storage location "${source.id}" is type "${source.type}", expected "${storageClass}"`);
297
316
  const target = this.deps.locations.getLocationById(targetId);
298
317
  if (!target) throw new Error(`Storage location "${targetId}" not found`);
299
318
  if (target.type !== storageClass) throw new Error(`Storage location "${targetId}" is type "${target.type}", expected "${storageClass}"`);
300
- if (source.id === target.id) throw new Error(namedSourceId ? `Source and destination are the same location ("${targetId}")` : `Storage location "${targetId}" is already the "${storageClass}" default`);
301
- const freezeSource = defaultLoc === null || source.id !== defaultLoc.id;
302
- 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.`);
319
+ 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}"`);
320
+ const freezeSource = !writeSet.some((loc) => loc.id === source.id);
321
+ 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.`);
303
322
  moves.push({
304
323
  storageClass,
305
324
  fromLocationId: source.id,
@@ -316,7 +335,7 @@ var StorageMigrationCoordinator = class {
316
335
  if (moves.some((move) => move.storageClass === "eventMedia")) {
317
336
  const unstamped = await this.deps.participants.analytics.countUnstamped();
318
337
  if (unstamped === null) {
319
- 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.");
338
+ 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.");
320
339
  findings.push({
321
340
  code: "unstampedEventMediaRows",
322
341
  storageClass: "eventMedia",
@@ -325,7 +344,7 @@ var StorageMigrationCoordinator = class {
325
344
  } else if (unstamped.anyPresent) {
326
345
  const scale = unstamped.total === null ? "an unknown number of" : unstamped.total.toString();
327
346
  const perLane = `${describeUnstamped(unstamped.media)} media, ${describeUnstamped(unstamped.retrainFrames)} retrain frames`;
328
- 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"}" }).`);
347
+ 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"}" }).`);
329
348
  findings.push({
330
349
  code: "unstampedEventMediaRows",
331
350
  storageClass: "eventMedia",
@@ -464,7 +483,7 @@ var StorageMigrationCoordinator = class {
464
483
  * remaining" action.
465
484
  *
466
485
  * A class appears here only when something is (or might be) left on a
467
- * location that is not its default. An empty result therefore means exactly
486
+ * location outside its write set. An empty result therefore means exactly
468
487
  * "there is nothing to drain", which is what lets the UI offer the action
469
488
  * only when it is true, and what lets {@link drain} refuse rather than start
470
489
  * a job that would move nothing and report `done` — the failure mode D295
@@ -478,11 +497,13 @@ var StorageMigrationCoordinator = class {
478
497
  const out = [];
479
498
  for (const storageClass of STORAGE_CLASSES) {
480
499
  if (!MOVER_CLASSES.includes(storageClass)) continue;
481
- const target = this.deps.locations.getDefaultLocation(storageClass);
500
+ const writeSet = this.deps.locations.writeTargets(storageClass);
501
+ const target = writeSet[0];
482
502
  if (!target) continue;
503
+ const inWriteSet = new Set(writeSet.map((loc) => loc.id));
483
504
  if (laneOf(storageClass) === "media") {
484
505
  for (const source of this.deps.locations.listLocations({ type: storageClass })) {
485
- if (source.id === target.id) continue;
506
+ if (inWriteSet.has(source.id)) continue;
486
507
  const count = await unanswerable(this.deps.participants.analytics.residue({
487
508
  toLocationId: target.id,
488
509
  fromLocationId: source.id,
@@ -500,7 +521,7 @@ var StorageMigrationCoordinator = class {
500
521
  continue;
501
522
  }
502
523
  for (const source of this.deps.locations.listLocations({ type: storageClass })) {
503
- if (source.id === target.id) continue;
524
+ if (inWriteSet.has(source.id)) continue;
504
525
  const census = await unanswerable(this.deps.participants.recorder.residue({
505
526
  fromLocationId: source.id,
506
527
  footageClass: storageClass === "recordingsLow" ? "recordingsLow" : "recordings"
@@ -518,12 +539,12 @@ var StorageMigrationCoordinator = class {
518
539
  return out;
519
540
  }
520
541
  /**
521
- * Run the DRAIN half alone, against classes whose default has already moved.
542
+ * Run the DRAIN half alone, against classes whose write set has already moved.
522
543
  *
523
544
  * ## Why this is a second verb rather than a looser `start`
524
545
  *
525
- * `start` refuses a destination that is already the class's default
526
- * (`"recordingsLow:ssd" is already the "recordingsLow" default`). That refusal
546
+ * `start` refuses a destination that is already the class's only write target
547
+ * (`"recordingsLow:ssd" is already the only write target for "recordingsLow"`). That refusal
527
548
  * is correct and it is load-bearing: there is genuinely nothing left to
528
549
  * repoint, and an operator must never be able to re-repoint a migrated class
529
550
  * by accident. Making `start` idempotent — "an already-repointed class
@@ -532,7 +553,7 @@ var StorageMigrationCoordinator = class {
532
553
  * operator reads ("pauses the writers…") would be a lie half the time.
533
554
  *
534
555
  * `drain` instead cannot repoint AT ALL: it never touches
535
- * `setDefaultLocations`, and its job starts in `draining` with `repointed`
556
+ * `setWriteTargets`, and its job starts in `draining` with `repointed`
536
557
  * already true, so the `repointing` / `refreshing` / `resuming` blocks of
537
558
  * {@link run} are behind it and unreachable. The two verbs are disjoint, and
538
559
  * `start`'s refusal keeps meaning exactly what it meant.
@@ -542,7 +563,7 @@ var StorageMigrationCoordinator = class {
542
563
  * The finished job is the audit of what happened; re-opening it destroys
543
564
  * that. And a drain is needed in cases where no migration job ever existed
544
565
  * (a mover armed by hand, footage stranded on a location an operator added
545
- * and then un-defaulted). One job = one operation, and the work list comes
566
+ * and then disabled again). One job = one operation, and the work list comes
546
567
  * from {@link residue} — the archive — not from what a previous job believed.
547
568
  */
548
569
  async drain(input) {
@@ -565,9 +586,10 @@ var StorageMigrationCoordinator = class {
565
586
  const destinations = {};
566
587
  for (const storageClass of input.classes) {
567
588
  const remaining = residue.filter((entry) => entry.storageClass === storageClass);
568
- 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.`);
589
+ 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.`);
569
590
  for (const entry of remaining) {
570
- 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.`);
591
+ const target = this.deps.locations.getLocationById(entry.toLocationId);
592
+ if (target !== void 0 && !mayWriteToLocation(target)) throw new Error(`The "${storageClass}" drain destination "${entry.toLocationId}" is not a write target (mode: ${resolveLocationMode(target)}) — it takes no writes, and a drain is the largest write there is. Set it active on the Storage screen first.`);
571
593
  moves.push({
572
594
  storageClass,
573
595
  fromLocationId: entry.fromLocationId,
@@ -685,8 +707,13 @@ var StorageMigrationCoordinator = class {
685
707
  await this.setPhase(job, "repointing");
686
708
  }
687
709
  if (job.phase === "repointing") {
688
- const targets = new Map(job.moves.filter((move) => move.freezeSource !== true).map((move) => [move.storageClass, move.toLocationId]));
689
- if (targets.size > 0) await this.deps.locations.setDefaultLocations(targets);
710
+ const targets = /* @__PURE__ */ new Map();
711
+ for (const move of job.moves) {
712
+ if (move.freezeSource === true) continue;
713
+ targets.set(move.toLocationId, true);
714
+ targets.set(move.fromLocationId, false);
715
+ }
716
+ if (targets.size > 0) await this.deps.locations.setWriteTargets(targets);
690
717
  job.repointed = true;
691
718
  await this.setPhase(job, "refreshing");
692
719
  }
@@ -885,21 +912,18 @@ var StorageMigrationCoordinator = class {
885
912
  for (const move of job.moves) if (move.state !== "done") throw new Error(`${move.storageClass} move did not complete verification`);
886
913
  }
887
914
  /**
888
- * After a from→to copy whose source was NOT the class default: stop new
889
- * writes to `from`. The default is unchanged (that is the whole point of
890
- * naming a non-default source). A location that is already disabled is
915
+ * After a from→to copy whose source was NOT a write target: stop new writes
916
+ * to `from`. The rest of the write set is unchanged (that is the whole point
917
+ * of naming a source outside it). A location that is already disabled is
891
918
  * left alone.
892
919
  */
893
920
  freezeDrainedSources(job) {
894
921
  for (const move of job.moves) {
895
922
  if (move.freezeSource !== true) continue;
896
923
  const loc = this.deps.locations.getLocationById(move.fromLocationId);
897
- if (!loc || loc.enabled === false) continue;
924
+ if (!loc || !mayWriteToLocation(loc)) continue;
898
925
  const { createdAt: _c, updatedAt: _u, capacity: _cap, ...rest } = loc;
899
- this.deps.locations.upsertLocation({
900
- ...rest,
901
- enabled: false
902
- });
926
+ this.deps.locations.upsertLocation(withLocationMode(rest, "readonly"));
903
927
  }
904
928
  }
905
929
  /**
@@ -1170,6 +1194,136 @@ function createDataStoreDispatch(getEngines, getRemote) {
1170
1194
  };
1171
1195
  }
1172
1196
  //#endregion
1197
+ //#region src/builtins/storage-orchestrator/drain-state-store.ts
1198
+ /**
1199
+ * @durable class=config owner=storage-orchestrator
1200
+ * write="one row per location the operator has switched to `drain`, rewritten on every pressure sweep (60 s) while it drains"
1201
+ * retention="the row is deleted the moment the location leaves `drain` mode. Bounded by the number of disks a human is retiring at once."
1202
+ */
1203
+ var STORAGE_DRAIN_STATE = "storage_drain_state";
1204
+ var STORAGE_DRAIN_STATE_COLUMNS = [
1205
+ {
1206
+ name: "location_id",
1207
+ type: "TEXT",
1208
+ primaryKey: true
1209
+ },
1210
+ {
1211
+ name: "started_at_ms",
1212
+ type: "INTEGER",
1213
+ notNull: true
1214
+ },
1215
+ {
1216
+ name: "start_bytes",
1217
+ type: "INTEGER",
1218
+ notNull: true
1219
+ },
1220
+ {
1221
+ name: "cap_bytes",
1222
+ type: "INTEGER",
1223
+ notNull: true
1224
+ },
1225
+ {
1226
+ name: "last_peer_bytes",
1227
+ type: "INTEGER"
1228
+ },
1229
+ {
1230
+ name: "last_sweep_ms",
1231
+ type: "INTEGER",
1232
+ notNull: true
1233
+ },
1234
+ {
1235
+ name: "observed_growth_bytes",
1236
+ type: "INTEGER",
1237
+ notNull: true,
1238
+ defaultValue: 0
1239
+ },
1240
+ {
1241
+ name: "observed_elapsed_ms",
1242
+ type: "INTEGER",
1243
+ notNull: true,
1244
+ defaultValue: 0
1245
+ }
1246
+ ];
1247
+ /** A finite number off a persisted row, or the documented fallback. A row
1248
+ * written by a hand-edit must never become a cap. */
1249
+ function num(raw, fallback) {
1250
+ return typeof raw === "number" && Number.isFinite(raw) ? raw : fallback;
1251
+ }
1252
+ function mapRow(row) {
1253
+ const locationId = String(row["location_id"] ?? "");
1254
+ if (!locationId) return null;
1255
+ const lastPeerRaw = row["last_peer_bytes"];
1256
+ return {
1257
+ locationId,
1258
+ startedAtMs: num(row["started_at_ms"], 0),
1259
+ startBytes: num(row["start_bytes"], 0),
1260
+ capBytes: num(row["cap_bytes"], 0),
1261
+ lastPeerBytes: typeof lastPeerRaw === "number" && Number.isFinite(lastPeerRaw) ? lastPeerRaw : null,
1262
+ lastSweepMs: num(row["last_sweep_ms"], 0),
1263
+ observedGrowthBytes: num(row["observed_growth_bytes"], 0),
1264
+ observedElapsedMs: num(row["observed_elapsed_ms"], 0)
1265
+ };
1266
+ }
1267
+ function mapState(state) {
1268
+ return {
1269
+ location_id: state.locationId,
1270
+ started_at_ms: state.startedAtMs,
1271
+ start_bytes: state.startBytes,
1272
+ cap_bytes: state.capBytes,
1273
+ last_peer_bytes: state.lastPeerBytes,
1274
+ last_sweep_ms: state.lastSweepMs,
1275
+ observed_growth_bytes: state.observedGrowthBytes,
1276
+ observed_elapsed_ms: state.observedElapsedMs
1277
+ };
1278
+ }
1279
+ /** {@link DrainStateStore} on the `settings-store` collection surface. */
1280
+ var SqliteDrainStateStore = class {
1281
+ store;
1282
+ declared = false;
1283
+ constructor(store) {
1284
+ this.store = store;
1285
+ }
1286
+ async ensureDeclared() {
1287
+ if (this.declared) return;
1288
+ await this.store.declareCollection({
1289
+ collection: STORAGE_DRAIN_STATE,
1290
+ columns: STORAGE_DRAIN_STATE_COLUMNS
1291
+ });
1292
+ this.declared = true;
1293
+ }
1294
+ async load() {
1295
+ await this.ensureDeclared();
1296
+ const rows = await this.store.query({ collection: STORAGE_DRAIN_STATE });
1297
+ const out = [];
1298
+ for (const row of rows) {
1299
+ const mapped = mapRow(row.data);
1300
+ if (mapped) out.push(mapped);
1301
+ }
1302
+ return out;
1303
+ }
1304
+ async save(state) {
1305
+ await this.ensureDeclared();
1306
+ await this.store.deleteWhere({
1307
+ collection: STORAGE_DRAIN_STATE,
1308
+ filter: { where: { location_id: state.locationId } }
1309
+ });
1310
+ await this.store.insert({
1311
+ collection: STORAGE_DRAIN_STATE,
1312
+ record: {
1313
+ id: state.locationId,
1314
+ data: mapState(state)
1315
+ }
1316
+ });
1317
+ }
1318
+ async clear(locationId) {
1319
+ await this.ensureDeclared();
1320
+ await this.store.deleteWhere({
1321
+ collection: STORAGE_DRAIN_STATE,
1322
+ filter: { where: { location_id: locationId } }
1323
+ });
1324
+ }
1325
+ };
1326
+ //#endregion
1173
1327
  //#region src/builtins/storage-orchestrator/hub-settings-forward.ts
1174
1328
  /**
1175
1329
  * The data door with the HUB behind it — an agent's `settings-store` provider.
@@ -1381,6 +1535,139 @@ async function probeLocalDirectoryOccupancy(input) {
1381
1535
  return "empty";
1382
1536
  }
1383
1537
  //#endregion
1538
+ //#region src/builtins/storage-orchestrator/location-occupancy-slice.ts
1539
+ /** One minute: the same order as the pressure sweep, and far longer than an
1540
+ * admin-UI repaint. */
1541
+ var DEFAULT_STALE_AFTER_MS = 6e4;
1542
+ var LocationOccupancySlice = class {
1543
+ deps;
1544
+ slice = /* @__PURE__ */ new Map();
1545
+ refreshing = null;
1546
+ lastRefreshMs = 0;
1547
+ constructor(deps) {
1548
+ this.deps = deps;
1549
+ }
1550
+ /**
1551
+ * What the last completed refresh saw for this location, or `undefined` when
1552
+ * nothing has reported for it. There is deliberately no fallback: unknown is
1553
+ * the truth about a location nobody owns bytes on.
1554
+ */
1555
+ ownedFor(locationId) {
1556
+ return this.slice.get(locationId);
1557
+ }
1558
+ /**
1559
+ * Serve-and-revalidate. Called by every read: it returns immediately, and
1560
+ * kicks a background refresh when the slice is older than the window. A read
1561
+ * never awaits, and never joins a refresh already in flight (D224).
1562
+ */
1563
+ serveAndRevalidate() {
1564
+ if (this.refreshing !== null) return;
1565
+ const staleAfter = this.deps.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
1566
+ if (this.lastRefreshMs !== 0 && this.deps.now() - this.lastRefreshMs < staleAfter) return;
1567
+ this.refresh();
1568
+ }
1569
+ /**
1570
+ * Ask every provider once and replace the slice.
1571
+ *
1572
+ * A provider that throws contributes NOTHING and is logged — it does not
1573
+ * contribute a zero, and it does not take the other providers' answers down
1574
+ * with it. A location that ends up with no contribution at all simply stays
1575
+ * out of the slice, which is what makes it read back as unknown.
1576
+ */
1577
+ async refresh() {
1578
+ if (this.refreshing !== null) return this.refreshing;
1579
+ this.refreshing = this.runRefresh().finally(() => {
1580
+ this.refreshing = null;
1581
+ });
1582
+ return this.refreshing;
1583
+ }
1584
+ async runRefresh() {
1585
+ const locationIds = [...this.deps.locationIds()];
1586
+ if (locationIds.length === 0) {
1587
+ this.slice = /* @__PURE__ */ new Map();
1588
+ this.lastRefreshMs = this.deps.now();
1589
+ return;
1590
+ }
1591
+ const merged = /* @__PURE__ */ new Map();
1592
+ for (const provider of this.deps.getProviders()) {
1593
+ let reports;
1594
+ try {
1595
+ reports = await provider.getOccupancy({ locationIds });
1596
+ } catch (err) {
1597
+ 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) } });
1598
+ continue;
1599
+ }
1600
+ for (const report of reports) {
1601
+ const previous = merged.get(report.locationId);
1602
+ merged.set(report.locationId, {
1603
+ bytes: (previous?.bytes ?? 0) + report.ownedBytes,
1604
+ measuredAtMs: previous === void 0 ? report.measuredAtMs : Math.min(previous.measuredAtMs, report.measuredAtMs)
1605
+ });
1606
+ }
1607
+ }
1608
+ this.slice = merged;
1609
+ this.lastRefreshMs = this.deps.now();
1610
+ }
1611
+ };
1612
+ //#endregion
1613
+ //#region src/builtins/storage-orchestrator/location-protection.ts
1614
+ /**
1615
+ * The protection criterion, in the two units an operator thinks in.
1616
+ *
1617
+ * The disk-pressure guard has always spoken ONE language — a free-space
1618
+ * PERCENTAGE. An operator does not: on a 20 TB array "10%" is 2 TB of headroom
1619
+ * nobody asked for, and on a 500 GB SSD it is 50 GB. So a location may instead
1620
+ * declare `config.minFreeGb`, and this module converts it to the percentage the
1621
+ * guard already speaks using the volume total the orchestrator can already
1622
+ * stat.
1623
+ *
1624
+ * `minFreeGb` WINS when both are present, and the redundancy is logged — two
1625
+ * knobs that disagree is the shape D62 forbids, and the one the operator set
1626
+ * most recently is not knowable here. `maxUsedGb` stays in the model but stops
1627
+ * being an operator knob: it is the internal lever the drain ratchet drives
1628
+ * (D386).
1629
+ *
1630
+ * Pure. Every input is a parameter.
1631
+ */
1632
+ /** A finite, non-negative number off the untyped config blob, or `null`.
1633
+ * Garbage must never become a capacity limit. */
1634
+ function positiveNumber(raw) {
1635
+ if (typeof raw !== "number" || !Number.isFinite(raw) || raw < 0) return null;
1636
+ return raw;
1637
+ }
1638
+ /**
1639
+ * The free-space percentage the guard enforces for one location.
1640
+ *
1641
+ * Order:
1642
+ * 1. `config.minFreeGb`, converted against the volume total — the operator's
1643
+ * criterion in the unit they set it in.
1644
+ * 2. `config.minFreePercent` — the legacy per-location override, and the
1645
+ * fallback when `minFreeGb` cannot be converted (no volume total: a
1646
+ * remote-node or unstattable location). Falling back is the right
1647
+ * direction — the alternative is a location with no guard at all.
1648
+ * 3. the provider's declared default.
1649
+ */
1650
+ function resolveMinFreePercent(config, providerDefaultPercent, capacity, notice) {
1651
+ const minFreeGb = positiveNumber(config["minFreeGb"]);
1652
+ const minFreePercent = positiveNumber(config["minFreePercent"]);
1653
+ const fallback = minFreePercent ?? providerDefaultPercent;
1654
+ if (minFreeGb === null) return fallback;
1655
+ if (minFreePercent !== null) notice("storage-orchestrator: location declares BOTH minFreeGb and minFreePercent — minFreeGb wins and minFreePercent is ignored. Remove one.", {
1656
+ minFreeGb,
1657
+ minFreePercent
1658
+ });
1659
+ const totalBytes = capacity?.totalBytes ?? 0;
1660
+ if (totalBytes <= 0) {
1661
+ notice("storage-orchestrator: minFreeGb cannot be converted — the volume total is unknown (remote node, or an unstattable path). Falling back to the percentage guard.", {
1662
+ minFreeGb,
1663
+ fallbackPercent: fallback
1664
+ });
1665
+ return fallback;
1666
+ }
1667
+ const percent = gbToBytes(minFreeGb) / totalBytes * 100;
1668
+ return Math.min(100, percent);
1669
+ }
1670
+ //#endregion
1384
1671
  //#region src/builtins/storage-orchestrator/location-secrets.ts
1385
1672
  /**
1386
1673
  * The config keys the ORCHESTRATOR itself owns, as opposed to the ones a
@@ -1513,8 +1800,13 @@ var FOOTAGE_TYPES = new Set(["recordings", "recordingsLow"]);
1513
1800
  function isTerminal(job) {
1514
1801
  return job.phase === "done" || job.phase === "failed" || job.phase === "cancelled";
1515
1802
  }
1803
+ /**
1804
+ * A location whose footage the system will not remove on its own — eviction
1805
+ * policy `never` (D385): `readonly` and `disabled`. NOT `drain`, which is
1806
+ * shrinking on purpose and whose leftovers the ratchet is already removing.
1807
+ */
1516
1808
  function footageFrozen(location) {
1517
- return location.enabled === false || location.config["readOnly"] === true;
1809
+ return evictionPolicyOfLocation(location) === "never";
1518
1810
  }
1519
1811
  /**
1520
1812
  * One operator-facing cleanup of leftover analytics rows/blobs, optional
@@ -1675,6 +1967,78 @@ var StorageCleanupCoordinator = class {
1675
1967
  }
1676
1968
  };
1677
1969
  //#endregion
1970
+ //#region src/builtins/storage-orchestrator/location-root-collision.ts
1971
+ function basePathOf(row) {
1972
+ const raw = row.config["basePath"];
1973
+ return typeof raw === "string" && raw.length > 0 ? raw : null;
1974
+ }
1975
+ /** Both rows on one node? Absent `nodeId` means the hub (the seed writes
1976
+ * node-blind rows), so the comparison normalises rather than skipping. */
1977
+ function sameNode(a, b) {
1978
+ return (a.nodeId ?? "hub") === (b.nodeId ?? "hub");
1979
+ }
1980
+ /**
1981
+ * Does `next` collide with anything already stored?
1982
+ *
1983
+ * `existing` is every other location; the row being EDITED is excluded by id,
1984
+ * so re-saving a location without moving its root never refuses itself.
1985
+ */
1986
+ function findRootCollision(next, existing, resolveRoot, storedSelf) {
1987
+ if (storedSelf !== void 0 && basePathOf(storedSelf) === basePathOf(next)) return { outcome: "clear" };
1988
+ const basePath = basePathOf(next);
1989
+ if (basePath === null) return { outcome: "clear" };
1990
+ const root = resolveRoot(basePath);
1991
+ if (!root.resolved) return {
1992
+ outcome: "unknown",
1993
+ unknown: {
1994
+ reason: "unresolvable",
1995
+ detail: `could not resolve "${basePath}" on this node`
1996
+ }
1997
+ };
1998
+ for (const other of existing) {
1999
+ if (other.id === next.id) continue;
2000
+ if (other.type !== next.type) continue;
2001
+ if (!sameNode(other, next)) continue;
2002
+ const otherPath = basePathOf(other);
2003
+ if (otherPath === null) continue;
2004
+ const otherRoot = resolveRoot(otherPath);
2005
+ if (!otherRoot.resolved) continue;
2006
+ if (root.key === otherRoot.key) return {
2007
+ outcome: "collides",
2008
+ collision: {
2009
+ otherId: other.id,
2010
+ type: next.type,
2011
+ sharedRoot: root.key,
2012
+ kind: "equal"
2013
+ }
2014
+ };
2015
+ if (containsOrEquals(otherRoot.key, root.key)) return {
2016
+ outcome: "collides",
2017
+ collision: {
2018
+ otherId: other.id,
2019
+ type: next.type,
2020
+ sharedRoot: otherRoot.key,
2021
+ kind: "nested"
2022
+ }
2023
+ };
2024
+ if (containsOrEquals(root.key, otherRoot.key)) return {
2025
+ outcome: "collides",
2026
+ collision: {
2027
+ otherId: other.id,
2028
+ type: next.type,
2029
+ sharedRoot: root.key,
2030
+ kind: "nested"
2031
+ }
2032
+ };
2033
+ }
2034
+ return { outcome: "clear" };
2035
+ }
2036
+ /** The operator-facing refusal. Names the location it collides with and the
2037
+ * shared root — a refusal that says only "conflict" makes the operator hunt. */
2038
+ function rootCollisionMessage(id, collision) {
2039
+ 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.`;
2040
+ }
2041
+ //#endregion
1678
2042
  //#region src/builtins/storage-orchestrator/storage-orchestrator.service.ts
1679
2043
  /**
1680
2044
  * Routing core for the `storage-orchestrator` builtin.
@@ -1832,33 +2196,77 @@ var StorageOrchestratorService = class {
1832
2196
  backfilled: backfill.length,
1833
2197
  total: this.locations.size
1834
2198
  } });
1835
- this.reportDisabledDefaults();
2199
+ this.migrateOntoTheWriteSet(rows);
1836
2200
  }
1837
2201
  /**
1838
- * Shout about a persisted row that is BOTH the type default and disabled.
2202
+ * ONE explicit migration onto the four-state model (D385), run once per boot
2203
+ * against the rows that came off disk. Idempotent: the second boot finds
2204
+ * nothing to do.
1839
2205
  *
1840
- * `upsertLocation` can now produce this (enabled is the write set; `isDefault`
1841
- * is store residue). Hydrate also writes rows straight into the map, so a
1842
- * hand-edited store or a row written by another build can too. It matters
1843
- * because every bare-type ref still resolves through {@link getDefaultLocation}
1844
- * until Stage 5 of the enabled-write-set design: on such a row
1845
- * `storage.write({ location: 'eventMedia' })` and the migration coordinator's
1846
- * drain destination both point at a disk the operator turned off.
2206
+ * Two steps, in order, and each one is a WRITE — the point is that "absent"
2207
+ * stops existing rather than being re-interpreted on every read forever:
1847
2208
  *
1848
- * Reported, never repaired: flipping an operator's flag back on at boot would
1849
- * be the system overruling the switch instead of obeying it. The coordinator
1850
- * refuses the drain (`storage-migration-coordinator.drain`); the bare-ref read
1851
- * path is deliberately left working, because narrowing a READ is a worse bug
1852
- * than the one this line reports.
2209
+ * 1. A row with no `mode` is stamped with the mode its legacy flags imply
2210
+ * (`legacyModeOf`): `config.readOnly === true` → `readonly` and the
2211
+ * config key is DELETED; `enabled === false` → `readonly` (the
2212
+ * CONSERVATIVE direction — a state change must never start deleting
2213
+ * footage on its own); everything else → `active`. `withLocationMode`
2214
+ * derives `enabled` from the mode, so the two can never disagree, and
2215
+ * this subsumes the D383 pass that stamped flagless rows `enabled: true`.
2216
+ * 2. A type left with NO write target — only reachable from a hand-edited
2217
+ * store or a row an older build wrote — gets one made `active`, chosen as
2218
+ * the row the RETIRED default flag named, else the `:default`-slugged
2219
+ * row, else the lexicographically first. Loudly: a class with no write
2220
+ * target records nowhere, and refusing to repair it here would leave the
2221
+ * hub silently unable to write with no operator action pending.
2222
+ *
2223
+ * An operator's explicit `readonly`/`drain`/`disabled` is never reversed by
2224
+ * step 1 — only step 2 touches it, and only when the alternative is a class
2225
+ * that cannot write at all.
2226
+ *
2227
+ * On the live hub every location is enabled with no `readOnly`, so step 1 is
2228
+ * a no-op that stamps `active` and nothing else changes.
1853
2229
  */
1854
- reportDisabledDefaults() {
2230
+ migrateOntoTheWriteSet(rows) {
2231
+ const legacyDefaultOfType = /* @__PURE__ */ new Map();
2232
+ for (const row of rows) if (row.isDefault === true) legacyDefaultOfType.set(row.type, row.id);
2233
+ const stamped = [];
1855
2234
  for (const loc of this.locations.values()) {
1856
- if (!loc.isDefault || loc.enabled !== false) continue;
1857
- 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: {
1858
- id: loc.id,
1859
- type: loc.type
2235
+ if (loc.mode !== void 0) continue;
2236
+ this.persistMigrated(withLocationMode(loc, legacyModeOf(loc) ?? "active"));
2237
+ stamped.push(loc.id);
2238
+ }
2239
+ const repaired = [];
2240
+ for (const type of new Set([...this.locations.values()].map((l) => l.type))) {
2241
+ const ofType = [...this.locations.values()].filter((l) => l.type === type);
2242
+ if (ofType.some((l) => isLocationEnabled(l))) continue;
2243
+ const legacyDefault = legacyDefaultOfType.get(type);
2244
+ 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];
2245
+ if (!pick) continue;
2246
+ this.persistMigrated(withLocationMode(pick, "active"));
2247
+ repaired.push(pick.id);
2248
+ }
2249
+ if (stamped.length > 0 || repaired.length > 0) this.logger.info("storage-orchestrator: migrated locations onto the state model", { meta: {
2250
+ stamped,
2251
+ repaired
2252
+ } });
2253
+ 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 } });
2254
+ }
2255
+ /** Apply one migrated row in memory and mirror it to the store. */
2256
+ persistMigrated(next) {
2257
+ const withStamp = {
2258
+ ...next,
2259
+ updatedAt: Date.now()
2260
+ };
2261
+ this.locations.set(withStamp.id, withStamp);
2262
+ const store = this.locationStore;
2263
+ if (!store) return;
2264
+ store.upsert(withStamp).catch((err) => {
2265
+ this.logger.error("storage-orchestrator: state-model migration persist failed", { meta: {
2266
+ id: withStamp.id,
2267
+ error: err instanceof Error ? err.message : String(err)
1860
2268
  } });
1861
- }
2269
+ });
1862
2270
  }
1863
2271
  /**
1864
2272
  * Inject the declaration-driven cardinality source. Called once by the
@@ -1913,7 +2321,7 @@ var StorageOrchestratorService = class {
1913
2321
  const rows = await this.locationStore.loadAll();
1914
2322
  for (const loc of rows) this.locations.set(loc.id, loc);
1915
2323
  this.logger.info("storage-orchestrator: hydrated locations from store", { meta: { loaded: this.locations.size } });
1916
- this.reportDisabledDefaults();
2324
+ this.migrateOntoTheWriteSet(rows);
1917
2325
  }
1918
2326
  /**
1919
2327
  * Boot backfill (SP1): stamp `nodeId` on every persisted node-local
@@ -1962,40 +2370,50 @@ var StorageOrchestratorService = class {
1962
2370
  const all = [...this.locations.values()];
1963
2371
  return filter?.type ? all.filter((l) => l.type === filter.type) : all;
1964
2372
  }
1965
- getDefaultLocation(type) {
1966
- for (const loc of this.locations.values()) if (loc.type === type && loc.isDefault) return loc;
1967
- return null;
2373
+ /**
2374
+ * The WRITE SET of a type: every enabled location of it, in stable id order.
2375
+ * This replaced `getDefaultLocation` (D383) — a type does not have one write
2376
+ * target any more, it has a pool, and every consumer that must pick one picks
2377
+ * from here by its own rule (the recorder by headroom, a drain by an operator
2378
+ * -named destination).
2379
+ */
2380
+ writeTargets(type) {
2381
+ return [...this.locations.values()].filter((loc) => loc.type === type && isLocationEnabled(loc)).toSorted((a, b) => a.id.localeCompare(b.id));
1968
2382
  }
1969
2383
  /**
1970
- * Repoint several logical defaults as one in-memory commit. The migration
1971
- * coordinator persists its `repointing` phase before calling this method, so
1972
- * a process crash during the backing-store writes is recovered by replaying
1973
- * the same idempotent target set before anyone resumes writers.
2384
+ * Change the WRITE SET as one compensated commit: each named location is
2385
+ * enabled (it joins the pool) or disabled (it becomes read-only — still read,
2386
+ * still played back, still age-swept, still drained).
2387
+ *
2388
+ * This is the cutover verb, and its only caller is the migration coordinator,
2389
+ * which persists its `repointing` phase BEFORE calling so a crash mid-write
2390
+ * is recovered by replaying the same idempotent set before writers resume.
2391
+ *
2392
+ * It moves ONLY the locations it is given: a cutover from A to C on a class
2393
+ * that also writes to B leaves B in the pool, because the operator's pool is
2394
+ * the authority and a migration is not a licence to shrink it. The commit is
2395
+ * refused when it would leave a write-serving type with nothing enabled —
2396
+ * the same refusal `upsertLocation` applies, judged on the RESULT so an
2397
+ * enable and a disable in one commit are weighed together.
1974
2398
  */
1975
- async setDefaultLocations(targetByType) {
1976
- if (targetByType.size === 0) return;
2399
+ async setWriteTargets(enabledByLocationId) {
2400
+ if (enabledByLocationId.size === 0) return;
1977
2401
  const now = Date.now();
1978
2402
  const nextById = new Map(this.locations);
1979
2403
  const touched = /* @__PURE__ */ new Map();
1980
- for (const [type, targetId] of targetByType) {
1981
- const target = nextById.get(targetId);
1982
- if (!target) throw new Error(`Storage location "${targetId}" not found`);
1983
- if (target.type !== type) throw new Error(`Storage location "${targetId}" is type "${target.type}", expected "${type}"`);
1984
- for (const [id, location] of nextById) {
1985
- if (location.type !== type) continue;
1986
- const isDefault = id === targetId;
1987
- const enable = isDefault && location.enabled === false;
1988
- if (location.isDefault === isDefault && !enable) continue;
1989
- const updated = {
1990
- ...location,
1991
- isDefault,
1992
- ...enable ? { enabled: true } : {},
1993
- updatedAt: now
1994
- };
1995
- nextById.set(id, updated);
1996
- touched.set(id, updated);
1997
- }
2404
+ for (const [id, enabled] of enabledByLocationId) {
2405
+ const location = nextById.get(id);
2406
+ if (!location) throw new Error(`Storage location "${id}" not found`);
2407
+ if (isLocationEnabled(location) === enabled) continue;
2408
+ const updated = withLocationMode({
2409
+ ...location,
2410
+ updatedAt: now
2411
+ }, enabled ? "active" : "readonly");
2412
+ nextById.set(id, updated);
2413
+ touched.set(id, updated);
1998
2414
  }
2415
+ if (touched.size === 0) return;
2416
+ refuseTypeWithoutWriteTarget(touched.values(), nextById.values());
1999
2417
  if (this.locationStore) {
2000
2418
  const previous = /* @__PURE__ */ new Map();
2001
2419
  for (const id of touched.keys()) {
@@ -2015,13 +2433,10 @@ var StorageOrchestratorService = class {
2015
2433
  for (const [id, location] of nextById) this.locations.set(id, location);
2016
2434
  }
2017
2435
  /**
2018
- * Insert or update a location. If `isDefault: true`, atomically
2019
- * demotes any other default for the same type — operators always see
2020
- * exactly one default per type.
2436
+ * Insert or update a location.
2021
2437
  *
2022
- * When a persistence store is wired (Task 6), the new record AND any
2023
- * implicitly-demoted siblings are persisted before the in-memory map
2024
- * mutation returns. Persistence errors propagate to the caller.
2438
+ * When a persistence store is wired (Task 6), the record is mirrored to it;
2439
+ * the mirror is fire-and-forget and its errors reach the logger.
2025
2440
  */
2026
2441
  /**
2027
2442
  * Refuse a `(location kind, provider)` pair the kind cannot use.
@@ -2097,40 +2512,24 @@ var StorageOrchestratorService = class {
2097
2512
  ...input,
2098
2513
  nodeId: "hub"
2099
2514
  };
2100
- input = {
2101
- ...input,
2102
- ...resolveEnabled(input, existing, this.hasAnyLocationOfType(input.type))
2103
- };
2104
- const next = {
2515
+ this.refuseSameTypeRootCollision(input, existing);
2516
+ const next = withLocationMode({
2105
2517
  ...input,
2106
2518
  createdAt: existing?.createdAt ?? now,
2107
2519
  updatedAt: now
2108
- };
2520
+ }, resolveUpsertMode(input, existing, this.hasAnyLocationOfType(input.type)));
2109
2521
  refuseLastWriteTargetDisabled(existing, next, this.locations.values());
2110
- const demoted = [];
2111
- if (next.isDefault) {
2112
- for (const [otherId, otherLoc] of this.locations) if (otherLoc.type === next.type && otherLoc.id !== next.id && otherLoc.isDefault) {
2113
- const updated = {
2114
- ...otherLoc,
2115
- isDefault: false,
2116
- updatedAt: now
2117
- };
2118
- this.locations.set(otherId, updated);
2119
- demoted.push(updated);
2120
- }
2121
- }
2122
2522
  this.locations.set(next.id, next);
2123
2523
  this.logger.debug("storage-orchestrator: upsertLocation", { meta: {
2124
2524
  id: next.id,
2125
2525
  type: next.type,
2126
2526
  providerId: next.providerId,
2127
- isDefault: next.isDefault
2527
+ mode: next.mode
2128
2528
  } });
2129
2529
  if (this.locationStore) {
2130
2530
  const store = this.locationStore;
2131
2531
  (async () => {
2132
2532
  try {
2133
- for (const d of demoted) await store.upsert(d);
2134
2533
  await store.upsert(next);
2135
2534
  } catch (err) {
2136
2535
  this.logger.error("storage-orchestrator: upsert persistence failed", { meta: {
@@ -2145,8 +2544,7 @@ var StorageOrchestratorService = class {
2145
2544
  /**
2146
2545
  * Remove a location. Refuses the last location of a type and the last
2147
2546
  * enabled location of a type. `isSystem` is informational — a frozen
2148
- * recordings `:default` is deletable once a sibling is live. Deleting
2149
- * the flagged default promotes an enabled sibling. Occupancy still
2547
+ * recordings `:default` is deletable once a sibling is live. Occupancy still
2150
2548
  * refuses unless `force` is set; force never overrides uniqueness /
2151
2549
  * last-enabled.
2152
2550
  */
@@ -2155,10 +2553,8 @@ var StorageOrchestratorService = class {
2155
2553
  if (!loc) throw new Error(`Storage location "${id}" not found`);
2156
2554
  const ofType = [...this.locations.values()].filter((l) => l.type === loc.type);
2157
2555
  if (ofType.length <= 1) throw new Error(`Cannot delete "${id}" — it is the only location for type "${loc.type}"`);
2158
- const enabledOfType = ofType.filter((l) => l.enabled !== false);
2159
- if (loc.enabled !== false && enabledOfType.length <= 1) throw new Error(`Cannot delete "${id}" — it is the only enabled location for type "${loc.type}"`);
2160
- const successor = loc.isDefault === true ? ofType.find((l) => l.id !== id && l.enabled !== false) : void 0;
2161
- if (loc.isDefault === true && successor === void 0) throw new Error(`Cannot delete "${id}" — it is the only enabled location for type "${loc.type}"`);
2556
+ const enabledOfType = ofType.filter((l) => mayWriteToLocation(l));
2557
+ if (mayWriteToLocation(loc) && enabledOfType.length <= 1) throw new Error(`Cannot delete "${id}" — it is the only enabled location for type "${loc.type}"`);
2162
2558
  const occupancy = await this.probeOccupancy(loc);
2163
2559
  if (occupancy !== "empty") {
2164
2560
  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`);
@@ -2168,21 +2564,6 @@ var StorageOrchestratorService = class {
2168
2564
  occupancy
2169
2565
  } });
2170
2566
  }
2171
- if (successor !== void 0 && successor.isDefault !== true) {
2172
- const now = Date.now();
2173
- const promoted = {
2174
- ...successor,
2175
- isDefault: true,
2176
- updatedAt: now
2177
- };
2178
- this.locations.set(successor.id, promoted);
2179
- if (this.locationStore) this.locationStore.upsert(promoted).catch((err) => {
2180
- this.logger.error("storage-orchestrator: default promotion persist failed", { meta: {
2181
- id: successor.id,
2182
- error: err instanceof Error ? err.message : String(err)
2183
- } });
2184
- });
2185
- }
2186
2567
  this.locations.delete(id);
2187
2568
  if (this.locationStore) this.locationStore.delete(id).catch((err) => {
2188
2569
  this.logger.error("storage-orchestrator: delete persistence failed", { meta: {
@@ -2261,9 +2642,17 @@ var StorageOrchestratorService = class {
2261
2642
  if (!loc) throw new Error(`Storage location "${ref}" not found`);
2262
2643
  return loc;
2263
2644
  }
2264
- const def = this.getDefaultLocation(ref);
2265
- if (!def) throw new Error(`No default storage location configured for type "${ref}" — register one or pass a fully-qualified id (\`<type>:<slug>\`)`);
2266
- return def;
2645
+ const ofType = [...this.locations.values()].filter((loc) => loc.type === ref);
2646
+ if (ofType.length === 1) return ofType[0];
2647
+ if (ofType.length === 0) throw new Error(`No storage location configured for type "${ref}" — register one or pass a fully-qualified id (\`<type>:<slug>\`)`);
2648
+ const slugged = ofType.find((loc) => loc.id === `${ref}:default`);
2649
+ 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>\`)`);
2650
+ 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: {
2651
+ ref,
2652
+ resolved: slugged.id,
2653
+ candidates: ofType.length
2654
+ } });
2655
+ return slugged;
2267
2656
  }
2268
2657
  /**
2269
2658
  * Find the storage-provider that backs a given location — the entry point
@@ -2367,9 +2756,11 @@ var StorageOrchestratorService = class {
2367
2756
  * declarations on a populated map is a no-op (each declared `id`
2368
2757
  * already resolves to an existing `<id>:default`).
2369
2758
  *
2370
- * Convention: the bare-type ref `'recordings'` resolves via
2371
- * `getDefaultLocation('recordings')` (walks `isDefault === true`); the
2372
- * actual stored `id` is `'recordings:default'` so it satisfies the
2759
+ * The seeded row is born ENABLED — a fresh install must be able to write the
2760
+ * moment it boots, and `enabled` is the only write switch there is (D383).
2761
+ * The `:default` suffix is a NAME kept for idempotency across existing
2762
+ * installs, not a flag: the stored `id` is `'recordings:default'` so it
2763
+ * satisfies the
2373
2764
  * `^[a-z][a-z0-9-]*:[a-z0-9-]+$` regex on `StorageLocationSchema.id`.
2374
2765
  *
2375
2766
  * `basePath` is the storage root (e.g. `<CAMSTACK_DATA>`). Each
@@ -2394,7 +2785,7 @@ var StorageOrchestratorService = class {
2394
2785
  displayName: d.displayName,
2395
2786
  providerId: input.providerId,
2396
2787
  config: { basePath: base },
2397
- isDefault: true,
2788
+ enabled: true,
2398
2789
  isSystem: false
2399
2790
  });
2400
2791
  added++;
@@ -2404,6 +2795,34 @@ var StorageOrchestratorService = class {
2404
2795
  basePath: input.basePath
2405
2796
  } });
2406
2797
  }
2798
+ /**
2799
+ * The same-type physical-root refusal (D389).
2800
+ *
2801
+ * An unresolvable root — another node's disk, a remote provider, a path that
2802
+ * does not exist yet — WARNS and lets the write through. Refusing on a read
2803
+ * that could not be taken blocks the operator on a doubt, and the recorder's
2804
+ * demotion still catches what slips past (D49's spirit).
2805
+ */
2806
+ refuseSameTypeRootCollision(input, existing) {
2807
+ const verdict = findRootCollision(input, this.locations.values(), this.resolveRoot, existing);
2808
+ if (verdict.outcome === "clear") return;
2809
+ if (verdict.outcome === "unknown") {
2810
+ 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: {
2811
+ id: input.id,
2812
+ type: input.type,
2813
+ ...verdict.unknown
2814
+ } });
2815
+ return;
2816
+ }
2817
+ throw new Error(rootCollisionMessage(input.id, verdict.collision));
2818
+ }
2819
+ /** Injectable so a test can drive symlinks and unresolvable paths without
2820
+ * touching a real filesystem. Production takes the shared derivation. */
2821
+ resolveRoot = (basePath) => physicalRootOf(basePath);
2822
+ /** Test seam for {@link refuseSameTypeRootCollision}. */
2823
+ setRootResolver(resolveRoot) {
2824
+ this.resolveRoot = resolveRoot;
2825
+ }
2407
2826
  /** Internal: check whether any location of the given type exists. */
2408
2827
  hasAnyLocationOfType(type) {
2409
2828
  for (const loc of this.locations.values()) if (loc.type === type) return true;
@@ -2411,42 +2830,140 @@ var StorageOrchestratorService = class {
2411
2830
  }
2412
2831
  };
2413
2832
  /**
2414
- * Resolve `enabled` for one upsert — see the call site in `upsertLocation` for
2415
- * the ordered rules. Pure: every input it needs is a parameter.
2833
+ * Resolve the MODE one upsert lands on (D385). Pure: every input it needs is a
2834
+ * parameter. Ordered rules:
2835
+ *
2836
+ * 1. An explicit `mode` always wins — the operator named the state.
2837
+ * 2. An explicit `enabled` (the retired switch, still on the wire for one
2838
+ * release) means `active` / `readonly`. It can never select `drain` or
2839
+ * `disabled`: those are states only the new field can ask for, so an old
2840
+ * client cannot start erasing a disk or hide footage by accident.
2841
+ * 3. On an UPDATE, an omitted state preserves what is stored — a rename or a
2842
+ * path edit must never flip a live write target, and must never cancel a
2843
+ * drain in progress.
2844
+ * 4. On a CREATE, an omitted state is `readonly` — creating a disk must not
2845
+ * start writing to it — UNLESS it is the FIRST location of its type, where
2846
+ * there is nothing else that could serve.
2416
2847
  */
2417
- function resolveEnabled(input, existing, typeAlreadyHasLocation) {
2418
- const requested = input.enabled ?? existing?.enabled;
2419
- if (requested === void 0) {
2420
- if (existing) return {};
2421
- return { enabled: input.isDefault || !typeAlreadyHasLocation };
2848
+ function resolveUpsertMode(input, existing, typeAlreadyHasLocation) {
2849
+ if (input.mode !== void 0) {
2850
+ if (input.enabled === void 0 || input.enabled === modeMayWrite(input.mode)) return input.mode;
2422
2851
  }
2423
- return { enabled: requested };
2852
+ if (input.enabled !== void 0) return input.enabled ? "active" : "readonly";
2853
+ if (existing) return resolveLocationMode(existing);
2854
+ return typeAlreadyHasLocation ? "readonly" : "active";
2424
2855
  }
2425
2856
  /** Types that may have zero enabled locations. Backups with none is sad, not illegal. */
2426
2857
  var OPTIONAL_WRITE_TYPES = new Set(["backups"]);
2427
2858
  /**
2428
2859
  * Refuse an UPDATE that would leave a write-serving type with no enabled
2429
2860
  * location. Creates are exempt (an explicit `enabled: false` on the first
2430
- * location is already a named test). `isDefault` is not consulted — enabled
2431
- * is the write set.
2861
+ * location is already a named test). Enabled IS the write set.
2432
2862
  */
2433
2863
  function refuseLastWriteTargetDisabled(existing, next, locations) {
2434
2864
  if (OPTIONAL_WRITE_TYPES.has(next.type)) return;
2435
- if (next.enabled !== false) return;
2865
+ if (mayWriteToLocation(next)) return;
2436
2866
  if (existing === void 0) return;
2437
- if (existing.enabled === false) return;
2867
+ if (!mayWriteToLocation(existing)) return;
2438
2868
  for (const loc of locations) {
2439
2869
  if (loc.id === next.id || loc.type !== next.type) continue;
2440
- if (loc.enabled !== false) return;
2870
+ if (mayWriteToLocation(loc)) return;
2441
2871
  }
2442
2872
  throw new Error(`Cannot disable "${next.id}": it is the last enabled location of type "${next.type}". Enable another location of this type first.`);
2443
2873
  }
2874
+ /**
2875
+ * The same refusal, judged on a whole COMMIT rather than one row: a
2876
+ * multi-location change (a cutover enables its destination and disables its
2877
+ * source in one go) must be weighed on its RESULT, or the disable half looks
2878
+ * like the last write target leaving while the enable half is already in the
2879
+ * same map.
2880
+ */
2881
+ function refuseTypeWithoutWriteTarget(touched, result) {
2882
+ const rows = [...result];
2883
+ for (const type of new Set([...touched].map((loc) => loc.type))) {
2884
+ if (OPTIONAL_WRITE_TYPES.has(type)) continue;
2885
+ const ofType = rows.filter((loc) => loc.type === type);
2886
+ if (ofType.length === 0) continue;
2887
+ if (ofType.some((loc) => isLocationEnabled(loc))) continue;
2888
+ 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.`);
2889
+ }
2890
+ }
2891
+ //#endregion
2892
+ //#region src/builtins/storage-orchestrator/drain-ratchet.ts
2893
+ /** The state a drain starts from. No deletion happens on this sweep. */
2894
+ function beginDrain(observation) {
2895
+ return {
2896
+ locationId: observation.locationId,
2897
+ startedAtMs: observation.atMs,
2898
+ startBytes: observation.usedBytes,
2899
+ capBytes: observation.usedBytes,
2900
+ lastPeerBytes: observation.peerUsedBytes,
2901
+ lastSweepMs: observation.atMs,
2902
+ observedGrowthBytes: 0,
2903
+ observedElapsedMs: 0
2904
+ };
2905
+ }
2906
+ /**
2907
+ * Advance the ratchet by one sweep. `previous` is `null` on the first sweep of
2908
+ * a drain (and after a state the caller cleared), which only ARMS the ratchet:
2909
+ * the first growth can be measured no earlier than the second sweep, and a cap
2910
+ * derived from no measurement would be a rate the operator never asked for.
2911
+ */
2912
+ function stepDrain(previous, observation) {
2913
+ if (previous === null) return {
2914
+ state: beginDrain(observation),
2915
+ capBytes: null,
2916
+ drained: observation.usedBytes <= 0,
2917
+ growthBytes: null
2918
+ };
2919
+ const growthBytes = previous.lastPeerBytes !== null && observation.peerUsedBytes !== null ? observation.peerUsedBytes - previous.lastPeerBytes : null;
2920
+ const ceded = growthBytes !== null && growthBytes > 0 ? growthBytes : 0;
2921
+ const nextCap = Math.max(0, Math.min(previous.capBytes, observation.usedBytes) - ceded);
2922
+ const state = {
2923
+ ...previous,
2924
+ capBytes: nextCap,
2925
+ lastPeerBytes: observation.peerUsedBytes ?? previous.lastPeerBytes,
2926
+ lastSweepMs: observation.atMs,
2927
+ observedGrowthBytes: previous.observedGrowthBytes + ceded,
2928
+ observedElapsedMs: growthBytes === null ? previous.observedElapsedMs : previous.observedElapsedMs + Math.max(0, observation.atMs - previous.lastSweepMs)
2929
+ };
2930
+ if (observation.usedBytes <= 0) return {
2931
+ state: {
2932
+ ...state,
2933
+ capBytes: 0
2934
+ },
2935
+ capBytes: null,
2936
+ drained: true,
2937
+ growthBytes
2938
+ };
2939
+ return {
2940
+ state,
2941
+ capBytes: nextCap,
2942
+ drained: false,
2943
+ growthBytes
2944
+ };
2945
+ }
2946
+ /** Progress for a UI, derived from the persisted state and today's usage. */
2947
+ function drainProgressOf(state, usedBytes, nowMs) {
2948
+ const bytesRemaining = Math.max(0, usedBytes);
2949
+ const rateBytesPerMs = state.observedGrowthBytes > 0 && state.observedElapsedMs > 0 ? state.observedGrowthBytes / state.observedElapsedMs : null;
2950
+ return {
2951
+ locationId: state.locationId,
2952
+ startedAtMs: state.startedAtMs,
2953
+ startBytes: state.startBytes,
2954
+ bytesRemaining,
2955
+ drained: bytesRemaining <= 0,
2956
+ estimatedEmptyAtMs: rateBytesPerMs === null || bytesRemaining <= 0 ? null : nowMs + Math.ceil(bytesRemaining / rateBytesPerMs)
2957
+ };
2958
+ }
2444
2959
  //#endregion
2445
2960
  //#region src/builtins/storage-orchestrator/storage-pressure-manager.ts
2446
2961
  var DEFAULT_MAX_ROUNDS = 8;
2447
2962
  var DEFAULT_HYSTERESIS_PERCENT = 1;
2448
2963
  var StoragePressureManager = class {
2449
2964
  deps;
2965
+ /** Last sweep's drain progress, by location. Read-only surface for the UI. */
2966
+ drainProgress = /* @__PURE__ */ new Map();
2450
2967
  constructor(deps) {
2451
2968
  this.deps = deps;
2452
2969
  }
@@ -2488,6 +3005,73 @@ var StoragePressureManager = class {
2488
3005
  } });
2489
3006
  }
2490
3007
  }
3008
+ await this.sweepDrains();
3009
+ }
3010
+ /**
3011
+ * The drain pass (D386). A `drain` location is excluded from nothing — it is
3012
+ * given a monotonically descending effective usage cap and handed to the SAME
3013
+ * `relieveToUsageCap` machinery every other cap uses. There is no second
3014
+ * eviction path, and no operator-facing rate.
3015
+ */
3016
+ async sweepDrains() {
3017
+ const plan = await this.deps.getDrainPlan?.() ?? [];
3018
+ const store = this.deps.drainState;
3019
+ if (store === void 0) {
3020
+ 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) } });
3021
+ this.drainProgress = /* @__PURE__ */ new Map();
3022
+ return;
3023
+ }
3024
+ const persisted = new Map((await store.load()).map((state) => [state.locationId, state]));
3025
+ const draining = new Set(plan.map((entry) => entry.locationId));
3026
+ for (const locationId of persisted.keys()) {
3027
+ if (draining.has(locationId)) continue;
3028
+ await store.clear(locationId);
3029
+ this.drainProgress.delete(locationId);
3030
+ this.deps.logger.info("storage drain: stopped — location is no longer draining", { meta: { locationId } });
3031
+ }
3032
+ const nowMs = Date.now();
3033
+ const progress = /* @__PURE__ */ new Map();
3034
+ for (const entry of plan) try {
3035
+ const usedBytes = await this.evictableUsageOf(entry.locationId);
3036
+ const peerUsedBytes = entry.activePeerLocationIds.length === 0 ? null : await this.evictableUsageOfAll(entry.activePeerLocationIds);
3037
+ const step = stepDrain(persisted.get(entry.locationId) ?? null, {
3038
+ locationId: entry.locationId,
3039
+ usedBytes,
3040
+ peerUsedBytes,
3041
+ atMs: nowMs
3042
+ });
3043
+ await store.save(step.state);
3044
+ this.deps.logger.info("storage drain: sweep", { meta: {
3045
+ locationId: entry.locationId,
3046
+ usedBytes,
3047
+ peerUsedBytes,
3048
+ growthBytes: step.growthBytes,
3049
+ capBytes: step.capBytes,
3050
+ drained: step.drained
3051
+ } });
3052
+ if (step.capBytes !== null) await this.relieveToUsageCap(entry.locationId, step.capBytes);
3053
+ progress.set(entry.locationId, drainProgressOf(step.state, await this.evictableUsageOf(entry.locationId), nowMs));
3054
+ } catch (err) {
3055
+ this.deps.logger.warn("storage drain: sweep failed for location", { meta: {
3056
+ locationId: entry.locationId,
3057
+ error: err instanceof Error ? err.message : String(err)
3058
+ } });
3059
+ }
3060
+ this.drainProgress = progress;
3061
+ }
3062
+ /** Progress for every draining location, as of the last sweep. The read a UI
3063
+ * asks for — it never triggers work of its own. */
3064
+ drainProgressSnapshot() {
3065
+ return [...this.drainProgress.values()];
3066
+ }
3067
+ /** Evictable bytes on one location, summed across every provider. */
3068
+ async evictableUsageOf(locationId) {
3069
+ const providers = this.deps.getEvictableProviders();
3070
+ return (await Promise.all(providers.map(async (p) => (await p.getEvictableUsage({ locationId })).bytes))).reduce((sum, bytes) => sum + bytes, 0);
3071
+ }
3072
+ /** Evictable bytes across a set of locations. */
3073
+ async evictableUsageOfAll(locationIds) {
3074
+ return (await Promise.all(locationIds.map((id) => this.evictableUsageOf(id)))).reduce((sum, bytes) => sum + bytes, 0);
2491
3075
  }
2492
3076
  /** Evict oldest-first until the location's evictable usage fits `maxBytes`. */
2493
3077
  async relieveToUsageCap(locationId, maxBytes) {
@@ -2605,6 +3189,7 @@ var StoragePressureManager = class {
2605
3189
  var STORAGE_PROVIDER_CAP_NAME = "storage-provider";
2606
3190
  var DATA_STORE_PROVIDER_CAP_NAME = "data-store-provider";
2607
3191
  var STORAGE_EVICTABLE_CAP_NAME = "storage-evictable";
3192
+ var STORAGE_OCCUPANCY_CAP_NAME = "storage-occupancy";
2608
3193
  /**
2609
3194
  * Single-node default: existing node-local locations live on the hub.
2610
3195
  * The boot backfill (SP1) stamps this on any node-local location seeded
@@ -2622,6 +3207,24 @@ var CAP_PROVIDER_REGISTERED_CATEGORY = "capability:provider-registered";
2622
3207
  var StorageOrchestratorAddon = class extends BaseAddon {
2623
3208
  service = null;
2624
3209
  pressureManager = null;
3210
+ /**
3211
+ * How much of each location CamStack itself holds (D388).
3212
+ *
3213
+ * Built on first use rather than in a field initializer: `this.ctx` does not
3214
+ * exist until `initialize()` runs, and a field initializer touching it throws
3215
+ * in the constructor.
3216
+ */
3217
+ occupancySlice = null;
3218
+ get occupancy() {
3219
+ if (this.occupancySlice) return this.occupancySlice;
3220
+ this.occupancySlice = new LocationOccupancySlice({
3221
+ logger: this.ctx.logger,
3222
+ getProviders: () => this.ctx.kernel.capabilityRegistry?.getCollection(STORAGE_OCCUPANCY_CAP_NAME) ?? [],
3223
+ locationIds: () => this.service?.listLocations().map((loc) => loc.id) ?? [],
3224
+ now: () => Date.now()
3225
+ });
3226
+ return this.occupancySlice;
3227
+ }
2625
3228
  pressureTimer = null;
2626
3229
  pressureSweepInFlight = false;
2627
3230
  migration = null;
@@ -2680,16 +3283,15 @@ var StorageOrchestratorAddon = class extends BaseAddon {
2680
3283
  const provider = {
2681
3284
  listLocations: async ({ type }) => {
2682
3285
  const rows = type !== void 0 ? service.listLocations({ type }) : service.listLocations();
3286
+ this.occupancy.serveAndRevalidate();
2683
3287
  return Promise.all(rows.map(async (loc) => ({
2684
3288
  ...this.redacted(loc),
2685
- capacity: loc.enabled === false ? null : await this.localCapacityOf(loc)
3289
+ ...this.occupancy.ownedFor(loc.id) !== void 0 ? { owned: this.occupancy.ownedFor(loc.id) } : {},
3290
+ capacity: isLocationEnabled(loc) ? await this.localCapacityOf(loc) : null
2686
3291
  })));
2687
3292
  },
2688
- getDefaultLocation: async ({ type }) => {
2689
- const loc = service.getDefaultLocation(type);
2690
- return loc === null ? null : this.redacted(loc);
2691
- },
2692
3293
  listLocationDeclarations: async () => service.listDeclarations(),
3294
+ listDrainProgress: async () => this.drainProgress(),
2693
3295
  upsertLocation: async (input) => {
2694
3296
  const stored = service.getLocationById(input.id);
2695
3297
  const config = restoreRedactedSecrets(input.config, stored?.config, this.secretKeysFor(input.providerId, stored?.config ?? {}));
@@ -2697,10 +3299,13 @@ var StorageOrchestratorAddon = class extends BaseAddon {
2697
3299
  ...input,
2698
3300
  config
2699
3301
  });
3302
+ this.announceLocationsChanged(saved.id, saved.type, "upsert");
2700
3303
  return this.redacted(saved);
2701
3304
  },
2702
3305
  deleteLocation: async ({ id, force }) => {
3306
+ const doomed = service.getLocationById(id);
2703
3307
  await service.deleteLocation(id, { force: force === true });
3308
+ this.announceLocationsChanged(id, doomed?.type ?? "", "delete");
2704
3309
  },
2705
3310
  testLocation: async ({ id }) => {
2706
3311
  const loc = service.getLocationById(id);
@@ -2961,7 +3566,17 @@ var StorageOrchestratorAddon = class extends BaseAddon {
2961
3566
  getEvictableProviders: () => this.ctx.kernel.capabilityRegistry?.getCollection(STORAGE_EVICTABLE_CAP_NAME) ?? [],
2962
3567
  getLocationThresholds: () => this.resolveLocationThresholds(),
2963
3568
  getLocationMaxUsedBytes: () => this.resolveLocationMaxUsedBytes(),
2964
- getLocationDeviceKey: (id) => this.locationDeviceKey(id)
3569
+ getLocationDeviceKey: (id) => this.locationDeviceKey(id),
3570
+ getDrainPlan: () => Promise.resolve(this.resolveDrainPlan()),
3571
+ drainState: {
3572
+ load: async () => await this.resolveDrainStore()?.load() ?? [],
3573
+ save: async (state) => {
3574
+ await this.resolveDrainStore()?.save(state);
3575
+ },
3576
+ clear: async (locationId) => {
3577
+ await this.resolveDrainStore()?.clear(locationId);
3578
+ }
3579
+ }
2965
3580
  });
2966
3581
  this.pressureTimer = setInterval(() => {
2967
3582
  this.runPressureSweep();
@@ -3109,14 +3724,64 @@ var StorageOrchestratorAddon = class extends BaseAddon {
3109
3724
  const infos = await collectProviderInfos(this.storageProviders(), () => {});
3110
3725
  const byProvider = new Map(infos.map((i) => [i.providerId, i]));
3111
3726
  for (const loc of svc.listLocations()) {
3727
+ if (!mayReadLocation(loc)) continue;
3112
3728
  const info = byProvider.get(loc.providerId);
3113
3729
  if (!info || !info.shouldSaveDiskSpace) continue;
3114
- const override = loc.config["minFreePercent"];
3115
- const threshold = typeof override === "number" && Number.isFinite(override) && override >= 0 ? override : info.minFreePercent ?? 0;
3730
+ const threshold = resolveMinFreePercent(loc.config, info.minFreePercent ?? 0, await this.localCapacityOf(loc), (message, meta) => this.logMinFreeRedundancyOnce(loc.id, message, meta));
3116
3731
  if (threshold > 0) out.set(loc.id, threshold);
3117
3732
  }
3118
3733
  return out;
3119
3734
  }
3735
+ /** Location ids that have already logged the `minFreeGb` / `minFreePercent`
3736
+ * redundancy. The pressure sweep runs on a timer; the notice is worth
3737
+ * exactly one line per location per process, not one per minute. */
3738
+ minFreeRedundancyLogged = /* @__PURE__ */ new Set();
3739
+ logMinFreeRedundancyOnce(locationId, message, meta) {
3740
+ if (this.minFreeRedundancyLogged.has(locationId)) return;
3741
+ this.minFreeRedundancyLogged.add(locationId);
3742
+ this.ctx.logger.warn(message, { meta: {
3743
+ locationId,
3744
+ ...meta
3745
+ } });
3746
+ }
3747
+ /**
3748
+ * The draining locations and the ACTIVE peers of their class whose growth
3749
+ * paces them (D386). Rebuilt every sweep from the live mode of every
3750
+ * location, so this is also what STOPS a drain: a location the operator
3751
+ * switched back simply stops appearing.
3752
+ */
3753
+ drainStore = null;
3754
+ /** The drain ratchet's durable home, once a data-store engine exists. Same
3755
+ * late-wiring window as the location table — on an agent it is never. */
3756
+ resolveDrainStore() {
3757
+ if (this.drainStore) return this.drainStore;
3758
+ const engine = this.ctx.kernel.capabilityRegistry?.getCollection(DATA_STORE_PROVIDER_CAP_NAME)[0];
3759
+ if (!engine) return null;
3760
+ this.drainStore = new SqliteDrainStateStore(engine);
3761
+ return this.drainStore;
3762
+ }
3763
+ drainPlanRefusalLogged = false;
3764
+ resolveDrainPlan() {
3765
+ const svc = this.service;
3766
+ if (!svc) return [];
3767
+ if (this.resolveDrainStore() === null) {
3768
+ const draining = svc.listLocations().filter((l) => evictionPolicyOfLocation(l) === "drain");
3769
+ if (draining.length > 0 && !this.drainPlanRefusalLogged) {
3770
+ this.drainPlanRefusalLogged = true;
3771
+ 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) } });
3772
+ }
3773
+ return [];
3774
+ }
3775
+ const locations = svc.listLocations();
3776
+ return locations.filter((loc) => evictionPolicyOfLocation(loc) === "drain").map((loc) => ({
3777
+ locationId: loc.id,
3778
+ activePeerLocationIds: locations.filter((peer) => peer.type === loc.type && peer.id !== loc.id && mayWriteToLocation(peer)).map((peer) => peer.id)
3779
+ }));
3780
+ }
3781
+ /** Drain progress for every draining location, as of the last sweep. */
3782
+ drainProgress() {
3783
+ return this.pressureManager?.drainProgressSnapshot() ?? [];
3784
+ }
3120
3785
  /**
3121
3786
  * What the provider's own schema declared secret, or `undefined` when this
3122
3787
  * node has never heard from that provider. The distinction is the whole
@@ -3129,6 +3794,48 @@ var StorageOrchestratorAddon = class extends BaseAddon {
3129
3794
  secretKeysFor(providerId, config) {
3130
3795
  return secretKeysForConfig(config, this.declaredSecretKeys(providerId));
3131
3796
  }
3797
+ /**
3798
+ * Ring the doorbell: the SET of storage locations changed (D387).
3799
+ *
3800
+ * A consumer that resolves locations at boot had no way to learn that the
3801
+ * operator added, edited or removed one — the recorder kept writing to the
3802
+ * disk set it resolved hours ago, and an operator who added a second
3803
+ * recordings disk in the admin UI got nothing until something unrelated
3804
+ * happened to re-resolve.
3805
+ *
3806
+ * Telemetry, and deliberately so (D8/D11): it carries no row, it is allowed
3807
+ * to be dropped, and every consumer re-reads `listLocations` for itself and
3808
+ * converges on its own periodic path anyway. A bulk edit rings it once per
3809
+ * row — coalescing is the consumer's job, not this one's.
3810
+ *
3811
+ * Never throws into the mutation that produced it: the location IS saved,
3812
+ * and a bus that will not take a telemetry event must not turn that into a
3813
+ * failed upsert.
3814
+ */
3815
+ announceLocationsChanged(locationId, locationType, reason) {
3816
+ try {
3817
+ this.ctx.eventBus.emit({
3818
+ id: randomUUID(),
3819
+ timestamp: /* @__PURE__ */ new Date(),
3820
+ source: {
3821
+ type: "addon",
3822
+ id: this.ctx.id
3823
+ },
3824
+ category: EventCategory.StorageLocationsChanged,
3825
+ data: {
3826
+ locationId,
3827
+ locationType,
3828
+ reason
3829
+ }
3830
+ });
3831
+ } catch (err) {
3832
+ this.ctx.logger.warn("storage-orchestrator: could not announce the location change — consumers will converge on their own periodic path", { meta: {
3833
+ locationId,
3834
+ reason,
3835
+ error: err instanceof Error ? err.message : String(err)
3836
+ } });
3837
+ }
3838
+ }
3132
3839
  /** A location with its provider's declared secrets replaced by the sentinel. */
3133
3840
  redacted(location) {
3134
3841
  return redactLocation(location, this.secretKeysFor(location.providerId, location.config));
@@ -3187,8 +3894,9 @@ var StorageOrchestratorAddon = class extends BaseAddon {
3187
3894
  const svc = this.service;
3188
3895
  if (!svc) return out;
3189
3896
  for (const loc of svc.listLocations()) {
3897
+ if (!mayReadLocation(loc)) continue;
3190
3898
  const raw = loc.config["maxUsedGb"];
3191
- if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) out.set(loc.id, Math.floor(raw * 1024 ** 3));
3899
+ if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) out.set(loc.id, gbToBytes(raw));
3192
3900
  }
3193
3901
  return out;
3194
3902
  }