@camstack/system 1.2.155 → 1.2.157
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.
- package/dist/addon-runner.js +1 -1
- package/dist/addon-runner.mjs +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
- package/dist/builtins/alerts/alerts.addon.js +1 -1
- package/dist/builtins/alerts/alerts.addon.mjs +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
- package/dist/builtins/console-logging/index.js +1 -1
- package/dist/builtins/console-logging/index.mjs +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
- package/dist/builtins/device-manager/device-manager.addon.js +2 -2
- package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
- package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
- package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
- package/dist/builtins/hub-forwarder/index.js +1 -1
- package/dist/builtins/hub-forwarder/index.mjs +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
- package/dist/builtins/local-auth/local-auth.addon.js +1 -1
- package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
- package/dist/builtins/local-network/local-network.addon.js +1 -1
- package/dist/builtins/local-network/local-network.addon.mjs +1 -1
- package/dist/builtins/loki-logging/index.js +1 -1
- package/dist/builtins/loki-logging/index.mjs +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
- package/dist/builtins/platform-probe/index.js +1 -1
- package/dist/builtins/platform-probe/index.mjs +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
- package/dist/builtins/snapshot/index.js +1 -1
- package/dist/builtins/snapshot/index.mjs +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
- package/dist/builtins/storage-orchestrator/storage-migration-coordinator.d.ts +101 -1
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.d.ts +24 -0
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +349 -40
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +349 -40
- package/dist/builtins/storage-orchestrator/storage-orchestrator.service.d.ts +37 -1
- package/dist/builtins/system-config/system-config.addon.js +1 -1
- package/dist/builtins/system-config/system-config.addon.mjs +1 -1
- package/dist/builtins/winston-logging/index.js +1 -1
- package/dist/builtins/winston-logging/index.mjs +1 -1
- package/dist/{dist-BPlfW-CG.js → dist-CDgIzo82.js} +169 -12
- package/dist/{dist-D_NyUV6Y.mjs → dist-LvKus-mT.mjs} +169 -12
- package/dist/index.js +66 -14
- package/dist/index.mjs +66 -14
- package/dist/kernel/config-manager.d.ts +31 -0
- package/dist/kernel/settings-door-view.d.ts +2 -2
- package/dist/{manifest-python-deps-BiWtkFGY.mjs → manifest-python-deps-CD14Pv0-.mjs} +1 -1
- package/dist/{manifest-python-deps-w48vOhGR.js → manifest-python-deps-DVODn-qc.js} +1 -1
- package/dist/{retired-settings-keys-DBY6ebwV.js → retired-settings-keys-BfAzWvPC.js} +1 -1
- package/dist/{retired-settings-keys-Bq1Tb84I.mjs → retired-settings-keys-CV4QVdXj.mjs} +1 -1
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { St as storageMigrationCapability, Yt as parseJsonObject, b as StorageMigrationJobSchema, kt as BaseAddon, xt as storageCapability, y as StorageLocationTypeSchema, yt as settingsStoreCapability } from "../../dist-
|
|
1
|
+
import { St as storageMigrationCapability, Yt as parseJsonObject, b as StorageMigrationJobSchema, kt as BaseAddon, xt as storageCapability, y as StorageLocationTypeSchema, yt as settingsStoreCapability } from "../../dist-LvKus-mT.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";
|
|
@@ -229,8 +229,41 @@ function mapRowToLocation(row) {
|
|
|
229
229
|
var STORAGE_CLASSES = [
|
|
230
230
|
"recordings",
|
|
231
231
|
"recordingsLow",
|
|
232
|
-
"eventMedia"
|
|
232
|
+
"eventMedia",
|
|
233
|
+
"backups",
|
|
234
|
+
"galleryMedia"
|
|
233
235
|
];
|
|
236
|
+
/** The classes footage/media movers actually own. Everything else is planned,
|
|
237
|
+
* reported and refused rather than silently accepted. */
|
|
238
|
+
var MOVER_CLASSES = [
|
|
239
|
+
"recordings",
|
|
240
|
+
"recordingsLow",
|
|
241
|
+
"eventMedia",
|
|
242
|
+
"galleryMedia"
|
|
243
|
+
];
|
|
244
|
+
/**
|
|
245
|
+
* Classes that can never span two locations at once.
|
|
246
|
+
*
|
|
247
|
+
* `galleryMedia` is `cardinality: 'single'` and `upsertLocation` refuses a
|
|
248
|
+
* second instance of a single type, so the "both locations are first-class for
|
|
249
|
+
* reads while the past drains" transient the non-blocking mode rests on is not
|
|
250
|
+
* merely undesirable there — it is not constructible. Its move is therefore
|
|
251
|
+
* stop-the-world, always. That is acceptable only because the enrolled gallery
|
|
252
|
+
* is a few KB per sample, and it is said here rather than discovered.
|
|
253
|
+
*/
|
|
254
|
+
var BLOCKING_ONLY_CLASSES = ["galleryMedia"];
|
|
255
|
+
/**
|
|
256
|
+
* Location types whose volume `galleryMedia` must NEVER land on.
|
|
257
|
+
*
|
|
258
|
+
* Verbatim from its own declaration: *"Deliberately NOT on the recordings
|
|
259
|
+
* volume: these images survive every retention sweep, so they must also survive
|
|
260
|
+
* a footage wipe, a quota eviction and a recordings disk swap."* A planner that
|
|
261
|
+
* accepted `galleryMedia` → the recordings volume would let an operator destroy
|
|
262
|
+
* the enrolment gallery on the next disk swap, months later, with no visible
|
|
263
|
+
* link back to this decision.
|
|
264
|
+
*/
|
|
265
|
+
var GALLERY_FORBIDDEN_NEIGHBOUR_TYPES = ["recordings", "recordingsLow"];
|
|
266
|
+
var MOVE_LANES = ["media", "footage"];
|
|
234
267
|
var PARTICIPANTS = [
|
|
235
268
|
"pipeline",
|
|
236
269
|
"recorder",
|
|
@@ -255,7 +288,9 @@ var StorageMigrationCoordinator = class {
|
|
|
255
288
|
this.deps = deps;
|
|
256
289
|
}
|
|
257
290
|
async plan(input) {
|
|
291
|
+
const mode = input.mode ?? "blocking";
|
|
258
292
|
const moves = [];
|
|
293
|
+
const findings = [];
|
|
259
294
|
for (const storageClass of STORAGE_CLASSES) {
|
|
260
295
|
const targetId = input.destinations[storageClass];
|
|
261
296
|
if (targetId === void 0) continue;
|
|
@@ -265,6 +300,9 @@ var StorageMigrationCoordinator = class {
|
|
|
265
300
|
if (!target) throw new Error(`Storage location "${targetId}" not found`);
|
|
266
301
|
if (target.type !== storageClass) throw new Error(`Storage location "${targetId}" is type "${target.type}", expected "${storageClass}"`);
|
|
267
302
|
if (source.id === target.id) throw new Error(`Storage location "${targetId}" is already the "${storageClass}" default`);
|
|
303
|
+
if (!MOVER_CLASSES.includes(storageClass)) throw new Error(`No mover owns "${storageClass}" — the migration can repoint its default but cannot move its bytes. Move it by hand, then repoint the default with upsertLocation.`);
|
|
304
|
+
if (mode === "nonBlocking" && BLOCKING_ONLY_CLASSES.includes(storageClass)) throw new Error(`"${storageClass}" is a single-cardinality storage class: it can never exist at two locations at once, so it cannot be repointed first and drained behind. Use mode "blocking".`);
|
|
305
|
+
await this.refuseGalleryOntoRecordingsVolume(storageClass, targetId);
|
|
268
306
|
moves.push({
|
|
269
307
|
storageClass,
|
|
270
308
|
fromLocationId: source.id,
|
|
@@ -273,17 +311,79 @@ var StorageMigrationCoordinator = class {
|
|
|
273
311
|
state: null,
|
|
274
312
|
error: null
|
|
275
313
|
});
|
|
314
|
+
if (BLOCKING_ONLY_CLASSES.includes(storageClass)) findings.push({
|
|
315
|
+
code: "blockingOnly",
|
|
316
|
+
storageClass,
|
|
317
|
+
message: `"${storageClass}" is single-cardinality: this move is stop-the-world for its whole duration, in every mode. It is a few KB per enrolled sample, which is why that is acceptable.`
|
|
318
|
+
});
|
|
319
|
+
findings.push(...await this.deviceFindings(storageClass, source.id, target.id));
|
|
276
320
|
}
|
|
277
321
|
if (moves.length === 0) throw new Error("select at least one storage class");
|
|
322
|
+
if (moves.some((move) => move.storageClass === "eventMedia")) {
|
|
323
|
+
const unstamped = await this.deps.participants.analytics.countUnstamped();
|
|
324
|
+
if (unstamped.total > 0) {
|
|
325
|
+
if (mode === "nonBlocking") throw new Error(`${unstamped.total.toString()} event-media row(s) still carry no locationId (${unstamped.media.toString()} media, ${unstamped.retrainFrames.toString()} retrain frames). A non-blocking cutover would silently orphan them. Run the seal first: pipelineAnalytics.relocateMedia({ mode: "seal", toLocationId: "${this.deps.locations.getDefaultLocation("eventMedia")?.id ?? "eventMedia"}" }).`);
|
|
326
|
+
findings.push({
|
|
327
|
+
code: "unstampedEventMediaRows",
|
|
328
|
+
storageClass: "eventMedia",
|
|
329
|
+
message: `${unstamped.total.toString()} event-media row(s) carry no locationId. This blocking migration stamps them as it moves them, but a non-blocking one would be refused until a seal pass drives the count to zero.`
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
278
333
|
return {
|
|
279
334
|
destinations: input.destinations,
|
|
335
|
+
mode,
|
|
280
336
|
moves: moves.map(({ storageClass, fromLocationId, toLocationId }) => ({
|
|
281
337
|
storageClass,
|
|
282
338
|
fromLocationId,
|
|
283
339
|
toLocationId
|
|
284
|
-
}))
|
|
340
|
+
})),
|
|
341
|
+
findings
|
|
285
342
|
};
|
|
286
343
|
}
|
|
344
|
+
/**
|
|
345
|
+
* HARD refusal: `galleryMedia` onto a volume a recordings class lives on.
|
|
346
|
+
*
|
|
347
|
+
* This is the one same-device case that is a refusal rather than a finding,
|
|
348
|
+
* because its consequence is silent and arrives months later — the gallery is
|
|
349
|
+
* wiped by the next footage wipe, quota eviction or recordings disk swap, and
|
|
350
|
+
* nothing at that moment points back at this migration.
|
|
351
|
+
*
|
|
352
|
+
* It can only refuse what it can SEE. `deviceKeyOf` is a realpath, so it
|
|
353
|
+
* catches a symlink, a bind mount and a literally-shared directory — and
|
|
354
|
+
* misses two distinct directories on one filesystem, and any location pinned
|
|
355
|
+
* to another node. Those come back `null`/distinct and produce a
|
|
356
|
+
* `deviceIdentityUnknown` finding instead.
|
|
357
|
+
*/
|
|
358
|
+
async refuseGalleryOntoRecordingsVolume(storageClass, targetId) {
|
|
359
|
+
if (storageClass !== "galleryMedia") return;
|
|
360
|
+
const targetKey = await this.deviceKey(targetId);
|
|
361
|
+
if (targetKey === null) return;
|
|
362
|
+
for (const type of GALLERY_FORBIDDEN_NEIGHBOUR_TYPES) for (const neighbour of this.deps.locations.listLocations({ type })) {
|
|
363
|
+
if (await this.deviceKey(neighbour.id) !== targetKey) continue;
|
|
364
|
+
throw new Error(`Storage location "${targetId}" shares a volume with "${neighbour.id}" (${type}). galleryMedia is deliberately NOT on the recordings volume: enrolment images survive every retention sweep, so they must also survive a footage wipe, a quota eviction and a recordings disk swap. Choose a destination on another volume.`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
/** Same-device observations that are reported, not refused. */
|
|
368
|
+
async deviceFindings(storageClass, sourceId, targetId) {
|
|
369
|
+
if (this.deps.deviceKeyOf === void 0) return [];
|
|
370
|
+
const [sourceKey, targetKey] = await Promise.all([this.deviceKey(sourceId), this.deviceKey(targetId)]);
|
|
371
|
+
if (sourceKey === null || targetKey === null) return [{
|
|
372
|
+
code: "deviceIdentityUnknown",
|
|
373
|
+
storageClass,
|
|
374
|
+
message: `Could not establish whether "${sourceId}" and "${targetId}" are on the same device (a location with no basePath, or pinned to another node — the realpath would have to be taken there). Note also that this check is PATH identity, not st_dev: two distinct directories on one filesystem are never detected as one device.`
|
|
375
|
+
}];
|
|
376
|
+
if (sourceKey !== targetKey) return [];
|
|
377
|
+
return [{
|
|
378
|
+
code: "sharesDeviceWithSource",
|
|
379
|
+
storageClass,
|
|
380
|
+
message: `"${targetId}" resolves to the same path as "${sourceId}". The move will re-stamp rows rather than copy bytes, and it buys no redundancy — both "locations" are one disk.`
|
|
381
|
+
}];
|
|
382
|
+
}
|
|
383
|
+
async deviceKey(locationId) {
|
|
384
|
+
if (this.deps.deviceKeyOf === void 0) return null;
|
|
385
|
+
return this.deps.deviceKeyOf(locationId);
|
|
386
|
+
}
|
|
287
387
|
async start(input) {
|
|
288
388
|
if (this.startReserved) throw new Error("storage migration is already active");
|
|
289
389
|
this.startReserved = true;
|
|
@@ -300,6 +400,7 @@ var StorageMigrationCoordinator = class {
|
|
|
300
400
|
const job = {
|
|
301
401
|
jobId: this.deps.newId(),
|
|
302
402
|
phase: "planning",
|
|
403
|
+
mode: plan.mode,
|
|
303
404
|
destinations: input.destinations,
|
|
304
405
|
throttleMbps: input.throttleMbps ?? 40,
|
|
305
406
|
moves: plan.moves.map((move) => ({
|
|
@@ -317,8 +418,8 @@ var StorageMigrationCoordinator = class {
|
|
|
317
418
|
finishedAt: null,
|
|
318
419
|
error: null
|
|
319
420
|
};
|
|
320
|
-
this.active = job;
|
|
321
421
|
await this.persist(job);
|
|
422
|
+
this.active = job;
|
|
322
423
|
this.runPromise = this.run(job);
|
|
323
424
|
this.runPromise;
|
|
324
425
|
return job.jobId;
|
|
@@ -333,7 +434,8 @@ var StorageMigrationCoordinator = class {
|
|
|
333
434
|
}
|
|
334
435
|
async cancel(jobId) {
|
|
335
436
|
const job = await this.status(jobId);
|
|
336
|
-
|
|
437
|
+
const cutoverInFlight = job !== null && job.repointed && (job.phase === "refreshing" || job.phase === "resuming");
|
|
438
|
+
if (!job || isTerminal(job) || cutoverInFlight) return false;
|
|
337
439
|
job.cancelRequested = true;
|
|
338
440
|
await this.persist(job);
|
|
339
441
|
await Promise.all(job.moves.filter((move) => move.moverJobId !== null).map((move) => this.cancelMove(move)));
|
|
@@ -364,7 +466,7 @@ var StorageMigrationCoordinator = class {
|
|
|
364
466
|
job.pauseLeaseId ??= this.deps.newId();
|
|
365
467
|
if (job.repointed && job.phase === "resuming") job.phase = "refreshing";
|
|
366
468
|
await this.persist(job);
|
|
367
|
-
try {
|
|
469
|
+
if (!runsUnleased(job)) try {
|
|
368
470
|
await this.pauseParticipants(job, true);
|
|
369
471
|
} catch (err) {
|
|
370
472
|
job.error = err instanceof Error ? err.message : String(err);
|
|
@@ -376,14 +478,34 @@ var StorageMigrationCoordinator = class {
|
|
|
376
478
|
this.runPromise;
|
|
377
479
|
}
|
|
378
480
|
}
|
|
481
|
+
/**
|
|
482
|
+
* The state machine. Both modes run the SAME phase handlers; only the order
|
|
483
|
+
* differs, which is the whole of the non-blocking design — no second mover,
|
|
484
|
+
* no parallel code path, nothing that can drift between the two.
|
|
485
|
+
*
|
|
486
|
+
* blocking: planning → pausing → moving → verifying → repointing →
|
|
487
|
+
* refreshing → resuming → done
|
|
488
|
+
* nonBlocking: planning → sealing → pausing → repointing → refreshing →
|
|
489
|
+
* resuming → draining → verifying → done
|
|
490
|
+
*
|
|
491
|
+
* Written as a straight-line sequence of `if (phase === X)` blocks so boot
|
|
492
|
+
* recovery re-enters at whatever phase the durable record says, exactly as
|
|
493
|
+
* before.
|
|
494
|
+
*/
|
|
379
495
|
async run(job) {
|
|
496
|
+
const nonBlocking = job.mode === "nonBlocking";
|
|
380
497
|
try {
|
|
381
|
-
if (job.phase === "planning") await this.setPhase(job, "pausing");
|
|
498
|
+
if (job.phase === "planning") await this.setPhase(job, nonBlocking ? "sealing" : "pausing");
|
|
499
|
+
if (job.phase === "sealing") {
|
|
500
|
+
await this.sealEventMedia(job);
|
|
501
|
+
if (job.cancelRequested) return this.finishCancelled(job);
|
|
502
|
+
await this.setPhase(job, "pausing");
|
|
503
|
+
}
|
|
382
504
|
if (job.phase === "pausing") {
|
|
383
505
|
job.pauseLeaseId ??= job.jobId;
|
|
384
506
|
await this.pauseParticipants(job);
|
|
385
507
|
if (job.cancelRequested) return this.finishCancelled(job);
|
|
386
|
-
await this.setPhase(job, "moving");
|
|
508
|
+
await this.setPhase(job, nonBlocking ? "repointing" : "moving");
|
|
387
509
|
}
|
|
388
510
|
if (job.phase === "moving") {
|
|
389
511
|
await this.startOrResumeMoves(job);
|
|
@@ -391,7 +513,7 @@ var StorageMigrationCoordinator = class {
|
|
|
391
513
|
if (job.cancelRequested) return this.finishCancelled(job);
|
|
392
514
|
await this.setPhase(job, "verifying");
|
|
393
515
|
}
|
|
394
|
-
if (job.phase === "verifying") {
|
|
516
|
+
if (job.phase === "verifying" && !nonBlocking) {
|
|
395
517
|
await this.verifyMoves(job);
|
|
396
518
|
if (job.cancelRequested) return this.finishCancelled(job);
|
|
397
519
|
await this.setPhase(job, "repointing");
|
|
@@ -404,17 +526,28 @@ var StorageMigrationCoordinator = class {
|
|
|
404
526
|
}
|
|
405
527
|
if (job.phase === "refreshing") {
|
|
406
528
|
const leaseId = requireLease(job);
|
|
407
|
-
if (job.moves.some((move) => move.storageClass
|
|
408
|
-
if (job.moves.some((move) => move.storageClass === "
|
|
529
|
+
if (job.moves.some((move) => laneOf(move.storageClass) === "footage")) await this.deps.participants.recorder.refresh(leaseId);
|
|
530
|
+
if (job.moves.some((move) => laneOf(move.storageClass) === "media")) await this.deps.participants.analytics.refresh(leaseId);
|
|
409
531
|
await this.setPhase(job, "resuming");
|
|
410
532
|
}
|
|
411
533
|
if (job.phase === "resuming") {
|
|
412
534
|
await this.resumeParticipants(job);
|
|
535
|
+
await this.setPhase(job, nonBlocking ? "draining" : "done");
|
|
536
|
+
}
|
|
537
|
+
if (job.phase === "draining") {
|
|
538
|
+
await this.startOrResumeMoves(job);
|
|
539
|
+
await this.waitForMoves(job);
|
|
540
|
+
if (job.cancelRequested) return this.finishCancelled(job);
|
|
541
|
+
await this.setPhase(job, "verifying");
|
|
542
|
+
}
|
|
543
|
+
if (job.phase === "verifying" && nonBlocking) {
|
|
544
|
+
await this.verifyMoves(job);
|
|
545
|
+
if (job.cancelRequested) return this.finishCancelled(job);
|
|
413
546
|
await this.setPhase(job, "done");
|
|
414
547
|
}
|
|
415
548
|
} catch (err) {
|
|
416
549
|
job.error = err instanceof Error ? err.message : String(err);
|
|
417
|
-
if (job.repointed) {
|
|
550
|
+
if (job.repointed && job.pausedParticipants.length > 0) {
|
|
418
551
|
job.phase = "refreshing";
|
|
419
552
|
job.finishedAt = null;
|
|
420
553
|
await this.persist(job);
|
|
@@ -462,21 +595,28 @@ var StorageMigrationCoordinator = class {
|
|
|
462
595
|
} catch {}
|
|
463
596
|
}
|
|
464
597
|
}
|
|
598
|
+
/**
|
|
599
|
+
* Start whatever is startable, honouring the ONE constraint the engines
|
|
600
|
+
* impose: each is single-flight.
|
|
601
|
+
*
|
|
602
|
+
* Two lanes — the recorder's footage engine and post-analysis's media engine
|
|
603
|
+
* — run in parallel with each other and strictly FIFO within themselves.
|
|
604
|
+
* `galleryMedia` shares the media lane with `eventMedia` because it shares
|
|
605
|
+
* the same `MediaRelocateEngine`, which refuses a second running job; starting
|
|
606
|
+
* both would fail the second one instantly.
|
|
607
|
+
*/
|
|
465
608
|
async startOrResumeMoves(job) {
|
|
466
609
|
if (job.cancelRequested) return;
|
|
467
|
-
const
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
move.moverJobId = await this.startMove(job, move);
|
|
478
|
-
await this.persist(job);
|
|
479
|
-
return;
|
|
610
|
+
for (const lane of MOVE_LANES) {
|
|
611
|
+
const laneMoves = job.moves.filter((move) => laneOf(move.storageClass) === lane);
|
|
612
|
+
for (let index = 0; index < laneMoves.length; index++) {
|
|
613
|
+
const move = laneMoves[index];
|
|
614
|
+
if (move === void 0 || move.moverJobId !== null) continue;
|
|
615
|
+
if (!laneMoves.slice(0, index).every((predecessor) => predecessor.state === "done")) break;
|
|
616
|
+
move.moverJobId = await this.startMove(job, move);
|
|
617
|
+
await this.persist(job);
|
|
618
|
+
break;
|
|
619
|
+
}
|
|
480
620
|
}
|
|
481
621
|
}
|
|
482
622
|
async waitForMoves(job) {
|
|
@@ -487,7 +627,7 @@ var StorageMigrationCoordinator = class {
|
|
|
487
627
|
let complete = true;
|
|
488
628
|
for (const [index, move] of job.moves.entries()) {
|
|
489
629
|
if (job.cancelRequested && move.moverJobId === null) continue;
|
|
490
|
-
if (
|
|
630
|
+
if (job.moves.slice(0, index).filter((predecessor) => laneOf(predecessor.storageClass) === laneOf(move.storageClass)).some((predecessor) => predecessor.state !== "done")) {
|
|
491
631
|
complete = false;
|
|
492
632
|
continue;
|
|
493
633
|
}
|
|
@@ -521,30 +661,92 @@ var StorageMigrationCoordinator = class {
|
|
|
521
661
|
await sleep(pollMs);
|
|
522
662
|
}
|
|
523
663
|
}
|
|
664
|
+
/**
|
|
665
|
+
* Nail every unstamped `eventMedia`/retrain row to the location its bytes are
|
|
666
|
+
* ALREADY on, before anything repoints. Runs with every writer up — it moves
|
|
667
|
+
* no bytes at all.
|
|
668
|
+
*
|
|
669
|
+
* The gate is the RE-COUNT, not the seal job's terminal state: a seal that
|
|
670
|
+
* failed some rows still finishes, and "finished" is not "there are none
|
|
671
|
+
* left". A non-zero count here fails the job while nothing has been paused
|
|
672
|
+
* and nothing has been repointed, which is the cheapest possible place to
|
|
673
|
+
* discover it.
|
|
674
|
+
*
|
|
675
|
+
* The seal's own mover job id is deliberately NOT durable. It is idempotent
|
|
676
|
+
* and cheap, so a coordinator restart mid-seal simply re-runs the whole
|
|
677
|
+
* phase — and the count that follows is what actually decides, so a lost job
|
|
678
|
+
* id cannot make the gate pass.
|
|
679
|
+
*/
|
|
680
|
+
async sealEventMedia(job) {
|
|
681
|
+
const move = job.moves.find((candidate) => candidate.storageClass === "eventMedia");
|
|
682
|
+
if (move === void 0) return;
|
|
683
|
+
if ((await this.deps.participants.analytics.countUnstamped()).total > 0) {
|
|
684
|
+
const started = await this.deps.participants.analytics.startDrain({
|
|
685
|
+
toLocationId: move.fromLocationId,
|
|
686
|
+
mode: "seal"
|
|
687
|
+
});
|
|
688
|
+
await this.waitForSeal(job, started.jobId);
|
|
689
|
+
if (job.cancelRequested) return;
|
|
690
|
+
}
|
|
691
|
+
const after = await this.deps.participants.analytics.countUnstamped();
|
|
692
|
+
if (after.total > 0) throw new Error(`event-media seal left ${after.total.toString()} row(s) without a locationId (${after.media.toString()} media, ${after.retrainFrames.toString()} retrain frames). A non-blocking cutover would silently orphan them; nothing has been paused or repointed.`);
|
|
693
|
+
}
|
|
694
|
+
async waitForSeal(job, moverJobId) {
|
|
695
|
+
const sleep = this.deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
696
|
+
const pollMs = this.deps.pollMs ?? 250;
|
|
697
|
+
for (;;) {
|
|
698
|
+
if (job.cancelRequested) {
|
|
699
|
+
await this.deps.participants.analytics.cancelMove(moverJobId);
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
const status = await this.deps.participants.analytics.getMove(moverJobId);
|
|
703
|
+
if (status === null || status.state === "done" || status.state === "cancelled") return;
|
|
704
|
+
if (status.state === "failed") throw new Error(status.error ?? "event-media seal failed");
|
|
705
|
+
await sleep(pollMs);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
524
708
|
async verifyMoves(job) {
|
|
525
709
|
for (const move of job.moves) if (move.state !== "done") throw new Error(`${move.storageClass} move did not complete verification`);
|
|
526
710
|
}
|
|
711
|
+
/**
|
|
712
|
+
* Arm one class's mover.
|
|
713
|
+
*
|
|
714
|
+
* `draining` runs after every writer has been resumed, so there is no lease
|
|
715
|
+
* left to present — it goes through the participants' UNLEASED entry points
|
|
716
|
+
* (`recording.relocateFootage` / `pipelineAnalytics.relocateMedia`), which
|
|
717
|
+
* are the same engines, not a second mover.
|
|
718
|
+
*/
|
|
527
719
|
async startMove(job, move) {
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
720
|
+
const unleased = runsUnleased(job);
|
|
721
|
+
if (laneOf(move.storageClass) === "media") {
|
|
722
|
+
const input = {
|
|
723
|
+
toLocationId: move.toLocationId,
|
|
724
|
+
throttleMbps: job.throttleMbps,
|
|
725
|
+
mode: move.storageClass === "galleryMedia" ? "gallery" : "move"
|
|
726
|
+
};
|
|
727
|
+
return (unleased ? await this.deps.participants.analytics.startDrain(input) : await this.deps.participants.analytics.startMove({
|
|
728
|
+
...input,
|
|
729
|
+
leaseId: requireLease(job)
|
|
730
|
+
})).jobId;
|
|
731
|
+
}
|
|
732
|
+
const input = {
|
|
534
733
|
fromLocationId: move.fromLocationId,
|
|
535
734
|
toLocationId: move.toLocationId,
|
|
536
|
-
footageClass: move.storageClass,
|
|
537
|
-
throttleMbps: job.throttleMbps
|
|
735
|
+
footageClass: move.storageClass === "recordingsLow" ? "recordingsLow" : "recordings",
|
|
736
|
+
throttleMbps: job.throttleMbps
|
|
737
|
+
};
|
|
738
|
+
return (unleased ? await this.deps.participants.recorder.startDrain(input) : await this.deps.participants.recorder.startMove({
|
|
739
|
+
...input,
|
|
538
740
|
leaseId: requireLease(job)
|
|
539
741
|
})).jobId;
|
|
540
742
|
}
|
|
541
743
|
async moveStatus(move) {
|
|
542
744
|
if (move.moverJobId === null) return null;
|
|
543
|
-
return move.storageClass === "
|
|
745
|
+
return laneOf(move.storageClass) === "media" ? this.deps.participants.analytics.getMove(move.moverJobId) : this.deps.participants.recorder.getMove(move.moverJobId);
|
|
544
746
|
}
|
|
545
747
|
async cancelMove(move) {
|
|
546
748
|
if (move.moverJobId === null) return;
|
|
547
|
-
if (move.storageClass === "
|
|
749
|
+
if (laneOf(move.storageClass) === "media") await this.deps.participants.analytics.cancelMove(move.moverJobId);
|
|
548
750
|
else await this.deps.participants.recorder.cancelMove(move.moverJobId);
|
|
549
751
|
}
|
|
550
752
|
participant(name) {
|
|
@@ -573,6 +775,27 @@ function requireLease(job) {
|
|
|
573
775
|
function isTerminal(job) {
|
|
574
776
|
return job.phase === "done" || job.phase === "failed" || job.phase === "cancelled";
|
|
575
777
|
}
|
|
778
|
+
/**
|
|
779
|
+
* Which single-flight engine owns a class.
|
|
780
|
+
*
|
|
781
|
+
* `galleryMedia` is in the media lane not because it is event media — it
|
|
782
|
+
* explicitly is not (D197) — but because it is moved by the same
|
|
783
|
+
* `MediaRelocateEngine`, which refuses a second running job.
|
|
784
|
+
*/
|
|
785
|
+
function laneOf(storageClass) {
|
|
786
|
+
return storageClass === "eventMedia" || storageClass === "galleryMedia" ? "media" : "footage";
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* True when this phase runs with every writer already back up, so no
|
|
790
|
+
* maintenance lease exists to present to a participant.
|
|
791
|
+
*
|
|
792
|
+
* `verifying` is in both camps: it precedes the repoint (paused) under
|
|
793
|
+
* `blocking` and follows the drain (running) under `nonBlocking`.
|
|
794
|
+
*/
|
|
795
|
+
function runsUnleased(job) {
|
|
796
|
+
if (job.phase === "sealing" || job.phase === "draining") return true;
|
|
797
|
+
return job.phase === "verifying" && job.mode === "nonBlocking";
|
|
798
|
+
}
|
|
576
799
|
//#endregion
|
|
577
800
|
//#region src/kernel/storage-location-registry.ts
|
|
578
801
|
/**
|
|
@@ -758,6 +981,14 @@ var StorageOrchestratorService = class {
|
|
|
758
981
|
* early-boot path that predates the resolver wiring.
|
|
759
982
|
*/
|
|
760
983
|
nodeLocalResolver = null;
|
|
984
|
+
/**
|
|
985
|
+
* Injected occupancy probe (see {@link LocationOccupancyProbe}). `null` until
|
|
986
|
+
* {@link setOccupancyProbe} runs, and a `null` probe answers `unknown` — so a
|
|
987
|
+
* service with no probe REFUSES an unforced delete rather than performing one
|
|
988
|
+
* it could not check. The check is the point; a check that defaults to
|
|
989
|
+
* "permit" is a paragraph, not a guard.
|
|
990
|
+
*/
|
|
991
|
+
occupancyProbe = null;
|
|
761
992
|
/** `localNodeId` with any forked-child `/addon` suffix stripped. */
|
|
762
993
|
localNode;
|
|
763
994
|
/**
|
|
@@ -856,6 +1087,15 @@ var StorageOrchestratorService = class {
|
|
|
856
1087
|
this.nodeLocalResolver = resolver;
|
|
857
1088
|
}
|
|
858
1089
|
/**
|
|
1090
|
+
* Inject the "does this location still hold anything" probe read by
|
|
1091
|
+
* {@link deleteLocation}. Absent → every delete sees `unknown` and therefore
|
|
1092
|
+
* REFUSES without `force`, which is the safe direction for a service wired
|
|
1093
|
+
* without one.
|
|
1094
|
+
*/
|
|
1095
|
+
setOccupancyProbe(probe) {
|
|
1096
|
+
this.occupancyProbe = probe;
|
|
1097
|
+
}
|
|
1098
|
+
/**
|
|
859
1099
|
* The full set of addon-declared storage locations, as aggregated by
|
|
860
1100
|
* the kernel and injected via {@link setRegistry}. Powers the admin-UI
|
|
861
1101
|
* Data screen's per-declaration grouping. Empty until the registry is
|
|
@@ -1078,13 +1318,22 @@ var StorageOrchestratorService = class {
|
|
|
1078
1318
|
* Persistence (Task 6) mirrors the delete asynchronously, with errors
|
|
1079
1319
|
* routed to the logger — see `upsertLocation` for the rationale.
|
|
1080
1320
|
*/
|
|
1081
|
-
deleteLocation(id) {
|
|
1321
|
+
async deleteLocation(id, options) {
|
|
1082
1322
|
const loc = this.locations.get(id);
|
|
1083
1323
|
if (!loc) throw new Error(`Storage location "${id}" not found`);
|
|
1084
1324
|
if (loc.isSystem) throw new Error(`Storage location "${id}" is system-managed and cannot be deleted. Edit its config (path / providerId) instead.`);
|
|
1085
1325
|
if (loc.isDefault) {
|
|
1086
1326
|
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`);
|
|
1087
1327
|
}
|
|
1328
|
+
const occupancy = await this.probeOccupancy(loc);
|
|
1329
|
+
if (occupancy !== "empty") {
|
|
1330
|
+
if (options?.force !== true) throw new Error(occupancy === "occupied" ? `Storage location "${id}" still holds data — drain it first (storage migration / relocate), or pass force to delete the record anyway and strand what is on it` : `Storage location "${id}" could not be checked for remaining data (unreachable provider, another node, or an unmounted root) — pass force to delete the record anyway`);
|
|
1331
|
+
this.logger.warn("storage-orchestrator: FORCED delete of a location that was not verified empty", { meta: {
|
|
1332
|
+
id,
|
|
1333
|
+
type: loc.type,
|
|
1334
|
+
occupancy
|
|
1335
|
+
} });
|
|
1336
|
+
}
|
|
1088
1337
|
this.locations.delete(id);
|
|
1089
1338
|
if (this.locationStore) this.locationStore.delete(id).catch((err) => {
|
|
1090
1339
|
this.logger.error("storage-orchestrator: delete persistence failed", { meta: {
|
|
@@ -1094,6 +1343,23 @@ var StorageOrchestratorService = class {
|
|
|
1094
1343
|
});
|
|
1095
1344
|
}
|
|
1096
1345
|
/**
|
|
1346
|
+
* Run the injected occupancy probe, converting "no probe" and "the probe
|
|
1347
|
+
* threw" into `unknown` rather than into a permit.
|
|
1348
|
+
*/
|
|
1349
|
+
async probeOccupancy(location) {
|
|
1350
|
+
const probe = this.occupancyProbe;
|
|
1351
|
+
if (probe === null) return "unknown";
|
|
1352
|
+
try {
|
|
1353
|
+
return await probe(location);
|
|
1354
|
+
} catch (err) {
|
|
1355
|
+
this.logger.warn("storage-orchestrator: occupancy probe failed", { meta: {
|
|
1356
|
+
id: location.id,
|
|
1357
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1358
|
+
} });
|
|
1359
|
+
return "unknown";
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
/**
|
|
1097
1363
|
* Remove system-seeded locations whose type is no longer declared by any
|
|
1098
1364
|
* addon (stale defaults from a removed location type). Operator-added
|
|
1099
1365
|
* (non-system) locations of an undeclared type are KEPT but warned — the
|
|
@@ -1530,6 +1796,7 @@ var StorageOrchestratorAddon = class extends BaseAddon {
|
|
|
1530
1796
|
});
|
|
1531
1797
|
this.service = service;
|
|
1532
1798
|
service.setNodeLocalResolver((providerId) => this.nodeLocalByProvider.get(providerId));
|
|
1799
|
+
service.setOccupancyProbe((location) => this.locationOccupancy(location));
|
|
1533
1800
|
await service.initialize();
|
|
1534
1801
|
const provider = {
|
|
1535
1802
|
listLocations: async ({ type }) => {
|
|
@@ -1542,8 +1809,8 @@ var StorageOrchestratorAddon = class extends BaseAddon {
|
|
|
1542
1809
|
getDefaultLocation: async ({ type }) => service.getDefaultLocation(type),
|
|
1543
1810
|
listLocationDeclarations: async () => service.listDeclarations(),
|
|
1544
1811
|
upsertLocation: async (input) => service.upsertLocation(input),
|
|
1545
|
-
deleteLocation: async ({ id }) => {
|
|
1546
|
-
service.deleteLocation(id);
|
|
1812
|
+
deleteLocation: async ({ id, force }) => {
|
|
1813
|
+
await service.deleteLocation(id, { force: force === true });
|
|
1547
1814
|
},
|
|
1548
1815
|
testLocation: async ({ id }) => {
|
|
1549
1816
|
const loc = service.getLocationById(id);
|
|
@@ -1706,6 +1973,7 @@ var StorageOrchestratorAddon = class extends BaseAddon {
|
|
|
1706
1973
|
await this.ctx.api.recording.resumeForStorageMigration.mutate({ leaseId });
|
|
1707
1974
|
},
|
|
1708
1975
|
startMove: (input) => this.ctx.api.recording.startStorageMigrationMove.mutate(input),
|
|
1976
|
+
startDrain: (input) => this.ctx.api.recording.relocateFootage.mutate(input),
|
|
1709
1977
|
getMove: (jobId) => this.ctx.api.recording.getStorageMigrationMoveStatus.query({ jobId }),
|
|
1710
1978
|
cancelMove: async (jobId) => (await this.ctx.api.recording.cancelStorageMigrationMove.mutate({ jobId })).cancelled,
|
|
1711
1979
|
refresh: async (leaseId) => {
|
|
@@ -1720,6 +1988,8 @@ var StorageOrchestratorAddon = class extends BaseAddon {
|
|
|
1720
1988
|
await this.ctx.api.pipelineAnalytics.resumeForStorageMigration.mutate({ leaseId });
|
|
1721
1989
|
},
|
|
1722
1990
|
startMove: (input) => this.ctx.api.pipelineAnalytics.startStorageMigrationMove.mutate(input),
|
|
1991
|
+
startDrain: (input) => this.ctx.api.pipelineAnalytics.relocateMedia.mutate(input),
|
|
1992
|
+
countUnstamped: () => this.ctx.api.pipelineAnalytics.countUnstampedEventMedia.query({}),
|
|
1723
1993
|
getMove: (jobId) => this.ctx.api.pipelineAnalytics.getStorageMigrationMoveStatus.query({ jobId }),
|
|
1724
1994
|
cancelMove: async (jobId) => (await this.ctx.api.pipelineAnalytics.cancelStorageMigrationMove.mutate({ jobId })).cancelled,
|
|
1725
1995
|
refresh: async (leaseId) => {
|
|
@@ -1728,7 +1998,8 @@ var StorageOrchestratorAddon = class extends BaseAddon {
|
|
|
1728
1998
|
}
|
|
1729
1999
|
},
|
|
1730
2000
|
now: () => Date.now(),
|
|
1731
|
-
newId: () => randomUUID()
|
|
2001
|
+
newId: () => randomUUID(),
|
|
2002
|
+
deviceKeyOf: (locationId) => this.locationDeviceKey(locationId)
|
|
1732
2003
|
});
|
|
1733
2004
|
this.migration = migration;
|
|
1734
2005
|
const migrationProvider = {
|
|
@@ -1941,6 +2212,44 @@ var StorageOrchestratorAddon = class extends BaseAddon {
|
|
|
1941
2212
|
return path$1.resolve(bp);
|
|
1942
2213
|
}
|
|
1943
2214
|
}
|
|
2215
|
+
/**
|
|
2216
|
+
* Does this location still hold anything? A SHALLOW `readdir` of its
|
|
2217
|
+
* `basePath` — one syscall, never a walk. `/recordings` holds one entry per
|
|
2218
|
+
* camera, so this is cheap on a volume with millions of segments, and a
|
|
2219
|
+
* `find`-style walk of a saturated recordings disk is precisely what must
|
|
2220
|
+
* not happen here.
|
|
2221
|
+
*
|
|
2222
|
+
* **What it cannot see, stated rather than papered over:**
|
|
2223
|
+
*
|
|
2224
|
+
* - A location on ANOTHER node, or behind a remote provider (S3 / SFTP /
|
|
2225
|
+
* WebDAV): its bytes are not on this filesystem and this probe answers
|
|
2226
|
+
* `unknown`, never `empty`.
|
|
2227
|
+
* - A location with no `basePath` — same answer.
|
|
2228
|
+
* - The difference between "holds live footage" and "holds one stray
|
|
2229
|
+
* lock-file". It measures BYTES ON DISK, not durable rows, so it refuses
|
|
2230
|
+
* a location holding orphan files that no index names. That direction is
|
|
2231
|
+
* the safe one, and `force` is the escape hatch for it.
|
|
2232
|
+
*
|
|
2233
|
+
* A missing root (`ENOENT`) is `empty`, not `unknown`: an unmounted disk and
|
|
2234
|
+
* a deleted directory are indistinguishable here, and if the record's own
|
|
2235
|
+
* path does not exist there is nothing this delete can strand. Every OTHER
|
|
2236
|
+
* errno — `EACCES`, `EIO`, a stale NFS handle — is `unknown` and refuses.
|
|
2237
|
+
*/
|
|
2238
|
+
async locationOccupancy(location) {
|
|
2239
|
+
if ((location.nodeId === void 0 || location.nodeId === "" ? HUB_NODE_ID : location.nodeId) !== (this.service?.getLocalNodeId() ?? HUB_NODE_ID)) return "unknown";
|
|
2240
|
+
const basePath = this.locationBasePath(location.id);
|
|
2241
|
+
if (basePath === null) return "unknown";
|
|
2242
|
+
try {
|
|
2243
|
+
return (await fs.readdir(basePath)).length > 0 ? "occupied" : "empty";
|
|
2244
|
+
} catch (err) {
|
|
2245
|
+
if (err instanceof Error && "code" in err && err.code === "ENOENT") return "empty";
|
|
2246
|
+
this.ctx.logger.warn("storage-orchestrator: could not read a location root to check it", { meta: {
|
|
2247
|
+
id: location.id,
|
|
2248
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2249
|
+
} });
|
|
2250
|
+
return "unknown";
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
1944
2253
|
/** Free capacity (%) on a location's volume via `statfs`; 100 (guard inert) when unstattable. */
|
|
1945
2254
|
async locationFreePercent(locationId) {
|
|
1946
2255
|
const bp = this.locationBasePath(locationId);
|
|
@@ -37,6 +37,22 @@ export type StorageProviderLookup = () => readonly IStorageProviderImpl[];
|
|
|
37
37
|
* remote), the safe default for the early-boot / in-memory path.
|
|
38
38
|
*/
|
|
39
39
|
export type NodeLocalResolver = (providerId: string) => boolean | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* Whether a location still holds anything — the only question `deleteLocation`
|
|
42
|
+
* is allowed to destroy a record on the answer to.
|
|
43
|
+
*
|
|
44
|
+
* - `'occupied'` — something is there. Deleting the record now strands it.
|
|
45
|
+
* - `'empty'` — the location was reached and holds nothing.
|
|
46
|
+
* - `'unknown'` — it could not be reached at all: a remote provider, another
|
|
47
|
+
* node's disk, an unmounted root. NOT "empty". A read that
|
|
48
|
+
* fails must never authorise a destruction (D49).
|
|
49
|
+
*/
|
|
50
|
+
export type LocationOccupancy = 'occupied' | 'empty' | 'unknown';
|
|
51
|
+
export type LocationOccupancyProbe = (location: StorageLocation) => Promise<LocationOccupancy>;
|
|
52
|
+
export interface DeleteLocationOptions {
|
|
53
|
+
/** Override an `occupied` or `unknown` verdict. See the cap docblock. */
|
|
54
|
+
readonly force?: boolean;
|
|
55
|
+
}
|
|
40
56
|
/**
|
|
41
57
|
* Where a single dispatch must execute: which `storage-provider`, on which
|
|
42
58
|
* node. `providerId` alone is NOT a routing key — every node's filesystem
|
|
@@ -89,6 +105,14 @@ export declare class StorageOrchestratorService {
|
|
|
89
105
|
* early-boot path that predates the resolver wiring.
|
|
90
106
|
*/
|
|
91
107
|
private nodeLocalResolver;
|
|
108
|
+
/**
|
|
109
|
+
* Injected occupancy probe (see {@link LocationOccupancyProbe}). `null` until
|
|
110
|
+
* {@link setOccupancyProbe} runs, and a `null` probe answers `unknown` — so a
|
|
111
|
+
* service with no probe REFUSES an unforced delete rather than performing one
|
|
112
|
+
* it could not check. The check is the point; a check that defaults to
|
|
113
|
+
* "permit" is a paragraph, not a guard.
|
|
114
|
+
*/
|
|
115
|
+
private occupancyProbe;
|
|
92
116
|
/** `localNodeId` with any forked-child `/addon` suffix stripped. */
|
|
93
117
|
private readonly localNode;
|
|
94
118
|
/**
|
|
@@ -166,6 +190,13 @@ export declare class StorageOrchestratorService {
|
|
|
166
190
|
* changes. Powers the node-local upsert guard and the boot backfill.
|
|
167
191
|
*/
|
|
168
192
|
setNodeLocalResolver(resolver: NodeLocalResolver | null): void;
|
|
193
|
+
/**
|
|
194
|
+
* Inject the "does this location still hold anything" probe read by
|
|
195
|
+
* {@link deleteLocation}. Absent → every delete sees `unknown` and therefore
|
|
196
|
+
* REFUSES without `force`, which is the safe direction for a service wired
|
|
197
|
+
* without one.
|
|
198
|
+
*/
|
|
199
|
+
setOccupancyProbe(probe: LocationOccupancyProbe | null): void;
|
|
169
200
|
/**
|
|
170
201
|
* The full set of addon-declared storage locations, as aggregated by
|
|
171
202
|
* the kernel and injected via {@link setRegistry}. Powers the admin-UI
|
|
@@ -231,7 +262,12 @@ export declare class StorageOrchestratorService {
|
|
|
231
262
|
* Persistence (Task 6) mirrors the delete asynchronously, with errors
|
|
232
263
|
* routed to the logger — see `upsertLocation` for the rationale.
|
|
233
264
|
*/
|
|
234
|
-
deleteLocation(id: string): void
|
|
265
|
+
deleteLocation(id: string, options?: DeleteLocationOptions): Promise<void>;
|
|
266
|
+
/**
|
|
267
|
+
* Run the injected occupancy probe, converting "no probe" and "the probe
|
|
268
|
+
* threw" into `unknown` rather than into a permit.
|
|
269
|
+
*/
|
|
270
|
+
private probeOccupancy;
|
|
235
271
|
/**
|
|
236
272
|
* Remove system-seeded locations whose type is no longer declared by any
|
|
237
273
|
* addon (stale defaults from a removed location type). Operator-added
|