talon-agent 5.26.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "5.26.1",
3
+ "version": "5.26.2",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "The Falconry",
6
6
  "license": "Apache-2.0",
package/src/app.ts CHANGED
@@ -167,8 +167,49 @@ const restoreReport = await withConfigGuard(() =>
167
167
  bootPhase("staged restore", applyStagedRestore),
168
168
  );
169
169
 
170
+ /**
171
+ * The first boot of a new version (a Docker/TrueNAS image pull, an npm or
172
+ * binary upgrade — anything but `/update`, which checkpoints itself) takes
173
+ * a pinned `pre-upgrade <old>→<new>` checkpoint HERE: after a staged
174
+ * restore, before bootstrap opens the stores and the backend reconcile or
175
+ * any migration runs against them. A failed checkpoint alerts the admin
176
+ * and still boots, but with the destructive boot steps skipped.
177
+ *
178
+ * Never throws.
179
+ */
180
+ async function checkpointIfUpgraded(): Promise<{ safe: boolean }> {
181
+ try {
182
+ const { checkpointOnVersionChange } =
183
+ await import("./core/backup/index.js");
184
+ const { loadConfig } = await import("./core/config/index.js");
185
+ const { resolveBackupSettings } = await import("./core/backup/plan.js");
186
+ const { talonVersion } = await import("./util/version.js");
187
+ const result = await checkpointOnVersionChange({
188
+ settings: resolveBackupSettings(loadConfig().backup),
189
+ version: talonVersion(),
190
+ });
191
+ return { safe: result.status !== "failed" };
192
+ } catch (err) {
193
+ if (err instanceof ConfigFileError) throw err;
194
+ logError("backup", "Pre-upgrade version check failed", err);
195
+ const { raiseAlert } = await import("./core/frontend-runtime/alerts.js");
196
+ raiseAlert(
197
+ "backup.upgrade-checkpoint",
198
+ `The boot-time version check / pre-upgrade checkpoint crashed: ${String(err)}. Booted without it; boot-time cleanup skipped.`,
199
+ { severity: "critical" },
200
+ );
201
+ return { safe: false };
202
+ }
203
+ }
204
+
205
+ const upgrade = await withConfigGuard(() =>
206
+ bootPhase("upgrade checkpoint", checkpointIfUpgraded),
207
+ );
208
+
170
209
  const { config } = await withConfigGuard(() =>
171
- bootPhase("bootstrap", () => bootstrap()),
210
+ bootPhase("bootstrap", () =>
211
+ bootstrap({ skipDestructiveSteps: !upgrade.safe }),
212
+ ),
172
213
  );
173
214
 
174
215
  // Record this process as the daemon. The gateway port is appended once
package/src/bootstrap.ts CHANGED
@@ -15,7 +15,7 @@ import { loadSessions, resetSession } from "./storage/sessions.js";
15
15
  import { loadChatSettings } from "./storage/chat-settings.js";
16
16
  import { loadCronJobs } from "./storage/cron.js";
17
17
  import { loadTriggers } from "./storage/triggers.js";
18
- import { clearHistory, loadHistory } from "./storage/history.js";
18
+ import { loadHistory } from "./storage/history.js";
19
19
  import { loadMediaIndex } from "./storage/media-index.js";
20
20
  import { cleanupOldLogs } from "./storage/daily-log.js";
