talon-agent 5.26.0 → 5.26.2

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.
@@ -45,7 +45,10 @@ function checked(passphrase: string, source: string): string {
45
45
  return passphrase;
46
46
  }
47
47
 
48
- async function readPassphraseFile(raw: string): Promise<string> {
48
+ async function readPassphraseFile(
49
+ raw: string,
50
+ warnOnMode = true,
51
+ ): Promise<string> {
49
52
  const path = expandUserPath(raw);
50
53
  let text: string;
51
54
  try {
@@ -56,7 +59,7 @@ async function readPassphraseFile(raw: string): Promise<string> {
56
59
  );
57
60
  }
58
61
  const mode = (await stat(path)).mode;
59
- if (process.platform !== "win32" && (mode & 0o077) !== 0) {
62
+ if (warnOnMode && process.platform !== "win32" && (mode & 0o077) !== 0) {
60
63
  logWarn("backup", `${path} is readable by other users — chmod 600 it`);
61
64
  }
62
65
  return checked(text.trim(), path);
@@ -93,6 +96,56 @@ export async function resolvePassphrase(
93
96
  return null;
94
97
  }
95
98
 
99
+ /**
100
+ * What is wrong with the configured key, or null when nothing is. Unlike
101
+ * `resolvePassphrase` this looks at every configured source: a
102
+ * `passphraseFile` that has gone missing is reported even while
103
+ * `TALON_BACKUP_PASSPHRASE` keeps snapshots running, because restoring
104
+ * anywhere without that variable needs the file. `blocking` says whether
105
+ * snapshots fail because of it. Never throws.
106
+ */
107
+ export async function passphraseProblem(
108
+ settings: Pick<BackupSettings, "encryption">,
109
+ env: NodeJS.ProcessEnv = process.env,
110
+ ): Promise<{ message: string; blocking: boolean } | null> {
111
+ const fromEnv = env[PASSPHRASE_ENV]?.trim();
112
+ let envProblem: string | null = null;
113
+ if (fromEnv) {
114
+ try {
115
+ checked(fromEnv, PASSPHRASE_ENV);
116
+ } catch (err) {
117
+ envProblem = (err as Error).message;
118
+ }
119
+ }
120
+ const file = settings.encryption?.passphraseFile;
121
+ if (file) {
122
+ try {
123
+ await readPassphraseFile(file, false);
124
+ } catch (err) {
125
+ const message = (err as Error).message;
126
+ // A usable environment passphrase wins, so snapshots still run.
127
+ if (fromEnv && !envProblem) {
128
+ return {
129
+ message: `${message} (snapshots still run on ${PASSPHRASE_ENV}, but a restore without it needs this file)`,
130
+ blocking: false,
131
+ };
132
+ }
133
+ return {
134
+ message: envProblem ? `${envProblem}; ${message}` : message,
135
+ blocking: true,
136
+ };
137
+ }
138
+ }
139
+ if (envProblem) return { message: envProblem, blocking: true };
140
+ if (settings.encryption && !file && !fromEnv) {
141
+ return {
142
+ message: `backup.encryption is set but no passphrase was found: set backup.encryption.passphraseFile or ${PASSPHRASE_ENV}`,
143
+ blocking: true,
144
+ };
145
+ }
146
+ return null;
147
+ }
148
+
96
149
  /** Like `resolvePassphrase`, for callers that cannot go on without one. */
97
150
  export async function requirePassphrase(
98
151
  settings: Pick<BackupSettings, "encryption">,
@@ -37,7 +37,7 @@ import {
37
37
  copyFile,
38
38
  } from "node:fs/promises";
39
39
  import { homedir } from "node:os";
40
- import { dirname, join } from "node:path";
40
+ import { dirname, join, resolve } from "node:path";
41
41
  import writeFileAtomic from "write-file-atomic";
42
42
  import { dirs } from "../../util/paths.js";
43
43
  import { log, logWarn } from "../../util/log.js";
@@ -50,7 +50,7 @@ import {
50
50
  import { sha256File } from "./archive/digest.js";
51
51
  import { extractTar } from "./archive/tar.js";
52
52
  import { createDecompressor } from "./archive/zstd.js";
53
- import { requirePassphrase } from "./passphrase.js";
53
+ import { passphraseFilePath, requirePassphrase } from "./passphrase.js";
54
54
  import { collectTree, excludeForRoot } from "./plan.js";
55
55
  import {
56
56
  authenticateManifest,
@@ -348,20 +348,32 @@ export function destinationFor(
348
348
  return join(home, ...segments);
349
349
  }
350
350
 
351
+ /** True when `path` is the active backup passphrase file. */
352
+ function isKeyFile(path: string, keyFile: string | null): boolean {
353
+ return keyFile !== null && resolve(path) === keyFile;
354
+ }
355
+
351
356
  /**
352
357
  * Bring one include root to exactly the snapshot's state: remove what the
353
358
  * snapshot rules would have captured, keep what they deliberately skip.
354
359
  * Returns how many live files were removed.
360
+ *
361
+ * The active passphrase file is never removed. The snapshot builder leaves
362
+ * it out of every part (a key inside the backup it unlocks is no key), so
363
+ * when it sits inside an include root — `workspace/secrets/`, `keys/`, an
364
+ * extra path — nothing in the snapshot would ever put it back.
355
365
  */
356
366
  async function clearCovered(
357
367
  destRoot: string,
358
368
  archiveRoot: string,
369
+ keyFile: string | null,
359
370
  ): Promise<number> {
360
371
  const existing = await collectTree(destRoot, archiveRoot, {
361
372
  exclude: excludeForRoot(archiveRoot),
362
373
  });
363
374
  let removed = 0;
364
375
  for (const entry of [...existing].reverse()) {
376
+ if (isKeyFile(entry.source, keyFile)) continue;
365
377
  try {
366
378
  if (entry.type === "dir") await rmdir(entry.source).catch(() => {});
367
379
  else {
@@ -410,6 +422,7 @@ async function applyStaged(
410
422
  staging: string,
411
423
  home: string,
412
424
  external: readonly ExternalDestination[],
425
+ keyFile: string | null,
413
426
  ): Promise<RestoreReport> {
414
427
  const extras = manifest.extras ?? [];
415
428
  const report: RestoreReport = {
@@ -429,7 +442,7 @@ async function applyStaged(
429
442
  logWarn("backup", `No destination for ${root} on this machine — skipped`);
430
443
  continue;
431
444
  }
432
- report.removed += await clearCovered(destRoot, root);
445
+ report.removed += await clearCovered(destRoot, root, keyFile);
433
446
  // A session database's sidecars describe the file being replaced.
434
447
  if (external.some((entry) => entry.root === root && entry.sqlite)) {
435
448
  await rm(`${destRoot}-wal`, { force: true });
@@ -439,6 +452,15 @@ async function applyStaged(
439
452
  for (const entry of staged) {
440
453
  const dest = destinationFor(entry.archivePath, home, extras, external);
441
454
  if (!dest) continue;
455
+ // An older snapshot (or one from another machine) may carry a file
456
+ // where this machine keeps its key. The key path is never written.
457
+ if (entry.type !== "dir" && isKeyFile(dest, keyFile)) {
458
+ logWarn(
459
+ "backup",
460
+ `Left the backup passphrase file ${dest} untouched — the snapshot's copy was not applied`,
461
+ );
462
+ continue;
463
+ }
442
464
  if (entry.type === "dir") await mkdir(dest, { recursive: true });
443
465
  else {
444
466
  await placeFile(entry.source, dest);
@@ -585,7 +607,13 @@ export async function restoreSnapshot(
585
607
  const staging = join(snapshotDir(manifest.id, home), "restore-staging");
586
608
  await extractParts(manifest, home, staging, options.settings, parts);
587
609
  await options.beforeApply?.();
588
- const report = await applyStaged(manifest, staging, home, external);
610
+ const report = await applyStaged(
611
+ manifest,
612
+ staging,
613
+ home,
614
+ external,
615
+ passphraseFilePath(options.settings),
616
+ );
589
617
  report.checkpointId = checkpointId;
590
618
  if (options.clone && manifest.origin) {
591
619
  report.configRewritten = await rewriteConfigForClone(
@@ -31,6 +31,7 @@ import { faultText } from "../engine/fault-text.js";
31
31
  import { bus } from "../bus/index.js";
32
32
  import { log, logError } from "../../util/log.js";
33
33
  import { dirs } from "../../util/paths.js";
34
+ import { passphraseProblem } from "./passphrase.js";
34
35
  import { buildSnapshot } from "./snapshot.js";
35
36
  import { listLocalManifests, pruneLocal, reconcileIndex } from "./store.js";
36
37
  import { discoverTargets, selectTargets } from "./targets.js";
@@ -54,6 +55,7 @@ export type RunRequest = {
54
55
  /** Test seam: the expensive collaborators, swappable in unit tests. */
55
56
  export const _backupDeps = {
56
57
  build: buildSnapshot,
58
+ keyProblem: (settings: BackupSettings) => passphraseProblem(settings),
57
59
  discover: discoverTargets,
58
60
  upload: uploadSnapshot,
59
61
  pruneLocal,
@@ -85,6 +87,10 @@ type SchedulerState = {
85
87
  lastSnapshotId: string | undefined;
86
88
  lastError: string | undefined;
87
89
  nextRunAt: number | undefined;
90
+ /** The hourly passphrase check (see checkBackupKey). */
91
+ keyTimer: ReturnType<typeof setInterval> | null;
92
+ /** What was wrong with the key at the last check, if anything. */
93
+ keyProblem: string | undefined;
88
94
  };
89
95
 
90
96
  const state: SchedulerState = {
@@ -100,6 +106,8 @@ const state: SchedulerState = {
100
106
  lastSnapshotId: undefined,
101
107
  lastError: undefined,
102
108
  nextRunAt: undefined,
109
+ keyTimer: null,
110
+ keyProblem: undefined,
103
111
  };
104
112
 
105
113
  /**
@@ -242,6 +250,7 @@ async function tick(generation: number): Promise<void> {
242
250
  const intervalMs = settings.intervalHours * HOUR_MS;
243
251
  // Recomputed every tick: a suspended machine or a stepped clock lands
244
252
  // here late, and the answer is "run now", not "run N missed times".
253
+ await checkBackupKey();
245
254
  if (backoff.active()) {
246
255
  schedule(Math.min(intervalMs, BOOT_DELAY_MS), generation);
247
256
  return;
@@ -254,6 +263,82 @@ async function tick(generation: number): Promise<void> {
254
263
  schedule(intervalMs, generation);
255
264
  }
256
265
 
266
+ // ── The key check ───────────────────────────────────────────────────────────
267
+
268
+ const KEY_ALERT = "backup.key";
269
+ const KEY_CHECK_MS = HOUR_MS;
270
+
271
+ /**
272
+ * Is the configured passphrase still there? A key that disappears breaks
273
+ * every snapshot and the pre-update checkpoint, and a run only notices at
274
+ * its next window, hours later. So this runs at boot, every hour, before
275
+ * each scheduled run and on every status request.
276
+ *
277
+ * It tells the admin once when the key goes bad and once when it is back,
278
+ * never on every check. Returns the current problem, or null. Never throws.
279
+ */
280
+ export async function checkBackupKey(): Promise<string | null> {
281
+ const settings = state.settings;
282
+ if (!settings) return null;
283
+ let problem: { message: string; blocking: boolean } | null;
284
+ try {
285
+ problem = await _backupDeps.keyProblem(settings);
286
+ } catch (err) {
287
+ problem = {
288
+ message: err instanceof Error ? err.message : String(err),
289
+ blocking: true,
290
+ };
291
+ }
292
+ // A check that finished after a reconfiguration describes old settings.
293
+ if (state.settings !== settings) return state.keyProblem ?? null;
294
+ const previous = state.keyProblem;
295
+ state.keyProblem = problem?.message;
296
+ if (problem && previous === undefined) {
297
+ await reportKeyProblem(problem);
298
+ } else if (!problem && previous !== undefined) {
299
+ await reportKeyRecovered();
300
+ }
301
+ return problem?.message ?? null;
302
+ }
303
+
304
+ async function reportKeyProblem(problem: {
305
+ message: string;
306
+ blocking: boolean;
307
+ }): Promise<void> {
308
+ const consequence = problem.blocking
309
+ ? "Backups and the pre-update checkpoint will fail until it is fixed, and /update is refused unless forced."
310
+ : "Backups still run for now.";
311
+ const text = `Backup key problem: ${faultText(problem.message, 300)}. ${consequence}`;
312
+ logError("backup", text);
313
+ if (state.notify === notifyAdmin) {
314
+ raiseAlert(KEY_ALERT, text, {
315
+ severity: problem.blocking ? "error" : "warn",
316
+ });
317
+ return;
318
+ }
319
+ await state.notify(`⚠️ ${text}`).catch(() => {
320
+ /* the notifier logs its own failures */
321
+ });
322
+ }
323
+
324
+ async function reportKeyRecovered(): Promise<void> {
325
+ const text = "The backup passphrase is readable again.";
326
+ log("backup", text);
327
+ if (state.notify === notifyAdmin) {
328
+ resolveAlert(KEY_ALERT, text);
329
+ return;
330
+ }
331
+ await state.notify(`✅ ${text}`).catch(() => {
332
+ /* the notifier logs its own failures */
333
+ });
334
+ }
335
+
336
+ function armKeyCheck(): void {
337
+ if (state.keyTimer) clearInterval(state.keyTimer);
338
+ state.keyTimer = setInterval(() => void checkBackupKey(), KEY_CHECK_MS);
339
+ state.keyTimer.unref?.();
340
+ }
341
+
257
342
  /**
258
343
  * Wire the subsystem and arm the timer. Safe to call with backups
259
344
  * disabled — it reconciles the index either way, so the listing surfaces
@@ -274,6 +359,10 @@ export async function initBackup(options: {
274
359
  const newest = (await listLocalManifests(state.home))[0];
275
360
  state.lastRunAt = newest?.createdAt ?? 0;
276
361
  state.lastSnapshotId = newest?.id;
362
+ // The key is checked whether or not the schedule runs: manual
363
+ // checkpoints and the pre-update checkpoint need it too.
364
+ await checkBackupKey();
365
+ armKeyCheck();
277
366
  if (!options.settings.enabled) {
278
367
  log(
279
368
  "backup",
@@ -301,6 +390,8 @@ export function stopBackupScheduler(): void {
301
390
  if (state.timer) clearTimeout(state.timer);
302
391
  state.timer = null;
303
392
  state.nextRunAt = undefined;
393
+ if (state.keyTimer) clearInterval(state.keyTimer);
394
+ state.keyTimer = null;
304
395
  }
305
396
 
306
397
  /** What the status surfaces report about the schedule itself. */
@@ -326,18 +417,39 @@ export function schedulerStatus(): {
326
417
  };
327
418
  }
328
419
 
420
+ /**
421
+ * What the pre-update hook managed. `disabled` is the operator's explicit
422
+ * opt-out (`backup.checkpointBeforeUpdate: false`); `failed` covers both a
423
+ * run that threw and a process with no backup subsystem to ask.
424
+ */
425
+ export type UpdateCheckpoint =
426
+ | { status: "taken"; id: string }
427
+ | { status: "disabled" }
428
+ | { status: "failed"; error: string };
429
+
329
430
  /**
330
431
  * The self-update hook: a pinned checkpoint before the tree moves, so a
331
- * bad update is one `talon backup restore` away from undone. Returns the
332
- * checkpoint id, or null when the feature is off or the subsystem is not
333
- * initialised (a CLI-driven update in a process with no daemon state).
334
- * Never throws: failing to take a checkpoint must not block the update.
432
+ * bad update is one `talon backup restore` away from undone.
433
+ *
434
+ * Never throws — it reports. The caller decides: `/update` refuses to go
435
+ * on after a `failed` checkpoint unless the operator forces it, because
436
+ * an update with no way back is exactly when data goes missing.
437
+ *
438
+ * `backup.enabled: false` only stops the schedule; manual checkpoints
439
+ * still work, so this one is still taken.
335
440
  */
336
441
  export async function checkpointBeforeUpdate(
337
442
  fromVersion: string,
338
443
  toVersion: string,
339
- ): Promise<string | null> {
340
- if (!state.settings?.checkpointBeforeUpdate) return null;
444
+ ): Promise<UpdateCheckpoint> {
445
+ const settings = state.settings;
446
+ if (!settings) {
447
+ return {
448
+ status: "failed",
449
+ error: "the backup subsystem is not running in this process",
450
+ };
451
+ }
452
+ if (!settings.checkpointBeforeUpdate) return { status: "disabled" };
341
453
  try {
342
454
  const manifest = await runBackup({
343
455
  kind: "checkpoint",
@@ -345,14 +457,13 @@ export async function checkpointBeforeUpdate(
345
457
  pinned: true,
346
458
  trigger: "pre-update",
347
459
  });
348
- return manifest.id;
460
+ return { status: "taken", id: manifest.id };
349
461
  } catch (err) {
350
- logError(
351
- "backup",
352
- "Pre-update checkpoint failed; continuing with the update",
353
- err,
354
- );
355
- return null;
462
+ logError("backup", "Pre-update checkpoint failed", err);
463
+ return {
464
+ status: "failed",
465
+ error: err instanceof Error ? err.message : String(err),
466
+ };
356
467
  }
357
468
  }
358
469
 
@@ -372,5 +483,6 @@ export function _resetBackupScheduler(): void {
372
483
  state.lastRunAt = 0;
373
484
  state.lastSnapshotId = undefined;
374
485
  state.lastError = undefined;
486
+ state.keyProblem = undefined;
375
487
  backoff.succeed();
376
488
  }
@@ -15,8 +15,12 @@ import {
15
15
  selectTargets,
16
16
  type BackupTarget,
17
17
  } from "./targets.js";
18
- import { backupSettings, schedulerStatus } from "./scheduler.js";
19
- import { PASSPHRASE_ENV } from "./passphrase.js";
18
+ import {
19
+ backupSettings,
20
+ checkBackupKey,
21
+ schedulerStatus,
22
+ } from "./scheduler.js";
23
+ import { PASSPHRASE_ENV, passphraseProblem } from "./passphrase.js";
20
24
  import type { BackupSettings, SnapshotSummary } from "./types.js";
21
25
 
22
26
  type TargetStatus = {
@@ -41,6 +45,8 @@ export type BackupStatus = {
41
45
  snapshots: SnapshotSummary[];
42
46
  /** Retention and encryption, when the subsystem is initialised. */
43
47
  policy?: BackupPolicy;
48
+ /** What is wrong with the configured passphrase, if anything. */
49
+ keyProblem?: string;
44
50
  };
45
51
 
46
52
  /** The settings a status panel renders alongside the numbers. */
@@ -125,12 +131,25 @@ export async function collectBackupStatus(
125
131
  options: {
126
132
  home?: string;
127
133
  withTargets?: boolean;
134
+ /**
135
+ * Settings to describe when this process runs no backup subsystem
136
+ * (the CLI with the daemon down). The daemon's own settings win.
137
+ */
138
+ settings?: BackupSettings;
128
139
  } = {},
129
140
  ): Promise<BackupStatus> {
130
141
  const home = options.home ?? dirs.root;
131
142
  const snapshots = await listSnapshots(home);
132
143
  const local = snapshots.filter((snapshot) => snapshot.local);
133
- const settings = backupSettings();
144
+ const live = backupSettings();
145
+ const settings = live ?? options.settings ?? null;
146
+ // In the daemon the check also raises (or clears) the key alert; outside
147
+ // it there is nobody to alert, so it only reports.
148
+ const keyProblem = live
149
+ ? await checkBackupKey()
150
+ : settings
151
+ ? ((await passphraseProblem(settings))?.message ?? null)
152
+ : null;
134
153
  const targets =
135
154
  options.withTargets === false
136
155
  ? []
@@ -148,6 +167,7 @@ export async function collectBackupStatus(
148
167
  targets,
149
168
  snapshots,
150
169
  policy: describePolicy(settings),
170
+ ...(keyProblem ? { keyProblem } : {}),
151
171
  };
152
172
  }
153
173
 
@@ -198,6 +218,9 @@ export function formatBackupStatus(
198
218
  `Last run: ${formatRelative(schedule.lastRunAt, now)}` +
199
219
  (schedule.lastSnapshotId ? ` — ${schedule.lastSnapshotId}` : ""),
200
220
  );
221
+ if (status.keyProblem) {
222
+ lines.push(`Key: PROBLEM — ${status.keyProblem}`);
223
+ }
201
224
  if (schedule.consecutiveFailures > 0) {
202
225
  lines.push(
203
226
  `Failing: ${schedule.consecutiveFailures} consecutive — ${schedule.lastError ?? "unknown error"}`,
@@ -7,7 +7,10 @@
7
7
  * wiring, which does the same thing privately). Alerts raised before that
8
8
  * — early boot is exactly when restore reports and security alerts fire —
9
9
  * are held in a small bounded queue and flushed, oldest first, the moment
10
- * a notifier is wired. With nothing ever wired (tests, terminal mode with
10
+ * a notifier is wired. An alert carrying a key (operator alerts do) is
11
+ * deduplicated while it waits: a re-raise replaces the queued copy instead
12
+ * of queueing another, and a key that resolves before anyone could hear it
13
+ * is withdrawn outright. With nothing ever wired (tests, terminal mode with
11
14
  * no admin) they stay a log line; nothing throws.
12
15
  *
13
16
  * First consumer: WhatsApp pairing. When WhatsApp unlinks the device,
@@ -25,7 +28,14 @@ let deliver: Deliver | null = null;
25
28
  /** Most alerts held while no notifier is wired; the oldest are dropped past it. */
26
29
  export const ADMIN_NOTIFY_QUEUE_MAX = 20;
27
30
 
28
- type Pending = { text: string; at: number };
31
+ type Pending = {
32
+ text: string;
33
+ at: number;
34
+ /** Dedup key (operator alert key); unkeyed alerts never coalesce. */
35
+ key?: string;
36
+ /** Re-raises folded into this entry while it waited. */
37
+ repeats: number;
38
+ };
29
39
  const pending: Pending[] = [];
30
40
  let droppedWhileUnwired = 0;
31
41
  let flushing: Promise<void> | null = null;
@@ -68,6 +78,7 @@ async function flushPending(fn: Deliver): Promise<void> {
68
78
  batch.unshift({
69
79
  text: `${dropped} earlier admin alert(s) were dropped before a notifier was wired (queue holds ${ADMIN_NOTIFY_QUEUE_MAX}); see the daemon log.`,
70
80
  at: Date.now(),
81
+ repeats: 0,
71
82
  });
72
83
  }
73
84
  log("notify", `Flushing ${batch.length} queued admin alert(s)`);
@@ -79,7 +90,10 @@ async function flushPending(fn: Deliver): Promise<void> {
79
90
  continue;
80
91
  }
81
92
  const ageS = Math.round((Date.now() - item.at) / 1000);
82
- const text = ageS >= 5 ? `(delayed ${ageS}s) ${item.text}` : item.text;
93
+ const repeated =
94
+ item.repeats > 0 ? `\n(raised ${item.repeats + 1}× while starting)` : "";
95
+ const text =
96
+ (ageS >= 5 ? `(delayed ${ageS}s) ${item.text}` : item.text) + repeated;
83
97
  try {
84
98
  await fn(text);
85
99
  log("notify", `Admin notified (queued): ${preview(item.text)}`);
@@ -95,6 +109,17 @@ async function flushPending(fn: Deliver): Promise<void> {
95
109
  }
96
110
 
97
111
  function enqueue(item: Pending): void {
112
+ if (item.key !== undefined) {
113
+ const i = pending.findIndex((p) => p.key === item.key);
114
+ if (i >= 0) {
115
+ // Same fault raised again while waiting: keep one entry, newest text,
116
+ // original timestamp (so the "delayed" note stays honest).
117
+ const prior = pending[i];
118
+ prior.text = item.text;
119
+ prior.repeats += item.repeats + 1;
120
+ return;
121
+ }
122
+ }
98
123
  pending.push(item);
99
124
  while (pending.length > ADMIN_NOTIFY_QUEUE_MAX) {
100
125
  const lost = pending.shift();
@@ -111,14 +136,29 @@ function preview(text: string): string {
111
136
  return text.slice(0, 80).replace(/\n/g, " ");
112
137
  }
113
138
 
139
+ /**
140
+ * Withdraw a queued, not-yet-delivered alert by key (its fault cleared
141
+ * before a notifier was wired). Returns whether one was withdrawn.
142
+ */
143
+ export function withdrawAdminNotification(key: string): boolean {
144
+ const i = pending.findIndex((p) => p.key === key);
145
+ if (i < 0) return false;
146
+ const [gone] = pending.splice(i, 1);
147
+ log("notify", `Withdrew queued admin alert ${key}: ${preview(gone.text)}`);
148
+ return true;
149
+ }
150
+
114
151
  /**
115
152
  * Send `text` to the admin chat. Never throws; returns whether it was
116
153
  * delivered now (false = failed, or queued because no notifier is wired
117
- * yet — it is sent when one is).
154
+ * yet — it is sent when one is). `key` deduplicates while queued.
118
155
  */
119
- export async function notifyAdmin(text: string): Promise<boolean> {
156
+ export async function notifyAdmin(
157
+ text: string,
158
+ key?: string,
159
+ ): Promise<boolean> {
120
160
  if (!deliver) {
121
- enqueue({ text, at: Date.now() });
161
+ enqueue({ text, at: Date.now(), key, repeats: 0 });
122
162
  logWarn(
123
163
  "notify",
124
164
  `No admin notifier wired yet; queued (${pending.length}/${ADMIN_NOTIFY_QUEUE_MAX}): ${text.slice(0, 120)}`,
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import { log, logWarn } from "../../util/log.js";
18
- import { notifyAdmin } from "./admin-notify.js";
18
+ import { notifyAdmin, withdrawAdminNotification } from "./admin-notify.js";
19
19
 
20
20
  export type AlertSeverity = "warn" | "error" | "critical";
21
21
 
@@ -39,7 +39,8 @@ const RANK: Record<AlertSeverity, number> = { warn: 0, error: 1, critical: 2 };
39
39
  const active = new Map<string, ActiveAlert>();
40
40
  let cooldownMs = DEFAULT_COOLDOWN_MS;
41
41
  let enabled = true;
42
- let send: (text: string) => Promise<unknown> = notifyAdmin;
42
+ type Send = (text: string, key?: string) => Promise<unknown>;
43
+ let send: Send = notifyAdmin;
43
44
 
44
45
  /** Apply operator settings (config `alerts`). */
45
46
  export function configureAlerts(opts: {
@@ -86,7 +87,9 @@ export function raiseAlert(
86
87
  // Nobody heard it: don't let the cooldown swallow the next raise.
87
88
  if (active.get(key) === entry) entry.lastSentAt = 0;
88
89
  };
89
- void send(`${ICON[severity]} ${message}${repeat}`).then((ok) => {
90
+ // The key lets a still-queued copy (no notifier wired yet) be replaced by
91
+ // this raise rather than queued twice.
92
+ void send(`${ICON[severity]} ${message}${repeat}`, key).then((ok) => {
90
93
  if (ok === false) undelivered();
91
94
  }, undelivered);
92
95
  }
@@ -99,6 +102,9 @@ export function resolveAlert(key: string, message?: string): void {
99
102
  const mins = Math.max(1, Math.round((Date.now() - prior.firstAt) / 60_000));
100
103
  log("alert", `resolved ${key} after ${mins} min`);
101
104
  if (!enabled) return;
105
+ // Still queued for a notifier that never got to send it: withdraw it, and
106
+ // there is nothing to announce a recovery from.
107
+ if (withdrawAdminNotification(key)) return;
102
108
  void send(`✅ ${message ?? `Recovered: ${key}`} (after ${mins} min)`).catch(
103
109
  () => {},
104
110
  );
@@ -120,9 +126,7 @@ export function activeAlerts(): ReadonlyArray<{
120
126
  }
121
127
 
122
128
  /** Test seam: reset state and swap the delivery function. */
123
- export function resetAlertsForTest(
124
- deliver: (text: string) => Promise<unknown> = notifyAdmin,
125
- ): void {
129
+ export function resetAlertsForTest(deliver: Send = notifyAdmin): void {
126
130
  active.clear();
127
131
  cooldownMs = DEFAULT_COOLDOWN_MS;
128
132
  enabled = true;