@camstack/system 1.2.441 → 1.2.443

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 (91) hide show
  1. package/dist/addon-runner.js +72 -2
  2. package/dist/addon-runner.mjs +72 -2
  3. package/dist/addon-utils.js +1 -1
  4. package/dist/addon-utils.mjs +1 -1
  5. package/dist/auth/totp-manager.d.ts +46 -1
  6. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  7. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  8. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  9. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  10. package/dist/builtins/alerts/alerts.addon.d.ts +41 -1
  11. package/dist/builtins/alerts/alerts.addon.js +638 -26
  12. package/dist/builtins/alerts/alerts.addon.mjs +638 -26
  13. package/dist/builtins/alerts/critical-push.d.ts +86 -0
  14. package/dist/builtins/alerts/runner-alert.d.ts +71 -0
  15. package/dist/builtins/autotrack/index.js +1 -1
  16. package/dist/builtins/autotrack/index.mjs +1 -1
  17. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +2 -2
  18. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +2 -2
  19. package/dist/builtins/calendars/calendars.addon.js +1 -1
  20. package/dist/builtins/calendars/calendars.addon.mjs +1 -1
  21. package/dist/builtins/camera-grid/index.js +1 -1
  22. package/dist/builtins/camera-grid/index.mjs +1 -1
  23. package/dist/builtins/composer/composer.addon.js +2 -2
  24. package/dist/builtins/composer/composer.addon.mjs +2 -2
  25. package/dist/builtins/console-logging/index.js +1 -1
  26. package/dist/builtins/console-logging/index.mjs +1 -1
  27. package/dist/builtins/core-blocks/block-package.d.ts +4 -2
  28. package/dist/builtins/core-blocks/block-settings-store.d.ts +65 -19
  29. package/dist/builtins/core-blocks/core-blocks.addon.js +365 -38
  30. package/dist/builtins/core-blocks/core-blocks.addon.mjs +365 -38
  31. package/dist/builtins/device-manager/device-manager.addon.js +1 -1
  32. package/dist/builtins/device-manager/device-manager.addon.mjs +1 -1
  33. package/dist/builtins/hub-forwarder/index.js +1 -1
  34. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  35. package/dist/builtins/keychain/index.js +3 -3
  36. package/dist/builtins/keychain/index.mjs +3 -3
  37. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  38. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  39. package/dist/builtins/local-auth/auth-schema.d.ts +1 -1
  40. package/dist/builtins/local-auth/local-auth.addon.js +226 -17
  41. package/dist/builtins/local-auth/local-auth.addon.mjs +226 -17
  42. package/dist/builtins/local-network/local-network.addon.js +2 -2
  43. package/dist/builtins/local-network/local-network.addon.mjs +2 -2
  44. package/dist/builtins/loki-logging/index.js +1 -1
  45. package/dist/builtins/loki-logging/index.mjs +1 -1
  46. package/dist/builtins/native-metrics/native-metrics.addon.js +2 -2
  47. package/dist/builtins/native-metrics/native-metrics.addon.mjs +2 -2
  48. package/dist/builtins/platform-probe/index.js +2 -2
  49. package/dist/builtins/platform-probe/index.mjs +2 -2
  50. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  51. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  52. package/dist/builtins/snapshot/index.js +1 -1
  53. package/dist/builtins/snapshot/index.mjs +1 -1
  54. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  55. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  56. package/dist/builtins/sqlite-storage/index.js +1 -1
  57. package/dist/builtins/sqlite-storage/index.mjs +1 -1
  58. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +2 -2
  59. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +2 -2
  60. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  61. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  62. package/dist/builtins/system-config/system-config.addon.js +2 -2
  63. package/dist/builtins/system-config/system-config.addon.mjs +2 -2
  64. package/dist/builtins/winston-logging/index.js +1 -1
  65. package/dist/builtins/winston-logging/index.mjs +1 -1
  66. package/dist/{composition-sources-BG-W2U0d.js → composition-sources-BUXX1e8j.js} +1 -1
  67. package/dist/{composition-sources-ByNISOnW.mjs → composition-sources-C8tUG2uL.mjs} +1 -1
  68. package/dist/{constants-DJILnUSb.mjs → constants-CGwVaAV3.mjs} +1 -1
  69. package/dist/{constants-UojhAJcm.js → constants-DWmm7_of.js} +1 -1
  70. package/dist/{device-ca-D8Ri9TVz.mjs → device-ca-DBfiJ_SB.mjs} +1 -1
  71. package/dist/{device-ca-196DAEZT.js → device-ca-DGayRmC0.js} +1 -1
  72. package/dist/{dist-BWvkuc31.mjs → dist-Bxt_S-9a.mjs} +180 -13
  73. package/dist/{dist-BiTCcaf-.js → dist-D9qT3jHr.js} +203 -12
  74. package/dist/file-host-worker.js +1 -1
  75. package/dist/file-host-worker.mjs +1 -1
  76. package/dist/{hub-mdns-ciao-DOBTq8qr.js → hub-mdns-ciao-B-wk9nzx.js} +1 -1
  77. package/dist/{hub-mdns-ciao-Cnd0bTB9.mjs → hub-mdns-ciao-DpPcQDg-.mjs} +1 -1
  78. package/dist/index.js +145 -16
  79. package/dist/index.mjs +140 -17
  80. package/dist/kernel/addon-refused-error.d.ts +30 -0
  81. package/dist/kernel/index.d.ts +3 -0
  82. package/dist/kernel/legacy-blob-guard.d.ts +6 -1
  83. package/dist/kernel/moleculer/process-service.d.ts +16 -1
  84. package/dist/kernel/moleculer/runner-refusal.d.ts +27 -0
  85. package/dist/{restore-archive-4ziluTU3.mjs → restore-archive-9CGgeBzD.mjs} +1 -1
  86. package/dist/{restore-archive-CfSSjKnA.js → restore-archive-BMNXwWWD.js} +1 -1
  87. package/dist/{wal-maintenance-CgZTNWAz.js → wal-maintenance-cRHR4rUY.js} +0 -0
  88. package/dist/{wal-maintenance-B57SAp4a.mjs → wal-maintenance-sIB32ONp.mjs} +0 -0
  89. package/package.json +1 -1
  90. package/dist/{custom-action-registry-BKmoaqG0.js → custom-action-registry-COyb49zm.js} +1 -1
  91. package/dist/{custom-action-registry-CyUuhATc.mjs → custom-action-registry-stpC3Zy-.mjs} +1 -1
