@camstack/system 1.2.179 → 1.2.181
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 +2 -2
- package/dist/addon-runner.mjs +2 -2
- 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.d.ts +56 -10
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +657 -62
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +658 -63
- package/dist/builtins/backup-orchestrator/run-queue.d.ts +103 -0
- package/dist/builtins/backup-orchestrator/staging-paths.d.ts +36 -0
- 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 +349 -65
- package/dist/builtins/device-manager/device-manager.addon.mjs +349 -65
- package/dist/builtins/device-manager/device-meta-actions.d.ts +8 -19
- package/dist/builtins/device-manager/device-provider-context.d.ts +4 -0
- package/dist/builtins/device-manager/migrate-device.d.ts +47 -3
- package/dist/builtins/device-manager/migrate-guard.d.ts +39 -0
- package/dist/builtins/device-manager/migrate-hardware-state.d.ts +24 -0
- package/dist/builtins/device-manager/migrate-reload.d.ts +8 -0
- 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-orchestrator.addon.js +1 -1
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
- package/dist/builtins/system-backup/system-backup.service.d.ts +55 -2
- 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-L_39vRmH.mjs → dist-DbnNk4Lw.mjs} +211 -2
- package/dist/{dist-C7qWwrK1.js → dist-whe0b4_3.js} +211 -2
- package/dist/index.js +530 -2
- package/dist/index.mjs +533 -5
- package/dist/kernel/addon-installer.d.ts +69 -0
- package/dist/kernel/deps/closure-dedupe.d.ts +105 -0
- package/dist/{manifest-system-deps-CMOGc5nC.mjs → manifest-system-deps-DOcMuUWK.mjs} +1 -1
- package/dist/{manifest-system-deps-PAIfmNun.js → manifest-system-deps-y5vZBO6q.js} +1 -1
- package/dist/{retired-settings-keys-3soU9pfP.js → retired-settings-keys-BdvmiaK7.js} +1 -1
- package/dist/{retired-settings-keys-CnbKAxmX.mjs → retired-settings-keys-CyQG8b-A.mjs} +1 -1
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Ft as BaseAddon, j as backupCapability, sn as EventCategory } from "../../dist-
|
|
1
|
+
import { Ft as BaseAddon, j as backupCapability, sn as EventCategory } from "../../dist-DbnNk4Lw.mjs";
|
|
2
2
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
3
3
|
import { Buffer as Buffer$1 } from "buffer";
|
|
4
4
|
import * as fsp from "node:fs/promises";
|
|
@@ -9,7 +9,7 @@ import * as fs from "node:fs";
|
|
|
9
9
|
import Kt from "node:fs";
|
|
10
10
|
import * as zlib from "node:zlib";
|
|
11
11
|
import { EventEmitter } from "node:events";
|
|
12
|
-
import As from "node:stream";
|
|
12
|
+
import As, { Transform } from "node:stream";
|
|
13
13
|
import { StringDecoder } from "node:string_decoder";
|
|
14
14
|
import I from "fs";
|
|
15
15
|
import qs, { dirname as dirname$1, parse } from "path";
|
|
@@ -17,6 +17,7 @@ import * as Ms from "zlib";
|
|
|
17
17
|
import Jr from "zlib";
|
|
18
18
|
import Pi from "assert";
|
|
19
19
|
import $r, { EventEmitter as EventEmitter$1 } from "events";
|
|
20
|
+
import { pipeline } from "node:stream/promises";
|
|
20
21
|
import no from "node:assert";
|
|
21
22
|
//#region node_modules/tar/dist/esm/index.min.js
|
|
22
23
|
var Mr = Object.defineProperty;
|
|
@@ -2989,13 +2990,32 @@ var Ro = (s) => {
|
|
|
2989
2990
|
//#endregion
|
|
2990
2991
|
//#region src/builtins/system-backup/system-backup.service.ts
|
|
2991
2992
|
/**
|
|
2993
|
+
* Thrown when an archive build (or a backup run driving one) is
|
|
2994
|
+
* stopped via its `AbortSignal`. Callers use `instanceof` to tell a
|
|
2995
|
+
* deliberate operator cancel from a real failure — a cancelled run is
|
|
2996
|
+
* an outcome, not an error to alert on. (D342)
|
|
2997
|
+
*/
|
|
2998
|
+
var BackupCancelledError = class extends Error {
|
|
2999
|
+
constructor(message = "backup run cancelled") {
|
|
3000
|
+
super(message);
|
|
3001
|
+
this.name = "BackupCancelledError";
|
|
3002
|
+
}
|
|
3003
|
+
};
|
|
3004
|
+
/** Throw `BackupCancelledError` when the signal has fired. */
|
|
3005
|
+
function throwIfCancelled(signal) {
|
|
3006
|
+
if (signal?.aborted) throw new BackupCancelledError();
|
|
3007
|
+
}
|
|
3008
|
+
/**
|
|
2992
3009
|
* Default set of paths considered part of a "system backup". Includes
|
|
2993
3010
|
* everything that isn't pure backend code — config, sqlite, addons
|
|
2994
3011
|
* (runtime overlay), addons-data (per-addon private dir), TLS certs,
|
|
2995
3012
|
* auto-update marker.
|
|
2996
3013
|
*
|
|
2997
|
-
*
|
|
2998
|
-
* (too large or fully recreatable).
|
|
3014
|
+
* Anything not listed is excluded by omission — recordings/, media/,
|
|
3015
|
+
* models/, logs/, cache/ (too large or fully recreatable). Note this
|
|
3016
|
+
* covers only TOP-LEVEL trees: bulk nested INSIDE a listed location
|
|
3017
|
+
* is excluded via `DEFAULT_BACKUP_EXCLUDES` + the node_modules rule
|
|
3018
|
+
* below, enforced in the walk and the tar filter alike.
|
|
2999
3019
|
*/
|
|
3000
3020
|
var DEFAULT_BACKUP_LOCATIONS = [
|
|
3001
3021
|
"config.yaml",
|
|
@@ -3004,6 +3024,40 @@ var DEFAULT_BACKUP_LOCATIONS = [
|
|
|
3004
3024
|
"tls",
|
|
3005
3025
|
"auto-update.json"
|
|
3006
3026
|
];
|
|
3027
|
+
/**
|
|
3028
|
+
* Subtrees never captured by a system backup even though they live
|
|
3029
|
+
* under a `DEFAULT_BACKUP_LOCATIONS` entry. Paths are dataDir-relative
|
|
3030
|
+
* prefixes. Two lessons from the live hub (2026-09-04, D-record
|
|
3031
|
+
* "backup excludes"):
|
|
3032
|
+
*
|
|
3033
|
+
* - `addons-data/backup-orchestrator/staging` — the orchestrator's
|
|
3034
|
+
* LEGACY staging dir (now `<dataDir>/backup-staging`, outside every
|
|
3035
|
+
* location). Before Jul 28 every archive nested all previous
|
|
3036
|
+
* archives (175M → 503M → 4.5G); leaked files from that era must
|
|
3037
|
+
* never be re-swept into new backups.
|
|
3038
|
+
* - `addons-data/recorder/exports` — operator-exported clips (8.4 GB
|
|
3039
|
+
* on the hub). Media, not system state; a restore has no use for it.
|
|
3040
|
+
*/
|
|
3041
|
+
var DEFAULT_BACKUP_EXCLUDES = ["addons-data/backup-orchestrator/staging", "addons-data/recorder/exports"];
|
|
3042
|
+
/**
|
|
3043
|
+
* Directory name excluded wherever it appears in the tree.
|
|
3044
|
+
* `AddonInstaller` materialises runtime deps under
|
|
3045
|
+
* `addons/@camstack/<addon>/node_modules` (~196 MB on the hub); only
|
|
3046
|
+
* built `dist` + package.json + assets are restore-worthy — deps are
|
|
3047
|
+
* reinstalled from npm on demand. node_modules is NEVER archived.
|
|
3048
|
+
*/
|
|
3049
|
+
var EXCLUDED_DIR_NAME = "node_modules";
|
|
3050
|
+
/**
|
|
3051
|
+
* True when a dataDir-relative path must be omitted from archives,
|
|
3052
|
+
* manifests and location stats. Accepts tar-style paths (`./` prefix,
|
|
3053
|
+
* trailing `/`) and backslash separators.
|
|
3054
|
+
*/
|
|
3055
|
+
function isExcludedFromBackup(relPath) {
|
|
3056
|
+
const norm = relPath.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
|
3057
|
+
if (norm.length === 0) return false;
|
|
3058
|
+
if (norm.split("/").includes(EXCLUDED_DIR_NAME)) return true;
|
|
3059
|
+
return DEFAULT_BACKUP_EXCLUDES.some((prefix) => norm === prefix || norm.startsWith(`${prefix}/`));
|
|
3060
|
+
}
|
|
3007
3061
|
/** Path of the manifest file embedded inside every archive. */
|
|
3008
3062
|
var ARCHIVE_MANIFEST_NAME = ".camstack-backup-manifest.json";
|
|
3009
3063
|
var DEFAULT_STAT_CACHE_TTL_MS = 6e4;
|
|
@@ -3056,7 +3110,7 @@ var SystemBackupService = class {
|
|
|
3056
3110
|
fileCount: 0,
|
|
3057
3111
|
present: false
|
|
3058
3112
|
};
|
|
3059
|
-
const walked = await walkAbs(abs, abs);
|
|
3113
|
+
const walked = await walkAbs(abs, abs, this.exclusionPredicate());
|
|
3060
3114
|
return {
|
|
3061
3115
|
name: loc,
|
|
3062
3116
|
sizeBytes: walked.reduce((acc, e) => acc + e.sizeBytes, 0),
|
|
@@ -3076,8 +3130,20 @@ var SystemBackupService = class {
|
|
|
3076
3130
|
*
|
|
3077
3131
|
* Locations that don't exist are silently skipped. Throws if zero
|
|
3078
3132
|
* locations would be included.
|
|
3133
|
+
*
|
|
3134
|
+
* Cancellation: `signal` aborts the build IN FLIGHT — the manifest
|
|
3135
|
+
* walk checks it per filesystem node and the tar phase runs through
|
|
3136
|
+
* `stream.pipeline(..., { signal })`, which destroys the pack and
|
|
3137
|
+
* the file sink immediately. On abort the half-written archive is
|
|
3138
|
+
* deleted here (never left for the caller to remember) and the call
|
|
3139
|
+
* rejects with {@link BackupCancelledError}. (D342)
|
|
3140
|
+
*
|
|
3141
|
+
* Progress: `onArchiveBytes` receives the cumulative compressed
|
|
3142
|
+
* bytes written to `archivePath` — the live "how large has staging
|
|
3143
|
+
* grown" counter `backup.listRuns` exposes.
|
|
3079
3144
|
*/
|
|
3080
3145
|
async createArchive(opts) {
|
|
3146
|
+
throwIfCancelled(opts.signal);
|
|
3081
3147
|
const requested = opts.locations ?? DEFAULT_BACKUP_LOCATIONS;
|
|
3082
3148
|
const present = [];
|
|
3083
3149
|
for (const loc of requested) {
|
|
@@ -3090,9 +3156,10 @@ var SystemBackupService = class {
|
|
|
3090
3156
|
await this.checkpointDatabasesUnder(present);
|
|
3091
3157
|
const entries = [];
|
|
3092
3158
|
for (const loc of present) {
|
|
3159
|
+
throwIfCancelled(opts.signal);
|
|
3093
3160
|
const abs = path$1.join(this.dataDir, loc);
|
|
3094
3161
|
const stat = await fsp.stat(abs);
|
|
3095
|
-
if (stat.isDirectory()) for (const e of await walkAbs(abs, this.dataDir)) entries.push(e);
|
|
3162
|
+
if (stat.isDirectory()) for (const e of await walkAbs(abs, this.dataDir, this.exclusionPredicate(), opts.signal)) entries.push(e);
|
|
3096
3163
|
else entries.push(toArchiveEntry(abs, this.dataDir, stat));
|
|
3097
3164
|
}
|
|
3098
3165
|
const manifest = {
|
|
@@ -3118,18 +3185,28 @@ var SystemBackupService = class {
|
|
|
3118
3185
|
const stagingRel = path$1.relative(this.dataDir, path$1.dirname(opts.archivePath));
|
|
3119
3186
|
const excludePrefix = stagingRel && !stagingRel.startsWith("..") && !path$1.isAbsolute(stagingRel) ? stagingRel : null;
|
|
3120
3187
|
try {
|
|
3121
|
-
|
|
3122
|
-
file: opts.archivePath,
|
|
3188
|
+
const pack = Kn({
|
|
3123
3189
|
gzip: true,
|
|
3124
3190
|
cwd: this.dataDir,
|
|
3125
3191
|
follow: true,
|
|
3126
3192
|
portable: true,
|
|
3127
3193
|
filter: (entryPath) => {
|
|
3128
|
-
if (!excludePrefix) return true;
|
|
3129
3194
|
const norm = entryPath.replace(/^\.\//, "").replace(/\/+$/, "");
|
|
3195
|
+
if (isExcludedFromBackup(norm)) return false;
|
|
3196
|
+
if (!excludePrefix) return true;
|
|
3130
3197
|
return norm !== excludePrefix && !norm.startsWith(`${excludePrefix}/`);
|
|
3131
3198
|
}
|
|
3132
3199
|
}, [ARCHIVE_MANIFEST_NAME, ...present]);
|
|
3200
|
+
const sink = fs.createWriteStream(opts.archivePath);
|
|
3201
|
+
try {
|
|
3202
|
+
await pipeline(pack, byteCountingPassthrough(opts.onArchiveBytes), sink, opts.signal ? { signal: opts.signal } : {});
|
|
3203
|
+
} catch (err) {
|
|
3204
|
+
try {
|
|
3205
|
+
fs.rmSync(opts.archivePath, { force: true });
|
|
3206
|
+
} catch {}
|
|
3207
|
+
if (opts.signal?.aborted) throw new BackupCancelledError();
|
|
3208
|
+
throw err;
|
|
3209
|
+
}
|
|
3133
3210
|
} finally {
|
|
3134
3211
|
try {
|
|
3135
3212
|
fs.rmSync(stagedInDataDir, { force: true });
|
|
@@ -3163,7 +3240,7 @@ var SystemBackupService = class {
|
|
|
3163
3240
|
*/
|
|
3164
3241
|
async checkpointDatabasesUnder(locations) {
|
|
3165
3242
|
const dbPaths = /* @__PURE__ */ new Set();
|
|
3166
|
-
for (const loc of locations) await collectWalDatabases(path$1.isAbsolute(loc) ? loc : path$1.join(this.dataDir, loc), dbPaths);
|
|
3243
|
+
for (const loc of locations) await collectWalDatabases(path$1.isAbsolute(loc) ? loc : path$1.join(this.dataDir, loc), dbPaths, this.exclusionPredicate());
|
|
3167
3244
|
if (dbPaths.size === 0) return;
|
|
3168
3245
|
let Database;
|
|
3169
3246
|
try {
|
|
@@ -3308,6 +3385,15 @@ var SystemBackupService = class {
|
|
|
3308
3385
|
getRestoreMarkerPath() {
|
|
3309
3386
|
return path$1.join(this.dataDir, ".pending-restore.json");
|
|
3310
3387
|
}
|
|
3388
|
+
/**
|
|
3389
|
+
* Absolute-path form of {@link isExcludedFromBackup}, anchored on
|
|
3390
|
+
* this service's dataDir. Shared by the manifest walk, the location
|
|
3391
|
+
* stats and the WAL-checkpoint scan so all three agree with the tar
|
|
3392
|
+
* filter about what a backup contains.
|
|
3393
|
+
*/
|
|
3394
|
+
exclusionPredicate() {
|
|
3395
|
+
return (abs) => isExcludedFromBackup(path$1.relative(this.dataDir, abs));
|
|
3396
|
+
}
|
|
3311
3397
|
};
|
|
3312
3398
|
/**
|
|
3313
3399
|
* Recursively collect every SQLite database under `abs` that is in WAL
|
|
@@ -3316,7 +3402,8 @@ var SystemBackupService = class {
|
|
|
3316
3402
|
* (unreadable dir, broken symlink) are skipped silently — this feeds a
|
|
3317
3403
|
* best-effort step.
|
|
3318
3404
|
*/
|
|
3319
|
-
async function collectWalDatabases(abs, out) {
|
|
3405
|
+
async function collectWalDatabases(abs, out, shouldSkip) {
|
|
3406
|
+
if (shouldSkip?.(abs)) return;
|
|
3320
3407
|
let stat;
|
|
3321
3408
|
try {
|
|
3322
3409
|
stat = await fsp.stat(abs);
|
|
@@ -3330,7 +3417,7 @@ async function collectWalDatabases(abs, out) {
|
|
|
3330
3417
|
} catch {
|
|
3331
3418
|
return;
|
|
3332
3419
|
}
|
|
3333
|
-
for (const child of children) await collectWalDatabases(path$1.join(abs, child), out);
|
|
3420
|
+
for (const child of children) await collectWalDatabases(path$1.join(abs, child), out, shouldSkip);
|
|
3334
3421
|
return;
|
|
3335
3422
|
}
|
|
3336
3423
|
if (abs.endsWith("-wal")) {
|
|
@@ -3344,12 +3431,14 @@ async function collectWalDatabases(abs, out) {
|
|
|
3344
3431
|
* path. Symlinks are dereferenced (matches the `follow: true` flag we
|
|
3345
3432
|
* pass to tar.create).
|
|
3346
3433
|
*/
|
|
3347
|
-
async function walkAbs(absRoot, dataDir) {
|
|
3434
|
+
async function walkAbs(absRoot, dataDir, shouldSkip, signal) {
|
|
3348
3435
|
const out = [];
|
|
3349
|
-
await walkRec(absRoot, dataDir, out);
|
|
3436
|
+
await walkRec(absRoot, dataDir, out, shouldSkip, signal);
|
|
3350
3437
|
return out;
|
|
3351
3438
|
}
|
|
3352
|
-
async function walkRec(abs, dataDir, out) {
|
|
3439
|
+
async function walkRec(abs, dataDir, out, shouldSkip, signal) {
|
|
3440
|
+
throwIfCancelled(signal);
|
|
3441
|
+
if (shouldSkip?.(abs)) return;
|
|
3353
3442
|
let stat;
|
|
3354
3443
|
try {
|
|
3355
3444
|
stat = await fsp.stat(abs);
|
|
@@ -3364,9 +3453,22 @@ async function walkRec(abs, dataDir, out) {
|
|
|
3364
3453
|
} catch {
|
|
3365
3454
|
return;
|
|
3366
3455
|
}
|
|
3367
|
-
for (const child of children) await walkRec(path$1.join(abs, child), dataDir, out);
|
|
3456
|
+
for (const child of children) await walkRec(path$1.join(abs, child), dataDir, out, shouldSkip, signal);
|
|
3368
3457
|
}
|
|
3369
3458
|
}
|
|
3459
|
+
/**
|
|
3460
|
+
* Pass-through transform that reports the CUMULATIVE bytes flowed so
|
|
3461
|
+
* far. Sits between the tar pack and the file sink so the caller's
|
|
3462
|
+
* progress counter tracks the compressed archive as it grows on disk.
|
|
3463
|
+
*/
|
|
3464
|
+
function byteCountingPassthrough(onBytes) {
|
|
3465
|
+
let total = 0;
|
|
3466
|
+
return new Transform({ transform(chunk, _encoding, callback) {
|
|
3467
|
+
if (Buffer.isBuffer(chunk)) total += chunk.byteLength;
|
|
3468
|
+
onBytes?.(total);
|
|
3469
|
+
callback(null, chunk);
|
|
3470
|
+
} });
|
|
3471
|
+
}
|
|
3370
3472
|
function toArchiveEntry(abs, dataDir, stat) {
|
|
3371
3473
|
const rel = path$1.relative(dataDir, abs).replace(/\\/g, "/");
|
|
3372
3474
|
const kind = stat.isDirectory() ? "dir" : stat.isSymbolicLink() ? "symlink" : "file";
|
|
@@ -4586,6 +4688,257 @@ var LocationManifestLock = class {
|
|
|
4586
4688
|
}
|
|
4587
4689
|
};
|
|
4588
4690
|
//#endregion
|
|
4691
|
+
//#region src/builtins/backup-orchestrator/run-queue.ts
|
|
4692
|
+
/**
|
|
4693
|
+
* Backup run queue + per-run tracking (D342).
|
|
4694
|
+
*
|
|
4695
|
+
* Why a GLOBAL queue (concurrency 1), not per-destination: every run
|
|
4696
|
+
* walks the same `<nodeDataDir>` source tree and stages its tar.gz on
|
|
4697
|
+
* the same disk. On 2026-09-04 two schedule ticks built two multi-GB
|
|
4698
|
+
* archives concurrently — staging went 5.1 GB → 18 GB in ~12 minutes
|
|
4699
|
+
* with host load 62, and each run archived the other's growing tar
|
|
4700
|
+
* (pre-D338 staging placement). A per-destination lock would still
|
|
4701
|
+
* allow two concurrent walks of the same tree and two staged archives;
|
|
4702
|
+
* the destination write is the CHEAP half. One run at a time bounds
|
|
4703
|
+
* peak staging disk to a single archive and the walk cost to one
|
|
4704
|
+
* traversal.
|
|
4705
|
+
*
|
|
4706
|
+
* This subsumes the earlier single-flight `buildLock`
|
|
4707
|
+
* (`LocationManifestLock.run('archive-build', …)`): that lock
|
|
4708
|
+
* serialised builds but was invisible (nothing reported a waiting
|
|
4709
|
+
* run), unjoinable and uncancellable. The queue is the one
|
|
4710
|
+
* serialisation authority for archive builds — do not reintroduce a
|
|
4711
|
+
* second lock around the executor.
|
|
4712
|
+
*
|
|
4713
|
+
* Every run is OBSERVABLE while it exists: id, state, phase, byte
|
|
4714
|
+
* counters, per-destination outcome. The registry keeps queued +
|
|
4715
|
+
* active + a bounded tail of finished runs so `backup.listRuns` can
|
|
4716
|
+
* answer "what is running, how far along, how big" without `du`.
|
|
4717
|
+
*/
|
|
4718
|
+
/** Finished runs kept for `listRuns` history. Older ones are dropped. */
|
|
4719
|
+
var DEFAULT_MAX_FINISHED = 20;
|
|
4720
|
+
/**
|
|
4721
|
+
* FIFO queue with concurrency 1. `submit` never starts a second
|
|
4722
|
+
* executor while one is in flight; an identical request already
|
|
4723
|
+
* WAITING (same destinations/locations/label/retention) is joined
|
|
4724
|
+
* instead of queued twice — two schedule ticks due on the same minute
|
|
4725
|
+
* produce one run, not two.
|
|
4726
|
+
*/
|
|
4727
|
+
var BackupRunQueue = class {
|
|
4728
|
+
executor;
|
|
4729
|
+
runs = /* @__PURE__ */ new Map();
|
|
4730
|
+
pending = [];
|
|
4731
|
+
finished = [];
|
|
4732
|
+
activeId = null;
|
|
4733
|
+
maxFinished;
|
|
4734
|
+
now;
|
|
4735
|
+
constructor(executor, opts) {
|
|
4736
|
+
this.executor = executor;
|
|
4737
|
+
this.maxFinished = opts?.maxFinished ?? DEFAULT_MAX_FINISHED;
|
|
4738
|
+
this.now = opts?.now ?? Date.now;
|
|
4739
|
+
}
|
|
4740
|
+
submit(request) {
|
|
4741
|
+
const signature = requestSignature(request);
|
|
4742
|
+
const busy = this.activeId !== null || this.pending.length > 0;
|
|
4743
|
+
for (const id of this.pending) {
|
|
4744
|
+
const existing = this.runs.get(id);
|
|
4745
|
+
if (existing && existing.signature === signature) return {
|
|
4746
|
+
runId: id,
|
|
4747
|
+
queued: true,
|
|
4748
|
+
joined: true,
|
|
4749
|
+
result: existing.resultPromise
|
|
4750
|
+
};
|
|
4751
|
+
}
|
|
4752
|
+
const record = this.createRecord(request, signature);
|
|
4753
|
+
this.runs.set(record.snapshot.id, record);
|
|
4754
|
+
this.pending.push(record.snapshot.id);
|
|
4755
|
+
this.pump();
|
|
4756
|
+
return {
|
|
4757
|
+
runId: record.snapshot.id,
|
|
4758
|
+
queued: busy,
|
|
4759
|
+
joined: false,
|
|
4760
|
+
result: record.resultPromise
|
|
4761
|
+
};
|
|
4762
|
+
}
|
|
4763
|
+
/**
|
|
4764
|
+
* Cancel a run. Queued: removed before it ever starts (its result
|
|
4765
|
+
* rejects `BackupCancelledError`). Running: the abort signal fires —
|
|
4766
|
+
* the executor's tar/upload streams stop and its cleanup runs.
|
|
4767
|
+
* Unknown or already terminal: `false`.
|
|
4768
|
+
*/
|
|
4769
|
+
cancel(runId) {
|
|
4770
|
+
const record = this.runs.get(runId);
|
|
4771
|
+
if (!record) return false;
|
|
4772
|
+
if (record.snapshot.state === "queued") {
|
|
4773
|
+
const idx = this.pending.indexOf(runId);
|
|
4774
|
+
if (idx >= 0) this.pending.splice(idx, 1);
|
|
4775
|
+
this.finish(record, "cancelled");
|
|
4776
|
+
record.rejectResult(new BackupCancelledError(`backup run ${runId} cancelled while queued`));
|
|
4777
|
+
return true;
|
|
4778
|
+
}
|
|
4779
|
+
if (record.snapshot.state === "running") {
|
|
4780
|
+
record.controller.abort();
|
|
4781
|
+
return true;
|
|
4782
|
+
}
|
|
4783
|
+
return false;
|
|
4784
|
+
}
|
|
4785
|
+
/**
|
|
4786
|
+
* EXECUTION order, not timestamp order: the running run first, then
|
|
4787
|
+
* every queued run in the exact order the pump will start them (each
|
|
4788
|
+
* stamped with `queuePosition`, 1 = next), then the bounded finished
|
|
4789
|
+
* history newest-first. The queued section is read straight off
|
|
4790
|
+
* `this.pending` — a consumer sorting by `requestedAt` descending
|
|
4791
|
+
* would put the run that starts NEXT at the BOTTOM, which is
|
|
4792
|
+
* backwards exactly when the queue matters (more than one waiter).
|
|
4793
|
+
*/
|
|
4794
|
+
listRuns() {
|
|
4795
|
+
const running = [];
|
|
4796
|
+
const finished = [];
|
|
4797
|
+
for (const record of this.runs.values()) {
|
|
4798
|
+
const snap = record.snapshot;
|
|
4799
|
+
if (snap.state === "running") running.push(snap);
|
|
4800
|
+
else if (snap.state !== "queued") finished.push(snap);
|
|
4801
|
+
}
|
|
4802
|
+
const queued = [];
|
|
4803
|
+
for (const [index, id] of this.pending.entries()) {
|
|
4804
|
+
const record = this.runs.get(id);
|
|
4805
|
+
if (record) queued.push({
|
|
4806
|
+
...record.snapshot,
|
|
4807
|
+
queuePosition: index + 1
|
|
4808
|
+
});
|
|
4809
|
+
}
|
|
4810
|
+
finished.sort((a, b) => b.requestedAt - a.requestedAt);
|
|
4811
|
+
return [
|
|
4812
|
+
...running,
|
|
4813
|
+
...queued,
|
|
4814
|
+
...finished
|
|
4815
|
+
];
|
|
4816
|
+
}
|
|
4817
|
+
getRun(runId) {
|
|
4818
|
+
const record = this.runs.get(runId);
|
|
4819
|
+
if (!record) return void 0;
|
|
4820
|
+
const pendingIndex = this.pending.indexOf(runId);
|
|
4821
|
+
return pendingIndex >= 0 ? {
|
|
4822
|
+
...record.snapshot,
|
|
4823
|
+
queuePosition: pendingIndex + 1
|
|
4824
|
+
} : record.snapshot;
|
|
4825
|
+
}
|
|
4826
|
+
createRecord(request, signature) {
|
|
4827
|
+
let resolveResult;
|
|
4828
|
+
let rejectResult;
|
|
4829
|
+
const resultPromise = new Promise((res, rej) => {
|
|
4830
|
+
resolveResult = res;
|
|
4831
|
+
rejectResult = rej;
|
|
4832
|
+
});
|
|
4833
|
+
resultPromise.catch(() => void 0);
|
|
4834
|
+
return {
|
|
4835
|
+
snapshot: {
|
|
4836
|
+
id: randomUUID(),
|
|
4837
|
+
state: "queued",
|
|
4838
|
+
phase: "queued",
|
|
4839
|
+
destinationIds: request.destinations ?? [],
|
|
4840
|
+
...request.label !== void 0 ? { label: request.label } : {},
|
|
4841
|
+
requestedAt: this.now(),
|
|
4842
|
+
stagedBytes: 0,
|
|
4843
|
+
uploadedBytes: 0,
|
|
4844
|
+
completedDestinationIds: [],
|
|
4845
|
+
failedDestinationIds: []
|
|
4846
|
+
},
|
|
4847
|
+
request,
|
|
4848
|
+
signature,
|
|
4849
|
+
controller: new AbortController(),
|
|
4850
|
+
resultPromise,
|
|
4851
|
+
resolveResult,
|
|
4852
|
+
rejectResult
|
|
4853
|
+
};
|
|
4854
|
+
}
|
|
4855
|
+
pump() {
|
|
4856
|
+
if (this.activeId !== null) return;
|
|
4857
|
+
const nextId = this.pending.shift();
|
|
4858
|
+
if (nextId === void 0) return;
|
|
4859
|
+
const record = this.runs.get(nextId);
|
|
4860
|
+
if (!record) {
|
|
4861
|
+
this.pump();
|
|
4862
|
+
return;
|
|
4863
|
+
}
|
|
4864
|
+
this.activeId = nextId;
|
|
4865
|
+
this.patch(record, {
|
|
4866
|
+
state: "running",
|
|
4867
|
+
phase: "building",
|
|
4868
|
+
startedAt: this.now()
|
|
4869
|
+
});
|
|
4870
|
+
this.execute(record);
|
|
4871
|
+
}
|
|
4872
|
+
async execute(record) {
|
|
4873
|
+
const progress = this.progressFor(record);
|
|
4874
|
+
try {
|
|
4875
|
+
const value = await this.executor(record.request, record.controller.signal, progress);
|
|
4876
|
+
this.finish(record, record.controller.signal.aborted ? "cancelled" : "succeeded");
|
|
4877
|
+
record.resolveResult(value);
|
|
4878
|
+
} catch (err) {
|
|
4879
|
+
if (err instanceof BackupCancelledError || record.controller.signal.aborted) {
|
|
4880
|
+
this.finish(record, "cancelled");
|
|
4881
|
+
record.rejectResult(err instanceof BackupCancelledError ? err : new BackupCancelledError());
|
|
4882
|
+
} else {
|
|
4883
|
+
this.finish(record, "failed", err instanceof Error ? err.message : String(err));
|
|
4884
|
+
record.rejectResult(err);
|
|
4885
|
+
}
|
|
4886
|
+
} finally {
|
|
4887
|
+
this.activeId = null;
|
|
4888
|
+
this.pump();
|
|
4889
|
+
}
|
|
4890
|
+
}
|
|
4891
|
+
progressFor(record) {
|
|
4892
|
+
return {
|
|
4893
|
+
setPhase: (phase) => this.patch(record, { phase }),
|
|
4894
|
+
setDestinations: (ids) => this.patch(record, { destinationIds: [...ids] }),
|
|
4895
|
+
setStagedBytes: (bytes) => this.patch(record, { stagedBytes: bytes }),
|
|
4896
|
+
setArchiveSize: (bytes) => this.patch(record, { archiveSizeBytes: bytes }),
|
|
4897
|
+
setUploadedBytes: (bytes) => this.patch(record, { uploadedBytes: bytes }),
|
|
4898
|
+
destinationCompleted: (id) => this.patch(record, {
|
|
4899
|
+
completedDestinationIds: [...record.snapshot.completedDestinationIds, id],
|
|
4900
|
+
uploadedBytes: 0
|
|
4901
|
+
}),
|
|
4902
|
+
destinationFailed: (id) => this.patch(record, {
|
|
4903
|
+
failedDestinationIds: [...record.snapshot.failedDestinationIds, id],
|
|
4904
|
+
uploadedBytes: 0
|
|
4905
|
+
})
|
|
4906
|
+
};
|
|
4907
|
+
}
|
|
4908
|
+
patch(record, patch) {
|
|
4909
|
+
record.snapshot = {
|
|
4910
|
+
...record.snapshot,
|
|
4911
|
+
...patch
|
|
4912
|
+
};
|
|
4913
|
+
}
|
|
4914
|
+
finish(record, state, error) {
|
|
4915
|
+
this.patch(record, {
|
|
4916
|
+
state,
|
|
4917
|
+
phase: "done",
|
|
4918
|
+
finishedAt: this.now(),
|
|
4919
|
+
...error !== void 0 ? { error } : {}
|
|
4920
|
+
});
|
|
4921
|
+
this.finished.push(record.snapshot.id);
|
|
4922
|
+
while (this.finished.length > this.maxFinished) {
|
|
4923
|
+
const evicted = this.finished.shift();
|
|
4924
|
+
if (evicted !== void 0) this.runs.delete(evicted);
|
|
4925
|
+
}
|
|
4926
|
+
}
|
|
4927
|
+
};
|
|
4928
|
+
/**
|
|
4929
|
+
* Canonical signature for join-detection. Order-normalised so
|
|
4930
|
+
* `{ destinations: [a, b] }` and `{ destinations: [b, a] }` — the same
|
|
4931
|
+
* fan-out set — coalesce.
|
|
4932
|
+
*/
|
|
4933
|
+
function requestSignature(request) {
|
|
4934
|
+
return JSON.stringify({
|
|
4935
|
+
destinations: [...request.destinations ?? []].toSorted(),
|
|
4936
|
+
locations: [...request.locations ?? []].toSorted(),
|
|
4937
|
+
label: request.label ?? null,
|
|
4938
|
+
retentionCount: request.retentionCount ?? null
|
|
4939
|
+
});
|
|
4940
|
+
}
|
|
4941
|
+
//#endregion
|
|
4589
4942
|
//#region src/builtins/backup-orchestrator/schedule-store.ts
|
|
4590
4943
|
/**
|
|
4591
4944
|
* @durable class=config owner=backup-orchestrator
|
|
@@ -4762,6 +5115,107 @@ function mapRowToSchedule(row) {
|
|
|
4762
5115
|
};
|
|
4763
5116
|
}
|
|
4764
5117
|
//#endregion
|
|
5118
|
+
//#region src/builtins/backup-orchestrator/staging-paths.ts
|
|
5119
|
+
/** Basename of the staging dir under the node data root. */
|
|
5120
|
+
var STAGING_DIR_NAME = "backup-staging";
|
|
5121
|
+
/** Canonical staging dir for a node data root. */
|
|
5122
|
+
function resolveStagingDir(nodeDataDir) {
|
|
5123
|
+
return path$1.join(nodeDataDir, STAGING_DIR_NAME);
|
|
5124
|
+
}
|
|
5125
|
+
/**
|
|
5126
|
+
* Delete every top-level entry of `stagingDir` except those whose
|
|
5127
|
+
* resolved path appears in `keepPaths`. Per-entry failures are logged
|
|
5128
|
+
* and skipped — a locked file must not abort the sweep. A missing
|
|
5129
|
+
* staging dir is a clean no-op.
|
|
5130
|
+
*/
|
|
5131
|
+
async function sweepStagingDir(input) {
|
|
5132
|
+
const keep = new Set(input.keepPaths.map((p) => path$1.resolve(p)));
|
|
5133
|
+
let entries;
|
|
5134
|
+
try {
|
|
5135
|
+
entries = await fsp.readdir(input.stagingDir);
|
|
5136
|
+
} catch {
|
|
5137
|
+
return {
|
|
5138
|
+
removedCount: 0,
|
|
5139
|
+
freedBytes: 0,
|
|
5140
|
+
keptCount: 0
|
|
5141
|
+
};
|
|
5142
|
+
}
|
|
5143
|
+
let removedCount = 0;
|
|
5144
|
+
let freedBytes = 0;
|
|
5145
|
+
let keptCount = 0;
|
|
5146
|
+
for (const name of entries) {
|
|
5147
|
+
const abs = path$1.join(input.stagingDir, name);
|
|
5148
|
+
if (keep.has(path$1.resolve(abs))) {
|
|
5149
|
+
keptCount++;
|
|
5150
|
+
continue;
|
|
5151
|
+
}
|
|
5152
|
+
try {
|
|
5153
|
+
const footprint = await measureDirectoryFootprint(abs);
|
|
5154
|
+
await fsp.rm(abs, {
|
|
5155
|
+
recursive: true,
|
|
5156
|
+
force: true
|
|
5157
|
+
});
|
|
5158
|
+
removedCount++;
|
|
5159
|
+
freedBytes += footprint.totalBytes;
|
|
5160
|
+
} catch (err) {
|
|
5161
|
+
input.logger.warn("backup staging sweep: entry removal failed — skipping", { meta: {
|
|
5162
|
+
path: abs,
|
|
5163
|
+
error: err instanceof Error ? err.message : String(err)
|
|
5164
|
+
} });
|
|
5165
|
+
}
|
|
5166
|
+
}
|
|
5167
|
+
return {
|
|
5168
|
+
removedCount,
|
|
5169
|
+
freedBytes,
|
|
5170
|
+
keptCount
|
|
5171
|
+
};
|
|
5172
|
+
}
|
|
5173
|
+
/**
|
|
5174
|
+
* Recursive size + file count of a path (file or directory). Absent
|
|
5175
|
+
* path → `present: false` with zeros. Used by the sweep to report
|
|
5176
|
+
* reclaimed bytes and by the orchestrator to surface the legacy
|
|
5177
|
+
* staging dir's footprint without touching it.
|
|
5178
|
+
*/
|
|
5179
|
+
async function measureDirectoryFootprint(dir) {
|
|
5180
|
+
let stat;
|
|
5181
|
+
try {
|
|
5182
|
+
stat = await fsp.stat(dir);
|
|
5183
|
+
} catch {
|
|
5184
|
+
return {
|
|
5185
|
+
present: false,
|
|
5186
|
+
totalBytes: 0,
|
|
5187
|
+
fileCount: 0
|
|
5188
|
+
};
|
|
5189
|
+
}
|
|
5190
|
+
if (!stat.isDirectory()) return {
|
|
5191
|
+
present: true,
|
|
5192
|
+
totalBytes: stat.size,
|
|
5193
|
+
fileCount: 1
|
|
5194
|
+
};
|
|
5195
|
+
let totalBytes = 0;
|
|
5196
|
+
let fileCount = 0;
|
|
5197
|
+
let children;
|
|
5198
|
+
try {
|
|
5199
|
+
children = await fsp.readdir(dir);
|
|
5200
|
+
} catch {
|
|
5201
|
+
return {
|
|
5202
|
+
present: true,
|
|
5203
|
+
totalBytes: 0,
|
|
5204
|
+
fileCount: 0
|
|
5205
|
+
};
|
|
5206
|
+
}
|
|
5207
|
+
for (const child of children) {
|
|
5208
|
+
const nested = await measureDirectoryFootprint(path$1.join(dir, child));
|
|
5209
|
+
totalBytes += nested.totalBytes;
|
|
5210
|
+
fileCount += nested.fileCount;
|
|
5211
|
+
}
|
|
5212
|
+
return {
|
|
5213
|
+
present: true,
|
|
5214
|
+
totalBytes,
|
|
5215
|
+
fileCount
|
|
5216
|
+
};
|
|
5217
|
+
}
|
|
5218
|
+
//#endregion
|
|
4765
5219
|
//#region src/builtins/backup-orchestrator/write-targets.ts
|
|
4766
5220
|
/**
|
|
4767
5221
|
* Pick the destinations an archive may be fanned out to.
|
|
@@ -4806,8 +5260,9 @@ function selectBackupTargets(input) {
|
|
|
4806
5260
|
* that list with the `backup_destination_policies` table to surface
|
|
4807
5261
|
* `enabled` / `retentionCount` / `label`. The actual archive bytes
|
|
4808
5262
|
* travel through `api.storage.beginUpload` / `writeChunk` /
|
|
4809
|
-
* `finalizeUpload`, with the staging archive built into
|
|
4810
|
-
*
|
|
5263
|
+
* `finalizeUpload`, with the staging archive built into
|
|
5264
|
+
* `<nodeDataDir>/backup-staging` (outside every backed-up location —
|
|
5265
|
+
* see staging-paths.ts).
|
|
4811
5266
|
*/
|
|
4812
5267
|
/**
|
|
4813
5268
|
* Hardcoded seed for newly-discovered `backups` locations. Operators
|
|
@@ -4859,10 +5314,12 @@ var BackupOrchestratorAddon = class extends BaseAddon {
|
|
|
4859
5314
|
/**
|
|
4860
5315
|
* Transient build buffer for archive fan-out. The tar is built here
|
|
4861
5316
|
* ONCE, streamed to every destination via the storage cap's chunked
|
|
4862
|
-
* upload, then removed. Lives
|
|
4863
|
-
*
|
|
4864
|
-
*
|
|
4865
|
-
*
|
|
5317
|
+
* upload, then removed. Lives at `<nodeDataDir>/backup-staging` —
|
|
5318
|
+
* OUTSIDE every backed-up location, so a backup can never contain a
|
|
5319
|
+
* backup (the old `ctx.dataDir/staging` sat inside `addons-data` and
|
|
5320
|
+
* compounded 175M → 4.5G before Jul 28), and distinct from
|
|
5321
|
+
* `<nodeDataDir>/backups` where the default `backups` destination
|
|
5322
|
+
* seeds when `CAMSTACK_BACKUP_ROOT` is unset. See staging-paths.ts.
|
|
4866
5323
|
*/
|
|
4867
5324
|
stagingDir = "";
|
|
4868
5325
|
scheduleTimer = null;
|
|
@@ -4874,13 +5331,24 @@ var BackupOrchestratorAddon = class extends BaseAddon {
|
|
|
4874
5331
|
*/
|
|
4875
5332
|
scheduleBaselineAt = Date.now();
|
|
4876
5333
|
manifestLock = new LocationManifestLock();
|
|
5334
|
+
/**
|
|
5335
|
+
* Global execution queue — at most ONE archive build at a time,
|
|
5336
|
+
* cluster-node-wide (D342). Replaces the earlier invisible
|
|
5337
|
+
* single-flight `buildLock`: same serialisation guarantee (two
|
|
5338
|
+
* schedules due one tick apart built 8.2 GB + 3.7 GB side by side on
|
|
5339
|
+
* the hub, 2026-09-04), but every run is now observable via
|
|
5340
|
+
* `listRuns`, joinable when identical, and cancellable mid-stream.
|
|
5341
|
+
*/
|
|
5342
|
+
runQueue = null;
|
|
4877
5343
|
constructor() {
|
|
4878
5344
|
super({});
|
|
4879
5345
|
}
|
|
4880
5346
|
async onInitialize() {
|
|
4881
5347
|
this.dataDir = this.ctx.nodeDataDir;
|
|
4882
|
-
this.stagingDir =
|
|
5348
|
+
this.stagingDir = resolveStagingDir(this.ctx.nodeDataDir);
|
|
4883
5349
|
this.systemBackup = new SystemBackupService(this.dataDir, this.ctx.logger);
|
|
5350
|
+
this.runQueue = new BackupRunQueue((request, signal, progress) => this.runBackupBuild(request, signal, progress));
|
|
5351
|
+
await this.cleanStagingArea();
|
|
4884
5352
|
this.policies = this.resolvePolicyService();
|
|
4885
5353
|
if (this.policies) try {
|
|
4886
5354
|
await this.policies.initialize();
|
|
@@ -4900,6 +5368,8 @@ var BackupOrchestratorAddon = class extends BaseAddon {
|
|
|
4900
5368
|
list: () => this.aggregatedList(),
|
|
4901
5369
|
listLocations: () => this.systemBackup.statLocations(),
|
|
4902
5370
|
trigger: (input) => this.triggerBackup(input),
|
|
5371
|
+
listRuns: () => this.listRuns(),
|
|
5372
|
+
cancel: (input) => this.cancelRun(input),
|
|
4903
5373
|
getEntries: (input) => this.getEntries(input),
|
|
4904
5374
|
restore: (input) => this.scheduleRestore(input),
|
|
4905
5375
|
delete: (input) => this.deleteArchive(input),
|
|
@@ -4922,6 +5392,10 @@ var BackupOrchestratorAddon = class extends BaseAddon {
|
|
|
4922
5392
|
}
|
|
4923
5393
|
async onShutdown() {
|
|
4924
5394
|
this.stopScheduleTimer();
|
|
5395
|
+
if (this.runQueue) {
|
|
5396
|
+
for (const run of this.runQueue.listRuns()) if (run.state === "queued" || run.state === "running") this.runQueue.cancel(run.id);
|
|
5397
|
+
}
|
|
5398
|
+
this.runQueue = null;
|
|
4925
5399
|
this.systemBackup = null;
|
|
4926
5400
|
this.policies = null;
|
|
4927
5401
|
this.schedules = null;
|
|
@@ -5072,64 +5546,180 @@ var BackupOrchestratorAddon = class extends BaseAddon {
|
|
|
5072
5546
|
return path$1.join(this.stagingDir, filename);
|
|
5073
5547
|
}
|
|
5074
5548
|
/**
|
|
5075
|
-
*
|
|
5076
|
-
*
|
|
5077
|
-
*
|
|
5078
|
-
*
|
|
5079
|
-
*
|
|
5080
|
-
*
|
|
5549
|
+
* Boot-time staging hygiene. Sweeps the staging dir (sparing an
|
|
5550
|
+
* archive a pending-restore marker references — the boot hook has
|
|
5551
|
+
* not consumed it yet), then reports — without touching — the LEGACY
|
|
5552
|
+
* staging dir inside `addons-data`, where pre-move leaks live. The
|
|
5553
|
+
* legacy dir held 5.1 GB on the hub; deleting operator-visible
|
|
5554
|
+
* gigabytes silently is not this addon's call, so it warns with the
|
|
5555
|
+
* footprint instead.
|
|
5556
|
+
*/
|
|
5557
|
+
async cleanStagingArea() {
|
|
5558
|
+
const marker = this.systemBackup?.readPendingMarker() ?? null;
|
|
5559
|
+
const keepPaths = marker ? [marker.archivePath] : [];
|
|
5560
|
+
try {
|
|
5561
|
+
const swept = await sweepStagingDir({
|
|
5562
|
+
stagingDir: this.stagingDir,
|
|
5563
|
+
keepPaths,
|
|
5564
|
+
logger: this.ctx.logger
|
|
5565
|
+
});
|
|
5566
|
+
if (swept.removedCount > 0) this.ctx.logger.info("backup staging swept — leaked transient files removed", { meta: {
|
|
5567
|
+
stagingDir: this.stagingDir,
|
|
5568
|
+
removedCount: swept.removedCount,
|
|
5569
|
+
freedBytes: swept.freedBytes,
|
|
5570
|
+
keptForPendingRestore: swept.keptCount
|
|
5571
|
+
} });
|
|
5572
|
+
} catch (err) {
|
|
5573
|
+
this.ctx.logger.warn("backup staging sweep failed — continuing", { meta: {
|
|
5574
|
+
stagingDir: this.stagingDir,
|
|
5575
|
+
error: err instanceof Error ? err.message : String(err)
|
|
5576
|
+
} });
|
|
5577
|
+
}
|
|
5578
|
+
const legacyDir = path$1.join(this.ctx.dataDir, "staging");
|
|
5579
|
+
try {
|
|
5580
|
+
const legacy = await measureDirectoryFootprint(legacyDir);
|
|
5581
|
+
if (legacy.present && legacy.fileCount > 0) this.ctx.logger.warn("legacy backup staging dir still holds files inside addons-data — excluded from archives, but the disk space is only reclaimed by deleting it", { meta: {
|
|
5582
|
+
legacyDir,
|
|
5583
|
+
fileCount: legacy.fileCount,
|
|
5584
|
+
totalBytes: legacy.totalBytes
|
|
5585
|
+
} });
|
|
5586
|
+
} catch {}
|
|
5587
|
+
}
|
|
5588
|
+
/**
|
|
5589
|
+
* Trigger a backup run through the global execution queue (D342):
|
|
5590
|
+
* at most one archive build at a time, a trigger that arrives while
|
|
5591
|
+
* one is in flight WAITS (or joins an identical queued run), and the
|
|
5592
|
+
* result says which happened. The call still resolves when the run
|
|
5593
|
+
* terminates; a cancelled run resolves with `cancelled: true` and
|
|
5594
|
+
* whatever entries landed before the stop — a deliberate operator
|
|
5595
|
+
* cancel is an outcome, not an error.
|
|
5081
5596
|
*/
|
|
5082
5597
|
async triggerBackup(input) {
|
|
5598
|
+
if (!this.runQueue) throw new Error("orchestrator not initialized");
|
|
5599
|
+
const request = {
|
|
5600
|
+
...input?.destinations ? { destinations: input.destinations } : {},
|
|
5601
|
+
...input?.locations ? { locations: input.locations } : {},
|
|
5602
|
+
...input?.label !== void 0 ? { label: input.label } : {},
|
|
5603
|
+
...input?.retentionCount !== void 0 ? { retentionCount: input.retentionCount } : {}
|
|
5604
|
+
};
|
|
5605
|
+
const submitted = this.runQueue.submit(request);
|
|
5606
|
+
if (submitted.queued) this.ctx.logger.info(submitted.joined ? "backup trigger: identical run already queued — joined it" : "backup trigger: a run is in flight — enqueued behind it", { meta: { runId: submitted.runId } });
|
|
5607
|
+
let entries;
|
|
5608
|
+
try {
|
|
5609
|
+
entries = await submitted.result;
|
|
5610
|
+
} catch (err) {
|
|
5611
|
+
if (!(err instanceof BackupCancelledError)) throw err;
|
|
5612
|
+
entries = [];
|
|
5613
|
+
}
|
|
5614
|
+
const finalState = this.runQueue?.getRun(submitted.runId)?.state;
|
|
5615
|
+
return {
|
|
5616
|
+
runId: submitted.runId,
|
|
5617
|
+
queued: submitted.queued,
|
|
5618
|
+
joined: submitted.joined,
|
|
5619
|
+
cancelled: finalState === "cancelled",
|
|
5620
|
+
entries
|
|
5621
|
+
};
|
|
5622
|
+
}
|
|
5623
|
+
/** Wire `backup.listRuns` — queued + active + bounded history. */
|
|
5624
|
+
async listRuns() {
|
|
5625
|
+
return this.runQueue?.listRuns() ?? [];
|
|
5626
|
+
}
|
|
5627
|
+
/**
|
|
5628
|
+
* Wire `backup.cancel`. Delegates to the queue: a queued run is
|
|
5629
|
+
* removed before it starts; the running run's abort signal fires and
|
|
5630
|
+
* `runBackupBuild` stops the tar/upload streams and deletes its
|
|
5631
|
+
* staging archive on the way out.
|
|
5632
|
+
*/
|
|
5633
|
+
async cancelRun(input) {
|
|
5634
|
+
const cancelled = this.runQueue?.cancel(input.runId) ?? false;
|
|
5635
|
+
if (cancelled) this.ctx.logger.info("backup run cancel requested", { meta: { runId: input.runId } });
|
|
5636
|
+
else this.ctx.logger.warn("backup cancel: run unknown or already terminal", { meta: { runId: input.runId } });
|
|
5637
|
+
return { cancelled };
|
|
5638
|
+
}
|
|
5639
|
+
/**
|
|
5640
|
+
* Execute ONE backup run — only ever called by the queue, never
|
|
5641
|
+
* directly (the queue is the single serialisation authority).
|
|
5642
|
+
* Builds the archive in the private staging dir, then pushes it to
|
|
5643
|
+
* every selected destination via the storage cap's chunked upload
|
|
5644
|
+
* protocol. Per-destination errors are logged and recorded on the
|
|
5645
|
+
* run — one bad sink doesn't kill the run.
|
|
5646
|
+
*
|
|
5647
|
+
* Cleanup contract (D342): the staging file is deleted in `finally`,
|
|
5648
|
+
* which covers success, per-destination failure, build failure AND
|
|
5649
|
+
* cancellation alike. The only leak this cannot cover — the process
|
|
5650
|
+
* dying inside the run — is reclaimed by the boot sweep in
|
|
5651
|
+
* `cleanStagingArea`.
|
|
5652
|
+
*/
|
|
5653
|
+
async runBackupBuild(input, signal, progress) {
|
|
5083
5654
|
if (!this.systemBackup) throw new Error("orchestrator not initialized");
|
|
5084
|
-
const targets = await this.resolveTargets(input
|
|
5655
|
+
const targets = await this.resolveTargets(input.destinations !== void 0 ? [...input.destinations] : void 0);
|
|
5085
5656
|
if (targets.length === 0) {
|
|
5086
5657
|
this.ctx.logger.warn("backup trigger: no destinations selected");
|
|
5087
5658
|
return [];
|
|
5088
5659
|
}
|
|
5660
|
+
progress.setDestinations(targets.map((t) => t.id));
|
|
5089
5661
|
const policies = await this.loadPoliciesIndexed(targets.map((t) => t.id));
|
|
5090
5662
|
const archiveId = randomUUID();
|
|
5091
5663
|
const archiveFilename = `${archiveId}.tar.gz`;
|
|
5092
5664
|
const cachePath = await this.stagingPath(archiveFilename);
|
|
5093
5665
|
const result = await this.systemBackup.createArchive({
|
|
5094
5666
|
archivePath: cachePath,
|
|
5095
|
-
...input
|
|
5667
|
+
...input.locations ? { locations: [...input.locations] } : {},
|
|
5668
|
+
signal,
|
|
5669
|
+
onArchiveBytes: (bytes) => progress.setStagedBytes(bytes)
|
|
5096
5670
|
});
|
|
5671
|
+
progress.setArchiveSize(result.sizeBytes);
|
|
5672
|
+
progress.setPhase("uploading");
|
|
5097
5673
|
try {
|
|
5098
5674
|
const entries = [];
|
|
5099
5675
|
const createdAt = Date.now();
|
|
5100
|
-
for (const dest of targets)
|
|
5101
|
-
|
|
5102
|
-
|
|
5103
|
-
|
|
5104
|
-
|
|
5105
|
-
|
|
5106
|
-
|
|
5107
|
-
|
|
5108
|
-
|
|
5109
|
-
|
|
5110
|
-
|
|
5111
|
-
|
|
5112
|
-
|
|
5113
|
-
|
|
5114
|
-
|
|
5115
|
-
|
|
5116
|
-
|
|
5117
|
-
|
|
5118
|
-
|
|
5119
|
-
|
|
5120
|
-
|
|
5121
|
-
|
|
5122
|
-
|
|
5676
|
+
for (const dest of targets) {
|
|
5677
|
+
if (signal.aborted) {
|
|
5678
|
+
this.ctx.logger.info("backup run cancelled — skipping remaining destinations", { meta: {
|
|
5679
|
+
runCancelledAfter: entries.length,
|
|
5680
|
+
skippedDestinationIds: targets.slice(targets.indexOf(dest)).map((t) => t.id)
|
|
5681
|
+
} });
|
|
5682
|
+
break;
|
|
5683
|
+
}
|
|
5684
|
+
try {
|
|
5685
|
+
const policy = policies.get(dest.id) ?? this.fallbackPolicy(dest.id);
|
|
5686
|
+
const stored = await this.uploadAndIndex({
|
|
5687
|
+
locationId: dest.id,
|
|
5688
|
+
archiveId,
|
|
5689
|
+
archiveFilename,
|
|
5690
|
+
cachePath,
|
|
5691
|
+
sizeBytes: result.sizeBytes,
|
|
5692
|
+
archiveManifest: result.manifest,
|
|
5693
|
+
retentionCount: input.retentionCount ?? policy.retentionCount,
|
|
5694
|
+
createdAt,
|
|
5695
|
+
label: formatArchiveLabel(dest.id, createdAt, input.label),
|
|
5696
|
+
signal,
|
|
5697
|
+
onUploadedBytes: (bytes) => progress.setUploadedBytes(bytes)
|
|
5698
|
+
});
|
|
5699
|
+
entries.push(stored);
|
|
5700
|
+
progress.destinationCompleted(dest.id);
|
|
5701
|
+
this.ctx.eventBus.emit({
|
|
5702
|
+
id: randomUUID(),
|
|
5703
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
5704
|
+
source: {
|
|
5705
|
+
type: "addon",
|
|
5706
|
+
id: "backup-orchestrator"
|
|
5707
|
+
},
|
|
5708
|
+
category: EventCategory.BackupCompleted,
|
|
5709
|
+
data: {
|
|
5710
|
+
destinationId: dest.id,
|
|
5711
|
+
backupId: stored.id,
|
|
5712
|
+
sizeMB: Math.round((stored.sizeBytes ?? 0) / (1024 * 1024))
|
|
5713
|
+
}
|
|
5714
|
+
});
|
|
5715
|
+
} catch (err) {
|
|
5716
|
+
if (err instanceof BackupCancelledError) continue;
|
|
5717
|
+
progress.destinationFailed(dest.id);
|
|
5718
|
+
this.ctx.logger.error("backup trigger: destination failed", { meta: {
|
|
5123
5719
|
destinationId: dest.id,
|
|
5124
|
-
|
|
5125
|
-
|
|
5126
|
-
|
|
5127
|
-
});
|
|
5128
|
-
} catch (err) {
|
|
5129
|
-
this.ctx.logger.error("backup trigger: destination failed", { meta: {
|
|
5130
|
-
destinationId: dest.id,
|
|
5131
|
-
error: err instanceof Error ? err.message : String(err)
|
|
5132
|
-
} });
|
|
5720
|
+
error: err instanceof Error ? err.message : String(err)
|
|
5721
|
+
} });
|
|
5722
|
+
}
|
|
5133
5723
|
}
|
|
5134
5724
|
return entries;
|
|
5135
5725
|
} finally {
|
|
@@ -5158,6 +5748,10 @@ var BackupOrchestratorAddon = class extends BaseAddon {
|
|
|
5158
5748
|
let offset = 0;
|
|
5159
5749
|
const stream = fs.createReadStream(input.cachePath, { highWaterMark: UPLOAD_CHUNK_BYTES });
|
|
5160
5750
|
for await (const rawChunk of stream) {
|
|
5751
|
+
if (input.signal?.aborted) {
|
|
5752
|
+
stream.destroy();
|
|
5753
|
+
throw new BackupCancelledError(`backup upload to ${input.locationId} cancelled mid-stream`);
|
|
5754
|
+
}
|
|
5161
5755
|
const chunk = toBufferChunk(rawChunk);
|
|
5162
5756
|
const data = new Uint8Array(chunk.byteLength);
|
|
5163
5757
|
data.set(chunk);
|
|
@@ -5167,6 +5761,7 @@ var BackupOrchestratorAddon = class extends BaseAddon {
|
|
|
5167
5761
|
data
|
|
5168
5762
|
});
|
|
5169
5763
|
offset += data.byteLength;
|
|
5764
|
+
input.onUploadedBytes?.(offset);
|
|
5170
5765
|
}
|
|
5171
5766
|
await this.ctx.api.storage.finalizeUpload.mutate({ uploadId });
|
|
5172
5767
|
} catch (err) {
|