@camstack/system 1.2.202 → 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 (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-BFvWv_bz.js} +730 -372
  55. package/dist/{dist-CiHYspij.mjs → dist-DcMUcdeq.mjs} +671 -373
  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-CJfgnUSb.mjs} +1 -1
  59. package/dist/{retired-settings-keys-Dxv9S_lO.js → retired-settings-keys-Dd7K3K3r.js} +1 -1
  60. package/package.json +1 -1
@@ -1,7 +1,8 @@
1
- import { At as storageCapability, E as StorageMigrationJobSchema, L as collectSecretConfigKeys, Ot as settingsStoreCapability, Rt as BaseAddon, T as StorageLocationTypeSchema, g as REDACTED_SECRET, in as parseJsonObject, jt as storageMigrationCapability, m as LegacyStorageLocationDefaultSchema, rt as isLocationEnabled, w as StorageCleanupJobSchema } from "../../dist-CiHYspij.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,6 +194,7 @@ 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,
@@ -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,6 +238,7 @@ function mapRowToLocation(row) {
222
238
  config,
223
239
  ...nodeId !== void 0 ? { nodeId } : {},
224
240
  ...enabled !== void 0 ? { enabled } : {},
241
+ ...mode !== void 0 ? { mode } : {},
225
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),
@@ -571,7 +588,8 @@ var StorageMigrationCoordinator = class {
571
588
  const remaining = residue.filter((entry) => entry.storageClass === storageClass);
572
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.`);
573
590
  for (const entry of remaining) {
574
- 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.`);
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.`);
575
593
  moves.push({
576
594
  storageClass,
577
595
  fromLocationId: entry.fromLocationId,
@@ -903,12 +921,9 @@ var StorageMigrationCoordinator = class {
903
921
  for (const move of job.moves) {
904
922
  if (move.freezeSource !== true) continue;
905
923
  const loc = this.deps.locations.getLocationById(move.fromLocationId);
906
- if (!loc || loc.enabled === false) continue;
924
+ if (!loc || !mayWriteToLocation(loc)) continue;
907
925
  const { createdAt: _c, updatedAt: _u, capacity: _cap, ...rest } = loc;
908
- this.deps.locations.upsertLocation({
909
- ...rest,
910
- enabled: false
911
- });
926
+ this.deps.locations.upsertLocation(withLocationMode(rest, "readonly"));
912
927
  }
913
928
  }
914
929
  /**
@@ -1179,6 +1194,136 @@ function createDataStoreDispatch(getEngines, getRemote) {
1179
1194
  };
1180
1195
  }
1181
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
1182
1327
  //#region src/builtins/storage-orchestrator/hub-settings-forward.ts
1183
1328
  /**
1184
1329
  * The data door with the HUB behind it — an agent's `settings-store` provider.
@@ -1390,6 +1535,139 @@ async function probeLocalDirectoryOccupancy(input) {
1390
1535
  return "empty";
1391
1536
  }
1392
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
1393
1671
  //#region src/builtins/storage-orchestrator/location-secrets.ts
1394
1672
  /**
1395
1673
  * The config keys the ORCHESTRATOR itself owns, as opposed to the ones a
@@ -1522,8 +1800,13 @@ var FOOTAGE_TYPES = new Set(["recordings", "recordingsLow"]);
1522
1800
  function isTerminal(job) {
1523
1801
  return job.phase === "done" || job.phase === "failed" || job.phase === "cancelled";
1524
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
+ */
1525
1808
  function footageFrozen(location) {
1526
- return location.enabled === false || location.config["readOnly"] === true;
1809
+ return evictionPolicyOfLocation(location) === "never";
1527
1810
  }
1528
1811
  /**
1529
1812
  * One operator-facing cleanup of leftover analytics rows/blobs, optional
@@ -1684,6 +1967,78 @@ var StorageCleanupCoordinator = class {
1684
1967
  }
1685
1968
  };
1686
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
1687
2042
  //#region src/builtins/storage-orchestrator/storage-orchestrator.service.ts
1688
2043
  /**
1689
2044
  * Routing core for the `storage-orchestrator` builtin.
@@ -1844,38 +2199,41 @@ var StorageOrchestratorService = class {
1844
2199
  this.migrateOntoTheWriteSet(rows);
1845
2200
  }
1846
2201
  /**
1847
- * ONE explicit migration onto the enabled write set (D383), run once per
1848
- * boot against the rows that came off disk. Idempotent: the second boot finds
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
1849
2204
  * nothing to do.
1850
2205
  *
1851
2206
  * Two steps, in order, and each one is a WRITE — the point is that "absent"
1852
2207
  * stops existing rather than being re-interpreted on every read forever:
1853
2208
  *
1854
- * 1. A flagless row (persisted before `enabled` existed) is stamped
1855
- * `enabled: true`. Absence already meant active, so this preserves
1856
- * behaviour exactly; it is the operator's "the initial locations are
1857
- * enabled" spelled into the store.
1858
- * 2. A type left with NO enabled row — only reachable from a hand-edited
1859
- * store or a row an older build wrote — gets one enabled back, chosen as
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
1860
2218
  * the row the RETIRED default flag named, else the `:default`-slugged
1861
2219
  * row, else the lexicographically first. Loudly: a class with no write
1862
2220
  * target records nowhere, and refusing to repair it here would leave the
1863
2221
  * hub silently unable to write with no operator action pending.
1864
2222
  *
1865
- * An operator's explicit `enabled: false` is never reversed by step 1 — only
1866
- * step 2 touches it, and only when the alternative is a class that cannot
1867
- * write at all.
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.
1868
2229
  */
1869
2230
  migrateOntoTheWriteSet(rows) {
1870
2231
  const legacyDefaultOfType = /* @__PURE__ */ new Map();
1871
2232
  for (const row of rows) if (row.isDefault === true) legacyDefaultOfType.set(row.type, row.id);
1872
2233
  const stamped = [];
1873
2234
  for (const loc of this.locations.values()) {
1874
- if (loc.enabled !== void 0) continue;
1875
- this.persistMigrated({
1876
- ...loc,
1877
- enabled: true
1878
- });
2235
+ if (loc.mode !== void 0) continue;
2236
+ this.persistMigrated(withLocationMode(loc, legacyModeOf(loc) ?? "active"));
1879
2237
  stamped.push(loc.id);
1880
2238
  }
1881
2239
  const repaired = [];
@@ -1885,13 +2243,10 @@ var StorageOrchestratorService = class {
1885
2243
  const legacyDefault = legacyDefaultOfType.get(type);
1886
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];
1887
2245
  if (!pick) continue;
1888
- this.persistMigrated({
1889
- ...pick,
1890
- enabled: true
1891
- });
2246
+ this.persistMigrated(withLocationMode(pick, "active"));
1892
2247
  repaired.push(pick.id);
1893
2248
  }
1894
- if (stamped.length > 0 || repaired.length > 0) this.logger.info("storage-orchestrator: migrated locations onto the enabled write set", { meta: {
2249
+ if (stamped.length > 0 || repaired.length > 0) this.logger.info("storage-orchestrator: migrated locations onto the state model", { meta: {
1895
2250
  stamped,
1896
2251
  repaired
1897
2252
  } });
@@ -1907,7 +2262,7 @@ var StorageOrchestratorService = class {
1907
2262
  const store = this.locationStore;
1908
2263
  if (!store) return;
1909
2264
  store.upsert(withStamp).catch((err) => {
1910
- this.logger.error("storage-orchestrator: write-set migration persist failed", { meta: {
2265
+ this.logger.error("storage-orchestrator: state-model migration persist failed", { meta: {
1911
2266
  id: withStamp.id,
1912
2267
  error: err instanceof Error ? err.message : String(err)
1913
2268
  } });
@@ -2049,12 +2404,11 @@ var StorageOrchestratorService = class {
2049
2404
  for (const [id, enabled] of enabledByLocationId) {
2050
2405
  const location = nextById.get(id);
2051
2406
  if (!location) throw new Error(`Storage location "${id}" not found`);
2052
- if (location.enabled === enabled) continue;
2053
- const updated = {
2407
+ if (isLocationEnabled(location) === enabled) continue;
2408
+ const updated = withLocationMode({
2054
2409
  ...location,
2055
- enabled,
2056
2410
  updatedAt: now
2057
- };
2411
+ }, enabled ? "active" : "readonly");
2058
2412
  nextById.set(id, updated);
2059
2413
  touched.set(id, updated);
2060
2414
  }
@@ -2158,22 +2512,19 @@ var StorageOrchestratorService = class {
2158
2512
  ...input,
2159
2513
  nodeId: "hub"
2160
2514
  };
2161
- input = {
2162
- ...input,
2163
- ...resolveEnabled(input, existing, this.hasAnyLocationOfType(input.type))
2164
- };
2165
- const next = {
2515
+ this.refuseSameTypeRootCollision(input, existing);
2516
+ const next = withLocationMode({
2166
2517
  ...input,
2167
2518
  createdAt: existing?.createdAt ?? now,
2168
2519
  updatedAt: now
2169
- };
2520
+ }, resolveUpsertMode(input, existing, this.hasAnyLocationOfType(input.type)));
2170
2521
  refuseLastWriteTargetDisabled(existing, next, this.locations.values());
2171
2522
  this.locations.set(next.id, next);
2172
2523
  this.logger.debug("storage-orchestrator: upsertLocation", { meta: {
2173
2524
  id: next.id,
2174
2525
  type: next.type,
2175
2526
  providerId: next.providerId,
2176
- enabled: next.enabled
2527
+ mode: next.mode
2177
2528
  } });
2178
2529
  if (this.locationStore) {
2179
2530
  const store = this.locationStore;
@@ -2202,8 +2553,8 @@ var StorageOrchestratorService = class {
2202
2553
  if (!loc) throw new Error(`Storage location "${id}" not found`);
2203
2554
  const ofType = [...this.locations.values()].filter((l) => l.type === loc.type);
2204
2555
  if (ofType.length <= 1) throw new Error(`Cannot delete "${id}" — it is the only location for type "${loc.type}"`);
2205
- const enabledOfType = ofType.filter((l) => l.enabled !== false);
2206
- if (loc.enabled !== false && enabledOfType.length <= 1) 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}"`);
2207
2558
  const occupancy = await this.probeOccupancy(loc);
