@camstack/system 1.2.199 → 1.2.201

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 (54) hide show
  1. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  2. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  3. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/alerts/alerts.addon.js +1 -1
  6. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  7. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.d.ts +38 -9
  8. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +234 -81
  9. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +234 -81
  10. package/dist/builtins/backup-orchestrator/destination-policy.d.ts +32 -14
  11. package/dist/builtins/backup-orchestrator/schedule-store.d.ts +12 -2
  12. package/dist/builtins/console-logging/index.js +1 -1
  13. package/dist/builtins/console-logging/index.mjs +1 -1
  14. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  15. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  16. package/dist/builtins/device-manager/device-manager.addon.js +2 -2
  17. package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
  18. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  19. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  20. package/dist/builtins/hub-forwarder/index.js +1 -1
  21. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  22. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  23. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  24. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  25. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  26. package/dist/builtins/local-network/local-network.addon.js +1 -1
  27. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  28. package/dist/builtins/loki-logging/index.js +1 -1
  29. package/dist/builtins/loki-logging/index.mjs +1 -1
  30. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  31. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  32. package/dist/builtins/platform-probe/index.js +1 -1
  33. package/dist/builtins/platform-probe/index.mjs +1 -1
  34. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  35. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  36. package/dist/builtins/snapshot/index.js +1 -1
  37. package/dist/builtins/snapshot/index.mjs +1 -1
  38. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  39. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  40. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  41. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  42. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  43. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  44. package/dist/builtins/system-config/system-config.addon.js +1 -1
  45. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  46. package/dist/builtins/winston-logging/index.js +1 -1
  47. package/dist/builtins/winston-logging/index.mjs +1 -1
  48. package/dist/{dist-BSSH6C-h.mjs → dist-BmJNnb_s.mjs} +52 -18
  49. package/dist/{dist-HAP38XvL.js → dist-CcV3k-hD.js} +52 -18
  50. package/dist/index.js +1 -1
  51. package/dist/index.mjs +1 -1
  52. package/dist/{retired-settings-keys-BYiTW-q4.js → retired-settings-keys-Bd8fuwO1.js} +1 -1
  53. package/dist/{retired-settings-keys-DAl6xuAs.mjs → retired-settings-keys-nLVKlhe-.mjs} +1 -1
  54. package/package.json +1 -1
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-HAP38XvL.js");
6
+ const require_dist = require("../../dist-CcV3k-hD.js");
7
7
  let node_crypto = require("node:crypto");
8
8
  let buffer = require("buffer");
9
9
  let node_fs_promises = require("node:fs/promises");