@@ -3,7 +3,111 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-BiTCcaf-.js");
6
+ const require_dist = require("../../dist-D9qT3jHr.js");
7
+ //#region src/builtins/alerts/critical-push.ts
8
+ /** Alert categories that are critical system findings. */
9
+ var CRITICAL_CATEGORIES = new Set([
10
+ "addon.runner-failed",
11
+ "addon.runner-refused",
12
+ "system.runner-check"
13
+ ]);
14
+ function isPushState(value) {
15
+ if (typeof value !== "object" || value === null) return false;
16
+ const rec = value;
17
+ return (rec["state"] === "pending" || rec["state"] === "delivered" || rec["state"] === "disabled") && typeof rec["key"] === "string" && typeof rec["attempts"] === "number";
18
+ }
19
+ function selectPushTargets(targets, policy) {
20
+ const enabled = targets.filter((t) => t.enabled);
21
+ if (policy.targetIds.length > 0) return enabled.filter((t) => policy.targetIds.includes(t.id));
22
+ return enabled.filter((t) => t.ownerUserId === void 0);
23
+ }
24
+ var MAX_DIGEST_LINES = 20;
25
+ /** The one message a target gets for a set of findings. */
26
+ function composeDigest(items) {
27
+ const first = items[0];
28
+ if (items.length === 1 && first !== void 0) return {
29
+ title: first.title,
30
+ body: first.line
31
+ };
32
+ const shown = items.slice(0, MAX_DIGEST_LINES).map((i) => i.line);
33
+ const more = items.length - shown.length;
34
+ return {
35
+ title: `${String(items.length)} addons need attention`,
36
+ body: [...shown, ...more > 0 ? [`... and ${String(more)} more`] : []].join("\n")
37
+ };
38
+ }
39
+ /**
40
+ * Push every item that still needs it, as ONE message per target. Returns the new
41
+ * state of each item whose state changed (an item already delivered/disabled for
42
+ * its key is absent from the result).
43
+ */
44
+ async function deliverDigest(input) {
45
+ const out = /* @__PURE__ */ new Map();
46
+ const work = input.items.filter((i) => !(i.previous !== null && i.previous.key === i.key && i.previous.state !== "pending"));
47
+ if (work.length === 0) return out;
48
+ const sameKey = (i) => i.previous !== null && i.previous.key === i.key ? i.previous : null;
49
+ const attemptsOf = (i) => (sameKey(i)?.attempts ?? 0) + 1;
50
+ const sentOf = (i) => sameKey(i)?.sentTo ?? [];
51
+ if (!input.policy.enabled) {
52
+ for (const i of work) out.set(i.alertId, {
53
+ state: "disabled",
54
+ key: i.key,
55
+ attempts: attemptsOf(i)
56
+ });
57
+ return out;
58
+ }
59
+ const fail = (error) => {
60
+ for (const i of work) out.set(i.alertId, {
61
+ state: "pending",
62
+ key: i.key,
63
+ attempts: attemptsOf(i),
64
+ lastError: error,
65
+ ...sentOf(i).length > 0 ? { sentTo: sentOf(i) } : {}
66
+ });
67
+ return out;
68
+ };
69
+ let targets;
70
+ try {
71
+ targets = selectPushTargets(await input.ports.listTargets(), input.policy);
72
+ } catch (err) {
73
+ return fail(`notifiers unavailable: ${err instanceof Error ? err.message : String(err)}`);
74
+ }
75
+ if (targets.length === 0) return fail("no eligible notifier target");
76
+ const sent = new Map(work.map((i) => [i.alertId, [...sentOf(i)]]));
77
+ const errors = new Map(work.map((i) => [i.alertId, []]));
78
+ for (const target of targets) {
79
+ const todo = work.filter((i) => !(sent.get(i.alertId) ?? []).includes(target.id));
80
+ if (todo.length === 0) continue;
81
+ let failure = null;
82
+ try {
83
+ const result = await input.ports.send(target, composeDigest(todo));
84
+ if (!result.success) failure = `${target.id}: ${result.error ?? "send failed"}`;
85
+ } catch (err) {
86
+ failure = `${target.id}: ${err instanceof Error ? err.message : String(err)}`;
87
+ }
88
+ for (const i of todo) if (failure === null) sent.get(i.alertId)?.push(target.id);
89
+ else errors.get(i.alertId)?.push(failure);
90
+ }
91
+ for (const i of work) {
92
+ const sentTo = sent.get(i.alertId) ?? [];
93
+ const complete = targets.every((t) => sentTo.includes(t.id));
94
+ out.set(i.alertId, complete ? {
95
+ state: "delivered",
96
+ key: i.key,
97
+ at: input.now,
98
+ attempts: attemptsOf(i),
99
+ sentTo
100
+ } : {
101
+ state: "pending",
102
+ key: i.key,
103
+ attempts: attemptsOf(i),
104
+ lastError: (errors.get(i.alertId) ?? []).join("; "),
105
+ ...sentTo.length > 0 ? { sentTo } : {}
106
+ });
107
+ }
108
+ return out;
109
+ }
110
+ //#endregion
7
111
  //#region src/builtins/alerts/device-label.ts