21
21
  import {
@@ -90,6 +90,12 @@ function resolveFrontendByNumericId(
90
90
  export type BootstrapOptions = {
91
91
  /** Override frontend names for plugin loading (e.g. ["terminal"]). */
92
92
  frontendNames?: string[];
93
+ /**
94
+ * Skip the boot steps that delete data (expired daily logs and memory
95
+ * notes, expired media). Set when the pre-upgrade checkpoint failed:
96
+ * with no snapshot to fall back on, this boot deletes nothing.
97
+ */
98
+ skipDestructiveSteps?: boolean;
93
99
  };
94
100
 
95
101
  export type BootstrapResult = {
@@ -165,9 +171,16 @@ export async function bootstrap(
165
171
  loadCronJobs();
166
172
  loadTriggers();
167
173
  loadHistory();
168
- loadMediaIndex();
174
+ loadMediaIndex({ purgeExpired: !options.skipDestructiveSteps });
169
175
  });
170
- cleanupOldLogs();
176
+ if (options.skipDestructiveSteps) {
177
+ logWarn(
178
+ "bot",
179
+ "Skipping boot-time cleanup (old daily logs, expired media): no pre-upgrade checkpoint to fall back on",
180
+ );
181
+ } else {
182
+ cleanupOldLogs();
183
+ }
171
184
 
172
185
  return { config };
173
186
  }
@@ -195,89 +208,191 @@ type ChatBindingDeps = {
195
208
  getBackendIdForChat: (chatId: string) => string;
196
209
  getBackendForChat: (chatId: string) => Backend;
197
210
  isModelValidForBackend: (backend: Backend, model: string) => Promise<boolean>;
211
+ /**
212
+ * Tell the operator what the reconcile changed — once per boot, one
213
+ * message for every chat. Defaults to an operator alert.
214
+ */
215
+ notify?: (text: string) => void;
198
216
  };
199
217
 
200
218
  const CHAT_BINDING_CONCURRENCY = 8;
201
219
  const REBIND_RETRY_DELAY_MS = 1_500;
220
+ const ONE_MILLION_SUFFIX = /\s*\[1m\]$/i;
202
221
 
203
222
  /**
204
223
  * Re-establish every chat's stored backend/model override against the
205
224
  * backends this boot actually has. Chats are independent, so they are
206
225
  * reconciled `CHAT_BINDING_CONCURRENCY` at a time; a shared backend that
207
226
  * two chats need at once is initialised exactly once by the pool.
227
+ *
228
+ * Never deletes chat history. A stale override is corrected (remapped to
229
+ * the closest valid id, or cleared so the backend default serves) and the
230
+ * operator is told once, naming every chat that changed. Only a chat whose
231
+ * backend is gone gets a fresh backend session — its old session id is
232
+ * archived by `resetSession`, and its history rows stay.
208
233
  */
209
234
  export async function reconcileChatBindings(
210
235
  config: TalonConfig,
211
236
  deps: ChatBindingDeps,
212
237
  ): Promise<void> {
213
238
  const { getAllChatSettings } = await import("./storage/chat-settings.js");
239
+ const changes: string[] = [];
214
240
  await mapConcurrent(
215
241
  Object.entries(getAllChatSettings()),
216
242
  CHAT_BINDING_CONCURRENCY,
217
- ([cid, settings]) => reconcileChatBinding(cid, settings, config, deps),
243
+ async ([cid, settings]) => {
244
+ const change = await reconcileChatBinding(cid, settings, config, deps);
245
+ if (change) changes.push(`• ${cid}: ${change}`);
246
+ },
247
+ );
248
+ if (changes.length === 0) return;
249
+ const text =
250
+ `Boot reconcile adjusted ${changes.length} chat(s) whose pinned backend/model ` +
251
+ `is no longer available (chat history kept):\n${changes.sort().join("\n")}`;
252
+ logWarn("bot", text);
253
+ (deps.notify ?? defaultReconcileNotify)(text);
254
+ }
255
+
256
+ function defaultReconcileNotify(text: string): void {
257
+ void import("./core/frontend-runtime/alerts.js").then(({ raiseAlert }) =>
258
+ raiseAlert("chat-bindings.reconciled", text, { severity: "warn" }),
218
259
  );
219
260
  }
220
261
 
262
+ /**
263
+ * The closest valid id for a model the catalog no longer lists, or null.
264
+ * Today: `<id>[1m]` → `<id>` (SDK catalogs stopped enumerating the 1M
265
+ * context variants), so a chat keeps its model family instead of being
266
+ * dropped to the backend default.
267
+ */
268
+ async function remapModelAlias(
269
+ backend: Backend,
270
+ model: string,
271
+ deps: ChatBindingDeps,
272
+ ): Promise<string | null> {
273
+ if (!ONE_MILLION_SUFFIX.test(model)) return null;
274
+ const stem = model.replace(ONE_MILLION_SUFFIX, "").trim();
275
+ if (!stem) return null;
276
+ return (await deps.isModelValidForBackend(backend, stem)) ? stem : null;
277
+ }
278
+
279
+ /**
280
+ * Reconcile one chat. Returns a short description of what changed, or
281
+ * null when nothing did.
282
+ */
221
283
  async function reconcileChatBinding(
222
284
  cid: string,
223
285
  settings: { backend?: string; model?: string },
224
286
  config: TalonConfig,
225
287
  deps: ChatBindingDeps,
226
- ): Promise<void> {
227
- const { getAllChatSettings, setChatBackend, setChatModel } =
228
- await import("./storage/chat-settings.js");
229
- let resetVolatileState = false;
288
+ ): Promise<string | null> {
289
+ const changes: string[] = [];
230
290
  if (settings.backend) {
231
- if (!deps.isBackendAvailable(settings.backend, config)) {
232
- log(
233
- "bot",
234
- `Per-chat backend ${settings.backend} for ${cid} is no longer available — resetting chat to default backend`,
235
- );
236
- await deps.releaseChat(cid);
237
- setChatBackend(cid, undefined);
238
- setChatModel(cid, undefined);
239
- resetVolatileState = true;
240
- } else {
241
- let result = await deps.rebindChat(cid, settings.backend, config);
242
- if (!result.ok) {
243
- await new Promise((r) => setTimeout(r, REBIND_RETRY_DELAY_MS));
244
- result = await deps.rebindChat(cid, settings.backend, config);
245
- }
246
- if (!result.ok) {
247
- log(
248
- "bot",
249
- `Per-chat backend rebind failed for ${cid} → ${settings.backend}: ${result.error} — keeping the setting; will serve on the default backend until re-selected`,
250
- );
251
- }
252
- }
291
+ const change = await reconcileChatBackend(
292
+ cid,
293
+ settings.backend,
294
+ config,
295
+ deps,
296
+ );
297
+ if (change) changes.push(change);
253
298
  }
254
299
  const bindingMatchesSetting =
255
300
  !settings.backend || deps.getBackendIdForChat(cid) === settings.backend;
256
- const currentModel = getAllChatSettings()[cid]?.model;
257
- if (currentModel && bindingMatchesSetting) {
258
- const be = deps.getBackendForChat(cid);
259
- try {
260
- const valid = await deps.isModelValidForBackend(be, currentModel);
261
- if (!valid) {
262
- log(
263
- "bot",
264
- `Per-chat model ${currentModel} for ${cid} is not valid for its backend — resetting model to default`,
265
- );
266
- setChatModel(cid, undefined);
267
- resetVolatileState = true;
268
- }
269
- } catch (err) {
270
- log(
301
+ if (bindingMatchesSetting) {
302
+ const change = await reconcileChatModel(cid, deps);
303
+ if (change) changes.push(change);
304
+ }
305
+ return changes.length > 0 ? changes.join("; ") : null;
306
+ }
307
+
308
+ async function reconcileChatBackend(
309
+ cid: string,
310
+ backendId: string,
311
+ config: TalonConfig,
312
+ deps: ChatBindingDeps,
313
+ ): Promise<string | null> {
314
+ const { setChatBackend, clearLegacyChatModel } =
315
+ await import("./storage/chat-settings.js");
316
+ if (!deps.isBackendAvailable(backendId, config)) {
317
+ logWarn(
318
+ "bot",
319
+ `Per-chat backend ${backendId} for ${cid} is no longer available — serving the chat on the default backend (history kept)`,
320
+ );
321
+ await deps.releaseChat(cid);
322
+ setChatBackend(cid, undefined);
323
+ // The per-backend model picks stay: they are keyed by backend, so the
324
+ // default backend's pick (if any) still applies and the vanished
325
+ // backend's comes back with it. Only the unkeyed legacy slot goes.
326
+ clearLegacyChatModel(cid);
327
+ // The stored session belongs to the vanished backend and cannot resume
328
+ // on another one. resetSession archives its id; history is untouched.
329
+ resetSession(cid, "backend-unavailable");
330
+ return `backend ${backendId} unavailable → default backend`;
331
+ }
332
+ let result = await deps.rebindChat(cid, backendId, config);
333
+ if (!result.ok) {
334
+ await new Promise((r) => setTimeout(r, REBIND_RETRY_DELAY_MS));
335
+ result = await deps.rebindChat(cid, backendId, config);
336
+ }
337
+ if (!result.ok) {
338
+ log(
339
+ "bot",
340
+ `Per-chat backend rebind failed for ${cid} → ${backendId}: ${result.error} — keeping the setting; will serve on the default backend until re-selected`,
341
+ );
342
+ }
343
+ return null;
344
+ }
345
+
346
+ /**
347
+ * Check the chat's pinned model against the backend that serves it.
348
+ * The unkeyed legacy slot is remapped or cleared when stale; a per-backend
349
+ * pick is only ever remapped (the send-time resolver already falls back
350
+ * past a stale one, and a catalog that failed to load must not erase it).
351
+ * The backend session is kept either way: a resume under a different
352
+ * model is fine, and the conversation is the point.
353
+ */
354
+ async function reconcileChatModel(
355
+ cid: string,
356
+ deps: ChatBindingDeps,
357
+ ): Promise<string | null> {
358
+ const {
359
+ getAllChatSettings,
360
+ getChatModelForBackend,
361
+ setChatModelForBackend,
362
+ clearLegacyChatModel,
363
+ } = await import("./storage/chat-settings.js");
364
+ const backendId = deps.getBackendIdForChat(cid);
365
+ const legacy = getAllChatSettings()[cid]?.model;
366
+ const model = legacy ?? getChatModelForBackend(cid, backendId);
367
+ if (!model) return null;
368
+ const be = deps.getBackendForChat(cid);
369
+ try {
370
+ if (await deps.isModelValidForBackend(be, model)) return null;
371
+ const remapped = await remapModelAlias(be, model, deps);
372
+ if (remapped) {
373
+ setChatModelForBackend(cid, backendId, remapped);
374
+ if (legacy) clearLegacyChatModel(cid);
375
+ logWarn(
271
376
  "bot",
272
- `Per-chat model validation failed for ${cid} (${currentModel}): ${
273
- err instanceof Error ? err.message : String(err)
274
- } — keeping stored model`,
377
+ `Per-chat model ${model} for ${cid} is not in the ${backendId} catalog — remapped to ${remapped} (history and session kept)`,
275
378
  );
379
+ return `model ${model} → ${remapped}`;
276
380
  }
277
- }
278
- if (resetVolatileState) {
279
- resetSession(cid);
280
- clearHistory(cid);
381
+ if (!legacy) return null;
382
+ clearLegacyChatModel(cid);
383
+ logWarn(
384
+ "bot",
385
+ `Per-chat model ${model} for ${cid} is not valid for its backend — falling back to the backend default (history and session kept)`,
386
+ );
387
+ return `model ${model} unavailable → backend default`;
388
+ } catch (err) {
389
+ log(
390
+ "bot",
391
+ `Per-chat model validation failed for ${cid} (${model}): ${
392
+ err instanceof Error ? err.message : String(err)
393
+ } — keeping stored model`,
394
+ );
395
+ return null;
281
396
  }
282
397
  }
283
398
 
@@ -370,11 +485,10 @@ export async function initBackendAndDispatcher(
370
485
  );
371
486
 
372
487
  // Re-acquire any persisted per-chat backend/model overrides so chats
373
- // resume exactly where they were before restart. If a backend has
374
- // since been disabled/removed, or the stored model is no longer valid
375
- // for the backend that would serve it, clear the override and reset
376
- // volatile chat state so the next user message starts a fresh default
377
- // session instead of crashing on an orphaned model id.
488
+ // resume exactly where they were before restart. A backend that has
489
+ // since gone, or a stored model its backend no longer lists, is
490
+ // corrected (remapped or cleared to the default) — never by deleting
491
+ // the chat's history. See reconcileChatBindings.
378
492
  await bootPhase("chat bindings", () =>
379
493
  reconcileChatBindings(config, {
380
494
  isBackendAvailable,
@@ -271,7 +271,10 @@ async function backupStatus(): Promise<void> {
271
271
  renderResult(result);
272
272
  return;
273
273
  }
274
- const status = await collectBackupStatus({ withTargets: false });
274
+ const status = await collectBackupStatus({
275
+ withTargets: false,
276
+ settings: resolveBackupSettings(loadConfig().backup),
277
+ });
275
278
  console.log(
276
279
  `\n${formatBackupStatus(status)
277
280
  .split("\n")
@@ -0,0 +1,199 @@
1
+ /**
2
+ * The boot-time upgrade checkpoint: a pinned snapshot taken the first time
3
+ * a new version boots, before anything in that version touches the data.
4
+ *
5
+ * `/update` on a git checkout takes its own pre-update checkpoint, but
6
+ * that is the only install shape that does. A Docker or TrueNAS update is
7
+ * a new image started against the old volume; npm and binary installs are
8
+ * the same story. The first code of the new version to run is this boot,
9
+ * so this boot is where the safety net has to be.
10
+ *
11
+ * The last version that booted is kept in a small marker file beside the
12
+ * Talon home's config (not in the database: the check runs before the
13
+ * database is opened, and a restore that rolls the database back should
14
+ * not also roll back the fact that a newer version ran). The database is
15
+ * captured through a read-only handle, so the checkpoint holds it exactly
16
+ * as the previous version left it — before this boot's schema setup.
17
+ *
18
+ * Never throws and never blocks boot: a daemon that refuses to start is
19
+ * worse than one that starts carefully. A failure is logged loudly,
20
+ * raised as a critical operator alert, and reported to the caller so the
21
+ * boot can skip its destructive steps. The marker is only advanced on
22
+ * success, so the next boot tries again.
23
+ */
24
+
25
+ import { existsSync } from "node:fs";
26
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
27
+ import { dirname, join } from "node:path";
28
+ import { dirs } from "../../../util/paths.js";
29
+ import { log, logError } from "../../../util/log.js";
30
+ import {
31
+ databasePath,
32
+ snapshotDatabase,
33
+ snapshotSqliteFile,
34
+ } from "../../../storage/backup/index.js";
35
+ import { raiseAlert } from "../../frontend-runtime/alerts.js";
36
+ import { buildSnapshot } from "../snapshot.js";
37
+ import type { BackupSettings } from "../types.js";
38
+
39
+ /** File (under the Talon home) recording the last version that booted. */
40
+ const BOOT_VERSION_MARKER = "last-boot-version.json";
41
+
42
+ /** Alert key for a failed upgrade checkpoint. */
43
+ export const UPGRADE_CHECKPOINT_ALERT = "backup.upgrade-checkpoint";
44
+
45
+ export type VersionCheckpointResult =
46
+ /** Same version as last boot — nothing to do. */
47
+ | { status: "unchanged"; version: string }
48
+ /** No marker and no database: a brand-new install, nothing to protect. */
49
+ | { status: "fresh-install"; version: string }
50
+ /** Version changed but `backup.checkpointBeforeUpdate` is off. */
51
+ | { status: "disabled"; from: string; to: string }
52
+ | { status: "taken"; id: string; from: string; to: string }
53
+ | { status: "failed"; from: string; to: string; error: string };
54
+
55
+ type MarkerFile = { version: string; bootedAt: string };
56
+
57
+ export type VersionCheckpointOptions = {
58
+ settings: BackupSettings;
59
+ /** The version now booting. */
60
+ version: string;
61
+ /** Talon home; tests point this at a scratch directory. */
62
+ home?: string;
63
+ /** The database file to capture; defaults to the daemon's. */
64
+ databaseFile?: string;
65
+ /** Snapshot builder — a test seam. */
66
+ build?: typeof buildSnapshot;
67
+ /** Operator alert — a test seam. */
68
+ alert?: typeof raiseAlert;
69
+ /** Clock, for tests. */
70
+ now?: Date;
71
+ };
72
+
73
+ export function bootVersionMarkerPath(home: string = dirs.root): string {
74
+ return join(home, BOOT_VERSION_MARKER);
75
+ }
76
+
77
+ /** The last version that booted, or null when none is recorded (or readable). */
78
+ export async function readLastBootVersion(
79
+ home: string = dirs.root,
80
+ ): Promise<string | null> {
81
+ try {
82
+ const raw = await readFile(bootVersionMarkerPath(home), "utf8");
83
+ const parsed = JSON.parse(raw) as Partial<MarkerFile>;
84
+ return typeof parsed.version === "string" && parsed.version
85
+ ? parsed.version
86
+ : null;
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ async function writeLastBootVersion(
93
+ home: string,
94
+ version: string,
95
+ now: Date,
96
+ ): Promise<void> {
97
+ const path = bootVersionMarkerPath(home);
98
+ const marker: MarkerFile = { version, bootedAt: now.toISOString() };
99
+ try {
100
+ await mkdir(dirname(path), { recursive: true });
101
+ const temp = `${path}.tmp`;
102
+ await writeFile(temp, `${JSON.stringify(marker, null, 2)}\n`, {
103
+ mode: 0o600,
104
+ });
105
+ await rename(temp, path);
106
+ } catch (err) {
107
+ // Worst case the next boot takes one more checkpoint than needed.
108
+ logError("backup", `Could not record boot version ${version}`, err);
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Copy the database through a read-only handle; fall back to the regular
114
+ * (opening) copy when SQLite refuses a read-only open, e.g. a WAL file
115
+ * whose shared-memory index can't be created on a read-only handle.
116
+ */
117
+ function copyDatabaseReadOnly(dbFile: string): (dest: string) => void {
118
+ return (dest) => {
119
+ try {
120
+ snapshotSqliteFile(dbFile, dest);
121
+ } catch (err) {
122
+ logError(
123
+ "backup",
124
+ "Read-only database copy failed; copying through the daemon handle",
125
+ err,
126
+ );
127
+ snapshotDatabase(dest);
128
+ }
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Take a pinned `pre-upgrade <old>→<new>` checkpoint when the version
134
+ * booting differs from the last one that did. See the module comment.
135
+ */
136
+ export async function checkpointOnVersionChange(
137
+ options: VersionCheckpointOptions,
138
+ ): Promise<VersionCheckpointResult> {
139
+ const home = options.home ?? dirs.root;
140
+ const now = options.now ?? new Date();
141
+ const to = options.version;
142
+ const previous = await readLastBootVersion(home);
143
+ if (previous === to) return { status: "unchanged", version: to };
144
+
145
+ const dbFile = options.databaseFile ?? databasePath();
146
+ const hasDatabase = existsSync(dbFile);
147
+ if (previous === null && !hasDatabase) {
148
+ await writeLastBootVersion(home, to, now);
149
+ return { status: "fresh-install", version: to };
150
+ }
151
+
152
+ // No marker but a database: an install from before the marker existed.
153
+ const from = previous ?? "unknown";
154
+ if (!options.settings.checkpointBeforeUpdate) {
155
+ log(
156
+ "backup",
157
+ `Version changed ${from}→${to}; pre-upgrade checkpoint disabled (backup.checkpointBeforeUpdate=false)`,
158
+ );
159
+ await writeLastBootVersion(home, to, now);
160
+ return { status: "disabled", from, to };
161
+ }
162
+
163
+ const build = options.build ?? buildSnapshot;
164
+ try {
165
+ const manifest = await build({
166
+ kind: "checkpoint",
167
+ label: `pre-upgrade ${from}→${to}`,
168
+ pinned: true,
169
+ settings: options.settings,
170
+ ...(options.home === undefined ? {} : { home, userHome: null }),
171
+ // Read-only: the copy is the database exactly as the previous
172
+ // version left it, before this boot opens it and sets up its schema.
173
+ ...(hasDatabase ? { copyDatabase: copyDatabaseReadOnly(dbFile) } : {}),
174
+ now,
175
+ });
176
+ await writeLastBootVersion(home, to, now);
177
+ log(
178
+ "backup",
179
+ `Pre-upgrade checkpoint ${manifest.id} taken (${from}→${to}, pinned)`,
180
+ );
181
+ return { status: "taken", id: manifest.id, from, to };
182
+ } catch (err) {
183
+ const error = err instanceof Error ? err.message : String(err);
184
+ logError(
185
+ "backup",
186
+ `PRE-UPGRADE CHECKPOINT FAILED (${from}→${to}) — booting without a safety snapshot; destructive boot steps are skipped`,
187
+ err,
188
+ );
189
+ (options.alert ?? raiseAlert)(
190
+ UPGRADE_CHECKPOINT_ALERT,
191
+ `Talon upgraded ${from}→${to} but the pre-upgrade checkpoint failed: ${error}\n` +
192
+ "The daemon booted anyway and skipped its boot-time cleanup. " +
193
+ "Fix the backup setup (e.g. a missing backup key) and restart; " +
194
+ "the checkpoint is retried on every boot until it succeeds.",
195
+ { severity: "critical" },
196
+ );
197
+ return { status: "failed", from, to, error };
198
+ }
199
+ }
@@ -25,6 +25,7 @@ export {
25
25
  runBackup,
26
26
  stopBackupScheduler,
27
27
  checkpointBeforeUpdate,
28
+ type UpdateCheckpoint,
28
29
  } from "./scheduler.js";
29
30
 
30
31
  export {
@@ -41,6 +42,8 @@ export {
41
42
  writeRestorePending,
42
43
  } from "./restore.js";
43
44
 
45
+ export { checkpointOnVersionChange } from "./boot/version-checkpoint.js";
46
+
44
47
  export { discoverTargets, type BackupTarget } from "./targets.js";
45
48
 
46
49
  export {
@@ -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">,