talon-agent 5.2.1 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/package.json +2 -2
  2. package/src/app.ts +94 -1
  3. package/src/backend/codex/mcp-config.ts +1 -1
  4. package/src/backend/openai-agents/mcp-pool.ts +1 -1
  5. package/src/backend/runtime/index.ts +1 -1
  6. package/src/cli/commands/backup.ts +396 -0
  7. package/src/cli/events.ts +14 -0
  8. package/src/cli/index.ts +64 -45
  9. package/src/core/backup/archive/digest.ts +77 -0
  10. package/src/core/backup/archive/tar.ts +567 -0
  11. package/src/core/backup/archive/zstd.ts +31 -0
  12. package/src/core/backup/index.ts +54 -0
  13. package/src/core/backup/plan.ts +273 -0
  14. package/src/core/backup/restore.ts +410 -0
  15. package/src/core/backup/scheduler.ts +357 -0
  16. package/src/core/backup/snapshot.ts +408 -0
  17. package/src/core/backup/status.ts +194 -0
  18. package/src/core/backup/store.ts +312 -0
  19. package/src/core/backup/targets.ts +281 -0
  20. package/src/core/backup/types.ts +96 -0
  21. package/src/core/backup/upload.ts +172 -0
  22. package/src/core/bus/events.ts +45 -1
  23. package/src/core/config/index.ts +52 -0
  24. package/src/core/daemon/handoff.ts +192 -0
  25. package/src/core/daemon/respawn.ts +127 -52
  26. package/src/core/engine/gateway-actions/backup/index.ts +129 -0
  27. package/src/core/engine/gateway-actions/index.ts +4 -0
  28. package/src/core/mcp-hub/talon-server.ts +1 -1
  29. package/src/core/plugin/actions.ts +34 -0
  30. package/src/core/plugin/index.ts +5 -1
  31. package/src/core/tools/{ops/bridge.ts → bridge.ts} +7 -2
  32. package/src/core/tools/index.ts +2 -0
  33. package/src/core/tools/ops/backup.ts +67 -0
  34. package/src/core/tools/types.ts +2 -1
  35. package/src/core/update/self-update.ts +47 -0
  36. package/src/frontend/discord/callbacks/components/index.ts +3 -0
  37. package/src/frontend/discord/commands/backup.ts +203 -0
  38. package/src/frontend/discord/commands/definitions.ts +35 -0
  39. package/src/frontend/discord/commands/router.ts +3 -0
  40. package/src/frontend/telegram/callbacks/backup.ts +55 -0
  41. package/src/frontend/telegram/callbacks/index.ts +8 -0
  42. package/src/frontend/telegram/commands/backup.ts +209 -0
  43. package/src/frontend/telegram/commands/definitions.ts +4 -0
  44. package/src/frontend/telegram/commands/index.ts +2 -0
  45. package/src/index.ts +13 -5
  46. package/src/plugins/playwright/index.ts +39 -3
  47. package/src/plugins/playwright/provision.ts +21 -0
  48. package/src/plugins/playwright/version-coupling.ts +195 -0
  49. package/src/storage/backup/index.ts +82 -0
  50. package/src/storage/backup/repo.ts +164 -0
  51. package/src/storage/db.ts +20 -0
  52. package/src/storage/sql/backups.sql +46 -0
  53. package/src/storage/sql/db.sql +8 -0
  54. package/src/storage/sql/schema.sql +30 -0
  55. package/src/storage/sql/statements.generated.ts +60 -1
  56. package/src/util/log.ts +129 -81
  57. package/src/util/paths.ts +5 -0
  58. /package/src/core/tools/{ops/mcp-env.ts → mcp-env.ts} +0 -0