8
112
  /**
9
113
  * Operator-facing device label for alert messages.
@@ -47,6 +151,137 @@ function selectOverflow(alerts, maxAlerts) {
47
151
  return [...byAge.filter((a) => a.read), ...byAge.filter((a) => !a.read)].slice(0, excess).map((a) => a.id);
48
152
  }
49
153
  //#endregion
154
+ //#region src/builtins/alerts/runner-alert.ts
155
+ var RUNNER_ALERT_CATEGORIES = [
156
+ "process.crashed",
157
+ "addon.crashed",
158
+ "addon.runner-failed",
159
+ "addon.runner-refused"
160
+ ];
161
+ function isRunnerAlertCategory(category) {
162
+ return RUNNER_ALERT_CATEGORIES.includes(category);
163
+ }
164
+ /** Stable alert id of a runner on a node. */
165
+ function runnerAlertId(nodeId, runnerId) {
166
+ return `runner:${nodeId}:${runnerId}`;
167
+ }
168
+ function str(data, key) {
169
+ const value = data[key];
170
+ return typeof value === "string" && value.length > 0 ? value : void 0;
171
+ }
172
+ function strList(data, key) {
173
+ const value = data[key];
174
+ return Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
175
+ }
176
+ /** Which runner an event is about; `null` when it names none. */
177
+ function runnerKeyOf(category, data, source) {
178
+ const nodeId = str(data, "nodeId") ?? source?.nodeId ?? "hub";
179
+ const runnerId = category === "process.crashed" ? str(data, "processId") ?? source?.id ?? "unknown" : category === "addon.crashed" ? str(data, "addonId") ?? source?.id ?? "unknown" : str(data, "runnerId");
180
+ return runnerId === void 0 ? null : {
181
+ nodeId,
182
+ runnerId
183
+ };
184
+ }
185
+ /** Fold one event into the runner's record. */
186
+ function foldRunnerEvent(previous, category, data, key, now) {
187
+ const base = previous ?? {
188
+ nodeId: key.nodeId,
189
+ runnerId: key.runnerId,
190
+ state: "crashed",
191
+ since: now,
192
+ count: 0,
193
+ addonIds: []
194
+ };
195
+ const addonIds = [...new Set([...base.addonIds, ...strList(data, "addonIds")])];
196
+ const count = base.count + 1;
197
+ switch (category) {
198
+ case "addon.runner-refused": return {
199
+ ...base,
200
+ state: "refused",
201
+ count,
202
+ addonIds,
203
+ reason: str(data, "reason") ?? "refused to start",
204
+ ...str(data, "remedy") !== void 0 ? { remedy: str(data, "remedy") } : {}
205
+ };
206
+ case "addon.runner-failed": {
207
+ if (base.state === "refused") return {
208
+ ...base,
209
+ count,
210
+ addonIds
211
+ };
212
+ const crashes = data["crashesInWindow"];
213
+ return {
214
+ ...base,
215
+ state: "failed",
216
+ count,
217
+ addonIds,
218
+ ...typeof crashes === "number" ? { crashesInWindow: crashes } : {}
219
+ };
220
+ }
221
+ case "addon.crashed": {
222
+ const error = str(data, "error");
223
+ const addonId = str(data, "addonId");
224
+ return {
225
+ ...base,
226
+ count,
227
+ addonIds: addonId !== void 0 ? [...new Set([...addonIds, addonId])] : addonIds,
228
+ ...error !== void 0 ? { lastError: error } : {}
229
+ };
230
+ }
231
+ case "process.crashed": return {
232
+ ...base,
233
+ count,
234
+ addonIds
235
+ };
236
+ }
237
+ }
238
+ function renderRunnerAlert(record) {
239
+ const where = record.nodeId === "hub" ? record.runnerId : `${record.runnerId} on ${record.nodeId}`;
240
+ switch (record.state) {
241
+ case "refused": return {
242
+ severity: "error",
243
+ title: "Addon refused to start",
244
+ message: `${where}: refused - ${record.reason ?? "no reason reported"}${record.remedy !== void 0 ? ` ${record.remedy}` : ""}`
245
+ };
246
+ case "failed": return {
247
+ severity: "error",
248
+ title: "Addon crash loop",
249
+ message: `Runner "${where}" gave up${record.crashesInWindow !== void 0 ? ` after ${String(record.crashesInWindow)} crashes` : ""}${record.addonIds.length > 0 ? ` (addons: ${record.addonIds.join(", ")})` : ""} - ${String(record.count)} events`
250
+ };
251
+ case "crashed": return {
252
+ severity: "error",
253
+ title: "Addon crashed",
254
+ message: `Addon "${where}" crashed${record.lastError !== void 0 ? `: ${record.lastError}` : ""}${record.count > 1 ? ` (${String(record.count)} events)` : ""}`
255
+ };
256
+ }
257
+ }
258
+ /** Is the record in a state an operator must act on (terminal)? */
259
+ function isTerminalRunnerState(state) {
260
+ return state === "failed" || state === "refused";
261
+ }
262
+ function hash(text) {
263
+ let h = 5381;
264
+ for (let i = 0; i < text.length; i++) h = (h << 5) + h + text.charCodeAt(i) | 0;
265
+ return (h >>> 0).toString(36);
266
+ }
267
+ /**
268
+ * The push key of a record: its episode and its cause. The same refusal of the
269
+ * same episode is never pushed twice; a refusal with another reason, a breaker
270
+ * trip, or any failure after a recovery (a new episode) is a new push.
271
+ */
272
+ function runnerPushKey(record) {
273
+ const cause = record.state === "refused" ? hash(`${record.reason ?? ""}|${record.remedy ?? ""}`) : "breaker";
274
+ return `runner:${record.state}:${String(record.since)}:${cause}`;
275
+ }
276
+ /**
277
+ * The one line a push carries: runner, state and the remedy. Deliberately NOT
278
+ * the raw reason or error text - those stay on the alert.
279
+ */
280
+ function runnerPushLine(record) {
281
+ const where = record.nodeId === "hub" ? record.runnerId : `${record.runnerId} on ${record.nodeId}`;
282
+ return record.state === "refused" ? `${where}: refused to start. ${record.remedy ?? "See the Addons page."}` : `${where}: stopped after repeated crashes. Restart it from the Addons page.`;
283
+ }
284
+ //#endregion
50
285
  //#region src/builtins/alerts/alerts.addon.ts
