@camstack/system 1.2.202 → 1.2.204

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/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/storage-orchestrator.addon.d.ts +47 -0
  46. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +714 -58
  47. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +714 -58
  48. package/dist/builtins/storage-orchestrator/storage-orchestrator.service.d.ts +32 -11
  49. package/dist/builtins/storage-orchestrator/storage-pressure-manager.d.ts +45 -0
  50. package/dist/builtins/system-config/system-config.addon.js +1 -1
  51. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  52. package/dist/builtins/winston-logging/index.js +1 -1
  53. package/dist/builtins/winston-logging/index.mjs +1 -1
  54. package/dist/{dist-jZb-HKI5.js → dist-BD7M9-5N.js} +749 -374
  55. package/dist/{dist-CiHYspij.mjs → dist-BmJpFsC2.mjs} +690 -375
  56. package/dist/index.js +1 -1
  57. package/dist/index.mjs +1 -1
  58. package/dist/{retired-settings-keys-DeRxo3vg.mjs → retired-settings-keys-BZJcJj6L.mjs} +1 -1
  59. package/dist/{retired-settings-keys-Dxv9S_lO.js → retired-settings-keys-h7YxHNjB.js} +1 -1
  60. 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-jZb-HKI5.js");
6
+ const require_dist = require("../../dist-BD7M9-5N.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,6 +199,7 @@ 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,
@@ -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,6 +243,7 @@ function mapRowToLocation(row) {
227
243
  config,
228
244
  ...nodeId !== void 0 ? { nodeId } : {},
229
245
  ...enabled !== void 0 ? { enabled } : {},
246
+ ...mode !== void 0 ? { mode } : {},
230
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),
@@ -576,7 +593,8 @@ var StorageMigrationCoordinator = class {
576
593
  const remaining = residue.filter((entry) => entry.storageClass === storageClass);
577
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.`);
578
595
  for (const entry of remaining) {
579
- 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 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.`);
580
598
  moves.push({
581
599
  storageClass,
582
600
  fromLocationId: entry.fromLocationId,
@@ -908,12 +926,9 @@ var StorageMigrationCoordinator = class {
908
926
  for (const move of job.moves) {
909
927
  if (move.freezeSource !== true) continue;
910
928
  const loc = this.deps.locations.getLocationById(move.fromLocationId);
911
- if (!loc || loc.enabled === false) continue;
929
+ if (!loc || !require_dist.mayWriteToLocation(loc)) continue;
912
930
  const { createdAt: _c, updatedAt: _u, capacity: _cap, ...rest } = loc;
913
- this.deps.locations.upsertLocation({
914
- ...rest,
915
- enabled: false
916
- });
931
+ this.deps.locations.upsertLocation(require_dist.withLocationMode(rest, "readonly"));
917
932
  }
918
933
  }
919
934
  /**
@@ -1184,6 +1199,136 @@ function createDataStoreDispatch(getEngines, getRemote) {
1184
1199
  };
1185
1200
  }
1186
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
1187
1332
  //#region src/builtins/storage-orchestrator/hub-settings-forward.ts
1188
1333
  /**
1189
1334
  * The data door with the HUB behind it — an agent's `settings-store` provider.
@@ -1395,6 +1540,139 @@ async function probeLocalDirectoryOccupancy(input) {
1395
1540
  return "empty";
1396
1541
  }
1397
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
1398
1676
  //#region src/builtins/storage-orchestrator/location-secrets.ts
1399
1677
  /**
1400
1678
  * The config keys the ORCHESTRATOR itself owns, as opposed to the ones a
@@ -1527,8 +1805,13 @@ var FOOTAGE_TYPES = new Set(["recordings", "recordingsLow"]);
1527
1805
  function isTerminal(job) {
1528
1806
  return job.phase === "done" || job.phase === "failed" || job.phase === "cancelled";
1529
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
+ */
1530
1813
  function footageFrozen(location) {
1531
- return location.enabled === false || location.config["readOnly"] === true;
1814
+ return require_dist.evictionPolicyOfLocation(location) === "never";
1532
1815
  }
1533
1816
  /**
1534
1817
  * One operator-facing cleanup of leftover analytics rows/blobs, optional
@@ -1689,6 +1972,78 @@ var StorageCleanupCoordinator = class {
1689
1972
  }
1690
1973
  };
1691
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
1692
2047
  //#region src/builtins/storage-orchestrator/storage-orchestrator.service.ts
1693
2048
  /**
1694
2049
  * Routing core for the `storage-orchestrator` builtin.
@@ -1849,38 +2204,41 @@ var StorageOrchestratorService = class {
1849
2204
  this.migrateOntoTheWriteSet(rows);
1850
2205
  }
1851
2206
  /**
1852
- * ONE explicit migration onto the enabled write set (D383), run once per
1853
- * boot against the rows that came off disk. Idempotent: the second boot finds
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
1854
2209
  * nothing to do.
1855
2210
  *
1856
2211
  * Two steps, in order, and each one is a WRITE — the point is that "absent"
1857
2212
  * stops existing rather than being re-interpreted on every read forever:
1858
2213
  *
1859
- * 1. A flagless row (persisted before `enabled` existed) is stamped
1860
- * `enabled: true`. Absence already meant active, so this preserves
1861
- * behaviour exactly; it is the operator's "the initial locations are
1862
- * enabled" spelled into the store.
1863
- * 2. A type left with NO enabled row — only reachable from a hand-edited
1864
- * store or a row an older build wrote — gets one enabled back, chosen as
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
1865
2223
  * the row the RETIRED default flag named, else the `:default`-slugged
1866
2224
  * row, else the lexicographically first. Loudly: a class with no write
1867
2225
  * target records nowhere, and refusing to repair it here would leave the
1868
2226
  * hub silently unable to write with no operator action pending.
1869
2227
  *
1870
- * An operator's explicit `enabled: false` is never reversed by step 1 — only
1871
- * step 2 touches it, and only when the alternative is a class that cannot
1872
- * write at all.
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.
1873
2234
  */
1874
2235
  migrateOntoTheWriteSet(rows) {
1875
2236
  const legacyDefaultOfType = /* @__PURE__ */ new Map();
1876
2237
  for (const row of rows) if (row.isDefault === true) legacyDefaultOfType.set(row.type, row.id);
1877
2238
  const stamped = [];
1878
2239
  for (const loc of this.locations.values()) {
1879
- if (loc.enabled !== void 0) continue;
1880
- this.persistMigrated({
1881
- ...loc,
1882
- enabled: true
1883
- });
2240
+ if (loc.mode !== void 0) continue;
2241
+ this.persistMigrated(require_dist.withLocationMode(loc, require_dist.legacyModeOf(loc) ?? "active"));
1884
2242
  stamped.push(loc.id);
1885
2243
  }
1886
2244
  const repaired = [];
@@ -1890,13 +2248,10 @@ var StorageOrchestratorService = class {
1890
2248
  const legacyDefault = legacyDefaultOfType.get(type);
1891
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];
1892
2250
  if (!pick) continue;
1893
- this.persistMigrated({
1894
- ...pick,
1895
- enabled: true
1896
- });
2251
+ this.persistMigrated(require_dist.withLocationMode(pick, "active"));
1897
2252
  repaired.push(pick.id);
1898
2253
  }
1899
- if (stamped.length > 0 || repaired.length > 0) this.logger.info("storage-orchestrator: migrated locations onto the enabled write set", { meta: {
2254
+ if (stamped.length > 0 || repaired.length > 0) this.logger.info("storage-orchestrator: migrated locations onto the state model", { meta: {
1900
2255
  stamped,
1901
2256
  repaired
1902
2257
  } });
@@ -1912,7 +2267,7 @@ var StorageOrchestratorService = class {
1912
2267
  const store = this.locationStore;
1913
2268
  if (!store) return;
1914
2269
  store.upsert(withStamp).catch((err) => {
1915
- this.logger.error("storage-orchestrator: write-set migration persist failed", { meta: {
2270
+ this.logger.error("storage-orchestrator: state-model migration persist failed", { meta: {
1916
2271
  id: withStamp.id,
1917
2272
  error: err instanceof Error ? err.message : String(err)
1918
2273
  } });
@@ -2054,12 +2409,11 @@ var StorageOrchestratorService = class {
2054
2409
  for (const [id, enabled] of enabledByLocationId) {
2055
2410
  const location = nextById.get(id);
2056
2411
  if (!location) throw new Error(`Storage location "${id}" not found`);
2057
- if (location.enabled === enabled) continue;
2058
- const updated = {
2412
+ if (require_dist.isLocationEnabled(location) === enabled) continue;
2413
+ const updated = require_dist.withLocationMode({
2059
2414
  ...location,
2060
- enabled,
2061
2415
  updatedAt: now
2062
- };
2416
+ }, enabled ? "active" : "readonly");
2063
2417
  nextById.set(id, updated);
2064
2418
  touched.set(id, updated);
2065
2419
  }
@@ -2163,22 +2517,19 @@ var StorageOrchestratorService = class {
2163
2517
  ...input,
2164
2518
  nodeId: "hub"
2165
2519
  };
2166
- input = {
2167
- ...input,
2168
- ...resolveEnabled(input, existing, this.hasAnyLocationOfType(input.type))
2169
- };
2170
- const next = {
2520
+ this.refuseSameTypeRootCollision(input, existing);
2521
+ const next = require_dist.withLocationMode({
2171
2522
  ...input,
2172
2523
  createdAt: existing?.createdAt ?? now,
2173
2524
  updatedAt: now
2174
- };
2525
+ }, resolveUpsertMode(input, existing, this.hasAnyLocationOfType(input.type)));
2175
2526
  refuseLastWriteTargetDisabled(existing, next, this.locations.values());
2176
2527
  this.locations.set(next.id, next);
2177
2528
  this.logger.debug("storage-orchestrator: upsertLocation", { meta: {
2178
2529
  id: next.id,
2179
2530
  type: next.type,
2180
2531
  providerId: next.providerId,
2181
- enabled: next.enabled
2532
+ mode: next.mode
2182
2533
  } });
2183
2534
  if (this.locationStore) {
2184
2535
  const store = this.locationStore;
@@ -2207,8 +2558,8 @@ var StorageOrchestratorService = class {
2207
2558
  if (!loc) throw new Error(`Storage location "${id}" not found`);
2208
2559
  const ofType = [...this.locations.values()].filter((l) => l.type === loc.type);
2209
2560
  if (ofType.length <= 1) throw new Error(`Cannot delete "${id}" — it is the only location for type "${loc.type}"`);
2210
- const enabledOfType = ofType.filter((l) => l.enabled !== false);
2211
- if (loc.enabled !== false && enabledOfType.length <= 1) 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}"`);
2212
2563
  const occupancy = await this.probeOccupancy(loc);
2213
2564
  if (occupancy !== "empty") {
2214
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`);
@@ -2449,6 +2800,34 @@ var StorageOrchestratorService = class {
2449
2800
  basePath: input.basePath
2450
2801
  } });
2451
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
+ }
2452
2831
  /** Internal: check whether any location of the given type exists. */
2453
2832
  hasAnyLocationOfType(type) {
2454
2833
  for (const loc of this.locations.values()) if (loc.type === type) return true;
@@ -2456,16 +2835,28 @@ var StorageOrchestratorService = class {
2456
2835
  }
2457
2836
  };
2458
2837
  /**
2459
- * Resolve `enabled` for one upsert — see the call site in `upsertLocation` for
2460
- * 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.
2461
2852
  */
2462
- function resolveEnabled(input, existing, typeAlreadyHasLocation) {
2463
- const requested = input.enabled ?? existing?.enabled;
2464
- if (requested === void 0) {
2465
- if (existing) return {};
2466
- return { enabled: !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;
2467
2856
  }
2468
- 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";
2469
2860
  }
2470
2861
  /** Types that may have zero enabled locations. Backups with none is sad, not illegal. */
2471
2862
  var OPTIONAL_WRITE_TYPES = new Set(["backups"]);
@@ -2476,12 +2867,12 @@ var OPTIONAL_WRITE_TYPES = new Set(["backups"]);
2476
2867
  */
2477
2868
  function refuseLastWriteTargetDisabled(existing, next, locations) {
2478
2869
  if (OPTIONAL_WRITE_TYPES.has(next.type)) return;
2479
- if (next.enabled !== false) return;
2870
+ if (require_dist.mayWriteToLocation(next)) return;
2480
2871
  if (existing === void 0) return;
2481
- if (existing.enabled === false) return;
2872
+ if (!require_dist.mayWriteToLocation(existing)) return;
2482
2873
  for (const loc of locations) {
2483
2874
  if (loc.id === next.id || loc.type !== next.type) continue;
2484
- if (loc.enabled !== false) return;
2875
+ if (require_dist.mayWriteToLocation(loc)) return;
2485
2876
  }
2486
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.`);
2487
2878
  }
@@ -2503,11 +2894,81 @@ function refuseTypeWithoutWriteTarget(touched, result) {
2503
2894
  }
2504
2895
  }
2505
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
+ }
2964
+ //#endregion
2506
2965
  //#region src/builtins/storage-orchestrator/storage-pressure-manager.ts
2507
2966
  var DEFAULT_MAX_ROUNDS = 8;
2508
2967
  var DEFAULT_HYSTERESIS_PERCENT = 1;
2509
2968
  var StoragePressureManager = class {
2510
2969
  deps;
2970
+ /** Last sweep's drain progress, by location. Read-only surface for the UI. */
2971
+ drainProgress = /* @__PURE__ */ new Map();
2511
2972
  constructor(deps) {
2512
2973
  this.deps = deps;
2513
2974
  }
@@ -2549,6 +3010,73 @@ var StoragePressureManager = class {
2549
3010
  } });
2550
3011
  }
2551
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);
2552
3080
  }
2553
3081
  /** Evict oldest-first until the location's evictable usage fits `maxBytes`. */
2554
3082
  async relieveToUsageCap(locationId, maxBytes) {
@@ -2666,6 +3194,7 @@ var StoragePressureManager = class {
2666
3194
  var STORAGE_PROVIDER_CAP_NAME = "storage-provider";
2667
3195
  var DATA_STORE_PROVIDER_CAP_NAME = "data-store-provider";
2668
3196
  var STORAGE_EVICTABLE_CAP_NAME = "storage-evictable";
3197
+ var STORAGE_OCCUPANCY_CAP_NAME = "storage-occupancy";
2669
3198
  /**
2670
3199
  * Single-node default: existing node-local locations live on the hub.
2671
3200
  * The boot backfill (SP1) stamps this on any node-local location seeded
@@ -2683,6 +3212,24 @@ var CAP_PROVIDER_REGISTERED_CATEGORY = "capability:provider-registered";
2683
3212
  var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
2684
3213
  service = null;
2685
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
+ }
2686
3233
  pressureTimer = null;
2687
3234
  pressureSweepInFlight = false;
2688
3235
  migration = null;
@@ -2741,12 +3288,15 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
2741
3288
  const provider = {
2742
3289
  listLocations: async ({ type }) => {
2743
3290
  const rows = type !== void 0 ? service.listLocations({ type }) : service.listLocations();
3291
+ this.occupancy.serveAndRevalidate();
2744
3292
  return Promise.all(rows.map(async (loc) => ({
2745
3293
  ...this.redacted(loc),
3294
+ ...this.occupancy.ownedFor(loc.id) !== void 0 ? { owned: this.occupancy.ownedFor(loc.id) } : {},
2746
3295
  capacity: require_dist.isLocationEnabled(loc) ? await this.localCapacityOf(loc) : null
2747
3296
  })));
2748
3297
  },
2749
3298
  listLocationDeclarations: async () => service.listDeclarations(),
3299
+ listDrainProgress: async () => this.drainProgress(),
2750
3300
  upsertLocation: async (input) => {
2751
3301
  const stored = service.getLocationById(input.id);
2752
3302
  const config = restoreRedactedSecrets(input.config, stored?.config, this.secretKeysFor(input.providerId, stored?.config ?? {}));
@@ -2754,10 +3304,13 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
2754
3304
  ...input,
2755
3305
  config
2756
3306
  });
3307
+ this.announceLocationsChanged(saved.id, saved.type, "upsert");
2757
3308
  return this.redacted(saved);
2758
3309
  },
2759
3310
  deleteLocation: async ({ id, force }) => {
3311
+ const doomed = service.getLocationById(id);
2760
3312
  await service.deleteLocation(id, { force: force === true });
3313
+ this.announceLocationsChanged(id, doomed?.type ?? "", "delete");
2761
3314
  },
2762
3315
  testLocation: async ({ id }) => {
2763
3316
  const loc = service.getLocationById(id);
@@ -3018,7 +3571,17 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
3018
3571
  getEvictableProviders: () => this.ctx.kernel.capabilityRegistry?.getCollection(STORAGE_EVICTABLE_CAP_NAME) ?? [],
3019
3572
  getLocationThresholds: () => this.resolveLocationThresholds(),
3020
3573
  getLocationMaxUsedBytes: () => this.resolveLocationMaxUsedBytes(),
3021
- 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
+ }
3022
3585
  });
3023
3586
  this.pressureTimer = setInterval(() => {
3024
3587
  this.runPressureSweep();
@@ -3166,14 +3729,64 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
3166
3729
  const infos = await collectProviderInfos(this.storageProviders(), () => {});
3167
3730
  const byProvider = new Map(infos.map((i) => [i.providerId, i]));
3168
3731
  for (const loc of svc.listLocations()) {
3732
+ if (!require_dist.mayReadLocation(loc)) continue;
3169
3733
  const info = byProvider.get(loc.providerId);
3170
3734
  if (!info || !info.shouldSaveDiskSpace) continue;
3171
- const override = loc.config["minFreePercent"];
3172
- 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));
3173
3736
  if (threshold > 0) out.set(loc.id, threshold);
3174
3737
  }
3175
3738
  return out;
3176
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
+ }
3177
3790
  /**
3178
3791
  * What the provider's own schema declared secret, or `undefined` when this
3179
3792
  * node has never heard from that provider. The distinction is the whole
@@ -3186,6 +3799,48 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
3186
3799
  secretKeysFor(providerId, config) {
3187
3800
  return secretKeysForConfig(config, this.declaredSecretKeys(providerId));
3188
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
+ }
3189
3844
  /** A location with its provider's declared secrets replaced by the sentinel. */
3190
3845
  redacted(location) {
3191
3846
  return redactLocation(location, this.secretKeysFor(location.providerId, location.config));
@@ -3244,8 +3899,9 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
3244
3899
  const svc = this.service;
3245
3900
  if (!svc) return out;
3246
3901
  for (const loc of svc.listLocations()) {
3902
+ if (!require_dist.mayReadLocation(loc)) continue;
3247
3903
  const raw = loc.config["maxUsedGb"];
3248
- 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));
3249
3905
  }
3250
3906
  return out;
3251
3907
  }