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
|
@@ -36,19 +36,20 @@
|
|
|
36
36
|
* 1. `modelByBackend[B]` — per-chat-per-backend pick, if it still
|
|
37
37
|
* validates against B's catalog. Cross-backend orphans surface
|
|
38
38
|
* as `kind: "missing"` and fall through.
|
|
39
|
-
* 2. `
|
|
39
|
+
* 2. `config.backendDefaults[B]` — operator-configured per-backend
|
|
40
|
+
* default in `talon.json`.
|
|
41
|
+
* 3. `config.model` — only when B is the global chat-role backend
|
|
42
|
+
* (`config.backend === B`).
|
|
43
|
+
* Operator picks (2/3) must validate against B's catalog when B
|
|
44
|
+
* has a canonical default to fall back to; an unknown pin falls
|
|
45
|
+
* through to step 4 (what the boot-time model audit warns about).
|
|
46
|
+
* 4. `backend.models?.getDefaultModelId()` — backend's canonical default.
|
|
40
47
|
* Codex picks auth-aware (`gpt-5-codex` on API key, `gpt-5.5`
|
|
41
48
|
* on ChatGPT OAuth). Claude SDK returns the `"default"` alias.
|
|
42
49
|
* Stock OpenAI Agents returns a constant.
|
|
43
50
|
* Catalog-driven backends without a canonical (Kilo, OpenCode,
|
|
44
51
|
* OpenAI Agents on OpenRouter / custom OpenAI-compatible) do
|
|
45
|
-
* NOT implement this
|
|
46
|
-
* 3. `config.backendDefaults[B]` — operator-configured per-backend
|
|
47
|
-
* default in `talon.json`. Escape hatch for "no canonical"
|
|
48
|
-
* backends.
|
|
49
|
-
* 4. `config.model` — only when B is the global chat-role backend
|
|
50
|
-
* (`config.backend === B`). Back-compat for installs that
|
|
51
|
-
* predate `backendDefaults`.
|
|
52
|
+
* NOT implement this.
|
|
52
53
|
* 5. `null` → UI renders "No model selected", send guard refuses
|
|
53
54
|
* with a "use /model to pick one" reply.
|
|
54
55
|
*
|
|
@@ -56,6 +57,10 @@
|
|
|
56
57
|
* is called. Only `kind: "exact"` with `selectable: true` honours the
|
|
57
58
|
* stored override. Anything else falls through to step 2.
|
|
58
59
|
*
|
|
60
|
+
* Operator config ranks above the canonical so a pinned `config.model`
|
|
61
|
+
* is actually honoured (it used to lose to Claude's `"default"`, so a
|
|
62
|
+
* claude install pinned to any model silently ran the default).
|
|
63
|
+
*
|
|
59
64
|
* Backends with no `resolveModel` (rare — defensive fallback only)
|
|
60
65
|
* have their stored override returned verbatim — no way to validate.
|
|
61
66
|
*/
|
|
@@ -157,8 +162,8 @@ async function runChain(
|
|
|
157
162
|
}
|
|
158
163
|
}
|
|
159
164
|
|
|
160
|
-
// ──
|
|
161
|
-
//
|
|
165
|
+
// ── Steps 2-5: backendDefaults → config.model (chat-role only) →
|
|
166
|
+
// backend canonical → null
|
|
162
167
|
return stepsTwoThroughFive(backend, backendId, config, null);
|
|
163
168
|
}
|
|
164
169
|
|
|
@@ -196,57 +201,61 @@ async function stepsTwoThroughFive(
|
|
|
196
201
|
config: TalonConfig,
|
|
197
202
|
fallbackSourceOverride: "override-invalid-fallback" | null,
|
|
198
203
|
): Promise<{ model: string | null; source: ActiveModelSource }> {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
204
|
+
const withSource = (model: string, source: ActiveModelSource) => ({
|
|
205
|
+
model,
|
|
206
|
+
source: fallbackSourceOverride ?? source,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const operatorPick = pickOperatorDefault(backendId, config);
|
|
210
|
+
const canonical = backend?.models ? await safeBackendDefault(backend) : null;
|
|
211
|
+
|
|
212
|
+
// Operator config (steps 2/3) beats the backend canonical (step 4): a
|
|
213
|
+
// pinned `config.model` is an explicit choice, and letting the canonical
|
|
214
|
+
// win meant a claude install pinned to e.g. "opus[1m]" silently ran
|
|
215
|
+
// "default" on every turn. The pin must still validate — a withdrawn id
|
|
216
|
+
// falls back to the canonical, exactly what the boot-time model audit
|
|
217
|
+
// warns about. Without a canonical to fall back to, the pin is returned
|
|
218
|
+
// unvalidated (catalog-driven backends with no default, unchanged).
|
|
219
|
+
if (operatorPick) {
|
|
220
|
+
if (!canonical) return withSource(operatorPick.model, operatorPick.source);
|
|
221
|
+
if (await validateModelOnBackend(backend, operatorPick.model)) {
|
|
222
|
+
return withSource(operatorPick.model, operatorPick.source);
|
|
207
223
|
}
|
|
208
224
|
}
|
|
209
225
|
|
|
210
|
-
// Step
|
|
226
|
+
// Step 4: backend.models.getDefaultModelId()
|
|
227
|
+
if (canonical) return withSource(canonical, "backend-canonical");
|
|
228
|
+
|
|
229
|
+
// Step 5: null. Callers must render "No model selected" / refuse send.
|
|
230
|
+
return { model: null, source: "none" };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The operator-configured default for a backend, if any:
|
|
235
|
+
* - `config.backendDefaults[B]`;
|
|
236
|
+
* - else `config.model` — only when B is the global chat-role backend
|
|
237
|
+
* (`config.backend === B`), or when no backend id is known at all
|
|
238
|
+
* (pre-bootstrap callers passing null).
|
|
239
|
+
*/
|
|
240
|
+
function pickOperatorDefault(
|
|
241
|
+
backendId: string | null,
|
|
242
|
+
config: TalonConfig,
|
|
243
|
+
): { model: string; source: ActiveModelSource } | null {
|
|
211
244
|
if (backendId && config.backendDefaults) {
|
|
212
245
|
const operatorDefault = config.backendDefaults[backendId];
|
|
213
246
|
if (operatorDefault && operatorDefault.length > 0) {
|
|
214
|
-
return {
|
|
215
|
-
model: operatorDefault,
|
|
216
|
-
source: fallbackSourceOverride ?? "config-backend-defaults",
|
|
217
|
-
};
|
|
247
|
+
return { model: operatorDefault, source: "config-backend-defaults" };
|
|
218
248
|
}
|
|
219
249
|
}
|
|
220
|
-
|
|
221
|
-
// Step 4: legacy config.model — only for the global chat-role backend
|
|
250
|
+
const modelAppliesHere = !backendId || backendId === config.backend;
|
|
222
251
|
if (
|
|
223
|
-
|
|
224
|
-
backendId === config.backend &&
|
|
252
|
+
modelAppliesHere &&
|
|
225
253
|
typeof config.model === "string" &&
|
|
226
254
|
config.model.length > 0
|
|
227
255
|
) {
|
|
228
|
-
return {
|
|
229
|
-
model: config.model,
|
|
230
|
-
source: fallbackSourceOverride ?? "config-legacy-global",
|
|
231
|
-
};
|
|
256
|
+
return { model: config.model, source: "config-legacy-global" };
|
|
232
257
|
}
|
|
233
|
-
|
|
234
|
-
// Step 4b: even without a backendId, honour config.model if no
|
|
235
|
-
// backend is bound at all (callers passing null for both — rare,
|
|
236
|
-
// typically pre-bootstrap code paths).
|
|
237
|
-
if (
|
|
238
|
-
!backendId &&
|
|
239
|
-
typeof config.model === "string" &&
|
|
240
|
-
config.model.length > 0
|
|
241
|
-
) {
|
|
242
|
-
return {
|
|
243
|
-
model: config.model,
|
|
244
|
-
source: fallbackSourceOverride ?? "config-legacy-global",
|
|
245
|
-
};
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Step 5: null. Callers must render "No model selected" / refuse send.
|
|
249
|
-
return { model: null, source: "none" };
|
|
258
|
+
return null;
|
|
250
259
|
}
|
|
251
260
|
|
|
252
261
|
async function validateModelOnBackend(
|
|
@@ -98,6 +98,15 @@ function registerPluginInstance(
|
|
|
98
98
|
return loaded;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Run a plugin's init, waiting at most `timeoutMs` for it before boot moves
|
|
103
|
+
* on. The deadline bounds how long boot waits, not the init itself: a
|
|
104
|
+
* plugin stays registered either way (its tools are served from
|
|
105
|
+
* `mcpServer` whether or not init finished), so an init that outlives the
|
|
106
|
+
* deadline keeps running and, when it does finish, clears the alert the
|
|
107
|
+
* timeout raised — a slow handshake on a busy boot is a delay, not a
|
|
108
|
+
* failure that lingers until the next restart.
|
|
109
|
+
*/
|
|
101
110
|
export async function initPluginWithTimeout(
|
|
102
111
|
plugin: TalonPlugin,
|
|
103
112
|
config: Record<string, unknown>,
|
|
@@ -108,28 +117,54 @@ export async function initPluginWithTimeout(
|
|
|
108
117
|
if (!plugin.init) return;
|
|
109
118
|
|
|
110
119
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
111
|
-
|
|
112
120
|
const alertKey = `plugin.${plugin.name}`;
|
|
121
|
+
const startedAt = Date.now();
|
|
122
|
+
// Started (and timed) only here, when this plugin's own init begins.
|
|
123
|
+
const init = Promise.resolve().then(() => plugin.init!(config));
|
|
124
|
+
const TIMED_OUT = Symbol("timed out");
|
|
125
|
+
|
|
113
126
|
try {
|
|
114
|
-
await Promise.race([
|
|
115
|
-
|
|
116
|
-
new Promise<
|
|
117
|
-
timer = setTimeout(() =>
|
|
118
|
-
reject(
|
|
119
|
-
new Error(`${timeoutLabel} timed out after ${timeoutMs / 1000}s`),
|
|
120
|
-
);
|
|
121
|
-
}, timeoutMs);
|
|
127
|
+
const outcome = await Promise.race([
|
|
128
|
+
init,
|
|
129
|
+
new Promise<typeof TIMED_OUT>((settle) => {
|
|
130
|
+
timer = setTimeout(() => settle(TIMED_OUT), timeoutMs);
|
|
122
131
|
timer.unref?.();
|
|
123
132
|
}),
|
|
124
133
|
]);
|
|
125
|
-
|
|
134
|
+
if (outcome !== TIMED_OUT) {
|
|
135
|
+
resolveAlert(alertKey, `Plugin "${plugin.name}" initialised normally.`);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const message = `${timeoutLabel} timed out after ${timeoutMs / 1000}s`;
|
|
139
|
+
logError("plugin", `${errorPrefix}: ${message}; still waiting for it`);
|
|
140
|
+
raiseAlert(
|
|
141
|
+
alertKey,
|
|
142
|
+
`Plugin "${plugin.name}" failed to initialise: ${message}. Its tools stay registered; this clears itself if init finishes late.`,
|
|
143
|
+
);
|
|
144
|
+
void init.then(
|
|
145
|
+
() => {
|
|
146
|
+
// Reloaded or unloaded meanwhile: this instance's verdict is moot.
|
|
147
|
+
if (registry.getByName(plugin.name)?.plugin !== plugin) return;
|
|
148
|
+
const took = Math.round((Date.now() - startedAt) / 1000);
|
|
149
|
+
log("plugin", `${plugin.name} init finished late (${took}s)`);
|
|
150
|
+
resolveAlert(
|
|
151
|
+
alertKey,
|
|
152
|
+
`Plugin "${plugin.name}" finished initialising late (${took}s).`,
|
|
153
|
+
);
|
|
154
|
+
},
|
|
155
|
+
(err: unknown) =>
|
|
156
|
+
logError(
|
|
157
|
+
"plugin",
|
|
158
|
+
`${errorPrefix} (after timing out): ${err instanceof Error ? err.message : err}`,
|
|
159
|
+
),
|
|
160
|
+
);
|
|
126
161
|
} catch (err) {
|
|
127
162
|
logError(
|
|
128
163
|
"plugin",
|
|
129
164
|
`${errorPrefix}: ${err instanceof Error ? err.message : err}`,
|
|
130
165
|
);
|
|
131
|
-
//
|
|
132
|
-
//
|
|
166
|
+
// An init that threw won't retry on its own: it needs a reload or
|
|
167
|
+
// restart, so this needs no threshold.
|
|
133
168
|
raiseAlert(
|
|
134
169
|
alertKey,
|
|
135
170
|
`Plugin "${plugin.name}" failed to initialise: ${faultText(err)}. Its tools may not work until the next reload or restart.`,
|
|
@@ -27,10 +27,19 @@
|
|
|
27
27
|
* can never leave the deployment un-updatable, which is what the old
|
|
28
28
|
* `pull --ff-only` did (it aborts on any dirty/diverged tree). `.gitignore`
|
|
29
29
|
* is respected (no `-x`), so node_modules, secrets and local config survive.
|
|
30
|
+
*
|
|
31
|
+
* Before anything in the checkout moves, a pinned pre-update checkpoint is
|
|
32
|
+
* taken. If it fails, the update is refused. An update with no way back is
|
|
33
|
+
* when data goes missing, so the operator must say `force` to go on
|
|
34
|
+
* without one. Opting out in config (`backup.checkpointBeforeUpdate:
|
|
35
|
+
* false`) is the only way to skip it without being asked.
|
|
30
36
|
*/
|
|
31
37
|
|
|
32
38
|
import { execFile } from "node:child_process";
|
|
33
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
checkpointBeforeUpdate,
|
|
41
|
+
type UpdateCheckpoint,
|
|
42
|
+
} from "../backup/index.js";
|
|
34
43
|
import { existsSync } from "node:fs";
|
|
35
44
|
import { dirname, join } from "node:path";
|
|
36
45
|
import { fileURLToPath } from "node:url";
|
|
@@ -60,6 +69,13 @@ export interface UpdateOptions {
|
|
|
60
69
|
entry?: { cmd: string; args: readonly string[] };
|
|
61
70
|
/** Injectable command runner (tests). */
|
|
62
71
|
runner?: CommandRunner;
|
|
72
|
+
/**
|
|
73
|
+
* Go on even when the pre-update checkpoint fails (`/update force`).
|
|
74
|
+
* The result still says the checkpoint failed.
|
|
75
|
+
*/
|
|
76
|
+
force?: boolean;
|
|
77
|
+
/** Injectable pre-update checkpoint (tests). */
|
|
78
|
+
checkpoint?: (from: string, to: string) => Promise<UpdateCheckpoint>;
|
|
63
79
|
}
|
|
64
80
|
|
|
65
81
|
/** One executed step in an update run. */
|
|
@@ -82,6 +98,13 @@ export interface UpdateResult {
|
|
|
82
98
|
changed: boolean;
|
|
83
99
|
/** Human-readable failure reason when `ok` is false. */
|
|
84
100
|
error?: string;
|
|
101
|
+
/** The pre-update checkpoint, when the update got far enough to take one. */
|
|
102
|
+
checkpoint?: UpdateCheckpoint;
|
|
103
|
+
/**
|
|
104
|
+
* True when the update was refused because the checkpoint failed. Nothing
|
|
105
|
+
* in the checkout was touched; `force` would have gone on.
|
|
106
|
+
*/
|
|
107
|
+
checkpointRefused?: boolean;
|
|
85
108
|
}
|
|
86
109
|
|
|
87
110
|
export type CommandRunner = (
|
|
@@ -171,12 +194,7 @@ export async function runSelfUpdate(
|
|
|
171
194
|
const branch = opts.branch?.trim() || "main";
|
|
172
195
|
const run = opts.runner ?? defaultRunner;
|
|
173
196
|
|
|
174
|
-
const record = async (
|
|
175
|
-
label: string,
|
|
176
|
-
cmd: string,
|
|
177
|
-
args: readonly string[],
|
|
178
|
-
timeoutMs: number,
|
|
179
|
-
): Promise<UpdateStep> => {
|
|
197
|
+
const record: Recorder = async (label, cmd, args, timeoutMs) => {
|
|
180
198
|
const { ok, output } = await run(cmd, args, repoRoot, timeoutMs);
|
|
181
199
|
const step: UpdateStep = { label, ok, output };
|
|
182
200
|
steps.push(step);
|
|
@@ -210,22 +228,58 @@ export async function runSelfUpdate(
|
|
|
210
228
|
);
|
|
211
229
|
if (!fetch.ok) return fail(`git fetch failed: ${fetch.output}`, before);
|
|
212
230
|
|
|
231
|
+
// Where the update is going, read before the tree moves: the checkpoint
|
|
232
|
+
// is labelled with it, and "already up to date" needs no checkpoint.
|
|
233
|
+
const targetRef = `${remote}/${branch}`;
|
|
234
|
+
const target = await record(
|
|
235
|
+
`rev-parse ${targetRef}`,
|
|
236
|
+
"git",
|
|
237
|
+
["rev-parse", targetRef],
|
|
238
|
+
GIT_TIMEOUT_MS,
|
|
239
|
+
);
|
|
240
|
+
if (!target.ok) {
|
|
241
|
+
return fail(`Failed to read ${targetRef}: ${target.output}`, before);
|
|
242
|
+
}
|
|
243
|
+
const upcoming = shortSha(target.output);
|
|
244
|
+
|
|
245
|
+
// The safety net goes up before anything is destroyed: the reset and
|
|
246
|
+
// clean below discard local state, and the new code may migrate data.
|
|
247
|
+
let checkpoint: UpdateCheckpoint | undefined;
|
|
248
|
+
if (upcoming !== before) {
|
|
249
|
+
checkpoint = await (opts.checkpoint ?? checkpointBeforeUpdate)(
|
|
250
|
+
before,
|
|
251
|
+
upcoming,
|
|
252
|
+
);
|
|
253
|
+
steps.push({
|
|
254
|
+
label: "pre-update checkpoint",
|
|
255
|
+
ok: checkpoint.status !== "failed",
|
|
256
|
+
output: describeCheckpoint(checkpoint),
|
|
257
|
+
});
|
|
258
|
+
if (checkpoint.status === "failed" && !opts.force) {
|
|
259
|
+
return {
|
|
260
|
+
...fail(refusal(checkpoint.error), before),
|
|
261
|
+
checkpoint,
|
|
262
|
+
checkpointRefused: true,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
213
267
|
// Force the checkout to exactly match the freshly-fetched remote
|
|
214
268
|
// branch, discarding ANY local edits or diverged commits. The old
|
|
215
269
|
// `pull --ff-only` aborted here whenever the tree was dirty, leaving the
|
|
216
270
|
// deployment stuck; a bot host is meant to mirror the remote, so resetting
|
|
217
271
|
// to it is both correct and reliable.
|
|
218
272
|
const reset = await record(
|
|
219
|
-
`reset --hard ${
|
|
273
|
+
`reset --hard ${targetRef}`,
|
|
220
274
|
"git",
|
|
221
|
-
["reset", "--hard",
|
|
275
|
+
["reset", "--hard", targetRef],
|
|
222
276
|
GIT_TIMEOUT_MS,
|
|
223
277
|
);
|
|
224
278
|
if (!reset.ok) {
|
|
225
|
-
return
|
|
226
|
-
`git reset --hard ${
|
|
227
|
-
|
|
228
|
-
|
|
279
|
+
return {
|
|
280
|
+
...fail(`git reset --hard ${targetRef} failed: ${reset.output}`, before),
|
|
281
|
+
checkpoint,
|
|
282
|
+
};
|
|
229
283
|
}
|
|
230
284
|
|
|
231
285
|
// Drop untracked files too so the tree is pristine and a future update
|
|
@@ -246,28 +300,62 @@ export async function runSelfUpdate(
|
|
|
246
300
|
// Nothing moved — skip the expensive install/setup and tell the
|
|
247
301
|
// caller no restart is needed.
|
|
248
302
|
if (!changed) {
|
|
249
|
-
return {
|
|
303
|
+
return {
|
|
304
|
+
ok: true,
|
|
305
|
+
repoRoot,
|
|
306
|
+
steps,
|
|
307
|
+
before,
|
|
308
|
+
after,
|
|
309
|
+
changed: false,
|
|
310
|
+
checkpoint,
|
|
311
|
+
};
|
|
250
312
|
}
|
|
251
313
|
|
|
252
|
-
await
|
|
314
|
+
const error = await installAndVerify(record, opts, before, after);
|
|
315
|
+
return {
|
|
316
|
+
ok: !error,
|
|
317
|
+
repoRoot,
|
|
318
|
+
steps,
|
|
319
|
+
before,
|
|
320
|
+
after,
|
|
321
|
+
changed: true,
|
|
322
|
+
checkpoint,
|
|
323
|
+
...(error ? { error } : {}),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
type Recorder = (
|
|
328
|
+
label: string,
|
|
329
|
+
cmd: string,
|
|
330
|
+
args: readonly string[],
|
|
331
|
+
timeoutMs: number,
|
|
332
|
+
) => Promise<UpdateStep>;
|
|
253
333
|
|
|
334
|
+
function refusal(error: string): string {
|
|
335
|
+
return (
|
|
336
|
+
`the pre-update checkpoint failed (${error}). ` +
|
|
337
|
+
`Nothing was changed. Fix the backup problem, or run the update ` +
|
|
338
|
+
`with "force" to go on without a checkpoint.`
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Reinstall, run setup, and prove the new tree imports. Returns the
|
|
344
|
+
* failure reason, or null when the tree is ready to restart into.
|
|
345
|
+
*/
|
|
346
|
+
async function installAndVerify(
|
|
347
|
+
record: Recorder,
|
|
348
|
+
opts: UpdateOptions,
|
|
349
|
+
before: string,
|
|
350
|
+
after: string,
|
|
351
|
+
): Promise<string | null> {
|
|
254
352
|
const install = await record(
|
|
255
353
|
"npm install",
|
|
256
354
|
"npm",
|
|
257
355
|
["install"],
|
|
258
356
|
INSTALL_TIMEOUT_MS,
|
|
259
357
|
);
|
|
260
|
-
if (!install.ok) {
|
|
261
|
-
return {
|
|
262
|
-
ok: false,
|
|
263
|
-
repoRoot,
|
|
264
|
-
steps,
|
|
265
|
-
before,
|
|
266
|
-
after,
|
|
267
|
-
changed,
|
|
268
|
-
error: `npm install failed: ${install.output}`,
|
|
269
|
-
};
|
|
270
|
-
}
|
|
358
|
+
if (!install.ok) return `npm install failed: ${install.output}`;
|
|
271
359
|
|
|
272
360
|
for (const cmd of opts.setup ?? []) {
|
|
273
361
|
const trimmed = cmd.trim();
|
|
@@ -279,15 +367,7 @@ export async function runSelfUpdate(
|
|
|
279
367
|
SETUP_TIMEOUT_MS,
|
|
280
368
|
);
|
|
281
369
|
if (!setup.ok) {
|
|
282
|
-
return {
|
|
283
|
-
ok: false,
|
|
284
|
-
repoRoot,
|
|
285
|
-
steps,
|
|
286
|
-
before,
|
|
287
|
-
after,
|
|
288
|
-
changed,
|
|
289
|
-
error: `setup command failed (${trimmed}): ${setup.output}`,
|
|
290
|
-
};
|
|
370
|
+
return `setup command failed (${trimmed}): ${setup.output}`;
|
|
291
371
|
}
|
|
292
372
|
}
|
|
293
373
|
|
|
@@ -299,18 +379,35 @@ export async function runSelfUpdate(
|
|
|
299
379
|
VERIFY_TIMEOUT_MS,
|
|
300
380
|
);
|
|
301
381
|
if (!verify.ok || !verify.output.includes(BOOT_SMOKE_OK)) {
|
|
302
|
-
return
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
382
|
+
return (
|
|
383
|
+
`the updated tree does not import — not restarting into it. ` +
|
|
384
|
+
`Still running ${before}; the checkout is at ${after}.`
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** One line for the step log and the chat reply. */
|
|
391
|
+
export function describeCheckpoint(checkpoint: UpdateCheckpoint): string {
|
|
392
|
+
switch (checkpoint.status) {
|
|
393
|
+
case "taken":
|
|
394
|
+
return `checkpoint ${checkpoint.id} taken`;
|
|
395
|
+
case "disabled":
|
|
396
|
+
return "no checkpoint (backup.checkpointBeforeUpdate is off)";
|
|
397
|
+
case "failed":
|
|
398
|
+
return `checkpoint failed: ${checkpoint.error}`;
|
|
313
399
|
}
|
|
400
|
+
}
|
|
314
401
|
|
|
315
|
-
|
|
402
|
+
/**
|
|
403
|
+
* Whether the arguments to `/update` ask to go on without a checkpoint.
|
|
404
|
+
* `force` and `--force` both work, so the chat command and a CLI habit
|
|
405
|
+
* read the same.
|
|
406
|
+
*/
|
|
407
|
+
export function wantsForce(args: string | undefined | null): boolean {
|
|
408
|
+
return (args ?? "")
|
|
409
|
+
.trim()
|
|
410
|
+
.toLowerCase()
|
|
411
|
+
.split(/\s+/)
|
|
412
|
+
.some((token) => token === "force" || token === "--force");
|
|
316
413
|
}
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
import {
|
|
33
33
|
getRepoRoot,
|
|
34
34
|
runSelfUpdate,
|
|
35
|
+
describeCheckpoint,
|
|
35
36
|
} from "../../../core/update/self-update.js";
|
|
36
37
|
import { reply } from "./interaction.js";
|
|
37
38
|
|
|
@@ -153,7 +154,8 @@ export async function handleDream(
|
|
|
153
154
|
}
|
|
154
155
|
|
|
155
156
|
/**
|
|
156
|
-
* /update — pull, reinstall, run setup, restart.
|
|
157
|
+
* /update [force] — pull, reinstall, run setup, restart. Refused when the
|
|
158
|
+
* pre-update checkpoint fails unless `force` is set. Only reachable on developer
|
|
157
159
|
* builds running from a git checkout; the command is not registered at all
|
|
158
160
|
* otherwise (see buildCommandDefinitions).
|
|
159
161
|
*/
|
|
@@ -172,8 +174,13 @@ export async function handleUpdate(
|
|
|
172
174
|
}
|
|
173
175
|
const remote = config.update?.remote ?? "origin";
|
|
174
176
|
const branch = config.update?.branch ?? "main";
|
|
177
|
+
const force = i.options.getBoolean("force") ?? false;
|
|
175
178
|
await i.deferReply({ flags: MessageFlags.Ephemeral });
|
|
176
|
-
await i.editReply(
|
|
179
|
+
await i.editReply(
|
|
180
|
+
`⏳ Updating from \`${remote}/${branch}\`` +
|
|
181
|
+
(force ? " (forced: a failed checkpoint will not stop it)" : "") +
|
|
182
|
+
"…",
|
|
183
|
+
);
|
|
177
184
|
const edit = (text: string) => i.editReply(safeSlice(text, DISCORD_MAX_TEXT));
|
|
178
185
|
|
|
179
186
|
// Fire-and-forget so the gateway keeps processing other interactions.
|
|
@@ -182,12 +189,24 @@ export async function handleUpdate(
|
|
|
182
189
|
branch,
|
|
183
190
|
setup: config.update?.setup,
|
|
184
191
|
repoRoot,
|
|
192
|
+
force,
|
|
185
193
|
})
|
|
186
194
|
.then(async (res) => {
|
|
195
|
+
if (res.checkpointRefused) {
|
|
196
|
+
await edit(
|
|
197
|
+
`🛑 Update refused: ${res.error ?? "the pre-update checkpoint failed"}\n\n` +
|
|
198
|
+
"Run `/update force:true` to update without a checkpoint.",
|
|
199
|
+
);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const note = res.checkpoint
|
|
203
|
+
? `\n${describeCheckpoint(res.checkpoint)}`
|
|
204
|
+
: "";
|
|
187
205
|
if (!res.ok) {
|
|
188
206
|
const tail = res.steps[res.steps.length - 1]?.output ?? "";
|
|
189
207
|
await edit(
|
|
190
208
|
`⚠️ Update failed: ${res.error ?? "unknown error"}` +
|
|
209
|
+
note +
|
|
191
210
|
(tail
|
|
192
211
|
? `\n\`\`\`\n${escapeForCodeBlock(tail.slice(-1200))}\n\`\`\``
|
|
193
212
|
: ""),
|
|
@@ -201,7 +220,7 @@ export async function handleUpdate(
|
|
|
201
220
|
return;
|
|
202
221
|
}
|
|
203
222
|
await edit(
|
|
204
|
-
`✅ Updated \`${res.before ?? "?"}\` → \`${res.after ?? "?"}
|
|
223
|
+
`✅ Updated \`${res.before ?? "?"}\` → \`${res.after ?? "?"}\`.${note}\n♻️ Restarting…`,
|
|
205
224
|
);
|
|
206
225
|
// The successor documents any provisioning changes (plugin runtime
|
|
207
226
|
// upgrades, migrations) back to this channel once it's up.
|
|
@@ -156,6 +156,13 @@ function buildCommandDefinitions(devBuild = false): unknown[] {
|
|
|
156
156
|
new SlashCommandBuilder()
|
|
157
157
|
.setName("update")
|
|
158
158
|
.setDescription("Pull latest, reinstall, restart (admin)")
|
|
159
|
+
.addBooleanOption((o) =>
|
|
160
|
+
o
|
|
161
|
+
.setName("force")
|
|
162
|
+
.setDescription(
|
|
163
|
+
"Update even if the pre-update checkpoint fails",
|
|
164
|
+
),
|
|
165
|
+
)
|
|
159
166
|
.toJSON(),
|
|
160
167
|
]
|
|
161
168
|
: []),
|