51
286
  var CATEGORY_MAPPINGS = {
52
287
  "addon.crashed": {
@@ -57,6 +292,14 @@ var CATEGORY_MAPPINGS = {
57
292
  severity: "error",
58
293
  titleTemplate: "Addon crash loop"
59
294
  },
295
+ "addon.runner-refused": {
296
+ severity: "error",
297
+ titleTemplate: "Addon refused to start"
298
+ },
299
+ "system.runner-check": {
300
+ severity: "error",
301
+ titleTemplate: "Runners degraded"
302
+ },
60
303
  "addon.error": {
61
304
  severity: "error",
62
305
  titleTemplate: "Addon error"
@@ -140,6 +383,8 @@ var CATEGORY_MAPPINGS = {
140
383
  * {@link CATEGORY_MAPPINGS}.
141
384
  */
142
385
  var SILENT_TRACKED_CATEGORIES = [
386
+ "addon.started",
387
+ "process.state-changed",
143
388
  "addon.load-recovered",
144
389
  "system.liveness-recovered",
145
390
  "stream.online"
@@ -161,6 +406,28 @@ var TRACKED_CATEGORIES = [...Object.keys(CATEGORY_MAPPINGS), ...SILENT_TRACKED_C
161
406
  */
162
407
  var RATE_LIMIT_WINDOW_MS = 6e4;
163
408
  var RATE_LIMIT_MAX_PER_SOURCE = 5;
409
+ /** The alert `category` a runner record carries - the state it is in. */
410
+ function runnerCategoryOf(state) {
411
+ return state === "refused" ? "addon.runner-refused" : state === "failed" ? "addon.runner-failed" : "addon.crashed";
412
+ }
413
+ function parseRunnerRecord(value) {
414
+ if (typeof value !== "object" || value === null) return null;
415
+ const rec = value;
416
+ const state = rec["state"];
417
+ if (typeof rec["nodeId"] !== "string" || typeof rec["runnerId"] !== "string" || state !== "crashed" && state !== "failed" && state !== "refused" || typeof rec["count"] !== "number" || !Array.isArray(rec["addonIds"])) return null;
418
+ return {
419
+ nodeId: rec["nodeId"],
420
+ runnerId: rec["runnerId"],
421
+ state,
422
+ since: typeof rec["since"] === "number" ? rec["since"] : 0,
423
+ count: rec["count"],
424
+ addonIds: rec["addonIds"].filter((x) => typeof x === "string"),
425
+ ...typeof rec["lastError"] === "string" ? { lastError: rec["lastError"] } : {},
426
+ ...typeof rec["reason"] === "string" ? { reason: rec["reason"] } : {},
427
+ ...typeof rec["remedy"] === "string" ? { remedy: rec["remedy"] } : {},
428
+ ...typeof rec["crashesInWindow"] === "number" ? { crashesInWindow: rec["crashesInWindow"] } : {}
429
+ };
430
+ }
164
431
  /** Parse a raw settings record into a validated Alert. */
165
432
  function parseAlert(record) {
166
433
  return require_dist.AlertSchema.parse({
@@ -170,8 +437,14 @@ function parseAlert(record) {
170
437
  }
171
438
  var DEFAULT_CONFIG = {
172
439
  maxAlerts: 500,
173
- retentionDays: 30
440
+ retentionDays: 30,
441
+ criticalPushEnabled: true,
442
+ criticalPushTargetIds: ""
174
443
  };
444
+ /** One burst of critical findings (a whole boot of refused runners) is ONE digest push. */
445
+ var PUSH_COALESCE_MS = 2e3;
446
+ /** Addons whose (re)start is a reason to retry pending critical pushes. */
447
+ var PUSH_RECONCILE_ADDONS = new Set(["notifiers", "notification-center"]);
175
448
  var ALERTS_COLLECTION = "alerts";
176
449
  var EVENT_SOURCE = {
177
450
  type: "addon",
@@ -194,8 +467,11 @@ function toPrunable(record) {
194
467
  }
195
468
  var AlertCenterAddon = class extends require_dist.BaseAddon {
196
469
  unsubscribers = [];
197
- constructor() {
470
+ /** How long a burst of critical findings is gathered into one digest push. */
471
+ pushCoalesceMs;
472
+ constructor(options) {
198
473
  super({ ...DEFAULT_CONFIG });
474
+ this.pushCoalesceMs = options?.pushCoalesceMs ?? PUSH_COALESCE_MS;
199
475
  }
200
476
  /**
201
477
  * Per `(category, source.id)` counter used to rate-limit chatty
@@ -221,6 +497,7 @@ var AlertCenterAddon = class extends require_dist.BaseAddon {
221
497
  unsubs.push(unsub);
222
498
  }
223
499
  this.unsubscribers = unsubs;
500
+ this.reconcilePendingPushes();
224
501
  this.ctx.logger.info("Initialized", { meta: { trackedCategoriesCount: TRACKED_CATEGORIES.length } });
225
502
  return [{
226
503
  capability: require_dist.alertsCapability,
@@ -239,29 +516,46 @@ var AlertCenterAddon = class extends require_dist.BaseAddon {
239
516
  description: "Configure alert storage and retention.",
240
517
  descriptionKey: "server:globalSettings.s_alerts.description",
241
518
  columns: 1,
242
- fields: [this.field({
243
- type: "number",
244
- key: "maxAlerts",
245
- label: "Maximum stored alerts",
246
- labelKey: "server:globalSettings.s_alerts.maxAlerts.label",
247
- description: "Oldest read alerts are deleted when this limit is exceeded.",
248
- descriptionKey: "server:globalSettings.s_alerts.maxAlerts.description",
249
- min: 50,
250
- max: 5e3,
251
- step: 50,
252
- default: 500
253
- }), this.field({
254
- type: "number",
255
- key: "retentionDays",
256
- label: "Alert retention (days)",
257
- labelKey: "server:globalSettings.s_alerts.retentionDays.label",
258
- description: "Alerts older than this are automatically removed.",
259
- descriptionKey: "server:globalSettings.s_alerts.retentionDays.description",
260
- min: 1,
261
- max: 365,
262
- step: 1,
263
- default: 30
264
- })]
519
+ fields: [
520
+ this.field({
521
+ type: "number",
522
+ key: "maxAlerts",
523
+ label: "Maximum stored alerts",
524
+ labelKey: "server:globalSettings.s_alerts.maxAlerts.label",
525
+ description: "Oldest read alerts are deleted when this limit is exceeded.",
526
+ descriptionKey: "server:globalSettings.s_alerts.maxAlerts.description",
527
+ min: 50,
528
+ max: 5e3,
529
+ step: 50,
530
+ default: 500
531
+ }),
532
+ this.field({
533
+ type: "number",
534
+ key: "retentionDays",
535
+ label: "Alert retention (days)",
536
+ labelKey: "server:globalSettings.s_alerts.retentionDays.label",
537
+ description: "Alerts older than this are automatically removed.",
538
+ descriptionKey: "server:globalSettings.s_alerts.retentionDays.description",
539
+ min: 1,
540
+ max: 365,
541
+ step: 1,
542
+ default: 30
543
+ }),
544
+ this.field({
545
+ type: "boolean",
546
+ key: "criticalPushEnabled",
547
+ label: "Push critical system alerts",
548
+ description: "A runner that failed or refused to start, or a node whose runners are degraded, is pushed straight to the notifier targets - even when notification-center is down.",
549
+ default: true
550
+ }),
551
+ this.field({
552
+ type: "text",
553
+ key: "criticalPushTargetIds",
554
+ label: "Critical push targets",
555
+ description: "Comma-separated notifier target ids. Empty = every enabled shared (not personal) target.",
556
+ default: ""
557
+ })
558
+ ]
265
559
  }] });
266
560
  }
267
561
  async listAlerts(filter) {
@@ -321,6 +615,23 @@ var AlertCenterAddon = class extends require_dist.BaseAddon {
321
615
  await this.handleModelDownload(event);
322
616
  return;
323
617
  }
618
+ if (isRunnerAlertCategory(category)) {
619
+ await this.handleRunnerEvent(category, event);
620
+ return;
621
+ }
622
+ if (category === require_dist.EventCategory.ProcessStateChanged) {
623
+ await this.handleRunnerStateChanged(event);
624
+ return;
625
+ }
626
+ if (category === require_dist.EventCategory.SystemRunnerCheck) {
627
+ await this.handleRunnerCheck(event);
628
+ return;
629
+ }
630
+ if (category === require_dist.EventCategory.AddonStarted) {
631
+ const addonId = event.data["addonId"];
632
+ if (typeof addonId === "string" && PUSH_RECONCILE_ADDONS.has(addonId)) await this.reconcilePendingPushes();
633
+ return;
634
+ }
324
635
  if (category === require_dist.EventCategory.AddonLoadFailed) {
325
636
  await this.handleAddonLoadFailed(event);
326
637
  return;
@@ -379,6 +690,307 @@ var AlertCenterAddon = class extends require_dist.BaseAddon {
379
690
  } });
380
691
  }
381
692
  }
693
+ async readRunnerRecord(alertId) {
694
+ const backend = this.ctx.api.settingsStore;
695
+ if (!backend) return null;
696
+ const raw = await backend.get.query({
697
+ collection: ALERTS_COLLECTION,
698
+ key: alertId
699
+ });
700
+ if (raw === null || raw === void 0 || typeof raw !== "object") return null;
701
+ const alert = require_dist.AlertSchema.safeParse(raw);
702
+ if (!alert.success || alert.data.status === "dismissed") return null;
703
+ return parseRunnerRecord(alert.data.metadata?.["runner"]);
704
+ }
705
+ async handleRunnerEvent(category, event) {
706
+ if (!isRunnerAlertCategory(category)) return;
707
+ const key = runnerKeyOf(category, event.data, event.source ? {
708
+ id: String(event.source.id),
709
+ ...typeof event.source.nodeId === "string" ? { nodeId: event.source.nodeId } : {}
710
+ } : void 0);
711
+ if (key === null) return;
712
+ const alertId = runnerAlertId(key.nodeId, key.runnerId);
713
+ const previous = await this.readRunnerRecord(alertId);
714
+ const record = foldRunnerEvent(previous, category, event.data, key, Date.now());
715
+ const rendered = renderRunnerAlert(record);
716
+ const existing = await this.readAlertRaw(alertId);
717
+ const now = Date.now();
718
+ const alert = {
719
+ id: alertId,
720
+ category: runnerCategoryOf(record.state),
721
+ severity: rendered.severity,
722
+ title: rendered.title,
723
+ message: rendered.message,
724
+ status: "active",
725
+ read: previous !== null && previous.state === record.state ? existing?.read === true : false,
726
+ createdAt: previous !== null ? existing?.createdAt ?? now : now,
727
+ updatedAt: now,
728
+ source: event.source ? {
729
+ type: event.source.type,
730
+ id: String(event.source.id)
731
+ } : {
732
+ type: "runner",
733
+ id: record.runnerId
734
+ },
735
+ metadata: {
736
+ runner: record,
737
+ pushLine: runnerPushLine(record),
738
+ ...existing?.push !== void 0 ? { push: existing.push } : {}
739
+ }
740
+ };
741
+ await this.persistAlert(alert);
742
+ if (previous === null) this.emitAlertCreatedEvent(alert);
743
+ else this.emitAlertUpdatedEvent(alertId, {
744
+ message: alert.message,
745
+ title: alert.title
746
+ });
747
+ await this.enforceMaxAlerts();
748
+ if (isTerminalRunnerState(record.state)) this.queuePush({
749
+ alertId,
750
+ key: runnerPushKey(record),
751
+ title: rendered.title,
752
+ line: runnerPushLine(record)
753
+ });
754
+ }
755
+ /**
756
+ * A runner reached `running`: its episode is over. Close the runner alert so a
757
+ * later failure opens a NEW episode (new push key), instead of folding into a
758
+ * stale `refused` record that would never push again.
759
+ */
760
+ async handleRunnerStateChanged(event) {
761
+ if (event.data["state"] !== "running") return;
762
+ const runnerId = typeof event.data["runnerId"] === "string" ? event.data["runnerId"] : null;
763
+ if (runnerId === null) return;
764
+ const alertId = runnerAlertId(typeof event.data["nodeId"] === "string" ? event.data["nodeId"] : "hub", runnerId);
765
+ if (await this.readRunnerRecord(alertId) === null) return;
766
+ await this.dismiss(alertId);
767
+ }
768
+ /** The node's one-shot runner boot check: one alert, dismissed when the node is clean. */
769
+ async handleRunnerCheck(event) {
770
+ const nodeId = typeof event.data["nodeId"] === "string" ? event.data["nodeId"] : "hub";
771
+ const alertId = `runner-check:${nodeId}`;
772
+ if (event.data["degraded"] !== true) {
773
+ await this.dismiss(alertId);
774
+ return;
775
+ }
776
+ const summary = typeof event.data["summary"] === "string" ? event.data["summary"] : "degraded";
777
+ const ids = (value) => Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
778
+ const refusedIds = Array.isArray(event.data["refused"]) ? event.data["refused"].flatMap((r) => {
779
+ const id = typeof r === "object" && r !== null ? r["runnerId"] : void 0;
780
+ return typeof id === "string" ? [id] : [];
781
+ }) : [];
782
+ const failedIds = ids(event.data["failed"]);
783
+ const missingIds = ids(event.data["missing"]);
784
+ const pushLine = `${nodeId}: runners degraded - ${[
785
+ ...failedIds.length > 0 ? [`failed: ${failedIds.join(", ")}`] : [],
786
+ ...refusedIds.length > 0 ? [`refused: ${refusedIds.join(", ")}`] : [],
787
+ ...missingIds.length > 0 ? [`missing: ${missingIds.join(", ")}`] : []
788
+ ].join("; ")}. See the Addons page.`;
789
+ const pushKey = `check:${[
790
+ ...failedIds,
791
+ ...refusedIds,
792
+ ...missingIds
793
+ ].sort().join(",")}`;
794
+ const now = Date.now();
795
+ const existing = await this.readAlertRaw(alertId);
796
+ const alert = {
797
+ id: alertId,
798
+ category: "system.runner-check",
799
+ severity: "error",
800
+ title: "Runners degraded",
801
+ message: `${nodeId}: ${summary}`,
802
+ status: "active",
803
+ read: false,
804
+ createdAt: existing?.createdAt ?? now,
805
+ updatedAt: now,
806
+ source: {
807
+ type: "node",
808
+ id: nodeId
809
+ },
810
+ metadata: {
811
+ nodeId,
812
+ failed: event.data["failed"],
813
+ refused: event.data["refused"],
814
+ missing: event.data["missing"],
815
+ pushLine,
816
+ ...existing?.push !== void 0 ? { push: existing.push } : {}
817
+ }
818
+ };
819
+ await this.persistAlert(alert);
820
+ this.emitAlertCreatedEvent(alert);
821
+ this.queuePush({
822
+ alertId,
823
+ key: pushKey,
824
+ title: alert.title,
825
+ line: pushLine
826
+ });
827
+ }
828
+ async readAlertRaw(alertId) {
829
+ const backend = this.ctx.api.settingsStore;
830
+ if (!backend) return null;
831
+ const raw = await backend.get.query({
832
+ collection: ALERTS_COLLECTION,
833
+ key: alertId
834
+ });
835
+ if (raw === null || raw === void 0 || typeof raw !== "object") return null;
836
+ const rec = Object.fromEntries(Object.entries(raw));
837
+ const meta = rec["metadata"];
838
+ const push = typeof meta === "object" && meta !== null && !Array.isArray(meta) ? meta["push"] : void 0;
839
+ return {
840
+ ...typeof rec["createdAt"] === "number" ? { createdAt: rec["createdAt"] } : {},
841
+ ...typeof rec["read"] === "boolean" ? { read: rec["read"] } : {},
842
+ ...isPushState(push) ? { push } : {}
843
+ };
844
+ }
845
+ /** Pushes run one at a time so a burst can never send the same state twice. */
846
+ pushChain = Promise.resolve();
847
+ /** Findings waiting for the next digest, by alert id. */
848
+ pushQueue = /* @__PURE__ */ new Map();
849
+ pushTimer = null;
850
+ pushPolicy() {
851
+ return {
852
+ enabled: this.config.criticalPushEnabled,
853
+ targetIds: this.config.criticalPushTargetIds.split(",").map((id) => id.trim()).filter((id) => id.length > 0)
854
+ };
855
+ }
856
+ pushPorts() {
857
+ const api = this.ctx.api;
858
+ return {
859
+ listTargets: async () => {
860
+ return (await api.notificationOutput.listTargets.query({})).map((t) => {
861
+ const owner = t.config["ownerUserId"];
862
+ return {
863
+ id: t.id,
864
+ addonId: t.addonId,
865
+ enabled: t.enabled,
866
+ ...typeof owner === "string" && owner.length > 0 ? { ownerUserId: owner } : {}
867
+ };
868
+ });
869
+ },
870
+ send: async (target, message) => api.notificationOutput.send.mutate({
871
+ addonId: target.addonId,
872
+ targetId: target.id,
873
+ notification: {
874
+ title: message.title,
875
+ body: message.body,
876
+ format: "text",
877
+ priority: 5
878
+ }
879
+ })
880
+ };
881
+ }
882
+ /**
883
+ * Queue a critical finding for the next digest. A burst (every runner of a boot)
884
+ * arrives within the coalesce window and leaves as ONE message per target.
885
+ */
886
+ queuePush(item) {
887
+ this.pushQueue.set(item.alertId, item);
888
+ if (this.pushTimer !== null) return;
889
+ this.pushTimer = setTimeout(() => {
890
+ this.pushTimer = null;
891
+ this.flushPushes();
892
+ }, this.pushCoalesceMs);
893
+ this.pushTimer.unref?.();
894
+ }
895
+ /** Send the queued findings now (also the reconcile path). */
896
+ flushPushes() {
897
+ this.pushChain = this.pushChain.then(async () => {
898
+ const queued = [...this.pushQueue.values()];
899
+ this.pushQueue.clear();
900
+ if (queued.length === 0) return;
901
+ try {
902
+ const items = [];
903
+ for (const q of queued) {
904
+ const existing = await this.readAlertRaw(q.alertId);
905
+ items.push({
906
+ ...q,
907
+ previous: existing?.push ?? null
908
+ });
909
+ }
910
+ const states = await deliverDigest({
911
+ items,
912
+ policy: this.pushPolicy(),
913
+ ports: this.pushPorts(),
914
+ now: Date.now()
915
+ });
916
+ for (const [alertId, state] of states) {
917
+ await this.storePushState(alertId, state);
918
+ this.ctx.logger?.info("critical alert push", { meta: {
919
+ alertId,
920
+ state: state.state,
921
+ attempts: state.attempts,
922
+ error: state.lastError
923
+ } });
924
+ }
925
+ } catch (err) {
926
+ this.ctx.logger?.warn("critical alert push failed unexpectedly", { meta: { error: require_dist.errMsg(err) } });
927
+ }
928
+ });
929
+ return this.pushChain;
930
+ }
931
+ async storePushState(alertId, push) {
932
+ const backend = this.ctx.api.settingsStore;
933
+ if (!backend) return;
934
+ const raw = await backend.get.query({
935
+ collection: ALERTS_COLLECTION,
936
+ key: alertId
937
+ });
938
+ if (raw === null || raw === void 0 || typeof raw !== "object") return;
939
+ const existing = Object.fromEntries(Object.entries(raw));
940
+ const meta = existing["metadata"];
941
+ const metadata = typeof meta === "object" && meta !== null && !Array.isArray(meta) ? meta : {};
942
+ await backend.set.mutate({
943
+ collection: ALERTS_COLLECTION,
944
+ key: alertId,
945
+ value: {
946
+ ...existing,
947
+ metadata: {
948
+ ...metadata,
949
+ push
950
+ }
951
+ }
952
+ });
953
+ }
954
+ /**
955
+ * Retry every critical alert whose push is still pending - at boot and when
956
+ * the notifiers or notification-center start. An alert whose state was already
957
+ * delivered is skipped by its key, so reconciling never sends twice.
958
+ */
959
+ async reconcilePendingPushes() {
960
+ const backend = this.ctx.api.settingsStore;
961
+ if (!backend) return;
962
+ try {
963
+ const rows = await backend.query.query({
964
+ collection: ALERTS_COLLECTION,
965
+ filter: {
966
+ orderBy: {
967
+ field: "updatedAt",
968
+ direction: "desc"
969
+ },
970
+ limit: 500
971
+ }
972
+ });
973
+ for (const row of rows) {
974
+ const parsed = require_dist.AlertSchema.safeParse({
975
+ id: row.id,
976
+ ...row.data
977
+ });
978
+ if (!parsed.success || parsed.data.status !== "active") continue;
979
+ if (!CRITICAL_CATEGORIES.has(parsed.data.category)) continue;
980
+ const push = parsed.data.metadata?.["push"];
981
+ if (!isPushState(push) || push.state !== "pending") continue;
982
+ this.pushQueue.set(parsed.data.id, {
983
+ alertId: parsed.data.id,
984
+ key: push.key,
985
+ title: parsed.data.title,
986
+ line: String(parsed.data.metadata?.["pushLine"] ?? parsed.data.title)
987
+ });
988
+ }
989
+ await this.flushPushes();
990
+ } catch (err) {
991
+ this.ctx.logger?.warn("pending critical push reconcile failed", { meta: { error: require_dist.errMsg(err) } });
992
+ }
993
+ }
382
994
  async handleModelDownload(event) {
383
995
  const data = event.data;
384
996
  const modelId = data.modelId ?? "unknown";