2208
2559
  if (occupancy !== "empty") {
2209
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`);
@@ -2444,6 +2795,34 @@ var StorageOrchestratorService = class {
2444
2795
  basePath: input.basePath
2445
2796
  } });
2446
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
+ }
2447
2826
  /** Internal: check whether any location of the given type exists. */
2448
2827
  hasAnyLocationOfType(type) {
2449
2828
  for (const loc of this.locations.values()) if (loc.type === type) return true;
@@ -2451,16 +2830,28 @@ var StorageOrchestratorService = class {
2451
2830
  }
2452
2831
  };
2453
2832
  /**
2454
- * Resolve `enabled` for one upsert — see the call site in `upsertLocation` for
2455
- * 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.
2456
2847
  */
2457
- function resolveEnabled(input, existing, typeAlreadyHasLocation) {
2458
- const requested = input.enabled ?? existing?.enabled;
2459
- if (requested === void 0) {
2460
- if (existing) return {};
2461
- return { enabled: !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;
2462
2851
  }
2463
- 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";
2464
2855
  }
2465
2856
  /** Types that may have zero enabled locations. Backups with none is sad, not illegal. */
2466
2857
  var OPTIONAL_WRITE_TYPES = new Set(["backups"]);
@@ -2471,12 +2862,12 @@ var OPTIONAL_WRITE_TYPES = new Set(["backups"]);
2471
2862
  */
2472
2863
  function refuseLastWriteTargetDisabled(existing, next, locations) {
2473
2864
  if (OPTIONAL_WRITE_TYPES.has(next.type)) return;
2474
- if (next.enabled !== false) return;
2865
+ if (mayWriteToLocation(next)) return;
2475
2866
  if (existing === void 0) return;
2476
- if (existing.enabled === false) return;
2867
+ if (!mayWriteToLocation(existing)) return;
2477
2868
  for (const loc of locations) {
2478
2869
  if (loc.id === next.id || loc.type !== next.type) continue;
2479
- if (loc.enabled !== false) return;
2870
+ if (mayWriteToLocation(loc)) return;
2480
2871
  }
2481
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.`);
2482
2873
  }