@@ -4309,14 +4309,17 @@ function previewNextRuns(cron, count, after) {
4309
4309
  /**
4310
4310
  * @durable class=config owner=backup-orchestrator
4311
4311
  * write="`ensureDefault` auto-creates one row the first time a `backups` storage
4312
- * location is listed (enabled, retentionCount 7, cron `0 3 * * *`), and an operator
4312
+ * location is listed (enabled, retentionCount 7, no cadence of its own), an operator
4313
4313
  * edit rewrites it (`upsert` = delete + insert, carrying `last_run_at` forward so a
4314
- * label change does not reset the schedule baseline)"
4314
+ * label change does not reset the baseline), and `recordAttempt` stamps
4315
+ * `last_run_at` + `last_error` on every attempted write to the destination"
4315
4316
  * retention="none — `delete(locationId)` exists but NOTHING calls it, so a policy row
4316
4317
  * outlives the `StorageLocation` it names and the table only ever grows, by one row
4317
4318
  * per backups location ever seen. Losing a row is recoverable: the next
4318
4319
  * `listDestinations` pass re-creates it from the defaults, which silently discards
4319
- * the operator's own retention count and cron."
4320
+ * the operator's own retention count. The legacy `cron` column is written NULL and
4321
+ * never read — it scheduled nothing after 2026-07-28 and reading it made a
4322
+ * destination advertise a nightly run nobody performed (D384)."
4320
4323
  */
4321
4324
  var BACKUP_DESTINATION_POLICIES = "backup_destination_policies";
4322
4325
  var BACKUP_DESTINATION_POLICIES_COLUMNS = [
@@ -4348,6 +4351,10 @@ var BACKUP_DESTINATION_POLICIES_COLUMNS = [
4348
4351
  {
4349
4352
  name: "last_run_at",
4350
4353
  type: "INTEGER"
4354
+ },
4355
+ {
4356
+ name: "last_error",
4357
+ type: "TEXT"
4351
4358
  }
4352
4359
  ];
4353
4360
  var BackupDestinationPolicyService = class {
@@ -4432,10 +4439,11 @@ var BackupDestinationPolicyService = class {
4432
4439
  * pass so newly-added storage locations are surfaced as enabled
4433
4440
  * destinations without an explicit configuration step.
4434
4441
  *
4435
- * Defaults are passed by the orchestrator (currently:
4436
- * `retentionCount=7` + `cron='0 3 * * *'`) so a fresh destination
4437
- * starts running daily at 03:00 with 7 archives kept. Operator can
4438
- * tune both inline on the destinations table.
4442
+ * Defaults are passed by the orchestrator (currently `retentionCount=7`).
4443
+ * A fresh destination is enabled and keeps 7 archives; it gets NO cadence
4444
+ * of its own — a destination runs when a schedule fans out to it, and
4445
+ * seeding a cron here would advertise a nightly run nothing performs
4446
+ * (D384).
4439
4447
  */
4440
4448
  async ensureDefault(locationId, defaults) {
4441
4449
  const existing = await this.get(locationId);
@@ -4443,12 +4451,32 @@ var BackupDestinationPolicyService = class {
4443
4451
  const fresh = {
4444
4452
  locationId,
4445
4453
  enabled: true,
4446
- retentionCount: defaults.retentionCount,
4447
- ...defaults.cron !== void 0 ? { cron: defaults.cron } : {}
4454
+ retentionCount: defaults.retentionCount
4448
4455
  };
4449
4456
  await this.upsert(fresh);
4450
4457
  return fresh;
4451
4458
  }
4459
+ /**
4460
+ * Stamp the outcome of ONE attempted write to this destination.
4461
+ *
4462
+ * Read-modify-write on the policy row so the operator's `enabled`,
4463
+ * `retentionCount` and `label` survive. A destination with no row yet
4464
+ * gets one — an attempt on an unseen location must still be recorded, or
4465
+ * the failure disappears with the process (which is exactly how a 03:00
4466
+ * SMB refusal stayed invisible until the operator went looking).
4467
+ */
4468
+ async recordAttempt(locationId, outcome, defaults) {
4469
+ const { lastError: _cleared, ...carried } = await this.get(locationId) ?? {
4470
+ locationId,
4471
+ enabled: true,
4472
+ retentionCount: defaults.retentionCount
4473
+ };
4474
+ await this.upsert({
4475
+ ...carried,
4476
+ lastAttemptAt: outcome.attemptAt,
4477
+ ...outcome.error !== void 0 ? { lastError: outcome.error } : {}
4478
+ });
4479
+ }
4452
4480
  };
4453
4481
  function mapPolicyToRow(p) {
4454
4482
  return {
@@ -4456,8 +4484,9 @@ function mapPolicyToRow(p) {
4456
4484
  enabled: p.enabled ? 1 : 0,
4457
4485
  retention_count: p.retentionCount,
4458
4486
  label: p.label ?? null,
4459
- cron: p.cron ?? null,
4460
- last_run_at: p.lastRunAt ?? null
4487
+ cron: null,
4488
+ last_run_at: p.lastAttemptAt ?? null,
4489
+ last_error: p.lastError ?? null
4461
4490
  };
4462
4491
  }
4463
4492
  /**
@@ -4471,17 +4500,17 @@ function mapRowToPolicy(row) {
4471
4500
  if (!locationId) return null;
4472
4501
  const labelRaw = row["label"];
4473
4502
  const label = typeof labelRaw === "string" && labelRaw.length > 0 ? labelRaw : void 0;
4474
- const cronRaw = row["cron"];
4475
- const cron = typeof cronRaw === "string" && cronRaw.length > 0 ? cronRaw : void 0;
4476
4503
  const lastRunRaw = row["last_run_at"];
4477
- const lastRunAt = typeof lastRunRaw === "number" && lastRunRaw > 0 ? lastRunRaw : void 0;
4504
+ const lastAttemptAt = typeof lastRunRaw === "number" && lastRunRaw > 0 ? lastRunRaw : void 0;
4505
+ const lastErrorRaw = row["last_error"];
4506
+ const lastError = typeof lastErrorRaw === "string" && lastErrorRaw.length > 0 ? lastErrorRaw : void 0;
4478
4507
  return {
4479
4508
  locationId,
4480
4509
  enabled: row["enabled"] === 1 || row["enabled"] === true,
4481
4510
  retentionCount: Number(row["retention_count"] ?? 0),
4482
4511
  ...label !== void 0 ? { label } : {},
4483
- ...cron !== void 0 ? { cron } : {},
4484
- ...lastRunAt !== void 0 ? { lastRunAt } : {}
4512
+ ...lastAttemptAt !== void 0 ? { lastAttemptAt } : {},
4513
+ ...lastError !== void 0 ? { lastError } : {}
4485
4514
  };
4486
4515
  }
4487
4516
  //#endregion
@@ -4888,7 +4917,12 @@ var BackupRunQueue = class {
4888
4917
  const progress = this.progressFor(record);
4889
4918
  try {
4890
4919
  const value = await this.executor(record.request, record.controller.signal, progress);
4891
- this.finish(record, record.controller.signal.aborted ? "cancelled" : "succeeded");
4920
+ if (record.controller.signal.aborted) this.finish(record, "cancelled");
4921
+ else {
4922
+ const { destinationIds, completedDestinationIds, failedDestinationIds } = record.snapshot;
4923
+ if (destinationIds.length > 0 && completedDestinationIds.length === 0) this.finish(record, "failed", `no destination accepted the archive (${(failedDestinationIds.length > 0 ? failedDestinationIds : destinationIds).join(", ")})`);
4924
+ else this.finish(record, "succeeded");
4925
+ }
4892
4926
  record.resolveResult(value);
4893
4927
  } catch (err) {
4894
4928
  if (err instanceof BackupCancelledError || record.controller.signal.aborted) {
@@ -4961,7 +4995,8 @@ function requestSignature(request) {
4961
4995
  * delete + insert: cron, enabled, the fan-out `location_ids`, `retention_count`,
4962
4996
  * optional `data_sources`), plus a `last_run_at` stamp the orchestrator writes on
4963
4997
  * every due tick BEFORE the archive runs, so an upload outliving the tick cannot
4964
- * fire the same schedule twice"
4998
+ * fire the same schedule twice, and a `last_success_at` / `last_error` pair it
4999
+ * writes AFTER the run resolves"
4965
5000
  * retention="only the operator's `delete(id)` — no sweep, no expiry, and `retention_count`
4966
5001
  * bounds the ARCHIVES at the destination, never these rows. Losing the table stops
4967
5002
  * every scheduled backup silently: no cron remains to fire, and the fan-out sets and
@@ -5011,6 +5046,14 @@ var BACKUP_SCHEDULES_COLUMNS = [
5011
5046
  {
5012
5047
  name: "last_run_at",
5013
5048
  type: "INTEGER"
5049
+ },
5050
+ {
5051
+ name: "last_success_at",
5052
+ type: "INTEGER"
5053
+ },
5054
+ {
5055
+ name: "last_error",
5056
+ type: "TEXT"
5014
5057
  }
5015
5058
  ];
5016
5059
  var BackupScheduleService = class {
@@ -5089,7 +5132,9 @@ function mapScheduleToRow(s) {
5089
5132
  location_ids: [...s.locationIds],
5090
5133
  retention_count: s.retentionCount,
5091
5134
  data_sources: s.dataSources ? [...s.dataSources] : null,
5092
- last_run_at: s.lastRunAt ?? null
5135
+ last_run_at: s.lastAttemptAt ?? null,
5136
+ last_success_at: s.lastSuccessAt ?? null,
5137
+ last_error: s.lastError ?? null
5093
5138
  };
5094
5139
  }
5095
5140
  /**
@@ -5117,7 +5162,11 @@ function mapRowToSchedule(row) {
5117
5162
  const dataSourcesRaw = row["data_sources"];
5118
5163
  const dataSources = dataSourcesRaw === null || dataSourcesRaw === void 0 ? void 0 : parseStringArray(dataSourcesRaw);
5119
5164
  const lastRunRaw = row["last_run_at"];
5120
- const lastRunAt = typeof lastRunRaw === "number" && lastRunRaw > 0 ? lastRunRaw : void 0;
5165
+ const lastAttemptAt = typeof lastRunRaw === "number" && lastRunRaw > 0 ? lastRunRaw : void 0;
5166
+ const lastSuccessRaw = row["last_success_at"];
5167
+ const lastSuccessAt = typeof lastSuccessRaw === "number" && lastSuccessRaw > 0 ? lastSuccessRaw : void 0;
5168
+ const lastErrorRaw = row["last_error"];
5169
+ const lastError = typeof lastErrorRaw === "string" && lastErrorRaw.length > 0 ? lastErrorRaw : void 0;
5121
5170
  return {
5122
5171
  id,
5123
5172
  label: typeof row["label"] === "string" ? String(row["label"]) : "",
@@ -5126,7 +5175,9 @@ function mapRowToSchedule(row) {
5126
5175
  locationIds: parseStringArray(row["location_ids"]),
5127
5176
  retentionCount: Number(row["retention_count"] ?? 7),
5128
5177
  ...dataSources !== void 0 ? { dataSources } : {},
5129
- ...lastRunAt !== void 0 ? { lastRunAt } : {}
5178
+ ...lastAttemptAt !== void 0 ? { lastAttemptAt } : {},
5179
+ ...lastSuccessAt !== void 0 ? { lastSuccessAt } : {},
5180
+ ...lastError !== void 0 ? { lastError } : {}
5130
5181
  };
5131
5182
  }
5132
5183
  //#endregion
@@ -5288,13 +5339,6 @@ function selectBackupTargets(input) {
5288
5339
  * the panel used to expose).
5289
5340
  */
5290
5341
  var DEFAULT_RETENTION_COUNT = 7;
5291
- /**
5292
- * Default cron stamped on a freshly-discovered `backups` location.
5293
- * Operators can edit it from the destination row; setting empty in
5294
- * the editor flips the destination back to manual-only. Daily 03:00
5295
- * server-local mirrors the previous global default.
5296
- */
5297
- var DEFAULT_CRON = "0 3 * * *";
5298
5342
  var SCHEDULE_TICK_MS = 6e4;
5299
5343
  /**
5300
5344
  * Build the human-facing archive label. Every archive always carries
@@ -5338,9 +5382,11 @@ var BackupOrchestratorAddon = class extends require_dist.BaseAddon {
5338
5382
  */
5339
5383
  stagingDir = "";
5340
5384
  scheduleTimer = null;
5385
+ /** Guards the disarmed-scheduler complaint to once per process. */
5386
+ warnedSchedulerDisarmed = false;
5341
5387
  /**
5342
- * Baseline used as the "after" anchor when a policy has a cron but
5343
- * has never run yet (no `lastRunAt`). Set on orchestrator boot so a
5388
+ * Baseline used as the "after" anchor when a schedule has a cron but
5389
+ * has never fired yet (no `lastAttemptAt`). Set on orchestrator boot so a
5344
5390
  * pattern that fires every minute doesn't immediately replay every
5345
5391
  * historical "missed" run from epoch 0.
5346
5392
  */
@@ -5434,7 +5480,37 @@ var BackupOrchestratorAddon = class extends require_dist.BaseAddon {
5434
5480
  }
5435
5481
  }));
5436
5482
  const lastSuccessByLoc = new Map(lastSuccess);
5437
- return locations.map((loc) => this.toDestinationWire(loc, policies.get(loc.id), lastSuccessByLoc.get(loc.id)));
5483
+ const cadence = await this.cadenceByLocation();
5484
+ return locations.map((loc) => this.toDestinationWire(loc, policies.get(loc.id), lastSuccessByLoc.get(loc.id), cadence.get(loc.id)));
5485
+ }
5486
+ /**
5487
+ * Join the enabled schedules by the locations they fan out to.
5488
+ * Comma-joined crons (a location can be targeted by several schedules),
5489
+ * earliest next firing. Computed from `Date.now()` — an operator asking
5490
+ * "when next" means from now, never from a stale run cursor.
5491
+ */
5492
+ async cadenceByLocation() {
5493
+ const out = /* @__PURE__ */ new Map();
5494
+ if (!this.schedules) return out;
5495
+ const schedules = await this.schedules.list().catch((err) => {
5496
+ this.ctx.logger.warn("backup-orchestrator: schedule list failed — cadence unknown", { meta: { error: err instanceof Error ? err.message : String(err) } });
5497
+ return [];
5498
+ });
5499
+ const now = Date.now();
5500
+ for (const s of schedules) {
5501
+ if (!s.enabled || s.cron.trim().length === 0) continue;
5502
+ const next = this.safeNextDue(s.cron, now);
5503
+ for (const id of s.locationIds) {
5504
+ const prev = out.get(id);
5505
+ const crons = prev ? [...prev.crons, s.cron] : [s.cron];
5506
+ const nextRunAt = next === void 0 ? prev?.nextRunAt : prev?.nextRunAt === void 0 ? next : Math.min(prev.nextRunAt, next);
5507
+ out.set(id, {
5508
+ crons,
5509
+ ...nextRunAt !== void 0 ? { nextRunAt } : {}
5510
+ });
5511
+ }
5512
+ }
5513
+ return out;
5438
5514
  }
5439
5515
  async fetchBackupLocations() {
5440
5516
  try {
@@ -5465,10 +5541,7 @@ var BackupOrchestratorAddon = class extends require_dist.BaseAddon {
5465
5541
  for (const id of locationIds) {
5466
5542
  if (out.has(id)) continue;
5467
5543
  try {
5468
- const fresh = await this.policies.ensureDefault(id, {
5469
- retentionCount: DEFAULT_RETENTION_COUNT,
5470
- cron: DEFAULT_CRON
5471
- });
5544
+ const fresh = await this.policies.ensureDefault(id, { retentionCount: DEFAULT_RETENTION_COUNT });
5472
5545
  out.set(id, fresh);
5473
5546
  } catch (err) {
5474
5547
  this.ctx.logger.warn("backup-orchestrator: ensureDefault failed", { meta: {
@@ -5484,11 +5557,10 @@ var BackupOrchestratorAddon = class extends require_dist.BaseAddon {
5484
5557
  return {
5485
5558
  locationId,
5486
5559
  enabled: true,
5487
- retentionCount: DEFAULT_RETENTION_COUNT,
5488
- cron: DEFAULT_CRON
5560
+ retentionCount: DEFAULT_RETENTION_COUNT
5489
5561
  };
5490
5562
  }
5491
- toDestinationWire(loc, policy, latest) {
5563
+ toDestinationWire(loc, policy, latest, cadence) {
5492
5564
  const effective = policy ?? this.fallbackPolicy(loc.id);
5493
5565
  const subId = extractSlug(loc.id);
5494
5566
  const displayName = effective.label && effective.label.length > 0 ? effective.label : loc.displayName;
@@ -5505,9 +5577,10 @@ var BackupOrchestratorAddon = class extends require_dist.BaseAddon {
5505
5577
  ...effective.label !== void 0 ? { label: effective.label } : {},
5506
5578
  ...latest !== void 0 ? { lastSuccessAt: latest.createdAt } : {},
5507
5579
  ...latest !== void 0 ? { lastSuccessSizeBytes: latest.sizeBytes } : {},
5508
- ...effective.cron !== void 0 ? { cron: effective.cron } : {},
5509
- ...effective.lastRunAt !== void 0 ? { lastRunAt: effective.lastRunAt } : {},
5510
- ...effective.cron !== void 0 && effective.cron.trim().length > 0 ? { nextRunAt: this.safeNextDue(effective.cron, effective.lastRunAt) } : {}
5580
+ ...cadence !== void 0 ? { cron: cadence.crons.join(", ") } : {},
5581
+ ...cadence?.nextRunAt !== void 0 ? { nextRunAt: cadence.nextRunAt } : {},
5582
+ ...effective.lastAttemptAt !== void 0 ? { lastAttemptAt: effective.lastAttemptAt } : {},
5583
+ ...effective.lastError !== void 0 ? { lastError: effective.lastError } : {}
5511
5584
  };
5512
5585
  }
5513
5586
  /**
@@ -5669,7 +5742,10 @@ var BackupOrchestratorAddon = class extends require_dist.BaseAddon {
5669
5742
  if (!this.systemBackup) throw new Error("orchestrator not initialized");
5670
5743
  const targets = await this.resolveTargets(input.destinations !== void 0 ? [...input.destinations] : void 0);
5671
5744
  if (targets.length === 0) {
5672
- this.ctx.logger.warn("backup trigger: no destinations selected");
5745
+ this.ctx.logger.warn("backup trigger: no destinations selected — this archive is being dropped", { meta: {
5746
+ requestedDestinationIds: input.destinations !== void 0 ? [...input.destinations] : [],
5747
+ label: input.label ?? null
5748
+ } });
5673
5749
  return [];
5674
5750
  }
5675
5751
  progress.setDestinations(targets.map((t) => t.id));
@@ -5713,6 +5789,7 @@ var BackupOrchestratorAddon = class extends require_dist.BaseAddon {
5713
5789
  });
5714
5790
  entries.push(stored);
5715
5791
  progress.destinationCompleted(dest.id);
5792
+ await this.recordDestinationOutcome(dest.id, createdAt);
5716
5793
  this.ctx.eventBus.emit({
5717
5794
  id: (0, node_crypto.randomUUID)(),
5718
5795
  timestamp: /* @__PURE__ */ new Date(),
@@ -5730,10 +5807,12 @@ var BackupOrchestratorAddon = class extends require_dist.BaseAddon {
5730
5807
  } catch (err) {
5731
5808
  if (err instanceof BackupCancelledError) continue;
5732
5809
  progress.destinationFailed(dest.id);
5810
+ const message = err instanceof Error ? err.message : String(err);
5733
5811
  this.ctx.logger.error("backup trigger: destination failed", { meta: {
5734
5812
  destinationId: dest.id,
5735
- error: err instanceof Error ? err.message : String(err)
5813
+ error: message
5736
5814
  } });
5815
+ await this.recordDestinationOutcome(dest.id, Date.now(), message);
5737
5816
  }
5738
5817
  }
5739
5818
  return entries;
@@ -5852,30 +5931,29 @@ var BackupOrchestratorAddon = class extends require_dist.BaseAddon {
5852
5931
  /**
5853
5932
  * Upsert the per-destination policy row. Used by the admin-UI
5854
5933
  * destinations table — toggle enabled / change retention / set
5855
- * label / set cron schedule. No-ops with a logged warning if the
5934
+ * label. No-ops with a logged warning if the
5856
5935
  * settings backend lacks structured-table support (no persistence
5857
5936
  * available).
5858
5937
  *
5859
- * Validates `cron` server-side: a malformed expression rejects the
5860
- * upsert with an actionable error so the operator sees the problem
5861
- * at save time instead of at scheduler-tick time. Empty / undefined
5862
- * `cron` clears any prior schedule (manual-only).
5938
+ * There is deliberately NO cadence here. A destination does not run on
5939
+ * its own timer — a schedule fans out to it — and the `cron` this method
5940
+ * used to accept had scheduled nothing since 2026-07-28 while still being
5941
+ * rendered as "daily at 03:00" (D384, and D62's "a switch that writes a
5942
+ * store nobody reads").
5863
5943
  */
5864
5944
  async upsertDestinationPolicy(input) {
5865
5945
  if (!this.policies) {
5866
5946
  this.ctx.logger.warn("backup upsertDestinationPolicy: no settings-store backend — change will not persist", { meta: { locationId: input.locationId } });
5867
5947
  return;
5868
5948
  }
5869
- const cron = typeof input.cron === "string" ? input.cron.trim() : "";
5870
- if (cron.length > 0 && !isValidCron(cron)) throw new Error(`Invalid cron expression: "${cron}"`);
5871
5949
  const existing = await this.policies.get(input.locationId).catch(() => null);
5872
5950
  await this.policies.upsert({
5873
5951
  locationId: input.locationId,
5874
5952
  enabled: input.enabled,
5875
5953
  retentionCount: input.retentionCount,
5876
5954
  ...input.label !== void 0 ? { label: input.label } : {},
5877
- ...cron.length > 0 ? { cron } : {},
5878
- ...existing?.lastRunAt !== void 0 ? { lastRunAt: existing.lastRunAt } : {}
5955
+ ...existing?.lastAttemptAt !== void 0 ? { lastAttemptAt: existing.lastAttemptAt } : {},
5956
+ ...existing?.lastError !== void 0 ? { lastError: existing.lastError } : {}
5879
5957
  });
5880
5958
  }
5881
5959
  /**
@@ -6013,6 +6091,29 @@ var BackupOrchestratorAddon = class extends require_dist.BaseAddon {
6013
6091
  if (selection.targets.length === 0 && selection.skippedDisabled.length > 0) throw new Error(`every selected backup destination is disabled (${selection.skippedDisabled.join(", ")}) — enable its storage location on the Storage screen, or choose another destination. Refusing to write an archive to a location the operator turned off.`);
6014
6092
  return selection.targets.map((t) => ({ id: t.id }));
6015
6093
  }
6094
+ /**
6095
+ * Persist the outcome of one attempted write to one destination.
6096
+ *
6097
+ * Best-effort by design: an unavailable settings backend must not turn a
6098
+ * successful backup into a failed one. But it is LOGGED when it cannot be
6099
+ * written — a status field that silently stops being updated is the whole
6100
+ * defect this method exists to close.
6101
+ */
6102
+ async recordDestinationOutcome(destinationId, attemptAt, error) {
6103
+ if (!this.policies) return;
6104
+ try {
6105
+ await this.policies.recordAttempt(destinationId, {
6106
+ attemptAt,
6107
+ ...error !== void 0 ? { error } : {}
6108
+ }, { retentionCount: DEFAULT_RETENTION_COUNT });
6109
+ } catch (err) {
6110
+ this.ctx.logger.warn("backup: could not persist the destination outcome", { meta: {
6111
+ destinationId,
6112
+ outcome: error === void 0 ? "succeeded" : "failed",
6113
+ error: err instanceof Error ? err.message : String(err)
6114
+ } });
6115
+ }
6116
+ }
6016
6117
  startScheduleTimer() {
6017
6118
  if (this.scheduleTimer) return;
6018
6119
  this.scheduleTimer = setInterval(() => {
@@ -6027,19 +6128,25 @@ var BackupOrchestratorAddon = class extends require_dist.BaseAddon {
6027
6128
  /**
6028
6129
  * Schedule-driven backup scheduling. Walks every enabled backup
6029
6130
  * SCHEDULE (the N:M entry), computes the next due time after
6030
- * `lastRunAt` (or the orchestrator's first-tick baseline if never
6131
+ * `lastAttemptAt` (or the orchestrator's first-tick baseline if never
6031
6132
  * run), and fires a SINGLE archive fanned out to ALL of the
6032
6133
  * schedule's `locationIds` when due — with the schedule's per-entry
6033
6134
  * retention applied to each. Each schedule keeps its own cadence.
6034
6135
  *
6035
6136
  * Errors per schedule are isolated; one misconfigured cron doesn't
6036
- * block the others. The schedule's `lastRunAt` is updated BEFORE the
6137
+ * block the others. The schedule's `lastAttemptAt` is updated BEFORE the
6037
6138
  * trigger to dedupe (the tick runs every SCHEDULE_TICK_MS — without
6038
6139
  * the up-front update a long-running upload could overlap with the
6039
6140
  * next tick's same-due check).
6040
6141
  */
6041
6142
  async onScheduleTick() {
6042
- if (!this.schedules) return;
6143
+ if (!this.schedules) {
6144
+ if (!this.warnedSchedulerDisarmed) {
6145
+ this.warnedSchedulerDisarmed = true;
6146
+ this.ctx.logger.error("backup scheduler is DISARMED — no settings backend, so no schedule can fire");
6147
+ }
6148
+ return;
6149
+ }
6043
6150
  const schedules = await this.schedules.list();
6044
6151
  const now = Date.now();
6045
6152
  for (const s of schedules) {
@@ -6047,7 +6154,7 @@ var BackupOrchestratorAddon = class extends require_dist.BaseAddon {
6047
6154
  if (s.locationIds.length === 0) continue;
6048
6155
  let due;
6049
6156
  try {
6050
- due = computeNextDue(s.cron, s.lastRunAt ?? this.scheduleBaselineAt);
6157
+ due = computeNextDue(s.cron, s.lastAttemptAt ?? this.scheduleBaselineAt);
6051
6158
  } catch (err) {
6052
6159
  this.ctx.logger.warn("Invalid cron — skipping schedule", { meta: {
6053
6160
  scheduleId: s.id,
@@ -6059,36 +6166,76 @@ var BackupOrchestratorAddon = class extends require_dist.BaseAddon {
6059
6166
  if (due === null || due > now) continue;
6060
6167
  const stamped = {
6061
6168
  ...s,
6062
- lastRunAt: now
6169
+ lastAttemptAt: now
6063
6170
  };
6064
6171
  try {
6065
6172
  await this.schedules.upsert(stamped);
6066
6173
  } catch (err) {
6067
6174
  this.ctx.logger.warn("Failed to persist scheduled-run timestamp", { meta: {
6068
6175
  scheduleId: s.id,
6176
+ scheduleLabel: s.label,
6177
+ destinationIds: [...s.locationIds],
6069
6178
  error: err instanceof Error ? err.message : String(err)
6070
6179
  } });
6071
6180
  }
6072
- try {
6073
- this.ctx.logger.info("Scheduled backup firing", { meta: {
6074
- scheduleId: s.id,
6075
- label: s.label,
6076
- locationIds: s.locationIds,
6077
- cron: s.cron,
6078
- dueAt: due
6079
- } });
6080
- await this.triggerBackup({
6081
- destinations: [...s.locationIds],
6082
- ...s.dataSources && s.dataSources.length > 0 ? { locations: [...s.dataSources] } : {},
6083
- label: s.label,
6084
- retentionCount: s.retentionCount
6085
- });
6086
- } catch (err) {
6087
- this.ctx.logger.error("Scheduled backup failed", { meta: {
6088
- scheduleId: s.id,
6089
- error: err instanceof Error ? err.message : String(err)
6090
- } });
6091
- }
6181
+ this.ctx.logger.info("Scheduled backup firing", { meta: {
6182
+ scheduleId: s.id,
6183
+ label: s.label,
6184
+ destinationIds: [...s.locationIds],
6185
+ cron: s.cron,
6186
+ dueAt: due
6187
+ } });
6188
+ await this.runScheduledBackup(stamped, now);
6189
+ }
6190
+ }
6191
+ /**
6192
+ * Fire one schedule and RECORD what happened to it, naming every
6193
+ * destination. Three outcomes, three different persisted states —
6194
+ * because "fired" and "backed up" were the same field until D384:
6195
+ *
6196
+ * - archive landed somewhere → `lastSuccessAt`, `lastError` cleared;
6197
+ * - the run threw, or landed nowhere → `lastError`, no success stamp;
6198
+ * - the stamp could not be written → logged, named, not swallowed.
6199
+ */
6200
+ async runScheduledBackup(schedule, firedAt) {
6201
+ const destinationIds = [...schedule.locationIds];
6202
+ let failure;
6203
+ try {
6204
+ const result = await this.triggerBackup({
6205
+ destinations: destinationIds,
6206
+ ...schedule.dataSources && schedule.dataSources.length > 0 ? { locations: [...schedule.dataSources] } : {},
6207
+ label: schedule.label,
6208
+ retentionCount: schedule.retentionCount
6209
+ });
6210
+ if (result.entries.length === 0 && !result.cancelled) failure = `no destination accepted the archive (${destinationIds.join(", ")})`;
6211
+ } catch (err) {
6212
+ failure = err instanceof Error ? err.message : String(err);
6213
+ }
6214
+ if (failure === void 0) this.ctx.logger.info("Scheduled backup completed", { meta: {
6215
+ scheduleId: schedule.id,
6216
+ label: schedule.label,
6217
+ destinationIds
6218
+ } });
6219
+ else this.ctx.logger.error("Scheduled backup failed", { meta: {
6220
+ scheduleId: schedule.id,
6221
+ label: schedule.label,
6222
+ destinationIds,
6223
+ error: failure
6224
+ } });
6225
+ if (!this.schedules) return;
6226
+ const { lastError: _cleared, ...carried } = schedule;
6227
+ try {
6228
+ await this.schedules.upsert({
6229
+ ...carried,
6230
+ ...failure === void 0 ? { lastSuccessAt: firedAt } : { lastError: failure }
6231
+ });
6232
+ } catch (err) {
6233
+ this.ctx.logger.warn("Failed to persist the scheduled-run outcome", { meta: {
6234
+ scheduleId: schedule.id,
6235
+ label: schedule.label,
6236
+ destinationIds,
6237
+ error: err instanceof Error ? err.message : String(err)
6238
+ } });
6092
6239
  }
6093
6240
  }
6094
6241
  async listSchedules() {
@@ -6107,7 +6254,9 @@ var BackupOrchestratorAddon = class extends require_dist.BaseAddon {
6107
6254
  locationIds: [...s.locationIds],
6108
6255
  retentionCount: s.retentionCount,
6109
6256
  ...s.dataSources !== void 0 ? { dataSources: [...s.dataSources] } : {},
6110
- ...s.lastRunAt !== void 0 ? { lastRunAt: s.lastRunAt } : {},
6257
+ ...s.lastAttemptAt !== void 0 ? { lastAttemptAt: s.lastAttemptAt } : {},
6258
+ ...s.lastSuccessAt !== void 0 ? { lastSuccessAt: s.lastSuccessAt } : {},
6259
+ ...s.lastError !== void 0 ? { lastError: s.lastError } : {},
6111
6260
  ...nextRunAt !== void 0 ? { nextRunAt } : {}
6112
6261
  };
6113
6262
  });
@@ -6126,7 +6275,9 @@ var BackupOrchestratorAddon = class extends require_dist.BaseAddon {
6126
6275
  locationIds: [...input.locationIds],
6127
6276
  retentionCount: input.retentionCount,
6128
6277
  ...input.dataSources && input.dataSources.length > 0 ? { dataSources: [...input.dataSources] } : {},
6129
- ...existing?.lastRunAt !== void 0 ? { lastRunAt: existing.lastRunAt } : {}
6278
+ ...existing?.lastAttemptAt !== void 0 ? { lastAttemptAt: existing.lastAttemptAt } : {},
6279
+ ...existing?.lastSuccessAt !== void 0 ? { lastSuccessAt: existing.lastSuccessAt } : {},
6280
+ ...existing?.lastError !== void 0 ? { lastError: existing.lastError } : {}
6130
6281
  };
6131
6282
  await this.schedules.upsert(next);
6132
6283
  let nextRunAt;
@@ -6142,7 +6293,9 @@ var BackupOrchestratorAddon = class extends require_dist.BaseAddon {
6142
6293
  locationIds: [...next.locationIds],
6143
6294
  retentionCount: next.retentionCount,
6144
6295
  ...next.dataSources !== void 0 ? { dataSources: [...next.dataSources] } : {},
6145
- ...next.lastRunAt !== void 0 ? { lastRunAt: next.lastRunAt } : {},
6296
+ ...next.lastAttemptAt !== void 0 ? { lastAttemptAt: next.lastAttemptAt } : {},
6297
+ ...next.lastSuccessAt !== void 0 ? { lastSuccessAt: next.lastSuccessAt } : {},
6298
+ ...next.lastError !== void 0 ? { lastError: next.lastError } : {},
6146
6299
  ...nextRunAt !== void 0 ? { nextRunAt } : {}
6147
6300
  };
6148
6301
  }