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.
- package/package.json +1 -1
- package/src/app.ts +42 -1
- package/src/backend/claude-sdk/model-provider.ts +59 -12
- package/src/bootstrap.ts +171 -57
- package/src/cli/commands/backup.ts +4 -1
- package/src/core/backup/boot/version-checkpoint.ts +199 -0
- package/src/core/backup/index.ts +3 -0
- package/src/core/backup/passphrase.ts +55 -2
- package/src/core/backup/restore.ts +32 -4
- package/src/core/backup/scheduler.ts +125 -13
- package/src/core/backup/status.ts +26 -3
- package/src/core/frontend-runtime/admin-notify.ts +46 -6
- package/src/core/frontend-runtime/alerts.ts +10 -6
- package/src/core/models/active-model.ts +57 -48
- package/src/core/plugin/loader.ts +47 -12
- package/src/core/update/self-update.ts +144 -47
- package/src/frontend/discord/commands/admin.ts +22 -3
- package/src/frontend/discord/commands/definitions.ts +7 -0
- package/src/frontend/telegram/actions/media.ts +60 -19
- package/src/frontend/telegram/actions/messaging.ts +9 -0
- package/src/frontend/telegram/commands/admin.ts +21 -3
- package/src/storage/backup/index.ts +1 -1
- package/src/storage/chat-settings.ts +13 -0
- package/src/storage/db.ts +5 -0
- package/src/storage/media-index.ts +4 -3
- package/src/storage/sessions.ts +60 -1
package/package.json
CHANGED
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", () =>
|
|
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
|
|
@@ -71,19 +71,68 @@ function isSelectedModel(currentModel: string, candidateId: string): boolean {
|
|
|
71
71
|
|
|
72
72
|
// ── Public API ─────────────────────────────────────────────────────────────
|
|
73
73
|
|
|
74
|
-
|
|
75
|
-
query: string,
|
|
76
|
-
): Promise<UnifiedModelResolution> {
|
|
77
|
-
const canonicalId = resolveModelId(query);
|
|
78
|
-
const model = getModel(canonicalId);
|
|
74
|
+
const ONE_MILLION_SUFFIX = "[1m]";
|
|
79
75
|
|
|
80
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Split a `[1m]` context-variant suffix off a query ("opus[1m]" →
|
|
78
|
+
* { stem: "opus", oneMillion: true }). Claude Code accepts the suffix on
|
|
79
|
+
* any alias or id; the SDK's `supportedModels()` list does not enumerate
|
|
80
|
+
* the suffixed forms, so the catalog can't match them directly.
|
|
81
|
+
*/
|
|
82
|
+
function splitOneMillion(query: string): { stem: string; oneMillion: boolean } {
|
|
83
|
+
const trimmed = query.trim();
|
|
84
|
+
if (trimmed.toLowerCase().endsWith(ONE_MILLION_SUFFIX)) {
|
|
81
85
|
return {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
storedValue: model.id,
|
|
86
|
+
stem: trimmed.slice(0, -ONE_MILLION_SUFFIX.length).trim(),
|
|
87
|
+
oneMillion: true,
|
|
85
88
|
};
|
|
86
89
|
}
|
|
90
|
+
return { stem: trimmed, oneMillion: false };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Exact-match a query against the registry: its id/alias directly, or —
|
|
95
|
+
* for a `[1m]` query the registry doesn't list — the base model, returned
|
|
96
|
+
* as a 1M variant whose id keeps the suffix so the SDK actually selects
|
|
97
|
+
* the 1M context window.
|
|
98
|
+
*
|
|
99
|
+
* The SDK reports no context-window metadata per model, so there's no
|
|
100
|
+
* evidence to reject a `[1m]` request on; the binary is the authority.
|
|
101
|
+
* The one form refused is `default[1m]`: "default" is a moving target,
|
|
102
|
+
* not an alias Claude Code suffixes.
|
|
103
|
+
*/
|
|
104
|
+
function resolveExactUnified(query: string): UnifiedModelInfo | undefined {
|
|
105
|
+
const direct = getModel(resolveModelId(query));
|
|
106
|
+
if (direct) return toUnified(direct);
|
|
107
|
+
|
|
108
|
+
const { stem, oneMillion } = splitOneMillion(query);
|
|
109
|
+
if (!oneMillion || !stem) return undefined;
|
|
110
|
+
const baseId = resolveModelId(stem);
|
|
111
|
+
const base = getModel(baseId);
|
|
112
|
+
if (!base || stem.toLowerCase() === "default") return undefined;
|
|
113
|
+
|
|
114
|
+
// Pass the SDK a form it knows: the canonical id when it's concrete, the
|
|
115
|
+
// user's own stem when the canonical is the "default" alias (e.g. "opus"
|
|
116
|
+
// folds into "default" when default currently serves Opus).
|
|
117
|
+
const sdkStem = baseId === "default" ? stem : baseId;
|
|
118
|
+
const displayName = /\(1m context\)/i.test(base.displayName)
|
|
119
|
+
? base.displayName
|
|
120
|
+
: `${base.displayName} (1M context)`;
|
|
121
|
+
return {
|
|
122
|
+
...toUnified(base),
|
|
123
|
+
id: `${sdkStem}${ONE_MILLION_SUFFIX}`,
|
|
124
|
+
displayName,
|
|
125
|
+
contextWindow: 1_000_000,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function resolveModel(
|
|
130
|
+
query: string,
|
|
131
|
+
): Promise<UnifiedModelResolution> {
|
|
132
|
+
const exact = resolveExactUnified(query);
|
|
133
|
+
if (exact) {
|
|
134
|
+
return { kind: "exact", model: exact, storedValue: exact.id };
|
|
135
|
+
}
|
|
87
136
|
|
|
88
137
|
// No exact match -- try a substring search across display names and aliases
|
|
89
138
|
const allModels = getModels(PROVIDER_ID);
|
|
@@ -112,9 +161,7 @@ export async function resolveModel(
|
|
|
112
161
|
export async function getModelInfo(
|
|
113
162
|
id: string,
|
|
114
163
|
): Promise<UnifiedModelInfo | undefined> {
|
|
115
|
-
|
|
116
|
-
const model = getModel(canonicalId);
|
|
117
|
-
return model ? toUnified(model) : undefined;
|
|
164
|
+
return resolveExactUnified(id);
|
|
118
165
|
}
|
|
119
166
|
|
|
120
167
|
export async function getSettingsPresentation(
|
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 {
|
|
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
|
-
|
|
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]) =>
|
|
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<
|
|
227
|
-
const
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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.
|
|
374
|
-
// since
|
|
375
|
-
//
|
|
376
|
-
//
|
|
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({
|
|
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
|
+
}
|
package/src/core/backup/index.ts
CHANGED
|
@@ -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 {
|