@camstack/system 1.2.83 → 1.2.84

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 (55) 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.js +1 -1
  8. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  9. package/dist/builtins/console-logging/index.js +1 -1
  10. package/dist/builtins/console-logging/index.mjs +1 -1
  11. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  12. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  13. package/dist/builtins/device-manager/device-manager.addon.js +1 -1
  14. package/dist/builtins/device-manager/device-manager.addon.mjs +1 -1
  15. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  16. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  17. package/dist/builtins/hub-forwarder/index.js +1 -1
  18. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  19. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  20. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  21. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  22. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  23. package/dist/builtins/local-network/local-network.addon.js +1 -1
  24. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  25. package/dist/builtins/loki-logging/index.js +1 -1
  26. package/dist/builtins/loki-logging/index.mjs +1 -1
  27. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  28. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  29. package/dist/builtins/platform-probe/index.js +1 -1
  30. package/dist/builtins/platform-probe/index.mjs +1 -1
  31. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  32. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  33. package/dist/builtins/snapshot/index.js +1 -1
  34. package/dist/builtins/snapshot/index.mjs +1 -1
  35. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +30 -10
  36. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +31 -11
  37. package/dist/builtins/sqlite-storage/path-guard.d.ts +8 -1
  38. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +1 -1
  39. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +1 -1
  40. package/dist/builtins/storage-orchestrator/index.d.ts +5 -6
  41. package/dist/builtins/storage-orchestrator/index.mjs +1 -1
  42. package/dist/builtins/storage-orchestrator/provider-discovery.d.ts +1 -1
  43. package/dist/builtins/storage-orchestrator/storage-migration-coordinator.d.ts +1 -1
  44. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.d.ts +1 -1
  45. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1252 -1191
  46. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1253 -1192
  47. package/dist/builtins/system-config/system-config.addon.js +1 -1
  48. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  49. package/dist/builtins/winston-logging/index.js +1 -1
  50. package/dist/builtins/winston-logging/index.mjs +1 -1
  51. package/dist/{dist-EAvNJD24.mjs → dist-BAwlOEUy.mjs} +19 -3
  52. package/dist/{dist-CqiMPPs0.js → dist-CQy9KjsL.js} +19 -3
  53. package/dist/index.js +1 -1
  54. package/dist/index.mjs +2 -2
  55. package/package.json +1 -1
@@ -1,793 +1,574 @@
1
- import { It as parseJsonObject, ct as storageCapability, g as StorageMigrationJobSchema, gt as BaseAddon, h as StorageLocationTypeSchema, lt as storageMigrationCapability, ot as settingsStoreCapability } from "../../dist-EAvNJD24.mjs";
1
+ import { It as parseJsonObject, ct as storageCapability, g as StorageMigrationJobSchema, gt as BaseAddon, h as StorageLocationTypeSchema, lt as storageMigrationCapability, ot as settingsStoreCapability } from "../../dist-BAwlOEUy.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import * as fs from "node:fs/promises";
4
4
  import * as path$1 from "node:path";
5
5
  import { buildStorageLocationRegistry } from "@camstack/system";
