@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.
Files changed (67) hide show
  1. package/dist/addon-runner.js +2 -2
  2. package/dist/addon-runner.mjs +2 -2
  3. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  6. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  7. package/dist/builtins/alerts/alerts.addon.js +1 -1
  8. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  9. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.d.ts +56 -10
  10. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +657 -62
  11. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +658 -63
  12. package/dist/builtins/backup-orchestrator/run-queue.d.ts +103 -0
  13. package/dist/builtins/backup-orchestrator/staging-paths.d.ts +36 -0
  14. package/dist/builtins/console-logging/index.js +1 -1
  15. package/dist/builtins/console-logging/index.mjs +1 -1
  16. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  17. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  18. package/dist/builtins/device-manager/device-manager.addon.js +349 -65
  19. package/dist/builtins/device-manager/device-manager.addon.mjs +349 -65
  20. package/dist/builtins/device-manager/device-meta-actions.d.ts +8 -19
  21. package/dist/builtins/device-manager/device-provider-context.d.ts +4 -0
  22. package/dist/builtins/device-manager/migrate-device.d.ts +47 -3
  23. package/dist/builtins/device-manager/migrate-guard.d.ts +39 -0
  24. package/dist/builtins/device-manager/migrate-hardware-state.d.ts +24 -0
  25. package/dist/builtins/device-manager/migrate-reload.d.ts +8 -0
  26. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  27. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  28. package/dist/builtins/hub-forwarder/index.js +1 -1
  29. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  30. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  31. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  32. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  33. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  34. package/dist/builtins/local-network/local-network.addon.js +1 -1
  35. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  36. package/dist/builtins/loki-logging/index.js +1 -1
  37. package/dist/builtins/loki-logging/index.mjs +1 -1
  38. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  39. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  40. package/dist/builtins/platform-probe/index.js +1 -1
  41. package/dist/builtins/platform-probe/index.mjs +1 -1
  42. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  43. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  44. package/dist/builtins/snapshot/index.js +1 -1
  45. package/dist/builtins/snapshot/index.mjs +1 -1
  46. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  47. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  48. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  49. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  50. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  51. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  52. package/dist/builtins/system-backup/system-backup.service.d.ts +55 -2
  53. package/dist/builtins/system-config/system-config.addon.js +1 -1
  54. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  55. package/dist/builtins/winston-logging/index.js +1 -1
  56. package/dist/builtins/winston-logging/index.mjs +1 -1
  57. package/dist/{dist-L_39vRmH.mjs → dist-DbnNk4Lw.mjs} +211 -2
  58. package/dist/{dist-C7qWwrK1.js → dist-whe0b4_3.js} +211 -2
  59. package/dist/index.js +530 -2
  60. package/dist/index.mjs +533 -5
  61. package/dist/kernel/addon-installer.d.ts +69 -0
  62. package/dist/kernel/deps/closure-dedupe.d.ts +105 -0
  63. package/dist/{manifest-system-deps-CMOGc5nC.mjs → manifest-system-deps-DOcMuUWK.mjs} +1 -1
  64. package/dist/{manifest-system-deps-PAIfmNun.js → manifest-system-deps-y5vZBO6q.js} +1 -1
  65. package/dist/{retired-settings-keys-3soU9pfP.js → retired-settings-keys-BdvmiaK7.js} +1 -1
  66. package/dist/{retired-settings-keys-CnbKAxmX.mjs → retired-settings-keys-CyQG8b-A.mjs} +1 -1
  67. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import { Ft as BaseAddon, j as backupCapability, sn as EventCategory } from "../../dist-L_39vRmH.mjs";
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
- * Excluded by default: recordings/, media/, models/, logs/, cache/
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
- await Kn({
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 the addon's
4810
- * private `ctx.dataDir/staging` dir.
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 in the addon's private `ctx.dataDir`
4863
- * (never `api.storage.resolve`, per the AddonContext contract) — a
4864
- * `cache` storage location is never declared, so resolving one threw
4865
- * and aborted every run before a byte was written.
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 = path$1.join(this.ctx.dataDir, "staging");
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
- * Build a single archive in the private staging dir, then push
5076
- * it to every selected destination via the storage cap's chunked
5077
- * upload protocol. Per-destination errors are logged and counted
5078
- * via allSettled semantics — one bad sink doesn't kill the run. The
5079
- * staging file is deleted in `finally` so a crash mid-fanout doesn't
5080
- * leak.
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?.destinations);
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?.locations ? { locations: input.locations } : {}
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) try {
5101
- const policy = policies.get(dest.id) ?? this.fallbackPolicy(dest.id);
5102
- const stored = await this.uploadAndIndex({
5103
- locationId: dest.id,
5104
- archiveId,
5105
- archiveFilename,
5106
- cachePath,
5107
- sizeBytes: result.sizeBytes,
5108
- archiveManifest: result.manifest,
5109
- retentionCount: input?.retentionCount ?? policy.retentionCount,
5110
- createdAt,
5111
- label: formatArchiveLabel(dest.id, createdAt, input?.label)
5112
- });
5113
- entries.push(stored);
5114
- this.ctx.eventBus.emit({
5115
- id: randomUUID(),
5116
- timestamp: /* @__PURE__ */ new Date(),
5117
- source: {
5118
- type: "addon",
5119
- id: "backup-orchestrator"
5120
- },
5121
- category: EventCategory.BackupCompleted,
5122
- data: {
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
- backupId: stored.id,
5125
- sizeMB: Math.round((stored.sizeBytes ?? 0) / (1024 * 1024))
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) {