@@ -0,0 +1,357 @@
1
+ /**
2
+ * The backup scheduler — one snapshot at a time, forever.
3
+ *
4
+ * Not a task: like the pulse ticker, this is not agent work, so it does
5
+ * not appear in the task table and costs no tokens. It is a timer, a
6
+ * mutex and a backoff window.
7
+ *
8
+ * Timing. The first run is five minutes after boot when the newest
9
+ * snapshot is already older than `intervalHours` (a daemon that restarts
10
+ * often must still back up, but not while it is still booting); if a
11
+ * recent snapshot exists the first run is when that one comes due. Every
12
+ * tick recomputes its own next delay from `Date.now()`, so a clock jump
13
+ * or a suspended laptop resynchronises instead of drifting or stampeding.
14
+ *
15
+ * Serialisation. Every request — scheduled, `/backup now`, a tool, the
16
+ * pre-update hook — goes through one queue. A manual request made while
17
+ * a snapshot is running waits for it rather than racing it, because two
18
+ * `VACUUM INTO`s and two tar streams over the same workspace is how a
19
+ * backup subsystem becomes the reason the daemon is slow.
20
+ *
21
+ * Failure. A failing run backs off (5 → 60 min, shared with the other
22
+ * background agents) and tells the admin ONCE per streak. The daily
23
+ * "backup failed" message nobody reads is worse than no message.
24
+ */
25
+
26
+ import { FailureBackoff } from "../background/failure-backoff.js";
27
+ import { TalonError } from "../errors.js";
28
+ import { notifyAdmin } from "../frontend-runtime/admin-notify.js";
29
+ import { bus } from "../bus/index.js";
30
+ import { log, logError } from "../../util/log.js";
31
+ import { dirs } from "../../util/paths.js";
32
+ import { buildSnapshot } from "./snapshot.js";
33
+ import { listLocalManifests, pruneLocal, reconcileIndex } from "./store.js";
34
+ import { discoverTargets, selectTargets } from "./targets.js";
35
+ import { pruneRemote, uploadSnapshot } from "./upload.js";
36
+ import type { BackupSettings, Manifest, SnapshotKind } from "./types.js";
37
+
38
+ /** How long after boot the first scheduled snapshot may run. */
39
+ export const BOOT_DELAY_MS = 5 * 60_000;
40
+ const HOUR_MS = 60 * 60_000;
41
+
42
+ export type RunRequest = {
43
+ kind: SnapshotKind;
44
+ label?: string;
45
+ pinned?: boolean;
46
+ /** Why this run happened: "schedule", "manual", "pre-update", … */
47
+ trigger: string;
48
+ /** Skip remote upload — used by the pre-restore checkpoint at boot. */
49
+ localOnly?: boolean;
50
+ };
51
+
52
+ /** Test seam: the expensive collaborators, swappable in unit tests. */
53
+ export const _backupDeps = {
54
+ build: buildSnapshot,
55
+ discover: discoverTargets,
56
+ upload: uploadSnapshot,
57
+ pruneLocal,
58
+ pruneRemote,
59
+ };
60
+
61
+ type SchedulerState = {
62
+ settings: BackupSettings | null;
63
+ home: string;
64
+ notify: (text: string) => Promise<unknown>;
65
+ timer: ReturnType<typeof setTimeout> | null;
66
+ queue: Promise<unknown>;
67
+ running: boolean;
68
+ /**
69
+ * Set by `stopBackupScheduler`. A tick that is mid-run when shutdown
70
+ * starts still reaches its `schedule()` call afterwards — without this
71
+ * flag it would re-arm the timer the shutdown just cleared, and the
72
+ * daemon would keep a live handle it believes it released.
73
+ */
74
+ stopped: boolean;
75
+ /**
76
+ * Bumped by every init and stop. A timer armed by an earlier
77
+ * configuration (or an earlier test) fires into a scheduler that has
78
+ * since been reconfigured; comparing generations makes that tick a
79
+ * no-op instead of a run nobody asked for.
80
+ */
81
+ generation: number;
82
+ lastRunAt: number;
83
+ lastSnapshotId: string | undefined;
84
+ lastError: string | undefined;
85
+ nextRunAt: number | undefined;
86
+ };
87
+
88
+ const state: SchedulerState = {
89
+ settings: null,
90
+ home: dirs.root,
91
+ notify: notifyAdmin,
92
+ timer: null,
93
+ queue: Promise.resolve(),
94
+ running: false,
95
+ stopped: false,
96
+ generation: 0,
97
+ lastRunAt: 0,
98
+ lastSnapshotId: undefined,
99
+ lastError: undefined,
100
+ nextRunAt: undefined,
101
+ };
102
+
103
+ /**
104
+ * Milliseconds until the first scheduled run. Pure, so the boot-delay and
105
+ * already-recent cases are testable without a clock.
106
+ */
107
+ export function firstRunDelayMs(
108
+ newestCreatedAt: number | undefined,
109
+ intervalHours: number,
110
+ now: number,
111
+ ): number {
112
+ if (newestCreatedAt === undefined) return BOOT_DELAY_MS;
113
+ const due = newestCreatedAt + intervalHours * HOUR_MS;
114
+ return Math.max(BOOT_DELAY_MS, due - now);
115
+ }
116
+
117
+ // ── Runs ────────────────────────────────────────────────────────────────────
118
+
119
+ const backoff = new FailureBackoff();
120
+
121
+ async function executeRun(request: RunRequest): Promise<Manifest> {
122
+ const settings = state.settings;
123
+ if (!settings) {
124
+ throw new TalonError("Backup subsystem is not initialised", {
125
+ reason: "bad_request",
126
+ });
127
+ }
128
+ state.running = true;
129
+ const started = Date.now();
130
+ bus.publish({
131
+ type: "backup.started",
132
+ kind: request.kind,
133
+ trigger: request.trigger,
134
+ });
135
+ try {
136
+ const manifest = await _backupDeps.build({
137
+ kind: request.kind,
138
+ label: request.label,
139
+ pinned: request.pinned,
140
+ settings,
141
+ home: state.home,
142
+ });
143
+ state.lastRunAt = Date.now();
144
+ state.lastSnapshotId = manifest.id;
145
+ state.lastError = undefined;
146
+ bus.publish({
147
+ type: "backup.completed",
148
+ snapshotId: manifest.id,
149
+ kind: manifest.kind,
150
+ sizeBytes: manifest.sizeBytes,
151
+ parts: manifest.parts.length,
152
+ durationMs: Date.now() - started,
153
+ });
154
+ await _backupDeps.pruneLocal(settings.keepLocal, state.home);
155
+ if (!request.localOnly) {
156
+ const targets = selectTargets(
157
+ await _backupDeps.discover(),
158
+ settings.targets,
159
+ );
160
+ if (targets.length > 0) {
161
+ await _backupDeps.upload(manifest, targets, state.home);
162
+ await _backupDeps.pruneRemote(targets, settings.keepRemote);
163
+ }
164
+ }
165
+ backoff.succeed();
166
+ return manifest;
167
+ } catch (err) {
168
+ const message = err instanceof Error ? err.message : String(err);
169
+ state.lastError = message;
170
+ const until = backoff.fail(err);
171
+ bus.publish({
172
+ type: "backup.failed",
173
+ trigger: request.trigger,
174
+ error: message,
175
+ consecutiveFailures: backoff.failures,
176
+ });
177
+ logError("backup", `Run failed (${request.trigger})`, err);
178
+ // Once per streak: the second identical failure tells the admin nothing
179
+ // the first one did not.
180
+ if (backoff.failures === 1) {
181
+ await state
182
+ .notify(
183
+ `⚠️ Backup failed: ${message}\nRetrying after ${new Date(until).toISOString().slice(11, 16)} UTC.`,
184
+ )
185
+ .catch(() => {
186
+ /* the notifier logs its own failures */
187
+ });
188
+ }
189
+ throw err;
190
+ } finally {
191
+ state.running = false;
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Take a snapshot. Requests queue behind whatever is already running, so
197
+ * this resolves with THIS request's snapshot, not someone else's.
198
+ */
199
+ export function runBackup(request: RunRequest): Promise<Manifest> {
200
+ const result = state.queue.then(
201
+ () => executeRun(request),
202
+ () => executeRun(request),
203
+ );
204
+ state.queue = result.catch(() => undefined);
205
+ return result;
206
+ }
207
+
208
+ // ── The timer ───────────────────────────────────────────────────────────────
209
+
210
+ function schedule(delayMs: number, generation = state.generation): void {
211
+ if (state.stopped || generation !== state.generation) return;
212
+ if (state.timer) clearTimeout(state.timer);
213
+ state.nextRunAt = Date.now() + delayMs;
214
+ state.timer = setTimeout(() => void tick(generation), delayMs);
215
+ state.timer.unref?.();
216
+ }
217
+
218
+ async function tick(generation: number): Promise<void> {
219
+ const settings = state.settings;
220
+ if (state.stopped || generation !== state.generation || !settings?.enabled) {
221
+ return;
222
+ }
223
+ const intervalMs = settings.intervalHours * HOUR_MS;
224
+ // Recomputed every tick: a suspended machine or a stepped clock lands
225
+ // here late, and the answer is "run now", not "run N missed times".
226
+ if (backoff.active()) {
227
+ schedule(Math.min(intervalMs, BOOT_DELAY_MS), generation);
228
+ return;
229
+ }
230
+ try {
231
+ await runBackup({ kind: "backup", trigger: "schedule" });
232
+ } catch {
233
+ /* executeRun logged, notified and armed the backoff */
234
+ }
235
+ schedule(intervalMs, generation);
236
+ }
237
+
238
+ /**
239
+ * Wire the subsystem and arm the timer. Safe to call with backups
240
+ * disabled — it reconciles the index either way, so the listing surfaces
241
+ * stay truthful on a deployment that only takes manual checkpoints.
242
+ */
243
+ export async function initBackup(options: {
244
+ settings: BackupSettings;
245
+ home?: string;
246
+ notify?: (text: string) => Promise<unknown>;
247
+ }): Promise<void> {
248
+ state.settings = options.settings;
249
+ state.stopped = false;
250
+ state.generation += 1;
251
+ state.nextRunAt = undefined;
252
+ state.home = options.home ?? dirs.root;
253
+ state.notify = options.notify ?? notifyAdmin;
254
+ await reconcileIndex(state.home);
255
+ const newest = (await listLocalManifests(state.home))[0];
256
+ state.lastRunAt = newest?.createdAt ?? 0;
257
+ state.lastSnapshotId = newest?.id;
258
+ if (!options.settings.enabled) {
259
+ log(
260
+ "backup",
261
+ "Scheduled backups are disabled (config.backup.enabled=false)",
262
+ );
263
+ return;
264
+ }
265
+ const delay = firstRunDelayMs(
266
+ newest?.createdAt,
267
+ options.settings.intervalHours,
268
+ Date.now(),
269
+ );
270
+ schedule(delay);
271
+ log(
272
+ "backup",
273
+ `Scheduled every ${options.settings.intervalHours}h; first run in ` +
274
+ `${Math.round(delay / 60_000)}m (keep ${options.settings.keepLocal} local, ` +
275
+ `${options.settings.keepRemote} remote)`,
276
+ );
277
+ }
278
+
279
+ export function stopBackupScheduler(): void {
280
+ state.stopped = true;
281
+ state.generation += 1;
282
+ if (state.timer) clearTimeout(state.timer);
283
+ state.timer = null;
284
+ state.nextRunAt = undefined;
285
+ }
286
+
287
+ /** What the status surfaces report about the schedule itself. */
288
+ export function schedulerStatus(): {
289
+ enabled: boolean;
290
+ running: boolean;
291
+ intervalHours: number | undefined;
292
+ lastRunAt: number | undefined;
293
+ lastSnapshotId: string | undefined;
294
+ nextRunAt: number | undefined;
295
+ consecutiveFailures: number;
296
+ lastError: string | undefined;
297
+ } {
298
+ return {
299
+ enabled: state.settings?.enabled ?? false,
300
+ running: state.running,
301
+ intervalHours: state.settings?.intervalHours,
302
+ lastRunAt: state.lastRunAt || undefined,
303
+ lastSnapshotId: state.lastSnapshotId,
304
+ nextRunAt: state.nextRunAt,
305
+ consecutiveFailures: backoff.failures,
306
+ lastError: state.lastError,
307
+ };
308
+ }
309
+
310
+ /**
311
+ * The self-update hook: a pinned checkpoint before the tree moves, so a
312
+ * bad update is one `talon backup restore` away from undone. Returns the
313
+ * checkpoint id, or null when the feature is off or the subsystem is not
314
+ * initialised (a CLI-driven update in a process with no daemon state).
315
+ * Never throws: failing to take a checkpoint must not block the update.
316
+ */
317
+ export async function checkpointBeforeUpdate(
318
+ fromVersion: string,
319
+ toVersion: string,
320
+ ): Promise<string | null> {
321
+ if (!state.settings?.checkpointBeforeUpdate) return null;
322
+ try {
323
+ const manifest = await runBackup({
324
+ kind: "checkpoint",
325
+ label: `pre-update ${fromVersion}→${toVersion}`,
326
+ pinned: true,
327
+ trigger: "pre-update",
328
+ });
329
+ return manifest.id;
330
+ } catch (err) {
331
+ logError(
332
+ "backup",
333
+ "Pre-update checkpoint failed; continuing with the update",
334
+ err,
335
+ );
336
+ return null;
337
+ }
338
+ }
339
+
340
+ /** The settings this process is running with (surfaces render them). */
341
+ export function backupSettings(): BackupSettings | null {
342
+ return state.settings;
343
+ }
344
+
345
+ /** Reset every module-level holder — tests only. */
346
+ export function _resetBackupScheduler(): void {
347
+ stopBackupScheduler();
348
+ state.settings = null;
349
+ state.home = dirs.root;
350
+ state.notify = notifyAdmin;
351
+ state.queue = Promise.resolve();
352
+ state.running = false;
353
+ state.lastRunAt = 0;
354
+ state.lastSnapshotId = undefined;
355
+ state.lastError = undefined;
356
+ backoff.succeed();
357
+ }