6
- //#region src/builtins/storage-orchestrator/storage-orchestrator.service.ts
7
- var SESSION_VOCABULARY = {
8
- upload: {
9
- idLabel: "uploadId",
10
- openMethod: "beginUpload"
6
+ //#region src/builtins/storage-orchestrator/location-store.ts
7
+ var STORAGE_LOCATIONS = "storage_locations";
8
+ /**
9
+ * Reserved key under which the cluster `nodeId` (SP1) is persisted INSIDE
10
+ * the `config` JSON blob. Storing it in the blob rather than a dedicated
11
+ * column avoids a destructive `ensureTable` DROP+recreate on deploy (a
12
+ * newly-declared column counts as a missing column → the structured
13
+ * backend drops the table, wiping operator location customizations such
14
+ * as `recordings:default`'s `minFreePercent`). The double-underscore
15
+ * prefix keeps it out of the provider-facing config namespace
16
+ * (`basePath`, `minFreePercent`, …) — the mapper strips it on read so
17
+ * API consumers and providers never see it.
18
+ */
19
+ var NODE_ID_CONFIG_KEY = "__nodeId";
20
+ /**
21
+ * Reserved key under which the operator opt-in flag (`StorageLocation.enabled`)
22
+ * is persisted INSIDE the `config` JSON blob — same channel, same reason, as
23
+ * {@link NODE_ID_CONFIG_KEY}: a newly-declared COLUMN counts as a missing
24
+ * column, and the structured backend answers that by dropping and recreating
25
+ * the table, wiping every operator customization on it.
26
+ *
27
+ * ABSENT means ACTIVE. A row written before the flag existed carries no key and
28
+ * reads back with `enabled: undefined`, which every consumer treats as enabled —
29
+ * that is why this ships with no migration and no backfill pass.
30
+ */
31
+ var ENABLED_CONFIG_KEY = "__enabled";
32
+ /**
33
+ * `config` is declared `JSON`, so the store hands the surface an object and
34
+ * gets an object back. There is deliberately NO `node_id` column — `nodeId`
35
+ * rides inside the blob under a reserved key (see {@link NODE_ID_CONFIG_KEY}),
36
+ * because a newly-declared column would put this table through
37
+ * `declareCollection`'s destructive rebuild and wipe operator customizations
38
+ * such as `recordings:default`'s `minFreePercent`.
39
+ */
40
+ var STORAGE_LOCATIONS_COLUMNS = [
41
+ {
42
+ name: "id",
43
+ type: "TEXT",
44
+ primaryKey: true
11
45
  },
12
- download: {
13
- idLabel: "downloadId",
14
- openMethod: "beginDownload"
46
+ {
47
+ name: "type",
48
+ type: "TEXT",
49
+ notNull: true
50
+ },
51
+ {
52
+ name: "display_name",
53
+ type: "TEXT",
54
+ notNull: true
55
+ },
56
+ {
57
+ name: "provider_id",
58
+ type: "TEXT",
59
+ notNull: true
60
+ },
61
+ {
62
+ name: "config",
63
+ type: "JSON",
64
+ notNull: true,
65
+ defaultValue: "{}"
66
+ },
67
+ {
68
+ name: "is_default",
69
+ type: "INTEGER",
70
+ notNull: true,
71
+ defaultValue: 0
72
+ },
73
+ {
74
+ name: "is_system",
75
+ type: "INTEGER",
76
+ notNull: true,
77
+ defaultValue: 0
78
+ },
79
+ {
80
+ name: "created_at",
81
+ type: "INTEGER",
82
+ notNull: true
83
+ },
84
+ {
85
+ name: "updated_at",
86
+ type: "INTEGER",
87
+ notNull: true
15
88
  }
16
- };
17
- /** Map key for a tracked chunked session — the id spaces never collide. */
18
- function sessionKey(kind, sessionId) {
19
- return `${kind}:${sessionId}`;
20
- }
89
+ ];
90
+ var STORAGE_LOCATIONS_INDEXES = [{
91
+ name: "idx_storage_locations_type",
92
+ columns: ["type"]
93
+ }];
21
94
  /**
22
- * Bare node id for a raw kernel `localNodeId`. Forked children carry a
23
- * `<node>/<addon>` suffix; routing is per-NODE, so the addon segment is
24
- * dropped. (Mirrors `normalizeNodeId` in `@camstack/types/addon`, which is a
25
- * leaf module not re-exported on any subpath this package can import.)
95
+ * `ILocationStore` on the `settings-store` collection surface the same door
96
+ * every other consumer uses. It holds the `data-store-provider` engine object
97
+ * directly (not the `settings-store` singleton) because since D44 the
98
+ * storage-orchestrator IS that singleton, and asking the registry for it would
99
+ * route this table back through the orchestrator itself.
26
100
  */
27
- function bareNodeId(raw) {
28
- if (raw === void 0 || raw === "") return "hub";
29
- const slash = raw.indexOf("/");
30
- if (slash < 0) return raw;
31
- const bare = raw.slice(0, slash);
32
- return bare === "" ? "hub" : bare;
33
- }
34
- var StorageOrchestratorService = class {
35
- logger;
36
- getProviders;
37
- locationStore;
38
- getRemoteProvider;
39
- locations = /* @__PURE__ */ new Map();
40
- /**
41
- * Declaration-driven cardinality source, injected by the addon after
42
- * the kernel aggregates `storageLocations` declarations. `null` until
43
- * `setRegistry` runs — `upsertLocation` then treats every type as
44
- * `'multi'` (no singleton enforcement), the safe default for the
45
- * in-memory-only / early-boot path.
46
- */
47
- registry = null;
48
- /**
49
- * Injected `providerId → nodeLocal` resolver (see {@link NodeLocalResolver}).
50
- * `null` until {@link setNodeLocalResolver} runs — `upsertLocation` then
51
- * skips the node-local requirement check entirely (every provider is
52
- * treated as node-agnostic), the safe default for the in-memory-only /
53
- * early-boot path that predates the resolver wiring.
54
- */
55
- nodeLocalResolver = null;
56
- /** `localNodeId` with any forked-child `/addon` suffix stripped. */
57
- localNode;
58
- /**
59
- * `<kind>:<sessionId>` → the dispatch target that OPENED the session.
60
- * Chunked I/O carries no `StorageLocation` after the first hop, so without
61
- * this the follow-up chunks fall back to a `providerId`-only lookup — which
62
- * on a multi-node cluster means the hub's filesystem, not the agent's.
63
- */
64
- sessionTargets = /* @__PURE__ */ new Map();
65
- constructor(logger, getProviders, locationStore = null, localNodeId = "hub", getRemoteProvider) {
66
- this.logger = logger;
67
- this.getProviders = getProviders;
68
- this.locationStore = locationStore;
69
- this.getRemoteProvider = getRemoteProvider;
70
- this.localNode = bareNodeId(localNodeId);
101
+ var SqliteLocationStore = class {
102
+ store;
103
+ declared = false;
104
+ constructor(store) {
105
+ this.store = store;
71
106
  }
72
107
  /**
73
- * The node this orchestrator runs on, with any forked-child `/addon`
74
- * suffix stripped. A location whose `nodeId` differs is NOT this node's
75
- * to touch its bytes, and its free space, live elsewhere.
108
+ * Lazy `declareCollection` keeps the constructor cheap and async-free.
109
+ * Called from every mutation/read path; `declareCollection` is idempotent
110
+ * (and additive on shape changes), so repeated calls collapse to a no-op.
76
111
  */
77
- getLocalNodeId() {
78
- return this.localNode;
112
+ async ensureDeclared() {
113
+ if (this.declared) return;
114
+ await this.store.declareCollection({
115
+ collection: STORAGE_LOCATIONS,
116
+ columns: STORAGE_LOCATIONS_COLUMNS,
117
+ indexes: STORAGE_LOCATIONS_INDEXES
118
+ });
119
+ this.declared = true;
79
120
  }
80
- /** True once a persistence store is wired (constructor or {@link attachStore}). */
81
- hasStore() {
82
- return this.locationStore !== null;
121
+ async loadAll() {
122
+ await this.ensureDeclared();
123
+ const rows = await this.store.query({
124
+ collection: STORAGE_LOCATIONS,
125
+ filter: { orderBy: {
126
+ field: "created_at",
127
+ direction: "asc"
128
+ } }
129
+ });
130
+ const out = [];
131
+ for (const row of rows) {
132
+ const mapped = mapRowToLocation(row.data);
133
+ if (mapped) out.push(mapped);
134
+ }
135
+ return out;
83
136
  }
84
137
  /**
85
- * Late-bind the persistence store (production boot: the `settings-store`
86
- * cap registers after the orchestrator initializes, so the service runs
87
- * in-memory-only until then). Idempotent a second call once a store is
88
- * wired is a no-op.
89
- *
90
- * Reconciles both directions so operator edits survive a reboot:
91
- * 1. hydrate — DB rows win over any early in-memory seed (restores
92
- * operator config like `minFreePercent` that the pre-store boot
93
- * can't see), with the same `isSystem` upgrade as {@link initialize};
94
- * 2. backfill — in-memory locations the DB doesn't have yet (the
95
- * pre-store seed defaults on a fresh install) are persisted, so the
96
- * store becomes the durable source of truth from here on.
138
+ * Replace-or-insert. Implemented as delete + insert rather than a
139
+ * conditional update because the orchestrator serializes every mutation
140
+ * through the in-memory map before dispatching to the store there's no
141
+ * contention to lose, and delete-then-insert is unconditionally simpler.
97
142
  */
98
- async attachStore(store) {
99
- if (this.locationStore) return;
100
- this.locationStore = store;
101
- const rows = await store.loadAll();
102
- const dbIds = new Set(rows.map((r) => r.id));
103
- for (const loc of rows) {
104
- const upgraded = !loc.isSystem && loc.id === `${loc.type}:default` ? {
105
- ...loc,
106
- isSystem: true
107
- } : loc;
108
- this.locations.set(upgraded.id, upgraded);
109
- if (upgraded !== loc) store.upsert(upgraded).catch(() => {});
110
- }
111
- const backfill = [...this.locations.values()].filter((l) => !dbIds.has(l.id));
112
- for (const loc of backfill) store.upsert(loc).catch((err) => {
113
- this.logger.warn("storage-orchestrator: attachStore backfill persist failed", { meta: {
143
+ async upsert(loc) {
144
+ await this.ensureDeclared();
145
+ await this.store.deleteWhere({
146
+ collection: STORAGE_LOCATIONS,
147
+ filter: { where: { id: loc.id } }
148
+ });
149
+ await this.store.insert({
150
+ collection: STORAGE_LOCATIONS,
151
+ record: {
114
152
  id: loc.id,
115
- error: err instanceof Error ? err.message : String(err)
116
- } });
153
+ data: mapLocationToRow(loc)
154
+ }
117
155
  });
118
- this.logger.info("storage-orchestrator: store attached + reconciled", { meta: {
119
- hydrated: rows.length,
120
- backfilled: backfill.length,
121
- total: this.locations.size
122
- } });
123
- }
124
- /**
125
- * Inject the declaration-driven cardinality source. Called once by the
126
- * addon after the kernel aggregates every addon's `storageLocations`
127
- * declarations into a `StorageLocationRegistry`.
128
- */
129
- setRegistry(registry) {
130
- this.registry = registry;
131
156
  }
132
- /**
133
- * Inject the synchronous `providerId → nodeLocal` resolver (SP1). Called
134
- * by the addon from a cached snapshot of every `storage-provider`'s
135
- * `getProviderInfo().nodeLocal`, refreshed when the provider collection
136
- * changes. Powers the node-local upsert guard and the boot backfill.
137
- */
138
- setNodeLocalResolver(resolver) {
139
- this.nodeLocalResolver = resolver;
157
+ async delete(id) {
158
+ await this.ensureDeclared();
159
+ await this.store.deleteWhere({
160
+ collection: STORAGE_LOCATIONS,
161
+ filter: { where: { id } }
162
+ });
140
163
  }
141
- /**
142
- * The full set of addon-declared storage locations, as aggregated by
143
- * the kernel and injected via {@link setRegistry}. Powers the admin-UI
144
- * Data screen's per-declaration grouping. Empty until the registry is
145
- * injected (early-boot / in-memory-only path).
146
- */
147
- listDeclarations() {
148
- return this.registry ? this.registry.list() : [];
164
+ };
165
+ function isPlainObject(value) {
166
+ return value !== null && typeof value === "object" && !Array.isArray(value);
167
+ }
168
+ function readConfig(raw) {
169
+ if (isPlainObject(raw)) return { ...raw };
170
+ if (typeof raw === "string" && raw.length > 0) return parseJsonObject(raw) ?? {};
171
+ return {};
172
+ }
173
+ function mapLocationToRow(loc) {
174
+ const storedConfig = { ...loc.config };
175
+ if (loc.nodeId !== void 0) storedConfig[NODE_ID_CONFIG_KEY] = loc.nodeId;
176
+ if (loc.enabled !== void 0) storedConfig[ENABLED_CONFIG_KEY] = loc.enabled;
177
+ return {
178
+ id: loc.id,
179
+ type: loc.type,
180
+ display_name: loc.displayName,
181
+ provider_id: loc.providerId,
182
+ config: storedConfig,
183
+ is_default: loc.isDefault ? 1 : 0,
184
+ is_system: loc.isSystem ? 1 : 0,
185
+ created_at: loc.createdAt,
186
+ updated_at: loc.updatedAt
187
+ };
188
+ }
189
+ /**
190
+ * Decode a row read from the collection. Returns `null` when the row's `type`
191
+ * fails the id-format schema or the row has no id — a corrupt row (hand-edited,
192
+ * partial migration) is skipped rather than taking the boot read down with it.
193
+ */
194
+ function mapRowToLocation(row) {
195
+ const typeRaw = row["type"];
196
+ const parsedType = StorageLocationTypeSchema.safeParse(typeRaw);
197
+ if (!parsedType.success) return null;
198
+ const type = parsedType.data;
199
+ const id = String(row["id"] ?? "");
200
+ if (!id) return null;
201
+ const storedConfig = readConfig(row["config"]);
202
+ const nodeIdRaw = storedConfig[NODE_ID_CONFIG_KEY];
203
+ const nodeId = typeof nodeIdRaw === "string" && nodeIdRaw.length > 0 ? nodeIdRaw : void 0;
204
+ const enabledRaw = storedConfig[ENABLED_CONFIG_KEY];
205
+ const enabled = typeof enabledRaw === "boolean" ? enabledRaw : void 0;
206
+ const config = { ...storedConfig };
207
+ delete config[NODE_ID_CONFIG_KEY];
208
+ delete config[ENABLED_CONFIG_KEY];
209
+ return {
210
+ id,
211
+ type,
212
+ displayName: String(row["display_name"] ?? ""),
213
+ providerId: String(row["provider_id"] ?? ""),
214
+ config,
215
+ ...nodeId !== void 0 ? { nodeId } : {},
216
+ ...enabled !== void 0 ? { enabled } : {},
217
+ isDefault: row["is_default"] === 1 || row["is_default"] === true,
218
+ isSystem: row["is_system"] === 1 || row["is_system"] === true,
219
+ createdAt: Number(row["created_at"] ?? 0),
220
+ updatedAt: Number(row["updated_at"] ?? 0)
221
+ };
222
+ }
223
+ //#endregion
224
+ //#region src/builtins/storage-orchestrator/storage-migration-coordinator.ts
225
+ var STORAGE_CLASSES = [
226
+ "recordings",
227
+ "recordingsLow",
228
+ "eventMedia"
229
+ ];
230
+ var PARTICIPANTS = [
231
+ "pipeline",
232
+ "recorder",
233
+ "analytics"
234
+ ];
235
+ var REVERSE_PARTICIPANTS = [
236
+ "analytics",
237
+ "recorder",
238
+ "pipeline"
239
+ ];
240
+ /**
241
+ * Durable orchestration layer over the independent recorder and event-media
242
+ * movers. It owns the only public migration state machine; participants expose
243
+ * intentionally narrow lease/move primitives and never alter camera settings.
244
+ */
245
+ var StorageMigrationCoordinator = class {
246
+ deps;
247
+ active = null;
248
+ runPromise = null;
249
+ startReserved = false;
250
+ constructor(deps) {
251
+ this.deps = deps;
149
252
  }
150
- /**
151
- * Hydrate the in-memory map from the persistence layer. Called once
152
- * by the addon during `onInitialize`, before the eager seed pass.
153
- * Idempotent running it twice with the same store contents
154
- * produces the same map. No-op when the service was constructed
155
- * without a store.
156
- *
157
- * Errors loading individual rows are tolerated: the bad row is
158
- * skipped and logged so a single corrupted record doesn't lock the
159
- * whole orchestrator out of boot. (The store-side validation pass
160
- * before insert means only schema-invalid SQL state can produce
161
- * such a row.)
162
- */
163
- async initialize() {
164
- if (!this.locationStore) return;
165
- const rows = await this.locationStore.loadAll();
166
- let upgraded = 0;
167
- for (const loc of rows) if (!loc.isSystem && loc.id === `${loc.type}:default`) {
168
- const upgradedLoc = {
169
- ...loc,
170
- isSystem: true
171
- };
172
- this.locations.set(upgradedLoc.id, upgradedLoc);
173
- this.locationStore.upsert(upgradedLoc).catch((err) => {
174
- this.logger.warn("storage-orchestrator: isSystem upgrade persist failed", { meta: {
175
- id: upgradedLoc.id,
176
- error: err instanceof Error ? err.message : String(err)
177
- } });
253
+ async plan(input) {
254
+ const moves = [];
255
+ for (const storageClass of STORAGE_CLASSES) {
256
+ const targetId = input.destinations[storageClass];
257
+ if (targetId === void 0) continue;
258
+ const source = this.deps.locations.getDefaultLocation(storageClass);
259
+ if (!source) throw new Error(`No default storage location for "${storageClass}"`);
260
+ const target = this.deps.locations.getLocationById(targetId);
261
+ if (!target) throw new Error(`Storage location "${targetId}" not found`);
262
+ if (target.type !== storageClass) throw new Error(`Storage location "${targetId}" is type "${target.type}", expected "${storageClass}"`);
263
+ if (source.id === target.id) throw new Error(`Storage location "${targetId}" is already the "${storageClass}" default`);
264
+ moves.push({
265
+ storageClass,
266
+ fromLocationId: source.id,
267
+ toLocationId: target.id,
268
+ moverJobId: null,
269
+ state: null,
270
+ error: null
178
271
  });
179
- upgraded++;
180
- } else this.locations.set(loc.id, loc);
181
- this.logger.info("storage-orchestrator: hydrated locations from store", { meta: {
182
- loaded: this.locations.size,
183
- isSystemUpgraded: upgraded
184
- } });
272
+ }
273
+ if (moves.length === 0) throw new Error("select at least one storage class");
274
+ return {
275
+ destinations: input.destinations,
276
+ moves: moves.map(({ storageClass, fromLocationId, toLocationId }) => ({
277
+ storageClass,
278
+ fromLocationId,
279
+ toLocationId
280
+ }))
281
+ };
185
282
  }
186
- /**
187
- * Boot backfill (SP1): stamp `nodeId` on every persisted node-local
188
- * location that lacks one. Single-node deployments seeded their
189
- * filesystem locations before `nodeId` existed — they live on `hubNodeId`
190
- * (the hub). Node-agnostic (remote) locations are left untouched: their
191
- * absent `nodeId` is correct (reachable from any node).
192
- *
193
- * Idempotent — a location that already carries a `nodeId` (any value) is
194
- * never re-touched, so re-running on a populated map (or a re-boot) is a
195
- * no-op. A plain local update, not versioned-migration machinery: only
196
- * the rows missing `nodeId` are mutated + persisted.
197
- *
198
- * No-op until the node-local resolver is wired (it classifies which
199
- * providers are node-local).
200
- */
201
- async backfillNodeIds(hubNodeId) {
202
- const resolver = this.nodeLocalResolver;
203
- if (!resolver) return;
204
- let stamped = 0;
205
- for (const [id, loc] of Array.from(this.locations)) {
206
- if (loc.nodeId) continue;
207
- if (resolver(loc.providerId) !== true) continue;
208
- const updated = {
209
- ...loc,
210
- nodeId: hubNodeId,
211
- updatedAt: Date.now()
212
- };
213
- this.locations.set(id, updated);
214
- stamped++;
215
- if (this.locationStore) try {
216
- await this.locationStore.upsert(updated);
217
- } catch (err) {
218
- this.logger.error("storage-orchestrator: nodeId backfill persist failed", { meta: {
219
- id,
220
- error: err instanceof Error ? err.message : String(err)
221
- } });
283
+ async start(input) {
284
+ if (this.startReserved) throw new Error("storage migration is already active");
285
+ this.startReserved = true;
286
+ try {
287
+ const existing = await this.status();
288
+ if (existing && isTerminal(existing) && existing.pausedParticipants.length > 0) {
289
+ await this.releaseAfterTerminal(existing);
290
+ await this.persist(existing);
291
+ if (existing.pausedParticipants.length > 0) throw new Error(`storage migration ${existing.jobId} still holds maintenance leases`);
222
292
  }
293
+ if (existing && !isTerminal(existing)) throw new Error(`storage migration is already active (${existing.jobId})`);
294
+ const plan = await this.plan(input);
295
+ const now = this.deps.now();
296
+ const job = {
297
+ jobId: this.deps.newId(),
298
+ phase: "planning",
299
+ destinations: input.destinations,
300
+ throttleMbps: input.throttleMbps ?? 40,
301
+ moves: plan.moves.map((move) => ({
302
+ ...move,
303
+ moverJobId: null,
304
+ state: null,
305
+ error: null
306
+ })),
307
+ pauseLeaseId: null,
308
+ pausedParticipants: [],
309
+ repointed: false,
310
+ cancelRequested: false,
311
+ startedAt: now,
312
+ updatedAt: now,
313
+ finishedAt: null,
314
+ error: null
315
+ };
316
+ this.active = job;
317
+ await this.persist(job);
318
+ this.runPromise = this.run(job);
319
+ this.runPromise;
320
+ return job.jobId;
321
+ } finally {
322
+ this.startReserved = false;
223
323
  }
224
- if (stamped > 0) this.logger.info("storage-orchestrator: backfilled nodeId on node-local locations", { meta: {
225
- stamped,
226
- hubNodeId
227
- } });
228
324
  }
229
- listLocations(filter) {
230
- const all = [...this.locations.values()];
231
- return filter?.type ? all.filter((l) => l.type === filter.type) : all;
325
+ async status(jobId) {
326
+ const job = this.active ?? await this.deps.state.get();
327
+ if (jobId !== void 0 && job?.jobId !== jobId) return null;
328
+ return job;
232
329
  }
233
- getDefaultLocation(type) {
234
- for (const loc of this.locations.values()) if (loc.type === type && loc.isDefault) return loc;
235
- return null;
330
+ async cancel(jobId) {
331
+ const job = await this.status(jobId);
332
+ if (!job || isTerminal(job) || job.repointed) return false;
333
+ job.cancelRequested = true;
334
+ await this.persist(job);
335
+ await Promise.all(job.moves.filter((move) => move.moverJobId !== null).map((move) => this.cancelMove(move)));
336
+ return true;
236
337
  }
237
- /**
238
- * Repoint several logical defaults as one in-memory commit. The migration
239
- * coordinator persists its `repointing` phase before calling this method, so
240
- * a process crash during the backing-store writes is recovered by replaying
241
- * the same idempotent target set before anyone resumes writers.
242
- */
243
- async setDefaultLocations(targetByType) {
244
- if (targetByType.size === 0) return;
245
- const now = Date.now();
246
- const nextById = new Map(this.locations);
247
- const touched = /* @__PURE__ */ new Map();
248
- for (const [type, targetId] of targetByType) {
249
- const target = nextById.get(targetId);
250
- if (!target) throw new Error(`Storage location "${targetId}" not found`);
251
- if (target.type !== type) throw new Error(`Storage location "${targetId}" is type "${target.type}", expected "${type}"`);
252
- for (const [id, location] of nextById) {
253
- if (location.type !== type) continue;
254
- const isDefault = id === targetId;
255
- if (location.isDefault === isDefault) continue;
256
- const updated = {
257
- ...location,
258
- isDefault,
259
- updatedAt: now
260
- };
261
- nextById.set(id, updated);
262
- touched.set(id, updated);
263
- }
338
+ /** Boot recovery resumes a durable unfinished state. A missing in-memory
339
+ * child mover is recreated from the same copy-if-absent input. */
340
+ async recover() {
341
+ const job = await this.deps.state.get();
342
+ if (!job) return;
343
+ if (job.cancelRequested) {
344
+ await Promise.all(job.moves.filter((move) => move.moverJobId !== null).map((move) => this.cancelMove(move)));
345
+ await this.waitForMoves(job);
346
+ await this.releaseAfterTerminal(job);
347
+ job.phase = "cancelled";
348
+ job.finishedAt ??= this.deps.now();
349
+ await this.persist(job);
350
+ return;
264
351
  }
265
- if (this.locationStore) {
266
- const previous = /* @__PURE__ */ new Map();
267
- for (const id of touched.keys()) {
268
- const location = this.locations.get(id);
269
- if (location) previous.set(id, location);
270
- }
271
- try {
272
- for (const location of touched.values()) await this.locationStore.upsert(location);
273
- } catch (err) {
274
- for (const location of previous.values()) try {
275
- await this.locationStore.upsert(location);
276
- } catch {}
277
- throw err;
352
+ if (isTerminal(job)) {
353
+ if (job.pausedParticipants.length > 0) {
354
+ await this.releaseAfterTerminal(job);
355
+ await this.persist(job);
278
356
  }
357
+ return;
358
+ }
359
+ this.active = job;
360
+ job.pauseLeaseId ??= this.deps.newId();
361
+ if (job.repointed && job.phase === "resuming") job.phase = "refreshing";
362
+ await this.persist(job);
363
+ try {
364
+ await this.pauseParticipants(job, true);
365
+ } catch (err) {
366
+ job.error = err instanceof Error ? err.message : String(err);
367
+ await this.persist(job);
368
+ return;
369
+ }
370
+ if (this.runPromise === null) {
371
+ this.runPromise = this.run(job);
372
+ this.runPromise;
279
373
  }
280
- this.locations.clear();
281
- for (const [id, location] of nextById) this.locations.set(id, location);
282
374
  }
283
- /**
284
- * Insert or update a location. If `isDefault: true`, atomically
285
- * demotes any other default for the same type — operators always see
286
- * exactly one default per type.
287
- *
288
- * When a persistence store is wired (Task 6), the new record AND any
289
- * implicitly-demoted siblings are persisted before the in-memory map
290
- * mutation returns. Persistence errors propagate to the caller.
291
- */
292
- upsertLocation(input) {
293
- const now = Date.now();
294
- const existing = this.locations.get(input.id);
295
- if (!existing) {
296
- if ((this.registry?.cardinalityOf(input.type) ?? "multi") === "single") {
297
- const already = [...this.locations.values()].find((l) => l.type === input.type);
298
- if (already) throw new Error(`Storage type "${input.type}" is single — only one location allowed (existing: "${already.id}"). Edit it instead of adding a new one.`);
375
+ async run(job) {
376
+ try {
377
+ if (job.phase === "planning") await this.setPhase(job, "pausing");
378
+ if (job.phase === "pausing") {
379
+ job.pauseLeaseId ??= job.jobId;
380
+ await this.pauseParticipants(job);
381
+ if (job.cancelRequested) return this.finishCancelled(job);
382
+ await this.setPhase(job, "moving");
299
383
  }
300
- }
301
- if (this.nodeLocalResolver?.(input.providerId) === true && !input.nodeId) input = {
302
- ...input,
303
- nodeId: "hub"
304
- };
305
- if (existing?.isSystem === true) input = {
306
- ...input,
307
- isSystem: true
308
- };
309
- const next = {
310
- ...input,
311
- createdAt: existing?.createdAt ?? now,
312
- updatedAt: now
313
- };
314
- const demoted = [];
315
- if (next.isDefault) {
316
- for (const [otherId, otherLoc] of this.locations) if (otherLoc.type === next.type && otherLoc.id !== next.id && otherLoc.isDefault) {
317
- const updated = {
318
- ...otherLoc,
319
- isDefault: false,
320
- updatedAt: now
321
- };
322
- this.locations.set(otherId, updated);
323
- demoted.push(updated);
384
+ if (job.phase === "moving") {
385
+ await this.startOrResumeMoves(job);
386
+ await this.waitForMoves(job);
387
+ if (job.cancelRequested) return this.finishCancelled(job);
388
+ await this.setPhase(job, "verifying");
389
+ }
390
+ if (job.phase === "verifying") {
391
+ await this.verifyMoves(job);
392
+ if (job.cancelRequested) return this.finishCancelled(job);
393
+ await this.setPhase(job, "repointing");
394
+ }
395
+ if (job.phase === "repointing") {
396
+ const targets = new Map(job.moves.map((move) => [move.storageClass, move.toLocationId]));
397
+ await this.deps.locations.setDefaultLocations(targets);
398
+ job.repointed = true;
399
+ await this.setPhase(job, "refreshing");
400
+ }
401
+ if (job.phase === "refreshing") {
402
+ const leaseId = requireLease(job);
403
+ if (job.moves.some((move) => move.storageClass !== "eventMedia")) await this.deps.participants.recorder.refresh(leaseId);
404
+ if (job.moves.some((move) => move.storageClass === "eventMedia")) await this.deps.participants.analytics.refresh(leaseId);
405
+ await this.setPhase(job, "resuming");
406
+ }
407
+ if (job.phase === "resuming") {
408
+ await this.resumeParticipants(job);
409
+ await this.setPhase(job, "done");
410
+ }
411
+ } catch (err) {
412
+ job.error = err instanceof Error ? err.message : String(err);
413
+ if (job.repointed) {
414
+ job.phase = "refreshing";
415
+ job.finishedAt = null;
416
+ await this.persist(job);
417
+ return;
418
+ }
419
+ job.phase = "failed";
420
+ job.finishedAt = this.deps.now();
421
+ await this.releaseAfterTerminal(job);
422
+ await this.persist(job);
423
+ } finally {
424
+ if (isTerminal(job)) {
425
+ await this.releaseAfterTerminal(job);
426
+ this.active = job;
324
427
  }
428
+ this.runPromise = null;
325
429
  }
326
- this.locations.set(next.id, next);
327
- this.logger.debug("storage-orchestrator: upsertLocation", { meta: {
328
- id: next.id,
329
- type: next.type,
330
- providerId: next.providerId,
331
- isDefault: next.isDefault
332
- } });
333
- if (this.locationStore) {
334
- const store = this.locationStore;
335
- (async () => {
336
- try {
337
- for (const d of demoted) await store.upsert(d);
338
- await store.upsert(next);
339
- } catch (err) {
340
- this.logger.error("storage-orchestrator: upsert persistence failed", { meta: {
341
- id: next.id,
342
- error: err instanceof Error ? err.message : String(err)
343
- } });
344
- }
345
- })();
430
+ }
431
+ async pauseParticipants(job, reacquire = false) {
432
+ const leaseId = requireLease(job);
433
+ for (const name of PARTICIPANTS) {
434
+ if (job.cancelRequested) return;
435
+ if (!reacquire && job.pausedParticipants.includes(name)) continue;
436
+ await this.participant(name).pause(leaseId);
437
+ if (!job.pausedParticipants.includes(name)) job.pausedParticipants.push(name);
438
+ await this.persist(job);
346
439
  }
347
- return next;
348
440
  }
349
- /**
350
- * Remove a location. Refuses to remove the default of a type unless a
351
- * sibling default exists (defensive `upsertLocation` already ensures
352
- * at most one default per type, but the logic guards against future
353
- * bypass paths e.g. SQLite migration that imports two defaults).
354
- *
355
- * Persistence (Task 6) mirrors the delete asynchronously, with errors
356
- * routed to the logger — see `upsertLocation` for the rationale.
357
- */
358
- deleteLocation(id) {
359
- const loc = this.locations.get(id);
360
- if (!loc) throw new Error(`Storage location "${id}" not found`);
361
- if (loc.isSystem) throw new Error(`Storage location "${id}" is system-managed and cannot be deleted. Edit its config (path / providerId) instead.`);
362
- if (loc.isDefault) {
363
- if (![...this.locations.values()].find((l) => l.type === loc.type && l.id !== id && l.isDefault)) throw new Error(`Cannot delete default location "${id}" for type "${loc.type}" — promote another location to default first`);
441
+ async resumeParticipants(job) {
442
+ const leaseId = requireLease(job);
443
+ for (const name of REVERSE_PARTICIPANTS) {
444
+ if (!job.pausedParticipants.includes(name)) continue;
445
+ await this.participant(name).resume(leaseId);
446
+ job.pausedParticipants = job.pausedParticipants.filter((participant) => participant !== name);
447
+ await this.persist(job);
364
448
  }
365
- this.locations.delete(id);
366
- if (this.locationStore) this.locationStore.delete(id).catch((err) => {
367
- this.logger.error("storage-orchestrator: delete persistence failed", { meta: {
368
- id,
369
- error: err instanceof Error ? err.message : String(err)
370
- } });
371
- });
372
449
  }
373
- /**
374
- * Remove system-seeded locations whose type is no longer declared by any
375
- * addon (stale defaults from a removed location type). Operator-added
376
- * (non-system) locations of an undeclared type are KEPT but warned — the
377
- * operator owns them. Requires the registry to be set first.
378
- *
379
- * FAIL-SAFE: if the registry is EMPTY (no addon declared any location), we
380
- * refuse to prune anything. An empty registry almost always means
381
- * declarations failed to load (boot ordering, a stale install) — pruning
382
- * "everything undeclared" in that state would wipe every system location
383
- * (data/logs/recordings/…). Better to keep stale rows than destroy live ones.
384
- */
385
- pruneUndeclaredSystemLocations() {
386
- if (!this.registry) return;
387
- if (this.registry.list().length === 0) {
388
- this.logger.warn("storage-orchestrator: skipping prune no location declarations loaded (fail-safe)");
450
+ async releaseAfterTerminal(job) {
451
+ if (job.pauseLeaseId === null) return;
452
+ for (const name of REVERSE_PARTICIPANTS) {
453
+ if (!job.pausedParticipants.includes(name)) continue;
454
+ try {
455
+ await this.participant(name).resume(job.pauseLeaseId);
456
+ job.pausedParticipants = job.pausedParticipants.filter((participant) => participant !== name);
457
+ await this.persist(job);
458
+ } catch {}
459
+ }
460
+ }
461
+ async startOrResumeMoves(job) {
462
+ if (job.cancelRequested) return;
463
+ const mediaMove = job.moves.find((move) => move.storageClass === "eventMedia");
464
+ if (mediaMove?.moverJobId === null) {
465
+ mediaMove.moverJobId = await this.startMove(job, mediaMove);
466
+ await this.persist(job);
467
+ }
468
+ const footageMoves = job.moves.filter((move) => move.storageClass !== "eventMedia");
469
+ for (let index = 0; index < footageMoves.length; index++) {
470
+ const move = footageMoves[index];
471
+ if (move.moverJobId !== null) continue;
472
+ if (!footageMoves.slice(0, index).every((predecessor) => predecessor.state === "done")) return;
473
+ move.moverJobId = await this.startMove(job, move);
474
+ await this.persist(job);
389
475
  return;
390
476
  }
391
- for (const [id, loc] of Array.from(this.locations)) {
392
- if (this.registry.cardinalityOf(loc.type) !== null) continue;
393
- if (loc.isSystem) {
394
- this.locations.delete(id);
395
- if (this.locationStore) this.locationStore.delete(id).catch((err) => {
396
- this.logger.error("storage-orchestrator: prune persistence failed", { meta: {
397
- id,
398
- error: err instanceof Error ? err.message : String(err)
399
- } });
400
- });
401
- this.logger.info("storage-orchestrator: pruned stale system location", { meta: {
402
- id,
403
- type: loc.type
404
- } });
405
- } else this.logger.warn("storage-orchestrator: location of undeclared type kept (operator-owned)", { meta: {
406
- id,
407
- type: loc.type
408
- } });
409
- }
410
477
  }
411
- /**
412
- * Resolve a `StorageLocationRef` to a concrete `StorageLocation`.
413
- * - bare type (e.g. `'backups'`) → default location of that type
414
- * - fully-qualified id (e.g. `'backups:nas-01'`) → that exact instance
415
- *
416
- * Throws an actionable error when nothing matches — every consumer of
417
- * the singleton `storage` cap funnels through here, so the error
418
- * message is what operators see when the orchestrator's view of the
419
- * world doesn't match expectations.
420
- */
421
- resolveRef(ref) {
422
- if (ref.includes(":")) {
423
- const loc = this.locations.get(ref);
424
- if (!loc) throw new Error(`Storage location "${ref}" not found`);
425
- return loc;
478
+ async waitForMoves(job) {
479
+ const sleep = this.deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
480
+ const pollMs = this.deps.pollMs ?? 250;
481
+ for (;;) {
482
+ if (!job.cancelRequested) await this.startOrResumeMoves(job);
483
+ let complete = true;
484
+ for (const [index, move] of job.moves.entries()) {
485
+ if (job.cancelRequested && move.moverJobId === null) continue;
486
+ if (move.storageClass !== "eventMedia" && job.moves.slice(0, index).filter((predecessor) => predecessor.storageClass !== "eventMedia").some((predecessor) => predecessor.state !== "done")) {
487
+ complete = false;
488
+ continue;
489
+ }
490
+ if (move.moverJobId === null) {
491
+ if (!job.cancelRequested) complete = false;
492
+ continue;
493
+ }
494
+ const status = await this.moveStatus(move);
495
+ if (status === null) {
496
+ if (job.cancelRequested) {
497
+ move.state = "cancelled";
498
+ continue;
499
+ }
500
+ move.moverJobId = await this.startMove(job, move);
501
+ move.state = "running";
502
+ await this.persist(job);
503
+ complete = false;
504
+ continue;
505
+ }
506
+ move.state = status.state;
507
+ move.error = status.error;
508
+ if (status.state === "failed") throw new Error(move.error ?? `${move.storageClass} move failed`);
509
+ if (status.state === "cancelled") {
510
+ job.cancelRequested = true;
511
+ continue;
512
+ }
513
+ if (status.state !== "done") complete = false;
514
+ }
515
+ await this.persist(job);
516
+ if (complete) return;
517
+ await sleep(pollMs);
426
518
  }
427
- const def = this.getDefaultLocation(ref);
428
- if (!def) throw new Error(`No default storage location configured for type "${ref}" — register one or pass a fully-qualified id (\`<type>:<slug>\`)`);
429
- return def;
430
- }
431
- /**
432
- * Find the storage-provider that backs a given location — the entry point
433
- * every consumer-facing `storage` cap method funnels through.
434
- *
435
- * Routing key is `(providerId, nodeId)`, never `providerId` alone: every
436
- * node's FilesystemStorageProvider registers under the SAME providerId, so
437
- * a providerId-only match resolves to whichever node asked. That is how a
438
- * location created on an agent ended up writing to the HUB's filesystem
439
- * (root-caused 2026-07-29).
440
- */
441
- async getProviderFor(location) {
442
- return this.getProviderForTarget({
443
- providerId: location.providerId,
444
- ...location.nodeId !== void 0 && { nodeId: location.nodeId }
445
- }, `location "${location.id}"`, location.id);
446
519
  }
447
- /**
448
- * Resolve one dispatch target to the provider that must execute it.
449
- *
450
- * `descriptor` is the operator-facing subject of the error message
451
- * (`location "recordings:disk"`, `uploadId "u-42"`); `locationId` is the
452
- * structured tag for the log line when there is a location behind the
453
- * dispatch (chunked sessions carry only the session id).
454
- *
455
- * Both failure branches LOG before throwing, and NEITHER falls back to the
456
- * local provider — a silent fallback is the bug this method exists to fix.
457
- */
458
- async getProviderForTarget(target, descriptor, locationId) {
459
- const nodeId = target.nodeId;
460
- if (nodeId !== void 0 && bareNodeId(nodeId) !== this.localNode) {
461
- const remote = this.getRemoteProvider?.(nodeId);
462
- if (remote) return remote;
463
- this.logger.error("storage-orchestrator: target node has no reachable storage-provider — dispatch refused", { meta: {
464
- ...locationId !== void 0 && { locationId },
465
- descriptor,
466
- nodeId,
467
- providerId: target.providerId,
468
- localNodeId: this.localNode,
469
- crossNodeResolverWired: this.getRemoteProvider !== void 0
470
- } });
471
- throw new Error(`storage-provider for ${descriptor} lives on node "${nodeId}" which is not reachable`);
472
- }
473
- const providers = this.getProviders();
474
- for (const p of providers) if ((await p.getProviderInfo()).providerId === target.providerId) return p;
475
- this.logger.error("storage-orchestrator: no local storage-provider for providerId", { meta: {
476
- ...locationId !== void 0 && { locationId },
477
- descriptor,
478
- providerId: target.providerId,
479
- localNodeId: this.localNode,
480
- registered: providers.length
481
- } });
482
- throw new Error(`No storage-provider registered for providerId "${target.providerId}" (${descriptor})`);
520
+ async verifyMoves(job) {
521
+ for (const move of job.moves) if (move.state !== "done") throw new Error(`${move.storageClass} move did not complete verification`);
483
522
  }
484
- /**
485
- * Remember which provider and on which NODE — opened a chunked session.
486
- * `beginUpload` / `beginDownload` carry a `StorageLocation`; every later
487
- * chunk carries only the session id, and a session id issued by an agent
488
- * means nothing to the hub's provider.
489
- */
490
- rememberSession(kind, sessionId, location) {
491
- this.sessionTargets.set(sessionKey(kind, sessionId), {
492
- providerId: location.providerId,
493
- ...location.nodeId !== void 0 && { nodeId: location.nodeId }
494
- });
523
+ async startMove(job, move) {
524
+ if (move.storageClass === "eventMedia") return (await this.deps.participants.analytics.startMove({
525
+ toLocationId: move.toLocationId,
526
+ throttleMbps: job.throttleMbps,
527
+ leaseId: requireLease(job)
528
+ })).jobId;
529
+ return (await this.deps.participants.recorder.startMove({
530
+ fromLocationId: move.fromLocationId,
531
+ toLocationId: move.toLocationId,
532
+ footageClass: move.storageClass,
533
+ throttleMbps: job.throttleMbps,
534
+ leaseId: requireLease(job)
535
+ })).jobId;
495
536
  }
496
- /** Drop a session's routing entry (finalize / abort / end). */
497
- forgetSession(kind, sessionId) {
498
- this.sessionTargets.delete(sessionKey(kind, sessionId));
537
+ async moveStatus(move) {
538
+ if (move.moverJobId === null) return null;
539
+ return move.storageClass === "eventMedia" ? this.deps.participants.analytics.getMove(move.moverJobId) : this.deps.participants.recorder.getMove(move.moverJobId);
499
540
  }
500
- /** Drop every session entry (addon shutdown). */
501
- clearSessions() {
502
- this.sessionTargets.clear();
541
+ async cancelMove(move) {
542
+ if (move.moverJobId === null) return;
543
+ if (move.storageClass === "eventMedia") await this.deps.participants.analytics.cancelMove(move.moverJobId);
544
+ else await this.deps.participants.recorder.cancelMove(move.moverJobId);
503
545
  }
504
- /**
505
- * Resolve the provider that owns an in-flight chunked session, on the node
506
- * that opened it. Throws when the orchestrator has no record of the open
507
- * call (provider gone, or a chunk for a session it never issued).
508
- */
509
- async getProviderForSession(kind, sessionId) {
510
- const vocabulary = SESSION_VOCABULARY[kind];
511
- const descriptor = `${vocabulary.idLabel} "${sessionId}"`;
512
- const target = this.sessionTargets.get(sessionKey(kind, sessionId));
513
- if (!target) {
514
- this.logger.error("storage-orchestrator: chunk for an unknown session — dispatch refused", { meta: {
515
- descriptor,
516
- kind,
517
- sessionId
518
- } });
519
- throw new Error(`Unknown ${vocabulary.idLabel} "${sessionId}" — orchestrator has no record of ${vocabulary.openMethod}`);
520
- }
521
- return this.getProviderForTarget(target, descriptor);
546
+ participant(name) {
547
+ return this.deps.participants[name];
522
548
  }
523
- /** Convenience: lookup by id (returns `undefined` if not found). */
524
- getLocationById(id) {
525
- return this.locations.get(id);
549
+ async setPhase(job, phase) {
550
+ job.phase = phase;
551
+ if (isTerminal(job)) job.finishedAt = this.deps.now();
552
+ await this.persist(job);
526
553
  }
527
- /**
528
- * Seed one default `StorageLocation` per addon-declared location that
529
- * doesn't already have one. Idempotent — re-running with the same
530
- * declarations on a populated map is a no-op (each declared `id`
531
- * already resolves to an existing `<id>:default`).
532
- *
533
- * Convention: the bare-type ref `'recordings'` resolves via
534
- * `getDefaultLocation('recordings')` (walks `isDefault === true`); the
535
- * actual stored `id` is `'recordings:default'` so it satisfies the
536
- * `^[a-z][a-z0-9-]*:[a-z0-9-]+$` regex on `StorageLocationSchema.id`.
537
- *
538
- * `basePath` is the storage root (e.g. `<CAMSTACK_DATA>`). Each
539
- * location's `config.basePath` is `<basePath>/<id>`, except when the
540
- * declaration sets `defaultsTo` — then it inherits the resolved root of
541
- * the referenced location's default instance (sharing the same root
542
- * directory for derivative slots like `recordingsLow` → `recordings`).
543
- */
544
- seedDefaults(input) {
545
- let added = 0;
546
- const declById = new Map(input.declarations.map((d) => [d.id, d]));
547
- const rootFor = (d) => {
548
- const dedicated = d.defaultRoot === "media" ? input.mediaBasePath : d.defaultRoot === "backup" ? input.backupBasePath : void 0;
549
- return path$1.join(dedicated ?? input.basePath, d.id);
550
- };
551
- for (const d of input.declarations) {
552
- if (this.hasAnyLocationOfType(d.id)) continue;
553
- const base = d.defaultsTo ? this.getLocationById(`${d.defaultsTo}:default`)?.config["basePath"] ?? rootFor(declById.get(d.defaultsTo) ?? d) : rootFor(d);
554
- this.upsertLocation({
555
- id: `${d.id}:default`,
556
- type: d.id,
557
- displayName: d.displayName,
558
- providerId: input.providerId,
559
- config: { basePath: base },
560
- isDefault: true,
561
- isSystem: true
562
- });
563
- added++;
564
- }
565
- if (added > 0) this.logger.info("storage-orchestrator: seeded default locations", { meta: {
566
- added,
567
- basePath: input.basePath
568
- } });
554
+ async finishCancelled(job) {
555
+ job.phase = "cancelled";
556
+ job.finishedAt = this.deps.now();
557
+ await this.releaseAfterTerminal(job);
558
+ await this.persist(job);
569
559
  }
570
- /** Internal: check whether any location of the given type exists. */
571
- hasAnyLocationOfType(type) {
572
- for (const loc of this.locations.values()) if (loc.type === type) return true;
573
- return false;
560
+ async persist(job) {
561
+ job.updatedAt = this.deps.now();
562
+ await this.deps.state.set(job);
574
563
  }
575
564
  };
576
- //#endregion
577
- //#region src/builtins/storage-orchestrator/location-store.ts
578
- var STORAGE_LOCATIONS = "storage_locations";
579
- /**
580
- * Reserved key under which the cluster `nodeId` (SP1) is persisted INSIDE
581
- * the `config` JSON blob. Storing it in the blob rather than a dedicated
582
- * column avoids a destructive `ensureTable` DROP+recreate on deploy (a
583
- * newly-declared column counts as a missing column → the structured
584
- * backend drops the table, wiping operator location customizations such
585
- * as `recordings:default`'s `minFreePercent`). The double-underscore
586
- * prefix keeps it out of the provider-facing config namespace
587
- * (`basePath`, `minFreePercent`, …) — the mapper strips it on read so
588
- * API consumers and providers never see it.
589
- */
590
- var NODE_ID_CONFIG_KEY = "__nodeId";
591
- /**
592
- * `config` is declared `JSON`, so the store hands the surface an object and
593
- * gets an object back. There is deliberately NO `node_id` column — `nodeId`
594
- * rides inside the blob under a reserved key (see {@link NODE_ID_CONFIG_KEY}),
595
- * because a newly-declared column would put this table through
596
- * `declareCollection`'s destructive rebuild and wipe operator customizations
597
- * such as `recordings:default`'s `minFreePercent`.
598
- */
599
- var STORAGE_LOCATIONS_COLUMNS = [
600
- {
601
- name: "id",
602
- type: "TEXT",
603
- primaryKey: true
604
- },
605
- {
606
- name: "type",
607
- type: "TEXT",
608
- notNull: true
609
- },
610
- {
611
- name: "display_name",
612
- type: "TEXT",
613
- notNull: true
614
- },
615
- {
616
- name: "provider_id",
617
- type: "TEXT",
618
- notNull: true
619
- },
620
- {
621
- name: "config",
622
- type: "JSON",
623
- notNull: true,
624
- defaultValue: "{}"
625
- },
626
- {
627
- name: "is_default",
628
- type: "INTEGER",
629
- notNull: true,
630
- defaultValue: 0
631
- },
632
- {
633
- name: "is_system",
634
- type: "INTEGER",
635
- notNull: true,
636
- defaultValue: 0
637
- },
638
- {
639
- name: "created_at",
640
- type: "INTEGER",
641
- notNull: true
642
- },
643
- {
644
- name: "updated_at",
645
- type: "INTEGER",
646
- notNull: true
647
- }
648
- ];
649
- var STORAGE_LOCATIONS_INDEXES = [{
650
- name: "idx_storage_locations_type",
651
- columns: ["type"]
652
- }];
653
- /**
654
- * `ILocationStore` on the `settings-store` collection surface — the same door
655
- * every other consumer uses. It holds the `data-store-provider` engine object
656
- * directly (not the `settings-store` singleton) because since D44 the
657
- * storage-orchestrator IS that singleton, and asking the registry for it would
658
- * route this table back through the orchestrator itself.
659
- */
660
- var SqliteLocationStore = class {
661
- store;
662
- declared = false;
663
- constructor(store) {
664
- this.store = store;
665
- }
666
- /**
667
- * Lazy `declareCollection` — keeps the constructor cheap and async-free.
668
- * Called from every mutation/read path; `declareCollection` is idempotent
669
- * (and additive on shape changes), so repeated calls collapse to a no-op.
670
- */
671
- async ensureDeclared() {
672
- if (this.declared) return;
673
- await this.store.declareCollection({
674
- collection: STORAGE_LOCATIONS,
675
- columns: STORAGE_LOCATIONS_COLUMNS,
676
- indexes: STORAGE_LOCATIONS_INDEXES
677
- });
678
- this.declared = true;
679
- }
680
- async loadAll() {
681
- await this.ensureDeclared();
682
- const rows = await this.store.query({
683
- collection: STORAGE_LOCATIONS,
684
- filter: { orderBy: {
685
- field: "created_at",
686
- direction: "asc"
687
- } }
688
- });
689
- const out = [];
690
- for (const row of rows) {
691
- const mapped = mapRowToLocation(row.data);
692
- if (mapped) out.push(mapped);
693
- }
694
- return out;
695
- }
696
- /**
697
- * Replace-or-insert. Implemented as delete + insert rather than a
698
- * conditional update because the orchestrator serializes every mutation
699
- * through the in-memory map before dispatching to the store — there's no
700
- * contention to lose, and delete-then-insert is unconditionally simpler.
701
- */
702
- async upsert(loc) {
703
- await this.ensureDeclared();
704
- await this.store.deleteWhere({
705
- collection: STORAGE_LOCATIONS,
706
- filter: { where: { id: loc.id } }
707
- });
708
- await this.store.insert({
709
- collection: STORAGE_LOCATIONS,
710
- record: {
711
- id: loc.id,
712
- data: mapLocationToRow(loc)
713
- }
714
- });
715
- }
716
- async delete(id) {
717
- await this.ensureDeclared();
718
- await this.store.deleteWhere({
719
- collection: STORAGE_LOCATIONS,
720
- filter: { where: { id } }
721
- });
722
- }
723
- };
724
- function isPlainObject(value) {
725
- return value !== null && typeof value === "object" && !Array.isArray(value);
726
- }
727
- function readConfig(raw) {
728
- if (isPlainObject(raw)) return { ...raw };
729
- if (typeof raw === "string" && raw.length > 0) return parseJsonObject(raw) ?? {};
730
- return {};
731
- }
732
- function mapLocationToRow(loc) {
733
- const storedConfig = loc.nodeId !== void 0 ? {
734
- ...loc.config,
735
- [NODE_ID_CONFIG_KEY]: loc.nodeId
736
- } : { ...loc.config };
737
- return {
738
- id: loc.id,
739
- type: loc.type,
740
- display_name: loc.displayName,
741
- provider_id: loc.providerId,
742
- config: storedConfig,
743
- is_default: loc.isDefault ? 1 : 0,
744
- is_system: loc.isSystem ? 1 : 0,
745
- created_at: loc.createdAt,
746
- updated_at: loc.updatedAt
747
- };
748
- }
749
- /**
750
- * Decode a row read from the collection. Returns `null` when the row's `type`
751
- * fails the id-format schema or the row has no id — a corrupt row (hand-edited,
752
- * partial migration) is skipped rather than taking the boot read down with it.
753
- */
754
- function mapRowToLocation(row) {
755
- const typeRaw = row["type"];
756
- const parsedType = StorageLocationTypeSchema.safeParse(typeRaw);
757
- if (!parsedType.success) return null;
758
- const type = parsedType.data;
759
- const id = String(row["id"] ?? "");
760
- if (!id) return null;
761
- const storedConfig = readConfig(row["config"]);
762
- const nodeIdRaw = storedConfig[NODE_ID_CONFIG_KEY];
763
- const nodeId = typeof nodeIdRaw === "string" && nodeIdRaw.length > 0 ? nodeIdRaw : void 0;
764
- const config = { ...storedConfig };
765
- delete config[NODE_ID_CONFIG_KEY];
766
- return {
767
- id,
768
- type,
769
- displayName: String(row["display_name"] ?? ""),
770
- providerId: String(row["provider_id"] ?? ""),
771
- config,
772
- ...nodeId !== void 0 ? { nodeId } : {},
773
- isDefault: row["is_default"] === 1 || row["is_default"] === true,
774
- isSystem: row["is_system"] === 1 || row["is_system"] === true,
775
- createdAt: Number(row["created_at"] ?? 0),
776
- updatedAt: Number(row["updated_at"] ?? 0)
777
- };
778
- }
779
- //#endregion
780
- //#region src/builtins/storage-orchestrator/provider-discovery.ts
781
- async function collectProviderInfos(providers, onError) {
782
- const out = [];
783
- for (const [index, p] of providers.entries()) try {
784
- const info = await p.getProviderInfo();
785
- out.push(info);
786
- } catch (err) {
787
- onError(p, err, index);
788
- }
789
- return out;
790
- }
565
+ function requireLease(job) {
566
+ if (job.pauseLeaseId === null) throw new Error("storage migration has no maintenance lease");
567
+ return job.pauseLeaseId;
568
+ }
569
+ function isTerminal(job) {
570
+ return job.phase === "done" || job.phase === "failed" || job.phase === "cancelled";
571
+ }
791
572
  //#endregion
792
573
  //#region src/builtins/storage-orchestrator/data-store-dispatch.ts
793
574
  /**
@@ -838,491 +619,771 @@ function createDataStoreDispatch(getEngines) {
838
619
  };
839
620
  }
840
621
  //#endregion
841
- //#region src/builtins/storage-orchestrator/storage-pressure-manager.ts
842
- var DEFAULT_MAX_ROUNDS = 8;
843
- var DEFAULT_HYSTERESIS_PERCENT = 1;
844
- var StoragePressureManager = class {
845
- deps;
846
- constructor(deps) {
847
- this.deps = deps;
622
+ //#region src/builtins/storage-orchestrator/provider-discovery.ts
623
+ async function collectProviderInfos(providers, onError) {
624
+ const out = [];
625
+ for (const [index, p] of providers.entries()) try {
626
+ const info = await p.getProviderInfo();
627
+ out.push(info);
628
+ } catch (err) {
629
+ onError(p, err, index);
630
+ }
631
+ return out;
632
+ }
633
+ //#endregion
634
+ //#region src/builtins/storage-orchestrator/storage-orchestrator.service.ts
635
+ /**
636
+ * Routing core for the `storage-orchestrator` builtin.
637
+ *
638
+ * Owns the in-memory `locationId → StorageLocation` map and the
639
+ * `(providerId, nodeId) → IStorageProviderImpl` lookup. Every consumer-facing
640
+ * `storage` cap call lands here:
641
+ * 1. `resolveRef(ref)` — bare type or fully-qualified id → concrete location
642
+ * 2. `getProviderFor(location)` — route `(providerId, nodeId)` to either a
643
+ * local `storage-provider` registrant or the provider ON the node the
644
+ * location lives on. `providerId` alone is NOT a routing key: every
645
+ * node's filesystem provider shares it.
646
+ * 3. `getProviderForSession(kind, id)` — the same routing for chunked I/O,
647
+ * which carries no location after the opening call.
648
+ *
649
+ * The provider lookup is injected (`getProviders`) so unit tests can
650
+ * pass fixtures without booting a real CapabilityRegistry. The addon
651
+ * wires the production lookup to
652
+ * `ctx.kernel.capabilityRegistry?.getCollection<IStorageProviderImpl>('storage-provider')`.
653
+ *
654
+ * Task 5 adds first-boot seeding of default locations from addon-declared
655
+ * location types (providers no longer gate by type).
656
+ * Task 6 adds an optional `ILocationStore` for SQLite persistence —
657
+ * `initialize()` hydrates the in-memory map from the store, and every
658
+ * `upsertLocation` / `deleteLocation` mirrors the change to the store.
659
+ * Tests pass `null` (or a fake `ILocationStore`) for in-memory-only
660
+ * operation; production wires `SqliteLocationStore`.
661
+ */
662
+ var SESSION_VOCABULARY = {
663
+ upload: {
664
+ idLabel: "uploadId",
665
+ openMethod: "beginUpload"
666
+ },
667
+ download: {
668
+ idLabel: "downloadId",
669
+ openMethod: "beginDownload"
670
+ }
671
+ };
672
+ /** Map key for a tracked chunked session — the id spaces never collide. */
673
+ function sessionKey(kind, sessionId) {
674
+ return `${kind}:${sessionId}`;
675
+ }
676
+ /**
677
+ * Bare node id for a raw kernel `localNodeId`. Forked children carry a
678
+ * `<node>/<addon>` suffix; routing is per-NODE, so the addon segment is
679
+ * dropped. (Mirrors `normalizeNodeId` in `@camstack/types/addon`, which is a
680
+ * leaf module not re-exported on any subpath this package can import.)
681
+ */
682
+ function bareNodeId(raw) {
683
+ if (raw === void 0 || raw === "") return "hub";
684
+ const slash = raw.indexOf("/");
685
+ if (slash < 0) return raw;
686
+ const bare = raw.slice(0, slash);
687
+ return bare === "" ? "hub" : bare;
688
+ }
689
+ var StorageOrchestratorService = class {
690
+ logger;
691
+ getProviders;
692
+ locationStore;
693
+ getRemoteProvider;
694
+ locations = /* @__PURE__ */ new Map();
695
+ /**
696
+ * Declaration-driven cardinality source, injected by the addon after
697
+ * the kernel aggregates `storageLocations` declarations. `null` until
698
+ * `setRegistry` runs — `upsertLocation` then treats every type as
699
+ * `'multi'` (no singleton enforcement), the safe default for the
700
+ * in-memory-only / early-boot path.
701
+ */
702
+ registry = null;
703
+ /**
704
+ * Injected `providerId → nodeLocal` resolver (see {@link NodeLocalResolver}).
705
+ * `null` until {@link setNodeLocalResolver} runs — `upsertLocation` then
706
+ * skips the node-local requirement check entirely (every provider is
707
+ * treated as node-agnostic), the safe default for the in-memory-only /
708
+ * early-boot path that predates the resolver wiring.
709
+ */
710
+ nodeLocalResolver = null;
711
+ /** `localNodeId` with any forked-child `/addon` suffix stripped. */
712
+ localNode;
713
+ /**
714
+ * `<kind>:<sessionId>` → the dispatch target that OPENED the session.
715
+ * Chunked I/O carries no `StorageLocation` after the first hop, so without
716
+ * this the follow-up chunks fall back to a `providerId`-only lookup — which
717
+ * on a multi-node cluster means the hub's filesystem, not the agent's.
718
+ */
719
+ sessionTargets = /* @__PURE__ */ new Map();
720
+ constructor(logger, getProviders, locationStore = null, localNodeId = "hub", getRemoteProvider) {
721
+ this.logger = logger;
722
+ this.getProviders = getProviders;
723
+ this.locationStore = locationStore;
724
+ this.getRemoteProvider = getRemoteProvider;
725
+ this.localNode = bareNodeId(localNodeId);
726
+ }
727
+ /**
728
+ * The node this orchestrator runs on, with any forked-child `/addon`
729
+ * suffix stripped. A location whose `nodeId` differs is NOT this node's
730
+ * to touch — its bytes, and its free space, live elsewhere.
731
+ */
732
+ getLocalNodeId() {
733
+ return this.localNode;
734
+ }
735
+ /** True once a persistence store is wired (constructor or {@link attachStore}). */
736
+ hasStore() {
737
+ return this.locationStore !== null;
738
+ }
739
+ /**
740
+ * Late-bind the persistence store (production boot: the `settings-store`
741
+ * cap registers after the orchestrator initializes, so the service runs
742
+ * in-memory-only until then). Idempotent — a second call once a store is
743
+ * wired is a no-op.
744
+ *
745
+ * Reconciles both directions so operator edits survive a reboot:
746
+ * 1. hydrate — DB rows win over any early in-memory seed (restores
747
+ * operator config like `minFreePercent` that the pre-store boot
748
+ * can't see), with the same `isSystem` upgrade as {@link initialize};
749
+ * 2. backfill — in-memory locations the DB doesn't have yet (the
750
+ * pre-store seed defaults on a fresh install) are persisted, so the
751
+ * store becomes the durable source of truth from here on.
752
+ */
753
+ async attachStore(store) {
754
+ if (this.locationStore) return;
755
+ this.locationStore = store;
756
+ const rows = await store.loadAll();
757
+ const dbIds = new Set(rows.map((r) => r.id));
758
+ for (const loc of rows) {
759
+ const upgraded = !loc.isSystem && loc.id === `${loc.type}:default` ? {
760
+ ...loc,
761
+ isSystem: true
762
+ } : loc;
763
+ this.locations.set(upgraded.id, upgraded);
764
+ if (upgraded !== loc) store.upsert(upgraded).catch(() => {});
765
+ }
766
+ const backfill = [...this.locations.values()].filter((l) => !dbIds.has(l.id));
767
+ for (const loc of backfill) store.upsert(loc).catch((err) => {
768
+ this.logger.warn("storage-orchestrator: attachStore backfill persist failed", { meta: {
769
+ id: loc.id,
770
+ error: err instanceof Error ? err.message : String(err)
771
+ } });
772
+ });
773
+ this.logger.info("storage-orchestrator: store attached + reconciled", { meta: {
774
+ hydrated: rows.length,
775
+ backfilled: backfill.length,
776
+ total: this.locations.size
777
+ } });
778
+ }
779
+ /**
780
+ * Inject the declaration-driven cardinality source. Called once by the
781
+ * addon after the kernel aggregates every addon's `storageLocations`
782
+ * declarations into a `StorageLocationRegistry`.
783
+ */
784
+ setRegistry(registry) {
785
+ this.registry = registry;
786
+ }
787
+ /**
788
+ * Inject the synchronous `providerId → nodeLocal` resolver (SP1). Called
789
+ * by the addon from a cached snapshot of every `storage-provider`'s
790
+ * `getProviderInfo().nodeLocal`, refreshed when the provider collection
791
+ * changes. Powers the node-local upsert guard and the boot backfill.
792
+ */
793
+ setNodeLocalResolver(resolver) {
794
+ this.nodeLocalResolver = resolver;
795
+ }
796
+ /**
797
+ * The full set of addon-declared storage locations, as aggregated by
798
+ * the kernel and injected via {@link setRegistry}. Powers the admin-UI
799
+ * Data screen's per-declaration grouping. Empty until the registry is
800
+ * injected (early-boot / in-memory-only path).
801
+ */
802
+ listDeclarations() {
803
+ return this.registry ? this.registry.list() : [];
804
+ }
805
+ /**
806
+ * Hydrate the in-memory map from the persistence layer. Called once
807
+ * by the addon during `onInitialize`, before the eager seed pass.
808
+ * Idempotent — running it twice with the same store contents
809
+ * produces the same map. No-op when the service was constructed
810
+ * without a store.
811
+ *
812
+ * Errors loading individual rows are tolerated: the bad row is
813
+ * skipped and logged so a single corrupted record doesn't lock the
814
+ * whole orchestrator out of boot. (The store-side validation pass
815
+ * before insert means only schema-invalid SQL state can produce
816
+ * such a row.)
817
+ */
818
+ async initialize() {
819
+ if (!this.locationStore) return;
820
+ const rows = await this.locationStore.loadAll();
821
+ let upgraded = 0;
822
+ for (const loc of rows) if (!loc.isSystem && loc.id === `${loc.type}:default`) {
823
+ const upgradedLoc = {
824
+ ...loc,
825
+ isSystem: true
826
+ };
827
+ this.locations.set(upgradedLoc.id, upgradedLoc);
828
+ this.locationStore.upsert(upgradedLoc).catch((err) => {
829
+ this.logger.warn("storage-orchestrator: isSystem upgrade persist failed", { meta: {
830
+ id: upgradedLoc.id,
831
+ error: err instanceof Error ? err.message : String(err)
832
+ } });
833
+ });
834
+ upgraded++;
835
+ } else this.locations.set(loc.id, loc);
836
+ this.logger.info("storage-orchestrator: hydrated locations from store", { meta: {
837
+ loaded: this.locations.size,
838
+ isSystemUpgraded: upgraded
839
+ } });
840
+ }
841
+ /**
842
+ * Boot backfill (SP1): stamp `nodeId` on every persisted node-local
843
+ * location that lacks one. Single-node deployments seeded their
844
+ * filesystem locations before `nodeId` existed — they live on `hubNodeId`
845
+ * (the hub). Node-agnostic (remote) locations are left untouched: their
846
+ * absent `nodeId` is correct (reachable from any node).
847
+ *
848
+ * Idempotent — a location that already carries a `nodeId` (any value) is
849
+ * never re-touched, so re-running on a populated map (or a re-boot) is a
850
+ * no-op. A plain local update, not versioned-migration machinery: only
851
+ * the rows missing `nodeId` are mutated + persisted.
852
+ *
853
+ * No-op until the node-local resolver is wired (it classifies which
854
+ * providers are node-local).
855
+ */
856
+ async backfillNodeIds(hubNodeId) {
857
+ const resolver = this.nodeLocalResolver;
858
+ if (!resolver) return;
859
+ let stamped = 0;
860
+ for (const [id, loc] of Array.from(this.locations)) {
861
+ if (loc.nodeId) continue;
862
+ if (resolver(loc.providerId) !== true) continue;
863
+ const updated = {
864
+ ...loc,
865
+ nodeId: hubNodeId,
866
+ updatedAt: Date.now()
867
+ };
868
+ this.locations.set(id, updated);
869
+ stamped++;
870
+ if (this.locationStore) try {
871
+ await this.locationStore.upsert(updated);
872
+ } catch (err) {
873
+ this.logger.error("storage-orchestrator: nodeId backfill persist failed", { meta: {
874
+ id,
875
+ error: err instanceof Error ? err.message : String(err)
876
+ } });
877
+ }
878
+ }
879
+ if (stamped > 0) this.logger.info("storage-orchestrator: backfilled nodeId on node-local locations", { meta: {
880
+ stamped,
881
+ hubNodeId
882
+ } });
883
+ }
884
+ listLocations(filter) {
885
+ const all = [...this.locations.values()];
886
+ return filter?.type ? all.filter((l) => l.type === filter.type) : all;
887
+ }
888
+ getDefaultLocation(type) {
889
+ for (const loc of this.locations.values()) if (loc.type === type && loc.isDefault) return loc;
890
+ return null;
891
+ }
892
+ /**
893
+ * Repoint several logical defaults as one in-memory commit. The migration
894
+ * coordinator persists its `repointing` phase before calling this method, so
895
+ * a process crash during the backing-store writes is recovered by replaying
896
+ * the same idempotent target set before anyone resumes writers.
897
+ */
898
+ async setDefaultLocations(targetByType) {
899
+ if (targetByType.size === 0) return;
900
+ const now = Date.now();
901
+ const nextById = new Map(this.locations);
902
+ const touched = /* @__PURE__ */ new Map();
903
+ for (const [type, targetId] of targetByType) {
904
+ const target = nextById.get(targetId);
905
+ if (!target) throw new Error(`Storage location "${targetId}" not found`);
906
+ if (target.type !== type) throw new Error(`Storage location "${targetId}" is type "${target.type}", expected "${type}"`);
907
+ for (const [id, location] of nextById) {
908
+ if (location.type !== type) continue;
909
+ const isDefault = id === targetId;
910
+ const enable = isDefault && location.enabled === false;
911
+ if (location.isDefault === isDefault && !enable) continue;
912
+ const updated = {
913
+ ...location,
914
+ isDefault,
915
+ ...enable ? { enabled: true } : {},
916
+ updatedAt: now
917
+ };
918
+ nextById.set(id, updated);
919
+ touched.set(id, updated);
920
+ }
921
+ }
922
+ if (this.locationStore) {
923
+ const previous = /* @__PURE__ */ new Map();
924
+ for (const id of touched.keys()) {
925
+ const location = this.locations.get(id);
926
+ if (location) previous.set(id, location);
927
+ }
928
+ try {
929
+ for (const location of touched.values()) await this.locationStore.upsert(location);
930
+ } catch (err) {
931
+ for (const location of previous.values()) try {
932
+ await this.locationStore.upsert(location);
933
+ } catch {}
934
+ throw err;
935
+ }
936
+ }
937
+ this.locations.clear();
938
+ for (const [id, location] of nextById) this.locations.set(id, location);
939
+ }
940
+ /**
941
+ * Insert or update a location. If `isDefault: true`, atomically
942
+ * demotes any other default for the same type — operators always see
943
+ * exactly one default per type.
944
+ *
945
+ * When a persistence store is wired (Task 6), the new record AND any
946
+ * implicitly-demoted siblings are persisted before the in-memory map
947
+ * mutation returns. Persistence errors propagate to the caller.
948
+ */
949
+ upsertLocation(input) {
950
+ const now = Date.now();
951
+ const existing = this.locations.get(input.id);
952
+ if (!existing) {
953
+ if ((this.registry?.cardinalityOf(input.type) ?? "multi") === "single") {
954
+ const already = [...this.locations.values()].find((l) => l.type === input.type);
955
+ if (already) throw new Error(`Storage type "${input.type}" is single — only one location allowed (existing: "${already.id}"). Edit it instead of adding a new one.`);
956
+ }
957
+ }
958
+ if (this.nodeLocalResolver?.(input.providerId) === true && !input.nodeId) input = {
959
+ ...input,
960
+ nodeId: "hub"
961
+ };
962
+ if (existing?.isSystem === true) input = {
963
+ ...input,
964
+ isSystem: true
965
+ };
966
+ input = {
967
+ ...input,
968
+ ...resolveEnabled(input, existing, this.hasAnyLocationOfType(input.type))
969
+ };
970
+ const next = {
971
+ ...input,
972
+ createdAt: existing?.createdAt ?? now,
973
+ updatedAt: now
974
+ };
975
+ const demoted = [];
976
+ if (next.isDefault) {
977
+ for (const [otherId, otherLoc] of this.locations) if (otherLoc.type === next.type && otherLoc.id !== next.id && otherLoc.isDefault) {
978
+ const updated = {
979
+ ...otherLoc,
980
+ isDefault: false,
981
+ updatedAt: now
982
+ };
983
+ this.locations.set(otherId, updated);
984
+ demoted.push(updated);
985
+ }
986
+ }
987
+ this.locations.set(next.id, next);
988
+ this.logger.debug("storage-orchestrator: upsertLocation", { meta: {
989
+ id: next.id,
990
+ type: next.type,
991
+ providerId: next.providerId,
992
+ isDefault: next.isDefault
993
+ } });
994
+ if (this.locationStore) {
995
+ const store = this.locationStore;
996
+ (async () => {
997
+ try {
998
+ for (const d of demoted) await store.upsert(d);
999
+ await store.upsert(next);
1000
+ } catch (err) {
1001
+ this.logger.error("storage-orchestrator: upsert persistence failed", { meta: {
1002
+ id: next.id,
1003
+ error: err instanceof Error ? err.message : String(err)
1004
+ } });
1005
+ }
1006
+ })();
1007
+ }
1008
+ return next;
1009
+ }
1010
+ /**
1011
+ * Remove a location. Refuses to remove the default of a type unless a
1012
+ * sibling default exists (defensive — `upsertLocation` already ensures
1013
+ * at most one default per type, but the logic guards against future
1014
+ * bypass paths e.g. SQLite migration that imports two defaults).
1015
+ *
1016
+ * Persistence (Task 6) mirrors the delete asynchronously, with errors
1017
+ * routed to the logger — see `upsertLocation` for the rationale.
1018
+ */
1019
+ deleteLocation(id) {
1020
+ const loc = this.locations.get(id);
1021
+ if (!loc) throw new Error(`Storage location "${id}" not found`);
1022
+ if (loc.isSystem) throw new Error(`Storage location "${id}" is system-managed and cannot be deleted. Edit its config (path / providerId) instead.`);
1023
+ if (loc.isDefault) {
1024
+ if (![...this.locations.values()].find((l) => l.type === loc.type && l.id !== id && l.isDefault)) throw new Error(`Cannot delete default location "${id}" for type "${loc.type}" — promote another location to default first`);
1025
+ }
1026
+ this.locations.delete(id);
1027
+ if (this.locationStore) this.locationStore.delete(id).catch((err) => {
1028
+ this.logger.error("storage-orchestrator: delete persistence failed", { meta: {
1029
+ id,
1030
+ error: err instanceof Error ? err.message : String(err)
1031
+ } });
1032
+ });
848
1033
  }
849
- /** Relieve every managed location currently under its threshold. */
850
- async sweep() {
851
- const thresholds = await this.deps.getLocationThresholds();
852
- const relievedDevices = /* @__PURE__ */ new Set();
853
- for (const [locationId, threshold] of thresholds) {
854
- if (threshold <= 0) continue;
855
- const deviceKey = await this.deps.getLocationDeviceKey?.(locationId) ?? null;
856
- if (deviceKey !== null && relievedDevices.has(deviceKey)) {
857
- this.deps.logger.debug("storage pressure: skipping location physical device already relieved this sweep", { meta: {
858
- locationId,
859
- deviceKey
860
- } });
861
- continue;
862
- }
863
- try {
864
- if (await this.relieveLocation(locationId, threshold) && deviceKey !== null) relievedDevices.add(deviceKey);
865
- } catch (err) {
866
- this.deps.logger.warn("storage pressure: relief failed for location", { meta: {
867
- locationId,
868
- error: err instanceof Error ? err.message : String(err)
869
- } });
870
- }
1034
+ /**
1035
+ * Remove system-seeded locations whose type is no longer declared by any
1036
+ * addon (stale defaults from a removed location type). Operator-added
1037
+ * (non-system) locations of an undeclared type are KEPT but warned — the
1038
+ * operator owns them. Requires the registry to be set first.
1039
+ *
1040
+ * FAIL-SAFE: if the registry is EMPTY (no addon declared any location), we
1041
+ * refuse to prune anything. An empty registry almost always means
1042
+ * declarations failed to load (boot ordering, a stale install) pruning
1043
+ * "everything undeclared" in that state would wipe every system location
1044
+ * (data/logs/recordings/…). Better to keep stale rows than destroy live ones.
1045
+ */
1046
+ pruneUndeclaredSystemLocations() {
1047
+ if (!this.registry) return;
1048
+ if (this.registry.list().length === 0) {
1049
+ this.logger.warn("storage-orchestrator: skipping prune no location declarations loaded (fail-safe)");
1050
+ return;
871
1051
  }
872
- const caps = await this.deps.getLocationMaxUsedBytes?.() ?? /* @__PURE__ */ new Map();
873
- for (const [locationId, maxBytes] of caps) {
874
- if (relievedDevices.size > 0) {
875
- const deviceKey = await this.deps.getLocationDeviceKey?.(locationId) ?? null;
876
- if (deviceKey !== null && relievedDevices.has(deviceKey)) continue;
877
- }
878
- try {
879
- await this.relieveToUsageCap(locationId, maxBytes);
880
- } catch (err) {
881
- this.deps.logger.warn("storage pressure: usage-cap relief failed", { meta: {
882
- locationId,
883
- error: err instanceof Error ? err.message : String(err)
1052
+ for (const [id, loc] of Array.from(this.locations)) {
1053
+ if (this.registry.cardinalityOf(loc.type) !== null) continue;
1054
+ if (loc.isSystem) {
1055
+ this.locations.delete(id);
1056
+ if (this.locationStore) this.locationStore.delete(id).catch((err) => {
1057
+ this.logger.error("storage-orchestrator: prune persistence failed", { meta: {
1058
+ id,
1059
+ error: err instanceof Error ? err.message : String(err)
1060
+ } });
1061
+ });
1062
+ this.logger.info("storage-orchestrator: pruned stale system location", { meta: {
1063
+ id,
1064
+ type: loc.type
884
1065
  } });
885
- }
1066
+ } else this.logger.warn("storage-orchestrator: location of undeclared type kept (operator-owned)", { meta: {
1067
+ id,
1068
+ type: loc.type
1069
+ } });
886
1070
  }
887
1071
  }
888
- /** Evict oldest-first until the location's evictable usage fits `maxBytes`. */
889
- async relieveToUsageCap(locationId, maxBytes) {
890
- const maxRounds = this.deps.maxRounds ?? DEFAULT_MAX_ROUNDS;
891
- for (let round = 0; round < maxRounds; round++) {
892
- const providers = this.deps.getEvictableProviders();
893
- const usages = await Promise.all(providers.map(async (p) => ({
894
- p,
895
- bytes: (await p.getEvictableUsage({ locationId })).bytes
896
- })));
897
- const totalUsed = usages.reduce((s, u) => s + u.bytes, 0);
898
- const excess = totalUsed - maxBytes;
899
- if (excess <= 0) return;
900
- let reclaimed = 0;
901
- for (const u of usages) {
902
- if (u.bytes <= 0) continue;
903
- const share = Math.max(1, Math.ceil(excess * u.bytes / totalUsed));
904
- const res = await u.p.evict({
905
- locationId,
906
- targetBytes: share
907
- });
908
- reclaimed += res.reclaimedBytes;
909
- }
910
- this.deps.logger.info("storage pressure: usage-cap relief round", { meta: {
911
- locationId,
912
- round,
913
- totalUsed,
914
- maxBytes,
915
- reclaimedBytes: reclaimed
1072
+ /**
1073
+ * Resolve a `StorageLocationRef` to a concrete `StorageLocation`.
1074
+ * - bare type (e.g. `'backups'`) → default location of that type
1075
+ * - fully-qualified id (e.g. `'backups:nas-01'`) that exact instance
1076
+ *
1077
+ * Throws an actionable error when nothing matches — every consumer of
1078
+ * the singleton `storage` cap funnels through here, so the error
1079
+ * message is what operators see when the orchestrator's view of the
1080
+ * world doesn't match expectations.
1081
+ */
1082
+ resolveRef(ref) {
1083
+ if (ref.includes(":")) {
1084
+ const loc = this.locations.get(ref);
1085
+ if (!loc) throw new Error(`Storage location "${ref}" not found`);
1086
+ return loc;
1087
+ }
1088
+ const def = this.getDefaultLocation(ref);
1089
+ if (!def) throw new Error(`No default storage location configured for type "${ref}" — register one or pass a fully-qualified id (\`<type>:<slug>\`)`);
1090
+ return def;
1091
+ }
1092
+ /**
1093
+ * Find the storage-provider that backs a given location — the entry point
1094
+ * every consumer-facing `storage` cap method funnels through.
1095
+ *
1096
+ * Routing key is `(providerId, nodeId)`, never `providerId` alone: every
1097
+ * node's FilesystemStorageProvider registers under the SAME providerId, so
1098
+ * a providerId-only match resolves to whichever node asked. That is how a
1099
+ * location created on an agent ended up writing to the HUB's filesystem
1100
+ * (root-caused 2026-07-29).
1101
+ */
1102
+ async getProviderFor(location) {
1103
+ return this.getProviderForTarget({
1104
+ providerId: location.providerId,
1105
+ ...location.nodeId !== void 0 && { nodeId: location.nodeId }
1106
+ }, `location "${location.id}"`, location.id);
1107
+ }
1108
+ /**
1109
+ * Resolve one dispatch target to the provider that must execute it.
1110
+ *
1111
+ * `descriptor` is the operator-facing subject of the error message
1112
+ * (`location "recordings:disk"`, `uploadId "u-42"`); `locationId` is the
1113
+ * structured tag for the log line when there is a location behind the
1114
+ * dispatch (chunked sessions carry only the session id).
1115
+ *
1116
+ * Both failure branches LOG before throwing, and NEITHER falls back to the
1117
+ * local provider — a silent fallback is the bug this method exists to fix.
1118
+ */
1119
+ async getProviderForTarget(target, descriptor, locationId) {
1120
+ const nodeId = target.nodeId;
1121
+ if (nodeId !== void 0 && bareNodeId(nodeId) !== this.localNode) {
1122
+ const remote = this.getRemoteProvider?.(nodeId);
1123
+ if (remote) return remote;
1124
+ this.logger.error("storage-orchestrator: target node has no reachable storage-provider — dispatch refused", { meta: {
1125
+ ...locationId !== void 0 && { locationId },
1126
+ descriptor,
1127
+ nodeId,
1128
+ providerId: target.providerId,
1129
+ localNodeId: this.localNode,
1130
+ crossNodeResolverWired: this.getRemoteProvider !== void 0
916
1131
  } });
917
- if (reclaimed <= 0) return;
1132
+ throw new Error(`storage-provider for ${descriptor} lives on node "${nodeId}" which is not reachable`);
918
1133
  }
1134
+ const providers = this.getProviders();
1135
+ for (const p of providers) if ((await p.getProviderInfo()).providerId === target.providerId) return p;
1136
+ this.logger.error("storage-orchestrator: no local storage-provider for providerId", { meta: {
1137
+ ...locationId !== void 0 && { locationId },
1138
+ descriptor,
1139
+ providerId: target.providerId,
1140
+ localNodeId: this.localNode,
1141
+ registered: providers.length
1142
+ } });
1143
+ throw new Error(`No storage-provider registered for providerId "${target.providerId}" (${descriptor})`);
919
1144
  }
920
1145
  /**
921
- * Relieve one location until it recovers, nothing is evictable, or a round
922
- * makes no progress. Returns `true` when at least one relief round actually
923
- * reclaimed bytes (so the caller marks the location's physical device relieved
924
- * for this sweep the trigger for the over-eviction dedup: freed bytes mean a
925
- * sibling alias may re-read a not-yet-settled `statfs`), `false` when nothing
926
- * was freed (never breached, or breached with no evictable data).
1146
+ * Remember which provider and on which NODE opened a chunked session.
1147
+ * `beginUpload` / `beginDownload` carry a `StorageLocation`; every later
1148
+ * chunk carries only the session id, and a session id issued by an agent
1149
+ * means nothing to the hub's provider.
927
1150
  */
928
- async relieveLocation(locationId, threshold) {
929
- const maxRounds = this.deps.maxRounds ?? DEFAULT_MAX_ROUNDS;
930
- const hysteresis = this.deps.hysteresisPercent ?? DEFAULT_HYSTERESIS_PERCENT;
931
- let reclaimedAny = false;
932
- for (let round = 0; round < maxRounds; round++) {
933
- const free = await this.deps.getFreePercent(locationId);
934
- if (free >= threshold) return reclaimedAny;
935
- const total = await this.deps.getVolumeTotalBytes(locationId);
936
- const deficitPercent = threshold - free + hysteresis;
937
- const targetBytes = Math.ceil(total * deficitPercent / 100);
938
- if (targetBytes <= 0) return reclaimedAny;
939
- const providers = this.deps.getEvictableProviders();
940
- const usages = await Promise.all(providers.map(async (p) => ({
941
- p,
942
- bytes: (await p.getEvictableUsage({ locationId })).bytes
943
- })));
944
- const totalEvictable = usages.reduce((s, u) => s + u.bytes, 0);
945
- if (totalEvictable <= 0) {
946
- this.deps.logger.debug("storage pressure: breach but no evictable data on location", { meta: {
947
- locationId,
948
- freePercent: free,
949
- threshold
950
- } });
951
- return reclaimedAny;
952
- }
953
- let reclaimed = 0;
954
- for (const u of usages) {
955
- if (u.bytes <= 0) continue;
956
- const share = Math.max(1, Math.ceil(targetBytes * u.bytes / totalEvictable));
957
- const res = await u.p.evict({
958
- locationId,
959
- targetBytes: share
960
- });
961
- reclaimed += res.reclaimedBytes;
962
- }
963
- if (reclaimed > 0) reclaimedAny = true;
964
- this.deps.logger.info("storage pressure: relief round", { meta: {
965
- locationId,
966
- round,
967
- freePercent: free,
968
- threshold,
969
- targetBytes,
970
- reclaimedBytes: reclaimed
1151
+ rememberSession(kind, sessionId, location) {
1152
+ this.sessionTargets.set(sessionKey(kind, sessionId), {
1153
+ providerId: location.providerId,
1154
+ ...location.nodeId !== void 0 && { nodeId: location.nodeId }
1155
+ });
1156
+ }
1157
+ /** Drop a session's routing entry (finalize / abort / end). */
1158
+ forgetSession(kind, sessionId) {
1159
+ this.sessionTargets.delete(sessionKey(kind, sessionId));
1160
+ }
1161
+ /** Drop every session entry (addon shutdown). */
1162
+ clearSessions() {
1163
+ this.sessionTargets.clear();
1164
+ }
1165
+ /**
1166
+ * Resolve the provider that owns an in-flight chunked session, on the node
1167
+ * that opened it. Throws when the orchestrator has no record of the open
1168
+ * call (provider gone, or a chunk for a session it never issued).
1169
+ */
1170
+ async getProviderForSession(kind, sessionId) {
1171
+ const vocabulary = SESSION_VOCABULARY[kind];
1172
+ const descriptor = `${vocabulary.idLabel} "${sessionId}"`;
1173
+ const target = this.sessionTargets.get(sessionKey(kind, sessionId));
1174
+ if (!target) {
1175
+ this.logger.error("storage-orchestrator: chunk for an unknown session — dispatch refused", { meta: {
1176
+ descriptor,
1177
+ kind,
1178
+ sessionId
971
1179
  } });
972
- if (reclaimed <= 0) return reclaimedAny;
1180
+ throw new Error(`Unknown ${vocabulary.idLabel} "${sessionId}" orchestrator has no record of ${vocabulary.openMethod}`);
973
1181
  }
974
- return reclaimedAny;
1182
+ return this.getProviderForTarget(target, descriptor);
1183
+ }
1184
+ /** Convenience: lookup by id (returns `undefined` if not found). */
1185
+ getLocationById(id) {
1186
+ return this.locations.get(id);
1187
+ }
1188
+ /**
1189
+ * Seed one default `StorageLocation` per addon-declared location that
1190
+ * doesn't already have one. Idempotent — re-running with the same
1191
+ * declarations on a populated map is a no-op (each declared `id`
1192
+ * already resolves to an existing `<id>:default`).
1193
+ *
1194
+ * Convention: the bare-type ref `'recordings'` resolves via
1195
+ * `getDefaultLocation('recordings')` (walks `isDefault === true`); the
1196
+ * actual stored `id` is `'recordings:default'` so it satisfies the
1197
+ * `^[a-z][a-z0-9-]*:[a-z0-9-]+$` regex on `StorageLocationSchema.id`.
1198
+ *
1199
+ * `basePath` is the storage root (e.g. `<CAMSTACK_DATA>`). Each
1200
+ * location's `config.basePath` is `<basePath>/<id>`, except when the
1201
+ * declaration sets `defaultsTo` — then it inherits the resolved root of
1202
+ * the referenced location's default instance (sharing the same root
1203
+ * directory for derivative slots like `recordingsLow` → `recordings`).
1204
+ */
1205
+ seedDefaults(input) {
1206
+ let added = 0;
1207
+ const declById = new Map(input.declarations.map((d) => [d.id, d]));
1208
+ const rootFor = (d) => {
1209
+ const dedicated = d.defaultRoot === "media" ? input.mediaBasePath : d.defaultRoot === "backup" ? input.backupBasePath : void 0;
1210
+ return path$1.join(dedicated ?? input.basePath, d.id);
1211
+ };
1212
+ for (const d of input.declarations) {
1213
+ if (this.hasAnyLocationOfType(d.id)) continue;
1214
+ const base = d.defaultsTo ? this.getLocationById(`${d.defaultsTo}:default`)?.config["basePath"] ?? rootFor(declById.get(d.defaultsTo) ?? d) : rootFor(d);
1215
+ this.upsertLocation({
1216
+ id: `${d.id}:default`,
1217
+ type: d.id,
1218
+ displayName: d.displayName,
1219
+ providerId: input.providerId,
1220
+ config: { basePath: base },
1221
+ isDefault: true,
1222
+ isSystem: true
1223
+ });
1224
+ added++;
1225
+ }
1226
+ if (added > 0) this.logger.info("storage-orchestrator: seeded default locations", { meta: {
1227
+ added,
1228
+ basePath: input.basePath
1229
+ } });
1230
+ }
1231
+ /** Internal: check whether any location of the given type exists. */
1232
+ hasAnyLocationOfType(type) {
1233
+ for (const loc of this.locations.values()) if (loc.type === type) return true;
1234
+ return false;
975
1235
  }
976
1236
  };
977
- //#endregion
978
- //#region src/builtins/storage-orchestrator/storage-migration-coordinator.ts
979
- var STORAGE_CLASSES = [
980
- "recordings",
981
- "recordingsLow",
982
- "eventMedia"
983
- ];
984
- var PARTICIPANTS = [
985
- "pipeline",
986
- "recorder",
987
- "analytics"
988
- ];
989
- var REVERSE_PARTICIPANTS = [
990
- "analytics",
991
- "recorder",
992
- "pipeline"
993
- ];
994
1237
  /**
995
- * Durable orchestration layer over the independent recorder and event-media
996
- * movers. It owns the only public migration state machine; participants expose
997
- * intentionally narrow lease/move primitives and never alter camera settings.
1238
+ * Resolve `enabled` for one upsert — see the call site in `upsertLocation` for
1239
+ * the ordered rules. Pure: every input it needs is a parameter.
998
1240
  */
999
- var StorageMigrationCoordinator = class {
1241
+ function resolveEnabled(input, existing, typeAlreadyHasLocation) {
1242
+ const requested = input.enabled ?? existing?.enabled;
1243
+ if (requested === void 0) {
1244
+ if (existing) return {};
1245
+ return { enabled: input.isDefault || !typeAlreadyHasLocation };
1246
+ }
1247
+ if (input.isDefault && requested === false) return { enabled: true };
1248
+ return { enabled: requested };
1249
+ }
1250
+ //#endregion
1251
+ //#region src/builtins/storage-orchestrator/storage-pressure-manager.ts
1252
+ var DEFAULT_MAX_ROUNDS = 8;
1253
+ var DEFAULT_HYSTERESIS_PERCENT = 1;
1254
+ var StoragePressureManager = class {
1000
1255
  deps;
1001
- active = null;
1002
- runPromise = null;
1003
- startReserved = false;
1004
1256
  constructor(deps) {
1005
1257
  this.deps = deps;
1006
1258
  }
1007
- async plan(input) {
1008
- const moves = [];
1009
- for (const storageClass of STORAGE_CLASSES) {
1010
- const targetId = input.destinations[storageClass];
1011
- if (targetId === void 0) continue;
1012
- const source = this.deps.locations.getDefaultLocation(storageClass);
1013
- if (!source) throw new Error(`No default storage location for "${storageClass}"`);
1014
- const target = this.deps.locations.getLocationById(targetId);
1015
- if (!target) throw new Error(`Storage location "${targetId}" not found`);
1016
- if (target.type !== storageClass) throw new Error(`Storage location "${targetId}" is type "${target.type}", expected "${storageClass}"`);
1017
- if (source.id === target.id) throw new Error(`Storage location "${targetId}" is already the "${storageClass}" default`);
1018
- moves.push({
1019
- storageClass,
1020
- fromLocationId: source.id,
1021
- toLocationId: target.id,
1022
- moverJobId: null,
1023
- state: null,
1024
- error: null
1025
- });
1026
- }
1027
- if (moves.length === 0) throw new Error("select at least one storage class");
1028
- return {
1029
- destinations: input.destinations,
1030
- moves: moves.map(({ storageClass, fromLocationId, toLocationId }) => ({
1031
- storageClass,
1032
- fromLocationId,
1033
- toLocationId
1034
- }))
1035
- };
1036
- }
1037
- async start(input) {
1038
- if (this.startReserved) throw new Error("storage migration is already active");
1039
- this.startReserved = true;
1040
- try {
1041
- const existing = await this.status();
1042
- if (existing && isTerminal(existing) && existing.pausedParticipants.length > 0) {
1043
- await this.releaseAfterTerminal(existing);
1044
- await this.persist(existing);
1045
- if (existing.pausedParticipants.length > 0) throw new Error(`storage migration ${existing.jobId} still holds maintenance leases`);
1046
- }
1047
- if (existing && !isTerminal(existing)) throw new Error(`storage migration is already active (${existing.jobId})`);
1048
- const plan = await this.plan(input);
1049
- const now = this.deps.now();
1050
- const job = {
1051
- jobId: this.deps.newId(),
1052
- phase: "planning",
1053
- destinations: input.destinations,
1054
- throttleMbps: input.throttleMbps ?? 40,
1055
- moves: plan.moves.map((move) => ({
1056
- ...move,
1057
- moverJobId: null,
1058
- state: null,
1059
- error: null
1060
- })),
1061
- pauseLeaseId: null,
1062
- pausedParticipants: [],
1063
- repointed: false,
1064
- cancelRequested: false,
1065
- startedAt: now,
1066
- updatedAt: now,
1067
- finishedAt: null,
1068
- error: null
1069
- };
1070
- this.active = job;
1071
- await this.persist(job);
1072
- this.runPromise = this.run(job);
1073
- this.runPromise;
1074
- return job.jobId;
1075
- } finally {
1076
- this.startReserved = false;
1077
- }
1078
- }
1079
- async status(jobId) {
1080
- const job = this.active ?? await this.deps.state.get();
1081
- if (jobId !== void 0 && job?.jobId !== jobId) return null;
1082
- return job;
1083
- }
1084
- async cancel(jobId) {
1085
- const job = await this.status(jobId);
1086
- if (!job || isTerminal(job) || job.repointed) return false;
1087
- job.cancelRequested = true;
1088
- await this.persist(job);
1089
- await Promise.all(job.moves.filter((move) => move.moverJobId !== null).map((move) => this.cancelMove(move)));
1090
- return true;
1091
- }
1092
- /** Boot recovery resumes a durable unfinished state. A missing in-memory
1093
- * child mover is recreated from the same copy-if-absent input. */
1094
- async recover() {
1095
- const job = await this.deps.state.get();
1096
- if (!job) return;
1097
- if (job.cancelRequested) {
1098
- await Promise.all(job.moves.filter((move) => move.moverJobId !== null).map((move) => this.cancelMove(move)));
1099
- await this.waitForMoves(job);
1100
- await this.releaseAfterTerminal(job);
1101
- job.phase = "cancelled";
1102
- job.finishedAt ??= this.deps.now();
1103
- await this.persist(job);
1104
- return;
1105
- }
1106
- if (isTerminal(job)) {
1107
- if (job.pausedParticipants.length > 0) {
1108
- await this.releaseAfterTerminal(job);
1109
- await this.persist(job);
1110
- }
1111
- return;
1112
- }
1113
- this.active = job;
1114
- job.pauseLeaseId ??= this.deps.newId();
1115
- if (job.repointed && job.phase === "resuming") job.phase = "refreshing";
1116
- await this.persist(job);
1117
- try {
1118
- await this.pauseParticipants(job, true);
1119
- } catch (err) {
1120
- job.error = err instanceof Error ? err.message : String(err);
1121
- await this.persist(job);
1122
- return;
1123
- }
1124
- if (this.runPromise === null) {
1125
- this.runPromise = this.run(job);
1126
- this.runPromise;
1127
- }
1128
- }
1129
- async run(job) {
1130
- try {
1131
- if (job.phase === "planning") await this.setPhase(job, "pausing");
1132
- if (job.phase === "pausing") {
1133
- job.pauseLeaseId ??= job.jobId;
1134
- await this.pauseParticipants(job);
1135
- if (job.cancelRequested) return this.finishCancelled(job);
1136
- await this.setPhase(job, "moving");
1137
- }
1138
- if (job.phase === "moving") {
1139
- await this.startOrResumeMoves(job);
1140
- await this.waitForMoves(job);
1141
- if (job.cancelRequested) return this.finishCancelled(job);
1142
- await this.setPhase(job, "verifying");
1143
- }
1144
- if (job.phase === "verifying") {
1145
- await this.verifyMoves(job);
1146
- if (job.cancelRequested) return this.finishCancelled(job);
1147
- await this.setPhase(job, "repointing");
1148
- }
1149
- if (job.phase === "repointing") {
1150
- const targets = new Map(job.moves.map((move) => [move.storageClass, move.toLocationId]));
1151
- await this.deps.locations.setDefaultLocations(targets);
1152
- job.repointed = true;
1153
- await this.setPhase(job, "refreshing");
1154
- }
1155
- if (job.phase === "refreshing") {
1156
- const leaseId = requireLease(job);
1157
- if (job.moves.some((move) => move.storageClass !== "eventMedia")) await this.deps.participants.recorder.refresh(leaseId);
1158
- if (job.moves.some((move) => move.storageClass === "eventMedia")) await this.deps.participants.analytics.refresh(leaseId);
1159
- await this.setPhase(job, "resuming");
1160
- }
1161
- if (job.phase === "resuming") {
1162
- await this.resumeParticipants(job);
1163
- await this.setPhase(job, "done");
1164
- }
1165
- } catch (err) {
1166
- job.error = err instanceof Error ? err.message : String(err);
1167
- if (job.repointed) {
1168
- job.phase = "refreshing";
1169
- job.finishedAt = null;
1170
- await this.persist(job);
1171
- return;
1259
+ /** Relieve every managed location currently under its threshold. */
1260
+ async sweep() {
1261
+ const thresholds = await this.deps.getLocationThresholds();
1262
+ const relievedDevices = /* @__PURE__ */ new Set();
1263
+ for (const [locationId, threshold] of thresholds) {
1264
+ if (threshold <= 0) continue;
1265
+ const deviceKey = await this.deps.getLocationDeviceKey?.(locationId) ?? null;
1266
+ if (deviceKey !== null && relievedDevices.has(deviceKey)) {
1267
+ this.deps.logger.debug("storage pressure: skipping location — physical device already relieved this sweep", { meta: {
1268
+ locationId,
1269
+ deviceKey
1270
+ } });
1271
+ continue;
1172
1272
  }
1173
- job.phase = "failed";
1174
- job.finishedAt = this.deps.now();
1175
- await this.releaseAfterTerminal(job);
1176
- await this.persist(job);
1177
- } finally {
1178
- if (isTerminal(job)) {
1179
- await this.releaseAfterTerminal(job);
1180
- this.active = job;
1273
+ try {
1274
+ if (await this.relieveLocation(locationId, threshold) && deviceKey !== null) relievedDevices.add(deviceKey);
1275
+ } catch (err) {
1276
+ this.deps.logger.warn("storage pressure: relief failed for location", { meta: {
1277
+ locationId,
1278
+ error: err instanceof Error ? err.message : String(err)
1279
+ } });
1181
1280
  }
1182
- this.runPromise = null;
1183
- }
1184
- }
1185
- async pauseParticipants(job, reacquire = false) {
1186
- const leaseId = requireLease(job);
1187
- for (const name of PARTICIPANTS) {
1188
- if (job.cancelRequested) return;
1189
- if (!reacquire && job.pausedParticipants.includes(name)) continue;
1190
- await this.participant(name).pause(leaseId);
1191
- if (!job.pausedParticipants.includes(name)) job.pausedParticipants.push(name);
1192
- await this.persist(job);
1193
- }
1194
- }
1195
- async resumeParticipants(job) {
1196
- const leaseId = requireLease(job);
1197
- for (const name of REVERSE_PARTICIPANTS) {
1198
- if (!job.pausedParticipants.includes(name)) continue;
1199
- await this.participant(name).resume(leaseId);
1200
- job.pausedParticipants = job.pausedParticipants.filter((participant) => participant !== name);
1201
- await this.persist(job);
1202
1281
  }
1203
- }
1204
- async releaseAfterTerminal(job) {
1205
- if (job.pauseLeaseId === null) return;
1206
- for (const name of REVERSE_PARTICIPANTS) {
1207
- if (!job.pausedParticipants.includes(name)) continue;
1282
+ const caps = await this.deps.getLocationMaxUsedBytes?.() ?? /* @__PURE__ */ new Map();
1283
+ for (const [locationId, maxBytes] of caps) {
1284
+ if (relievedDevices.size > 0) {
1285
+ const deviceKey = await this.deps.getLocationDeviceKey?.(locationId) ?? null;
1286
+ if (deviceKey !== null && relievedDevices.has(deviceKey)) continue;
1287
+ }
1208
1288
  try {
1209
- await this.participant(name).resume(job.pauseLeaseId);
1210
- job.pausedParticipants = job.pausedParticipants.filter((participant) => participant !== name);
1211
- await this.persist(job);
1212
- } catch {}
1289
+ await this.relieveToUsageCap(locationId, maxBytes);
1290
+ } catch (err) {
1291
+ this.deps.logger.warn("storage pressure: usage-cap relief failed", { meta: {
1292
+ locationId,
1293
+ error: err instanceof Error ? err.message : String(err)
1294
+ } });
1295
+ }
1213
1296
  }
1214
1297
  }
1215
- async startOrResumeMoves(job) {
1216
- if (job.cancelRequested) return;
1217
- const mediaMove = job.moves.find((move) => move.storageClass === "eventMedia");
1218
- if (mediaMove?.moverJobId === null) {
1219
- mediaMove.moverJobId = await this.startMove(job, mediaMove);
1220
- await this.persist(job);
1221
- }
1222
- const footageMoves = job.moves.filter((move) => move.storageClass !== "eventMedia");
1223
- for (let index = 0; index < footageMoves.length; index++) {
1224
- const move = footageMoves[index];
1225
- if (move.moverJobId !== null) continue;
1226
- if (!footageMoves.slice(0, index).every((predecessor) => predecessor.state === "done")) return;
1227
- move.moverJobId = await this.startMove(job, move);
1228
- await this.persist(job);
1229
- return;
1298
+ /** Evict oldest-first until the location's evictable usage fits `maxBytes`. */
1299
+ async relieveToUsageCap(locationId, maxBytes) {
1300
+ const maxRounds = this.deps.maxRounds ?? DEFAULT_MAX_ROUNDS;
1301
+ for (let round = 0; round < maxRounds; round++) {
1302
+ const providers = this.deps.getEvictableProviders();
1303
+ const usages = await Promise.all(providers.map(async (p) => ({
1304
+ p,
1305
+ bytes: (await p.getEvictableUsage({ locationId })).bytes
1306
+ })));
1307
+ const totalUsed = usages.reduce((s, u) => s + u.bytes, 0);
1308
+ const excess = totalUsed - maxBytes;
1309
+ if (excess <= 0) return;
1310
+ let reclaimed = 0;
1311
+ for (const u of usages) {
1312
+ if (u.bytes <= 0) continue;
1313
+ const share = Math.max(1, Math.ceil(excess * u.bytes / totalUsed));
1314
+ const res = await u.p.evict({
1315
+ locationId,
1316
+ targetBytes: share
1317
+ });
1318
+ reclaimed += res.reclaimedBytes;
1319
+ }
1320
+ this.deps.logger.info("storage pressure: usage-cap relief round", { meta: {
1321
+ locationId,
1322
+ round,
1323
+ totalUsed,
1324
+ maxBytes,
1325
+ reclaimedBytes: reclaimed
1326
+ } });
1327
+ if (reclaimed <= 0) return;
1230
1328
  }
1231
1329
  }
1232
- async waitForMoves(job) {
1233
- const sleep = this.deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
1234
- const pollMs = this.deps.pollMs ?? 250;
1235
- for (;;) {
1236
- if (!job.cancelRequested) await this.startOrResumeMoves(job);
1237
- let complete = true;
1238
- for (const [index, move] of job.moves.entries()) {
1239
- if (job.cancelRequested && move.moverJobId === null) continue;
1240
- if (move.storageClass !== "eventMedia" && job.moves.slice(0, index).filter((predecessor) => predecessor.storageClass !== "eventMedia").some((predecessor) => predecessor.state !== "done")) {
1241
- complete = false;
1242
- continue;
1243
- }
1244
- if (move.moverJobId === null) {
1245
- if (!job.cancelRequested) complete = false;
1246
- continue;
1247
- }
1248
- const status = await this.moveStatus(move);
1249
- if (status === null) {
1250
- if (job.cancelRequested) {
1251
- move.state = "cancelled";
1252
- continue;
1253
- }
1254
- move.moverJobId = await this.startMove(job, move);
1255
- move.state = "running";
1256
- await this.persist(job);
1257
- complete = false;
1258
- continue;
1259
- }
1260
- move.state = status.state;
1261
- move.error = status.error;
1262
- if (status.state === "failed") throw new Error(move.error ?? `${move.storageClass} move failed`);
1263
- if (status.state === "cancelled") {
1264
- job.cancelRequested = true;
1265
- continue;
1266
- }
1267
- if (status.state !== "done") complete = false;
1330
+ /**
1331
+ * Relieve one location until it recovers, nothing is evictable, or a round
1332
+ * makes no progress. Returns `true` when at least one relief round actually
1333
+ * reclaimed bytes (so the caller marks the location's physical device relieved
1334
+ * for this sweep — the trigger for the over-eviction dedup: freed bytes mean a
1335
+ * sibling alias may re-read a not-yet-settled `statfs`), `false` when nothing
1336
+ * was freed (never breached, or breached with no evictable data).
1337
+ */
1338
+ async relieveLocation(locationId, threshold) {
1339
+ const maxRounds = this.deps.maxRounds ?? DEFAULT_MAX_ROUNDS;
1340
+ const hysteresis = this.deps.hysteresisPercent ?? DEFAULT_HYSTERESIS_PERCENT;
1341
+ let reclaimedAny = false;
1342
+ for (let round = 0; round < maxRounds; round++) {
1343
+ const free = await this.deps.getFreePercent(locationId);
1344
+ if (free >= threshold) return reclaimedAny;
1345
+ const total = await this.deps.getVolumeTotalBytes(locationId);
1346
+ const deficitPercent = threshold - free + hysteresis;
1347
+ const targetBytes = Math.ceil(total * deficitPercent / 100);
1348
+ if (targetBytes <= 0) return reclaimedAny;
1349
+ const providers = this.deps.getEvictableProviders();
1350
+ const usages = await Promise.all(providers.map(async (p) => ({
1351
+ p,
1352
+ bytes: (await p.getEvictableUsage({ locationId })).bytes
1353
+ })));
1354
+ const totalEvictable = usages.reduce((s, u) => s + u.bytes, 0);
1355
+ if (totalEvictable <= 0) {
1356
+ this.deps.logger.debug("storage pressure: breach but no evictable data on location", { meta: {
1357
+ locationId,
1358
+ freePercent: free,
1359
+ threshold
1360
+ } });
1361
+ return reclaimedAny;
1268
1362
  }
1269
- await this.persist(job);
1270
- if (complete) return;
1271
- await sleep(pollMs);
1363
+ let reclaimed = 0;
1364
+ for (const u of usages) {
1365
+ if (u.bytes <= 0) continue;
1366
+ const share = Math.max(1, Math.ceil(targetBytes * u.bytes / totalEvictable));
1367
+ const res = await u.p.evict({
1368
+ locationId,
1369
+ targetBytes: share
1370
+ });
1371
+ reclaimed += res.reclaimedBytes;
1372
+ }
1373
+ if (reclaimed > 0) reclaimedAny = true;
1374
+ this.deps.logger.info("storage pressure: relief round", { meta: {
1375
+ locationId,
1376
+ round,
1377
+ freePercent: free,
1378
+ threshold,
1379
+ targetBytes,
1380
+ reclaimedBytes: reclaimed
1381
+ } });
1382
+ if (reclaimed <= 0) return reclaimedAny;
1272
1383
  }
1273
- }
1274
- async verifyMoves(job) {
1275
- for (const move of job.moves) if (move.state !== "done") throw new Error(`${move.storageClass} move did not complete verification`);
1276
- }
1277
- async startMove(job, move) {
1278
- if (move.storageClass === "eventMedia") return (await this.deps.participants.analytics.startMove({
1279
- toLocationId: move.toLocationId,
1280
- throttleMbps: job.throttleMbps,
1281
- leaseId: requireLease(job)
1282
- })).jobId;
1283
- return (await this.deps.participants.recorder.startMove({
1284
- fromLocationId: move.fromLocationId,
1285
- toLocationId: move.toLocationId,
1286
- footageClass: move.storageClass,
1287
- throttleMbps: job.throttleMbps,
1288
- leaseId: requireLease(job)
1289
- })).jobId;
1290
- }
1291
- async moveStatus(move) {
1292
- if (move.moverJobId === null) return null;
1293
- return move.storageClass === "eventMedia" ? this.deps.participants.analytics.getMove(move.moverJobId) : this.deps.participants.recorder.getMove(move.moverJobId);
1294
- }
1295
- async cancelMove(move) {
1296
- if (move.moverJobId === null) return;
1297
- if (move.storageClass === "eventMedia") await this.deps.participants.analytics.cancelMove(move.moverJobId);
1298
- else await this.deps.participants.recorder.cancelMove(move.moverJobId);
1299
- }
1300
- participant(name) {
1301
- return this.deps.participants[name];
1302
- }
1303
- async setPhase(job, phase) {
1304
- job.phase = phase;
1305
- if (isTerminal(job)) job.finishedAt = this.deps.now();
1306
- await this.persist(job);
1307
- }
1308
- async finishCancelled(job) {
1309
- job.phase = "cancelled";
1310
- job.finishedAt = this.deps.now();
1311
- await this.releaseAfterTerminal(job);
1312
- await this.persist(job);
1313
- }
1314
- async persist(job) {
1315
- job.updatedAt = this.deps.now();
1316
- await this.deps.state.set(job);
1384
+ return reclaimedAny;
1317
1385
  }
1318
1386
  };
1319
- function requireLease(job) {
1320
- if (job.pauseLeaseId === null) throw new Error("storage migration has no maintenance lease");
1321
- return job.pauseLeaseId;
1322
- }
1323
- function isTerminal(job) {
1324
- return job.phase === "done" || job.phase === "failed" || job.phase === "cancelled";
1325
- }
1326
1387
  //#endregion
1327
1388
  //#region src/builtins/storage-orchestrator/storage-orchestrator.addon.ts
1328
1389
  /**
@@ -1890,4 +1951,4 @@ var StorageOrchestratorAddon = class extends BaseAddon {
1890
1951
  }
1891
1952
  };
1892
1953
  //#endregion
1893
- export { StorageOrchestratorAddon, StorageOrchestratorAddon as default, SqliteLocationStore as n, StorageOrchestratorService as r, StorageMigrationCoordinator as t };
1954
+ export { StorageOrchestratorAddon, StorageOrchestratorAddon as default, StorageMigrationCoordinator as n, SqliteLocationStore as r, StorageOrchestratorService as t };