@@ -2498,11 +2889,81 @@ function refuseTypeWithoutWriteTarget(touched, result) {
2498
2889
  }
2499
2890
  }
2500
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
+ }
2959
+ //#endregion
2501
2960
  //#region src/builtins/storage-orchestrator/storage-pressure-manager.ts
2502
2961
  var DEFAULT_MAX_ROUNDS = 8;
2503
2962
  var DEFAULT_HYSTERESIS_PERCENT = 1;
2504
2963
  var StoragePressureManager = class {
2505
2964
  deps;
2965
+ /** Last sweep's drain progress, by location. Read-only surface for the UI. */
2966
+ drainProgress = /* @__PURE__ */ new Map();
2506
2967
  constructor(deps) {
2507
2968
  this.deps = deps;
2508
2969
  }
@@ -2544,6 +3005,73 @@ var StoragePressureManager = class {
2544
3005
  } });
2545
3006
  }
2546
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);
2547
3075
  }
2548
3076
  /** Evict oldest-first until the location's evictable usage fits `maxBytes`. */
2549
3077
  async relieveToUsageCap(locationId, maxBytes) {
@@ -2661,6 +3189,7 @@ var StoragePressureManager = class {
2661
3189
  var STORAGE_PROVIDER_CAP_NAME = "storage-provider";
2662
3190
  var DATA_STORE_PROVIDER_CAP_NAME = "data-store-provider";
2663
3191
  var STORAGE_EVICTABLE_CAP_NAME = "storage-evictable";
3192
+ var STORAGE_OCCUPANCY_CAP_NAME = "storage-occupancy";
2664
3193
  /**
2665
3194
  * Single-node default: existing node-local locations live on the hub.
2666
3195
  * The boot backfill (SP1) stamps this on any node-local location seeded
@@ -2678,6 +3207,24 @@ var CAP_PROVIDER_REGISTERED_CATEGORY = "capability:provider-registered";
2678
3207
  var StorageOrchestratorAddon = class extends BaseAddon {
2679
3208
  service = null;
2680
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
+ }
2681
3228
  pressureTimer = null;
2682
3229
  pressureSweepInFlight = false;
2683
3230
  migration = null;
@@ -2736,12 +3283,15 @@ var StorageOrchestratorAddon = class extends BaseAddon {
2736
3283
  const provider = {
2737
3284
  listLocations: async ({ type }) => {
2738
3285
  const rows = type !== void 0 ? service.listLocations({ type }) : service.listLocations();
3286
+ this.occupancy.serveAndRevalidate();
2739
3287
  return Promise.all(rows.map(async (loc) => ({
2740
3288
  ...this.redacted(loc),
3289
+ ...this.occupancy.ownedFor(loc.id) !== void 0 ? { owned: this.occupancy.ownedFor(loc.id) } : {},
2741
3290
  capacity: isLocationEnabled(loc) ? await this.localCapacityOf(loc) : null
2742
3291
  })));
2743
3292
  },
2744
3293
  listLocationDeclarations: async () => service.listDeclarations(),
3294
+ listDrainProgress: async () => this.drainProgress(),
2745
3295
  upsertLocation: async (input) => {
2746
3296
  const stored = service.getLocationById(input.id);
2747
3297
  const config = restoreRedactedSecrets(input.config, stored?.config, this.secretKeysFor(input.providerId, stored?.config ?? {}));
@@ -2749,10 +3299,13 @@ var StorageOrchestratorAddon = class extends BaseAddon {
2749
3299
  ...input,
2750
3300
  config
2751
3301
  });
3302
+ this.announceLocationsChanged(saved.id, saved.type, "upsert");
2752
3303
  return this.redacted(saved);
2753
3304
  },
2754
3305
  deleteLocation: async ({ id, force }) => {
3306
+ const doomed = service.getLocationById(id);
2755
3307
  await service.deleteLocation(id, { force: force === true });
3308
+ this.announceLocationsChanged(id, doomed?.type ?? "", "delete");
2756
3309
  },
2757
3310
  testLocation: async ({ id }) => {
2758
3311
  const loc = service.getLocationById(id);
@@ -3013,7 +3566,17 @@ var StorageOrchestratorAddon = class extends BaseAddon {
3013
3566
  getEvictableProviders: () => this.ctx.kernel.capabilityRegistry?.getCollection(STORAGE_EVICTABLE_CAP_NAME) ?? [],
3014
3567
  getLocationThresholds: () => this.resolveLocationThresholds(),
3015
3568
  getLocationMaxUsedBytes: () => this.resolveLocationMaxUsedBytes(),
3016
- 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
+ }
3017
3580
  });
3018
3581
  this.pressureTimer = setInterval(() => {
3019
3582
  this.runPressureSweep();
@@ -3161,14 +3724,64 @@ var StorageOrchestratorAddon = class extends BaseAddon {
3161
3724
  const infos = await collectProviderInfos(this.storageProviders(), () => {});
3162
3725
  const byProvider = new Map(infos.map((i) => [i.providerId, i]));
3163
3726
  for (const loc of svc.listLocations()) {
3727
+ if (!mayReadLocation(loc)) continue;
3164
3728
  const info = byProvider.get(loc.providerId);
3165
3729
  if (!info || !info.shouldSaveDiskSpace) continue;
3166
- const override = loc.config["minFreePercent"];
3167
- 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));
3168
3731
  if (threshold > 0) out.set(loc.id, threshold);
3169
3732
  }
3170
3733
  return out;
3171
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
+ }
3172
3785
  /**
3173
3786
  * What the provider's own schema declared secret, or `undefined` when this
3174
3787
  * node has never heard from that provider. The distinction is the whole
@@ -3181,6 +3794,48 @@ var StorageOrchestratorAddon = class extends BaseAddon {
3181
3794
  secretKeysFor(providerId, config) {
3182
3795
  return secretKeysForConfig(config, this.declaredSecretKeys(providerId));
3183
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
+ }
3184
3839
  /** A location with its provider's declared secrets replaced by the sentinel. */
3185
3840
  redacted(location) {
3186
3841
  return redactLocation(location, this.secretKeysFor(location.providerId, location.config));
@@ -3239,8 +3894,9 @@ var StorageOrchestratorAddon = class extends BaseAddon {
3239
3894
  const svc = this.service;
3240
3895
  if (!svc) return out;
3241
3896
  for (const loc of svc.listLocations()) {
3897
+ if (!mayReadLocation(loc)) continue;
3242
3898
  const raw = loc.config["maxUsedGb"];
3243
- 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));
3244
3900
  }
3245
3901
  return out;
3246
3902
  }