@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
@@ -1,4 +1,4 @@
1
- import { It as BaseAddon, M as backupCapability, cn as EventCategory } from "../../dist-BSSH6C-h.mjs";
1
+ import { It as BaseAddon, M as backupCapability, cn as EventCategory } from "../../dist-BmJNnb_s.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";
@@ -4294,14 +4294,17 @@ function previewNextRuns(cron, count, after) {
4294
4294
  /**
4295
4295
  * @durable class=config owner=backup-orchestrator
4296
4296
  * write="`ensureDefault` auto-creates one row the first time a `backups` storage
4297
- * location is listed (enabled, retentionCount 7, cron `0 3 * * *`), and an operator
4297
+ * location is listed (enabled, retentionCount 7, no cadence of its own), an operator
4298
4298
  * edit rewrites it (`upsert` = delete + insert, carrying `last_run_at` forward so a
4299
- * label change does not reset the schedule baseline)"
4299
+ * label change does not reset the baseline), and `recordAttempt` stamps
4300
+ * `last_run_at` + `last_error` on every attempted write to the destination"
4300
4301
  * retention="none — `delete(locationId)` exists but NOTHING calls it, so a policy row
4301
4302
  * outlives the `StorageLocation` it names and the table only ever grows, by one row
4302
4303
  * per backups location ever seen. Losing a row is recoverable: the next
4303
4304
  * `listDestinations` pass re-creates it from the defaults, which silently discards
4304
- * the operator's own retention count and cron."
4305
+ * the operator's own retention count. The legacy `cron` column is written NULL and
4306
+ * never read — it scheduled nothing after 2026-07-28 and reading it made a
4307
+ * destination advertise a nightly run nobody performed (D384)."
4305
4308
  */
4306
4309
  var BACKUP_DESTINATION_POLICIES = "backup_destination_policies";
4307
4310
  var BACKUP_DESTINATION_POLICIES_COLUMNS = [
@@ -4333,6 +4336,10 @@ var BACKUP_DESTINATION_POLICIES_COLUMNS = [
4333
4336
  {
4334
4337
  name: "last_run_at",
4335
4338
  type: "INTEGER"
4339
+ },
4340
+ {
4341
+ name: "last_error",
4342
+ type: "TEXT"
4336
4343
  }
4337
4344
  ];
4338
4345
  var BackupDestinationPolicyService = class {
@@ -4417,10 +4424,11 @@ var BackupDestinationPolicyService = class {
4417
4424
  * pass so newly-added storage locations are surfaced as enabled
4418
4425
  * destinations without an explicit configuration step.
4419
4426
  *
4420
- * Defaults are passed by the orchestrator (currently:
4421
- * `retentionCount=7` + `cron='0 3 * * *'`) so a fresh destination
4422
- * starts running daily at 03:00 with 7 archives kept. Operator can
4423
- * tune both inline on the destinations table.
4427
+ * Defaults are passed by the orchestrator (currently `retentionCount=7`).
4428
+ * A fresh destination is enabled and keeps 7 archives; it gets NO cadence
4429
+ * of its own — a destination runs when a schedule fans out to it, and
4430
+ * seeding a cron here would advertise a nightly run nothing performs
4431
+ * (D384).
4424
4432
  */
4425
4433
  async ensureDefault(locationId, defaults) {
4426
4434
  const existing = await this.get(locationId);
@@ -4428,12 +4436,32 @@ var BackupDestinationPolicyService = class {
4428
4436
  const fresh = {
4429
4437
  locationId,
4430
4438
  enabled: true,
4431
- retentionCount: defaults.retentionCount,
4432
- ...defaults.cron !== void 0 ? { cron: defaults.cron } : {}
4439
+ retentionCount: defaults.retentionCount
4433
4440
  };
4434
4441
  await this.upsert(fresh);
4435
4442
  return fresh;
4436
4443
  }
4444
+ /**
4445
+ * Stamp the outcome of ONE attempted write to this destination.
4446
+ *
4447
+ * Read-modify-write on the policy row so the operator's `enabled`,
4448
+ * `retentionCount` and `label` survive. A destination with no row yet
4449
+ * gets one — an attempt on an unseen location must still be recorded, or
4450
+ * the failure disappears with the process (which is exactly how a 03:00
4451
+ * SMB refusal stayed invisible until the operator went looking).
4452
+ */
4453
+ async recordAttempt(locationId, outcome, defaults) {
4454
+ const { lastError: _cleared, ...carried } = await this.get(locationId) ?? {
4455
+ locationId,
4456
+ enabled: true,
4457
+ retentionCount: defaults.retentionCount
4458
+ };
4459
+ await this.upsert({
4460
+ ...carried,
4461
+ lastAttemptAt: outcome.attemptAt,
4462
+ ...outcome.error !== void 0 ? { lastError: outcome.error } : {}
4463
+ });
4464
+ }
4437
4465
  };
4438
4466
  function mapPolicyToRow(p) {
4439
4467
  return {
@@ -4441,8 +4469,9 @@ function mapPolicyToRow(p) {
4441
4469
  enabled: p.enabled ? 1 : 0,
4442
4470
  retention_count: p.retentionCount,
4443
4471
  label: p.label ?? null,
4444
- cron: p.cron ?? null,
4445
- last_run_at: p.lastRunAt ?? null
4472
+ cron: null,
4473
+ last_run_at: p.lastAttemptAt ?? null,
4474
+ last_error: p.lastError ?? null
4446
4475
  };
4447
4476
  }
4448
4477
  /**
@@ -4456,17 +4485,17 @@ function mapRowToPolicy(row) {
4456
4485
  if (!locationId) return null;
4457
4486
  const labelRaw = row["label"];
4458
4487
  const label = typeof labelRaw === "string" && labelRaw.length > 0 ? labelRaw : void 0;
4459
- const cronRaw = row["cron"];
4460
- const cron = typeof cronRaw === "string" && cronRaw.length > 0 ? cronRaw : void 0;
4461
4488
  const lastRunRaw = row["last_run_at"];
4462
- const lastRunAt = typeof lastRunRaw === "number" && lastRunRaw > 0 ? lastRunRaw : void 0;
4489
+ const lastAttemptAt = typeof lastRunRaw === "number" && lastRunRaw > 0 ? lastRunRaw : void 0;
4490
+ const lastErrorRaw = row["last_error"];
4491
+ const lastError = typeof lastErrorRaw === "string" && lastErrorRaw.length > 0 ? lastErrorRaw : void 0;
4463
4492
  return {
4464
4493
  locationId,
4465
4494
  enabled: row["enabled"] === 1 || row["enabled"] === true,
4466
4495
  retentionCount: Number(row["retention_count"] ?? 0),
4467
4496
  ...label !== void 0 ? { label } : {},
4468
- ...cron !== void 0 ? { cron } : {},
4469
- ...lastRunAt !== void 0 ? { lastRunAt } : {}
4497
+ ...lastAttemptAt !== void 0 ? { lastAttemptAt } : {},
4498
+ ...lastError !== void 0 ? { lastError } : {}
4470
4499
  };
4471
4500
  }
4472
4501
  //#endregion
@@ -4873,7 +4902,12 @@ var BackupRunQueue = class {
4873
4902
  const progress = this.progressFor(record);
4874
4903
  try {
4875
4904
  const value = await this.executor(record.request, record.controller.signal, progress);
4876
- this.finish(record, record.controller.signal.aborted ? "cancelled" : "succeeded");
4905
+ if (record.controller.signal.aborted) this.finish(record, "cancelled");
4906
+ else {
4907
+ const { destinationIds, completedDestinationIds, failedDestinationIds } = record.snapshot;
4908
+ if (destinationIds.length > 0 && completedDestinationIds.length === 0) this.finish(record, "failed", `no destination accepted the archive (${(failedDestinationIds.length > 0 ? failedDestinationIds : destinationIds).join(", ")})`);
4909
+ else this.finish(record, "succeeded");
4910
+ }
4877
4911
  record.resolveResult(value);
4878
4912
  } catch (err) {
4879
4913
  if (err instanceof BackupCancelledError || record.controller.signal.aborted) {
@@ -4946,7 +4980,8 @@ function requestSignature(request) {
4946
4980
  * delete + insert: cron, enabled, the fan-out `location_ids`, `retention_count`,
4947
4981
  * optional `data_sources`), plus a `last_run_at` stamp the orchestrator writes on
4948
4982
  * every due tick BEFORE the archive runs, so an upload outliving the tick cannot
4949
- * fire the same schedule twice"
4983
+ * fire the same schedule twice, and a `last_success_at` / `last_error` pair it
4984
+ * writes AFTER the run resolves"
4950
4985
  * retention="only the operator's `delete(id)` — no sweep, no expiry, and `retention_count`
4951
4986
  * bounds the ARCHIVES at the destination, never these rows. Losing the table stops
4952
4987
  * every scheduled backup silently: no cron remains to fire, and the fan-out sets and
@@ -4996,6 +5031,14 @@ var BACKUP_SCHEDULES_COLUMNS = [
4996
5031
  {
4997
5032
  name: "last_run_at",
4998
5033
  type: "INTEGER"
5034
+ },
5035
+ {
5036
+ name: "last_success_at",
5037
+ type: "INTEGER"
5038
+ },
5039
+ {
5040
+ name: "last_error",
5041
+ type: "TEXT"
4999
5042
  }
5000
5043
  ];
5001
5044
  var BackupScheduleService = class {
@@ -5074,7 +5117,9 @@ function mapScheduleToRow(s) {
5074
5117
  location_ids: [...s.locationIds],
5075
5118
  retention_count: s.retentionCount,
5076
5119
  data_sources: s.dataSources ? [...s.dataSources] : null,
5077
- last_run_at: s.lastRunAt ?? null
5120
+ last_run_at: s.lastAttemptAt ?? null,
5121
+ last_success_at: s.lastSuccessAt ?? null,
5122
+ last_error: s.lastError ?? null
5078
5123
  };
5079
5124
  }
5080
5125
  /**
@@ -5102,7 +5147,11 @@ function mapRowToSchedule(row) {
5102
5147
  const dataSourcesRaw = row["data_sources"];
5103
5148
  const dataSources = dataSourcesRaw === null || dataSourcesRaw === void 0 ? void 0 : parseStringArray(dataSourcesRaw);
5104
5149
  const lastRunRaw = row["last_run_at"];
5105
- const lastRunAt = typeof lastRunRaw === "number" && lastRunRaw > 0 ? lastRunRaw : void 0;
5150
+ const lastAttemptAt = typeof lastRunRaw === "number" && lastRunRaw > 0 ? lastRunRaw : void 0;
5151
+ const lastSuccessRaw = row["last_success_at"];
5152
+ const lastSuccessAt = typeof lastSuccessRaw === "number" && lastSuccessRaw > 0 ? lastSuccessRaw : void 0;
5153
+ const lastErrorRaw = row["last_error"];
5154
+ const lastError = typeof lastErrorRaw === "string" && lastErrorRaw.length > 0 ? lastErrorRaw : void 0;
5106
5155
  return {
5107
5156
  id,
5108
5157
  label: typeof row["label"] === "string" ? String(row["label"]) : "",
@@ -5111,7 +5160,9 @@ function mapRowToSchedule(row) {
5111
5160
  locationIds: parseStringArray(row["location_ids"]),
5112
5161
  retentionCount: Number(row["retention_count"] ?? 7),
5113
5162
  ...dataSources !== void 0 ? { dataSources } : {},
5114
- ...lastRunAt !== void 0 ? { lastRunAt } : {}
5163
+ ...lastAttemptAt !== void 0 ? { lastAttemptAt } : {},
5164
+ ...lastSuccessAt !== void 0 ? { lastSuccessAt } : {},
5165
+ ...lastError !== void 0 ? { lastError } : {}
5115
5166
  };
5116
5167
  }
5117
5168
  //#endregion
@@ -5273,13 +5324,6 @@ function selectBackupTargets(input) {
5273
5324
  * the panel used to expose).
5274
5325
  */
5275
5326
  var DEFAULT_RETENTION_COUNT = 7;
5276
- /**
5277
- * Default cron stamped on a freshly-discovered `backups` location.
5278
- * Operators can edit it from the destination row; setting empty in
5279
- * the editor flips the destination back to manual-only. Daily 03:00
5280
- * server-local mirrors the previous global default.
5281
- */
5282
- var DEFAULT_CRON = "0 3 * * *";
5283
5327
  var SCHEDULE_TICK_MS = 6e4;
5284
5328
  /**
5285
5329
  * Build the human-facing archive label. Every archive always carries
@@ -5323,9 +5367,11 @@ var BackupOrchestratorAddon = class extends BaseAddon {
5323
5367
  */
5324
5368
  stagingDir = "";
5325
5369
  scheduleTimer = null;
5370
+ /** Guards the disarmed-scheduler complaint to once per process. */
5371
+ warnedSchedulerDisarmed = false;
5326
5372
  /**
5327
- * Baseline used as the "after" anchor when a policy has a cron but
5328
- * has never run yet (no `lastRunAt`). Set on orchestrator boot so a
5373
+ * Baseline used as the "after" anchor when a schedule has a cron but
5374
+ * has never fired yet (no `lastAttemptAt`). Set on orchestrator boot so a
5329
5375
  * pattern that fires every minute doesn't immediately replay every
5330
5376
  * historical "missed" run from epoch 0.
5331
5377
  */
@@ -5419,7 +5465,37 @@ var BackupOrchestratorAddon = class extends BaseAddon {
5419
5465
  }
5420
5466
  }));
5421
5467
  const lastSuccessByLoc = new Map(lastSuccess);
5422
- return locations.map((loc) => this.toDestinationWire(loc, policies.get(loc.id), lastSuccessByLoc.get(loc.id)));
5468
+ const cadence = await this.cadenceByLocation();
5469
+ return locations.map((loc) => this.toDestinationWire(loc, policies.get(loc.id), lastSuccessByLoc.get(loc.id), cadence.get(loc.id)));
5470
+ }
5471
+ /**
5472
+ * Join the enabled schedules by the locations they fan out to.
5473
+ * Comma-joined crons (a location can be targeted by several schedules),
5474
+ * earliest next firing. Computed from `Date.now()` — an operator asking
5475
+ * "when next" means from now, never from a stale run cursor.
5476
+ */
5477
+ async cadenceByLocation() {
5478
+ const out = /* @__PURE__ */ new Map();
5479
+ if (!this.schedules) return out;
5480
+ const schedules = await this.schedules.list().catch((err) => {
5481
+ this.ctx.logger.warn("backup-orchestrator: schedule list failed — cadence unknown", { meta: { error: err instanceof Error ? err.message : String(err) } });
5482
+ return [];
5483
+ });
5484
+ const now = Date.now();
5485
+ for (const s of schedules) {
5486
+ if (!s.enabled || s.cron.trim().length === 0) continue;
5487
+ const next = this.safeNextDue(s.cron, now);
5488
+ for (const id of s.locationIds) {
5489
+ const prev = out.get(id);
5490
+ const crons = prev ? [...prev.crons, s.cron] : [s.cron];
5491
+ const nextRunAt = next === void 0 ? prev?.nextRunAt : prev?.nextRunAt === void 0 ? next : Math.min(prev.nextRunAt, next);
5492
+ out.set(id, {
5493
+ crons,
5494
+ ...nextRunAt !== void 0 ? { nextRunAt } : {}
5495
+ });
5496
+ }
5497
+ }
5498
+ return out;
5423
5499
  }
5424
5500
  async fetchBackupLocations() {
5425
5501
  try {
@@ -5450,10 +5526,7 @@ var BackupOrchestratorAddon = class extends BaseAddon {
5450
5526
  for (const id of locationIds) {
5451
5527
  if (out.has(id)) continue;
5452
5528
  try {
5453
- const fresh = await this.policies.ensureDefault(id, {
5454
- retentionCount: DEFAULT_RETENTION_COUNT,
5455
- cron: DEFAULT_CRON
5456
- });
5529
+ const fresh = await this.policies.ensureDefault(id, { retentionCount: DEFAULT_RETENTION_COUNT });
5457
5530
  out.set(id, fresh);
5458
5531
  } catch (err) {
5459
5532
  this.ctx.logger.warn("backup-orchestrator: ensureDefault failed", { meta: {
@@ -5469,11 +5542,10 @@ var BackupOrchestratorAddon = class extends BaseAddon {
5469
5542
  return {
5470
5543
  locationId,
5471
5544
  enabled: true,
5472
- retentionCount: DEFAULT_RETENTION_COUNT,
5473
- cron: DEFAULT_CRON
5545
+ retentionCount: DEFAULT_RETENTION_COUNT
5474
5546
  };
5475
5547
  }
5476
- toDestinationWire(loc, policy, latest) {
5548
+ toDestinationWire(loc, policy, latest, cadence) {
5477
5549
  const effective = policy ?? this.fallbackPolicy(loc.id);
5478
5550
  const subId = extractSlug(loc.id);
5479
5551
  const displayName = effective.label && effective.label.length > 0 ? effective.label : loc.displayName;
@@ -5490,9 +5562,10 @@ var BackupOrchestratorAddon = class extends BaseAddon {
5490
5562
  ...effective.label !== void 0 ? { label: effective.label } : {},
5491
5563
  ...latest !== void 0 ? { lastSuccessAt: latest.createdAt } : {},
5492
5564
  ...latest !== void 0 ? { lastSuccessSizeBytes: latest.sizeBytes } : {},
5493
- ...effective.cron !== void 0 ? { cron: effective.cron } : {},
5494
- ...effective.lastRunAt !== void 0 ? { lastRunAt: effective.lastRunAt } : {},
5495
- ...effective.cron !== void 0 && effective.cron.trim().length > 0 ? { nextRunAt: this.safeNextDue(effective.cron, effective.lastRunAt) } : {}
5565
+ ...cadence !== void 0 ? { cron: cadence.crons.join(", ") } : {},
5566
+ ...cadence?.nextRunAt !== void 0 ? { nextRunAt: cadence.nextRunAt } : {},
5567
+ ...effective.lastAttemptAt !== void 0 ? { lastAttemptAt: effective.lastAttemptAt } : {},
5568
+ ...effective.lastError !== void 0 ? { lastError: effective.lastError } : {}
5496
5569
  };
5497
5570
  }
5498
5571
  /**
@@ -5654,7 +5727,10 @@ var BackupOrchestratorAddon = class extends BaseAddon {
5654
5727
  if (!this.systemBackup) throw new Error("orchestrator not initialized");
5655
5728
  const targets = await this.resolveTargets(input.destinations !== void 0 ? [...input.destinations] : void 0);
5656
5729
  if (targets.length === 0) {
5657
- this.ctx.logger.warn("backup trigger: no destinations selected");
5730
+ this.ctx.logger.warn("backup trigger: no destinations selected — this archive is being dropped", { meta: {
5731
+ requestedDestinationIds: input.destinations !== void 0 ? [...input.destinations] : [],
5732
+ label: input.label ?? null
5733
+ } });
5658
5734
  return [];
5659
5735
  }
5660
5736
  progress.setDestinations(targets.map((t) => t.id));
@@ -5698,6 +5774,7 @@ var BackupOrchestratorAddon = class extends BaseAddon {
5698
5774
  });
5699
5775
  entries.push(stored);
5700
5776
  progress.destinationCompleted(dest.id);
5777
+ await this.recordDestinationOutcome(dest.id, createdAt);
5701
5778
  this.ctx.eventBus.emit({
5702
5779
  id: randomUUID(),
5703
5780
  timestamp: /* @__PURE__ */ new Date(),
@@ -5715,10 +5792,12 @@ var BackupOrchestratorAddon = class extends BaseAddon {
5715
5792
  } catch (err) {
5716
5793
  if (err instanceof BackupCancelledError) continue;
5717
5794
  progress.destinationFailed(dest.id);
5795
+ const message = err instanceof Error ? err.message : String(err);
5718
5796
  this.ctx.logger.error("backup trigger: destination failed", { meta: {
5719
5797
  destinationId: dest.id,
5720
- error: err instanceof Error ? err.message : String(err)
5798
+ error: message
5721
5799
  } });
5800
+ await this.recordDestinationOutcome(dest.id, Date.now(), message);
5722
5801
  }
5723
5802
  }
5724
5803
  return entries;
@@ -5837,30 +5916,29 @@ var BackupOrchestratorAddon = class extends BaseAddon {
5837
5916
  /**
5838
5917
  * Upsert the per-destination policy row. Used by the admin-UI
5839
5918
  * destinations table — toggle enabled / change retention / set
5840
- * label / set cron schedule. No-ops with a logged warning if the
5919
+ * label. No-ops with a logged warning if the
5841
5920
  * settings backend lacks structured-table support (no persistence
5842
5921
  * available).
5843
5922
  *
5844
- * Validates `cron` server-side: a malformed expression rejects the
5845
- * upsert with an actionable error so the operator sees the problem
5846
- * at save time instead of at scheduler-tick time. Empty / undefined
5847
- * `cron` clears any prior schedule (manual-only).
5923
+ * There is deliberately NO cadence here. A destination does not run on
5924
+ * its own timer — a schedule fans out to it — and the `cron` this method
5925
+ * used to accept had scheduled nothing since 2026-07-28 while still being
5926
+ * rendered as "daily at 03:00" (D384, and D62's "a switch that writes a
5927
+ * store nobody reads").
5848
5928
  */
5849
5929
  async upsertDestinationPolicy(input) {
5850
5930
  if (!this.policies) {
5851
5931
  this.ctx.logger.warn("backup upsertDestinationPolicy: no settings-store backend — change will not persist", { meta: { locationId: input.locationId } });
5852
5932
  return;
5853
5933
  }
5854
- const cron = typeof input.cron === "string" ? input.cron.trim() : "";
5855
- if (cron.length > 0 && !isValidCron(cron)) throw new Error(`Invalid cron expression: "${cron}"`);
5856
5934
  const existing = await this.policies.get(input.locationId).catch(() => null);
5857
5935
  await this.policies.upsert({
5858
5936
  locationId: input.locationId,
5859
5937
  enabled: input.enabled,
5860
5938
  retentionCount: input.retentionCount,
5861
5939
  ...input.label !== void 0 ? { label: input.label } : {},
5862
- ...cron.length > 0 ? { cron } : {},
5863
- ...existing?.lastRunAt !== void 0 ? { lastRunAt: existing.lastRunAt } : {}
5940
+ ...existing?.lastAttemptAt !== void 0 ? { lastAttemptAt: existing.lastAttemptAt } : {},
5941
+ ...existing?.lastError !== void 0 ? { lastError: existing.lastError } : {}
5864
5942
  });
5865
5943
  }
5866
5944
  /**
@@ -5998,6 +6076,29 @@ var BackupOrchestratorAddon = class extends BaseAddon {
5998
6076
  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.`);
5999
6077
  return selection.targets.map((t) => ({ id: t.id }));
6000
6078
  }
6079
+ /**
6080
+ * Persist the outcome of one attempted write to one destination.
6081
+ *
6082
+ * Best-effort by design: an unavailable settings backend must not turn a
6083
+ * successful backup into a failed one. But it is LOGGED when it cannot be
6084
+ * written — a status field that silently stops being updated is the whole
6085
+ * defect this method exists to close.
6086
+ */
6087
+ async recordDestinationOutcome(destinationId, attemptAt, error) {
6088
+ if (!this.policies) return;
6089
+ try {
6090
+ await this.policies.recordAttempt(destinationId, {
6091
+ attemptAt,
6092
+ ...error !== void 0 ? { error } : {}
6093
+ }, { retentionCount: DEFAULT_RETENTION_COUNT });
6094
+ } catch (err) {
6095
+ this.ctx.logger.warn("backup: could not persist the destination outcome", { meta: {
6096
+ destinationId,
6097
+ outcome: error === void 0 ? "succeeded" : "failed",
6098
+ error: err instanceof Error ? err.message : String(err)
6099
+ } });
6100
+ }
6101
+ }
6001
6102
  startScheduleTimer() {
6002
6103
  if (this.scheduleTimer) return;
6003
6104
  this.scheduleTimer = setInterval(() => {
@@ -6012,19 +6113,25 @@ var BackupOrchestratorAddon = class extends BaseAddon {
6012
6113
  /**
6013
6114
  * Schedule-driven backup scheduling. Walks every enabled backup
6014
6115
  * SCHEDULE (the N:M entry), computes the next due time after
6015
- * `lastRunAt` (or the orchestrator's first-tick baseline if never
6116
+ * `lastAttemptAt` (or the orchestrator's first-tick baseline if never
6016
6117
  * run), and fires a SINGLE archive fanned out to ALL of the
6017
6118
  * schedule's `locationIds` when due — with the schedule's per-entry
6018
6119
  * retention applied to each. Each schedule keeps its own cadence.
6019
6120
  *
6020
6121
  * Errors per schedule are isolated; one misconfigured cron doesn't
6021
- * block the others. The schedule's `lastRunAt` is updated BEFORE the
6122
+ * block the others. The schedule's `lastAttemptAt` is updated BEFORE the
6022
6123
  * trigger to dedupe (the tick runs every SCHEDULE_TICK_MS — without
6023
6124
  * the up-front update a long-running upload could overlap with the
6024
6125
  * next tick's same-due check).
6025
6126
  */
6026
6127
  async onScheduleTick() {
6027
- if (!this.schedules) return;
6128
+ if (!this.schedules) {
6129
+ if (!this.warnedSchedulerDisarmed) {
6130
+ this.warnedSchedulerDisarmed = true;
6131
+ this.ctx.logger.error("backup scheduler is DISARMED — no settings backend, so no schedule can fire");
6132
+ }
6133
+ return;
6134
+ }
6028
6135
  const schedules = await this.schedules.list();
6029
6136
  const now = Date.now();
6030
6137
  for (const s of schedules) {
@@ -6032,7 +6139,7 @@ var BackupOrchestratorAddon = class extends BaseAddon {
6032
6139
  if (s.locationIds.length === 0) continue;
6033
6140
  let due;
6034
6141
  try {
6035
- due = computeNextDue(s.cron, s.lastRunAt ?? this.scheduleBaselineAt);
6142
+ due = computeNextDue(s.cron, s.lastAttemptAt ?? this.scheduleBaselineAt);
6036
6143
  } catch (err) {
6037
6144
  this.ctx.logger.warn("Invalid cron — skipping schedule", { meta: {
6038
6145
  scheduleId: s.id,
@@ -6044,36 +6151,76 @@ var BackupOrchestratorAddon = class extends BaseAddon {
6044
6151
  if (due === null || due > now) continue;
6045
6152
  const stamped = {
6046
6153
  ...s,
6047
- lastRunAt: now
6154
+ lastAttemptAt: now
6048
6155
  };
6049
6156
  try {
6050
6157
  await this.schedules.upsert(stamped);
6051
6158
  } catch (err) {
6052
6159
  this.ctx.logger.warn("Failed to persist scheduled-run timestamp", { meta: {
6053
6160
  scheduleId: s.id,
6161
+ scheduleLabel: s.label,
6162
+ destinationIds: [...s.locationIds],
6054
6163
  error: err instanceof Error ? err.message : String(err)
6055
6164
  } });
6056
6165
  }
6057
- try {
6058
- this.ctx.logger.info("Scheduled backup firing", { meta: {
6059
- scheduleId: s.id,
6060
- label: s.label,
6061
- locationIds: s.locationIds,
6062
- cron: s.cron,
6063
- dueAt: due
6064
- } });
6065
- await this.triggerBackup({
6066
- destinations: [...s.locationIds],
6067
- ...s.dataSources && s.dataSources.length > 0 ? { locations: [...s.dataSources] } : {},
6068
- label: s.label,
6069
- retentionCount: s.retentionCount
6070
- });
6071
- } catch (err) {
6072
- this.ctx.logger.error("Scheduled backup failed", { meta: {
6073
- scheduleId: s.id,
6074
- error: err instanceof Error ? err.message : String(err)
6075
- } });
6076
- }
6166
+ this.ctx.logger.info("Scheduled backup firing", { meta: {
6167
+ scheduleId: s.id,
6168
+ label: s.label,
6169
+ destinationIds: [...s.locationIds],
6170
+ cron: s.cron,
6171
+ dueAt: due
6172
+ } });
6173
+ await this.runScheduledBackup(stamped, now);
6174
+ }
6175
+ }
6176
+ /**
6177
+ * Fire one schedule and RECORD what happened to it, naming every
6178
+ * destination. Three outcomes, three different persisted states —
6179
+ * because "fired" and "backed up" were the same field until D384:
6180
+ *
6181
+ * - archive landed somewhere → `lastSuccessAt`, `lastError` cleared;
6182
+ * - the run threw, or landed nowhere → `lastError`, no success stamp;
6183
+ * - the stamp could not be written → logged, named, not swallowed.
6184
+ */
6185
+ async runScheduledBackup(schedule, firedAt) {
6186
+ const destinationIds = [...schedule.locationIds];
6187
+ let failure;
6188
+ try {
6189
+ const result = await this.triggerBackup({
6190
+ destinations: destinationIds,
6191
+ ...schedule.dataSources && schedule.dataSources.length > 0 ? { locations: [...schedule.dataSources] } : {},
6192
+ label: schedule.label,
6193
+ retentionCount: schedule.retentionCount
6194
+ });
6195
+ if (result.entries.length === 0 && !result.cancelled) failure = `no destination accepted the archive (${destinationIds.join(", ")})`;
6196
+ } catch (err) {
6197
+ failure = err instanceof Error ? err.message : String(err);
6198
+ }
6199
+ if (failure === void 0) this.ctx.logger.info("Scheduled backup completed", { meta: {
6200
+ scheduleId: schedule.id,
6201
+ label: schedule.label,
6202
+ destinationIds
6203
+ } });
6204
+ else this.ctx.logger.error("Scheduled backup failed", { meta: {
6205
+ scheduleId: schedule.id,
6206
+ label: schedule.label,
6207
+ destinationIds,
6208
+ error: failure
6209
+ } });
6210
+ if (!this.schedules) return;
6211
+ const { lastError: _cleared, ...carried } = schedule;
6212
+ try {
6213
+ await this.schedules.upsert({
6214
+ ...carried,
6215
+ ...failure === void 0 ? { lastSuccessAt: firedAt } : { lastError: failure }
6216
+ });
6217
+ } catch (err) {
6218
+ this.ctx.logger.warn("Failed to persist the scheduled-run outcome", { meta: {
6219
+ scheduleId: schedule.id,
6220
+ label: schedule.label,
6221
+ destinationIds,
6222
+ error: err instanceof Error ? err.message : String(err)
6223
+ } });
6077
6224
  }
6078
6225
  }
6079
6226
  async listSchedules() {
@@ -6092,7 +6239,9 @@ var BackupOrchestratorAddon = class extends BaseAddon {
6092
6239
  locationIds: [...s.locationIds],
6093
6240
  retentionCount: s.retentionCount,
6094
6241
  ...s.dataSources !== void 0 ? { dataSources: [...s.dataSources] } : {},
6095
- ...s.lastRunAt !== void 0 ? { lastRunAt: s.lastRunAt } : {},
6242
+ ...s.lastAttemptAt !== void 0 ? { lastAttemptAt: s.lastAttemptAt } : {},
6243
+ ...s.lastSuccessAt !== void 0 ? { lastSuccessAt: s.lastSuccessAt } : {},
6244
+ ...s.lastError !== void 0 ? { lastError: s.lastError } : {},
6096
6245
  ...nextRunAt !== void 0 ? { nextRunAt } : {}
6097
6246
  };
6098
6247
  });
@@ -6111,7 +6260,9 @@ var BackupOrchestratorAddon = class extends BaseAddon {
6111
6260
  locationIds: [...input.locationIds],
6112
6261
  retentionCount: input.retentionCount,
6113
6262
  ...input.dataSources && input.dataSources.length > 0 ? { dataSources: [...input.dataSources] } : {},
6114
- ...existing?.lastRunAt !== void 0 ? { lastRunAt: existing.lastRunAt } : {}
6263
+ ...existing?.lastAttemptAt !== void 0 ? { lastAttemptAt: existing.lastAttemptAt } : {},
6264
+ ...existing?.lastSuccessAt !== void 0 ? { lastSuccessAt: existing.lastSuccessAt } : {},
6265
+ ...existing?.lastError !== void 0 ? { lastError: existing.lastError } : {}
6115
6266
  };
6116
6267
  await this.schedules.upsert(next);
6117
6268
  let nextRunAt;
@@ -6127,7 +6278,9 @@ var BackupOrchestratorAddon = class extends BaseAddon {
6127
6278
  locationIds: [...next.locationIds],
6128
6279
  retentionCount: next.retentionCount,
6129
6280
  ...next.dataSources !== void 0 ? { dataSources: [...next.dataSources] } : {},
6130
- ...next.lastRunAt !== void 0 ? { lastRunAt: next.lastRunAt } : {},
6281
+ ...next.lastAttemptAt !== void 0 ? { lastAttemptAt: next.lastAttemptAt } : {},
6282
+ ...next.lastSuccessAt !== void 0 ? { lastSuccessAt: next.lastSuccessAt } : {},
6283
+ ...next.lastError !== void 0 ? { lastError: next.lastError } : {},
6131
6284
  ...nextRunAt !== void 0 ? { nextRunAt } : {}
6132
6285
  };
6133
6286
  }
@@ -19,16 +19,19 @@ export interface BackupDestinationPolicy {
19
19
  readonly retentionCount: number;
20
20
  readonly label?: string;
21
21
  /**
22
- * Cron expression for scheduled backups to this destination. When
23
- * undefined / empty the destination only receives backups from
24
- * manual `runNow()` calls. Each destination has its own cadence —
25
- * an offsite SFTP can run weekly while local stays nightly. The
26
- * orchestrator parses with `cron-parser` (5-field POSIX cron, no
27
- * seconds field).
22
+ * ms-epoch of the last time a run ATTEMPTED to write an archive here,
23
+ * whatever the outcome. Never a success stamp — `lastError` says whether
24
+ * the attempt landed, and the newest manifest entry says when one last
25
+ * did. Rows written before 2026-07-28 carry the old per-location
26
+ * scheduler's stamp, which had exactly this meaning.
28
27
  */
29
- readonly cron?: string;
30
- /** ms-epoch of the last successful backup completion to this destination. */
31
- readonly lastRunAt?: number;
28
+ readonly lastAttemptAt?: number;
29
+ /**
30
+ * Why the last attempt failed, verbatim from the provider. Cleared on the
31
+ * next attempt that lands. A destination that has never been written to
32
+ * has neither field, and that is REPORTED as never — D384.
33
+ */
34
+ readonly lastError?: string;
32
35
  }
33
36
  export declare const BACKUP_DESTINATION_POLICIES_COLUMNS: readonly CollectionColumn[];
34
37
  export declare class BackupDestinationPolicyService {
@@ -58,13 +61,28 @@ export declare class BackupDestinationPolicyService {
58
61
  * pass so newly-added storage locations are surfaced as enabled
59
62
  * destinations without an explicit configuration step.
60
63
  *
61
- * Defaults are passed by the orchestrator (currently:
62
- * `retentionCount=7` + `cron='0 3 * * *'`) so a fresh destination
63
- * starts running daily at 03:00 with 7 archives kept. Operator can
64
- * tune both inline on the destinations table.
64
+ * Defaults are passed by the orchestrator (currently `retentionCount=7`).
65
+ * A fresh destination is enabled and keeps 7 archives; it gets NO cadence
66
+ * of its own — a destination runs when a schedule fans out to it, and
67
+ * seeding a cron here would advertise a nightly run nothing performs
68
+ * (D384).
65
69
  */
66
70
  ensureDefault(locationId: string, defaults: {
67
71
  retentionCount: number;
68
- cron?: string;
69
72
  }): Promise<BackupDestinationPolicy>;
73
+ /**
74
+ * Stamp the outcome of ONE attempted write to this destination.
75
+ *
76
+ * Read-modify-write on the policy row so the operator's `enabled`,
77
+ * `retentionCount` and `label` survive. A destination with no row yet
78
+ * gets one — an attempt on an unseen location must still be recorded, or
79
+ * the failure disappears with the process (which is exactly how a 03:00
80
+ * SMB refusal stayed invisible until the operator went looking).
81
+ */
82
+ recordAttempt(locationId: string, outcome: {
83
+ attemptAt: number;
84
+ error?: string;
85
+ }, defaults: {
86
+ retentionCount: number;
87
+ }): Promise<void>;
70
88
  }
@@ -15,8 +15,18 @@ export interface BackupSchedule {
15
15
  readonly locationIds: readonly string[];
16
16
  readonly retentionCount: number;
17
17
  readonly dataSources?: readonly string[];
18
- /** ms-epoch of the last successful run. */
19
- readonly lastRunAt?: number;
18
+ /**
19
+ * ms-epoch of the last tick that FIRED this schedule. Stamped BEFORE the
20
+ * archive runs — it is the dedupe anchor and the cron's "after" cursor —
21
+ * so it means "attempted", never "succeeded". It was documented as the
22
+ * latter and rendered as "last run", which is how a schedule whose only
23
+ * destination refused every byte read as a healthy nightly backup (D384).
24
+ */
25
+ readonly lastAttemptAt?: number;
26
+ /** ms-epoch of the last fired run that landed at ≥1 destination. */
27
+ readonly lastSuccessAt?: number;
28
+ /** Why the last fired run failed, verbatim. Cleared by the next success. */
29
+ readonly lastError?: string;
20
30
  }
21
31
  export declare const BACKUP_SCHEDULES_COLUMNS: readonly CollectionColumn[];
22
32
  export declare class BackupScheduleService {
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-HAP38XvL.js");
6
+ const require_dist = require("../../dist-CcV3k-hD.js");
7
7
  const require_formatter = require("../../formatter-DqAKDlvN.js");
8
8
  //#region src/builtins/console-logging/console-destination.ts
9
9
  var LEVEL_RANK = {