talon-agent 5.2.2 → 5.4.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 (74) hide show
  1. package/README.md +10 -6
  2. package/package.json +1 -1
  3. package/src/app.ts +78 -0
  4. package/src/backend/agy/auth.ts +128 -0
  5. package/src/backend/agy/constants.ts +88 -0
  6. package/src/backend/agy/doctor.ts +161 -0
  7. package/src/backend/agy/effort.ts +76 -0
  8. package/src/backend/agy/events.ts +402 -0
  9. package/src/backend/agy/factory.ts +137 -0
  10. package/src/backend/agy/handler/index.ts +9 -0
  11. package/src/backend/agy/handler/message.ts +444 -0
  12. package/src/backend/agy/init.ts +64 -0
  13. package/src/backend/agy/mcp/config.ts +336 -0
  14. package/src/backend/agy/mcp/register.ts +134 -0
  15. package/src/backend/agy/models.ts +335 -0
  16. package/src/backend/agy/one-shot.ts +301 -0
  17. package/src/backend/agy/process/child.ts +378 -0
  18. package/src/backend/agy/process/orphans.ts +106 -0
  19. package/src/backend/agy/sessions.ts +90 -0
  20. package/src/backend/agy/state.ts +77 -0
  21. package/src/backend/builtins.ts +1 -0
  22. package/src/backend/codex/mcp-config.ts +1 -1
  23. package/src/backend/openai-agents/mcp-pool.ts +1 -1
  24. package/src/backend/runtime/index.ts +1 -1
  25. package/src/cli/commands/backup.ts +396 -0
  26. package/src/cli/config-view.ts +5 -0
  27. package/src/cli/config.ts +4 -2
  28. package/src/cli/events.ts +14 -0
  29. package/src/cli/index.ts +64 -45
  30. package/src/cli/setup.ts +49 -0
  31. package/src/core/agent-runtime/model-ref.ts +1 -0
  32. package/src/core/backup/archive/digest.ts +77 -0
  33. package/src/core/backup/archive/tar.ts +567 -0
  34. package/src/core/backup/archive/zstd.ts +31 -0
  35. package/src/core/backup/index.ts +54 -0
  36. package/src/core/backup/plan.ts +273 -0
  37. package/src/core/backup/restore.ts +410 -0
  38. package/src/core/backup/scheduler.ts +357 -0
  39. package/src/core/backup/snapshot.ts +408 -0
  40. package/src/core/backup/status.ts +194 -0
  41. package/src/core/backup/store.ts +312 -0
  42. package/src/core/backup/targets.ts +281 -0
  43. package/src/core/backup/types.ts +96 -0
  44. package/src/core/backup/upload.ts +172 -0
  45. package/src/core/bus/events.ts +45 -1
  46. package/src/core/config/index.ts +53 -0
  47. package/src/core/engine/gateway-actions/backup/index.ts +129 -0
  48. package/src/core/engine/gateway-actions/index.ts +4 -0
  49. package/src/core/mcp-hub/talon-server.ts +1 -1
  50. package/src/core/plugin/actions.ts +34 -0
  51. package/src/core/plugin/index.ts +5 -1
  52. package/src/core/tools/{ops/bridge.ts → bridge.ts} +7 -2
  53. package/src/core/tools/index.ts +2 -0
  54. package/src/core/tools/ops/backup.ts +67 -0
  55. package/src/core/tools/types.ts +2 -1
  56. package/src/core/update/self-update.ts +3 -0
  57. package/src/frontend/discord/callbacks/components/index.ts +3 -0
  58. package/src/frontend/discord/commands/backup.ts +203 -0
  59. package/src/frontend/discord/commands/definitions.ts +35 -0
  60. package/src/frontend/discord/commands/router.ts +3 -0
  61. package/src/frontend/telegram/callbacks/backup.ts +55 -0
  62. package/src/frontend/telegram/callbacks/index.ts +8 -0
  63. package/src/frontend/telegram/commands/backup.ts +209 -0
  64. package/src/frontend/telegram/commands/definitions.ts +4 -0
  65. package/src/frontend/telegram/commands/index.ts +2 -0
  66. package/src/storage/backup/index.ts +82 -0
  67. package/src/storage/backup/repo.ts +164 -0
  68. package/src/storage/db.ts +20 -0
  69. package/src/storage/sql/backups.sql +46 -0
  70. package/src/storage/sql/db.sql +8 -0
  71. package/src/storage/sql/schema.sql +30 -0
  72. package/src/storage/sql/statements.generated.ts +60 -1
  73. package/src/util/log.ts +1 -0
  74. /package/src/core/tools/{ops/mcp-env.ts → mcp-env.ts} +0 -0
@@ -0,0 +1,312 @@
1
+ /**
2
+ * The local snapshot store — layout on disk, manifest I/O, retention,
3
+ * and the SQLite index that the listing surfaces read.
4
+ *
5
+ * Layout (relative to the Talon home):
6
+ *
7
+ * backups/<id>/manifest.json what this snapshot is
8
+ * backups/<id>/state.tar.zst identity + state + database
9
+ * backups/<id>/palace-<hash12>.tar.zst the memory palace, when present
10
+ *
11
+ * The manifest on disk is authoritative. SQLite is a cache so `/backup`
12
+ * and `talon backup list` answer without opening a file per snapshot —
13
+ * `reconcileIndex` rebuilds rows from the directories on every boot, and
14
+ * a row whose directory has gone is kept rather than deleted, because
15
+ * the snapshot may still exist on a remote target.
16
+ */
17
+
18
+ import { randomBytes } from "node:crypto";
19
+ import {
20
+ link,
21
+ copyFile,
22
+ mkdir,
23
+ readdir,
24
+ readFile,
25
+ rm,
26
+ stat,
27
+ } from "node:fs/promises";
28
+ import { join, resolve } from "node:path";
29
+ import writeFileAtomic from "write-file-atomic";
30
+ import { dirs } from "../../util/paths.js";
31
+ import { log, logWarn } from "../../util/log.js";
32
+ import {
33
+ backupIds,
34
+ deleteBackup,
35
+ getBackup,
36
+ listBackupRemotes,
37
+ listBackups,
38
+ pinBackup,
39
+ recordBackup,
40
+ updateBackupManifest,
41
+ } from "../../storage/backup/index.js";
42
+ import type {
43
+ Manifest,
44
+ RemoteState,
45
+ SnapshotKind,
46
+ SnapshotSummary,
47
+ } from "./types.js";
48
+
49
+ /** `20260918T233400Z-a1b2c3` — chronological and filesystem-safe. */
50
+ const SNAPSHOT_ID_RE = /^\d{8}T\d{6}Z-[0-9a-f]{6}$/;
51
+ const MANIFEST_NAME = "manifest.json";
52
+ export const STATE_PART = "state.tar.zst";
53
+
54
+ function backupsRoot(home: string = dirs.root): string {
55
+ return join(home, "backups");
56
+ }
57
+
58
+ /** True for a well-formed snapshot id. Ids reach us from chat and CLI. */
59
+ export function isSnapshotId(id: string): boolean {
60
+ return SNAPSHOT_ID_RE.test(id);
61
+ }
62
+
63
+ export function snapshotDir(id: string, home: string = dirs.root): string {
64
+ return join(backupsRoot(home), id);
65
+ }
66
+
67
+ /** Mint a new id. Sorts chronologically; the suffix separates same-second runs. */
68
+ export function newSnapshotId(
69
+ now: Date = new Date(),
70
+ suffix: string = randomBytes(3).toString("hex"),
71
+ ): string {
72
+ const stamp = now
73
+ .toISOString()
74
+ .replace(/[-:]/g, "")
75
+ .replace(/\.\d{3}Z$/, "Z");
76
+ return `${stamp}-${suffix}`;
77
+ }
78
+
79
+ // ── Manifest I/O ────────────────────────────────────────────────────────────
80
+
81
+ export async function readManifest(
82
+ id: string,
83
+ home: string = dirs.root,
84
+ ): Promise<Manifest | null> {
85
+ if (!isSnapshotId(id)) return null;
86
+ try {
87
+ const body = await readFile(
88
+ join(snapshotDir(id, home), MANIFEST_NAME),
89
+ "utf8",
90
+ );
91
+ const parsed = JSON.parse(body) as Manifest;
92
+ return parsed.id === id ? parsed : null;
93
+ } catch {
94
+ return null; // absent or unreadable — the caller reports "not found"
95
+ }
96
+ }
97
+
98
+ export async function writeManifest(
99
+ manifest: Manifest,
100
+ home: string = dirs.root,
101
+ ): Promise<void> {
102
+ const dir = snapshotDir(manifest.id, home);
103
+ await mkdir(dir, { recursive: true });
104
+ await writeFileAtomic(
105
+ join(dir, MANIFEST_NAME),
106
+ JSON.stringify(manifest, null, 2) + "\n",
107
+ );
108
+ }
109
+
110
+ /** Every snapshot directory that holds a readable manifest, newest first. */
111
+ export async function listLocalManifests(
112
+ home: string = dirs.root,
113
+ ): Promise<Manifest[]> {
114
+ let names: string[] = [];
115
+ try {
116
+ names = await readdir(backupsRoot(home));
117
+ } catch {
118
+ return []; // no backups taken yet
119
+ }
120
+ const manifests: Manifest[] = [];
121
+ for (const name of names.filter(isSnapshotId)) {
122
+ const manifest = await readManifest(name, home);
123
+ if (manifest) manifests.push(manifest);
124
+ }
125
+ return manifests.sort((a, b) => b.createdAt - a.createdAt);
126
+ }
127
+
128
+ /**
129
+ * Hard-link `source` to `dest`, copying when the link cannot be made
130
+ * (different filesystem, a host that refuses links). Content-addressed
131
+ * parts are identical bytes, so a link is free and a copy is correct.
132
+ */
133
+ export async function linkOrCopy(source: string, dest: string): Promise<void> {
134
+ try {
135
+ await link(source, dest);
136
+ } catch {
137
+ await copyFile(source, dest);
138
+ }
139
+ }
140
+
141
+ // ── SQLite index ────────────────────────────────────────────────────────────
142
+
143
+ function toRecord(manifest: Manifest) {
144
+ return {
145
+ id: manifest.id,
146
+ kind: manifest.kind,
147
+ label: manifest.label,
148
+ pinned: manifest.pinned,
149
+ createdAt: manifest.createdAt,
150
+ sizeBytes: manifest.sizeBytes,
151
+ manifestJson: JSON.stringify(manifest),
152
+ };
153
+ }
154
+
155
+ /** Record (or refresh) one snapshot in the index. */
156
+ export function indexSnapshot(manifest: Manifest): void {
157
+ recordBackup(toRecord(manifest));
158
+ }
159
+
160
+ /** Replace the indexed manifest after it changed (pin, upload, prune). */
161
+ export function reindexSnapshot(manifest: Manifest): void {
162
+ updateBackupManifest(
163
+ manifest.id,
164
+ JSON.stringify(manifest),
165
+ manifest.pinned,
166
+ manifest.sizeBytes,
167
+ );
168
+ }
169
+
170
+ /**
171
+ * Bring the index in line with the directories on disk. Snapshots present
172
+ * on disk but missing from the index are added — that is how a restored
173
+ * (or hand-copied) backup directory becomes visible, and how the snapshot
174
+ * a staged restore took before the database was swapped comes back.
175
+ */
176
+ export async function reconcileIndex(
177
+ home: string = dirs.root,
178
+ ): Promise<number> {
179
+ const known = backupIds();
180
+ let added = 0;
181
+ for (const manifest of await listLocalManifests(home)) {
182
+ if (known.has(manifest.id)) continue;
183
+ indexSnapshot(manifest);
184
+ added += 1;
185
+ }
186
+ if (added > 0) log("backup", `Indexed ${added} snapshot(s) found on disk`);
187
+ return added;
188
+ }
189
+
190
+ /** The listing every surface renders, newest first. */
191
+ export async function listSnapshots(
192
+ home: string = dirs.root,
193
+ ): Promise<SnapshotSummary[]> {
194
+ const remotes = listBackupRemotes();
195
+ const summaries: SnapshotSummary[] = [];
196
+ for (const record of listBackups()) {
197
+ const remote: Record<string, RemoteState> = {};
198
+ for (const row of remotes.filter((r) => r.backupId === record.id)) {
199
+ remote[row.targetId] = {
200
+ status: row.status as RemoteState["status"],
201
+ remoteId: row.remoteId,
202
+ uploadedAt: row.uploadedAt,
203
+ error: row.error,
204
+ };
205
+ }
206
+ summaries.push({
207
+ id: record.id,
208
+ kind: record.kind as SnapshotKind,
209
+ label: record.label,
210
+ pinned: record.pinned,
211
+ createdAt: record.createdAt,
212
+ sizeBytes: record.sizeBytes,
213
+ local: await exists(join(snapshotDir(record.id, home), MANIFEST_NAME)),
214
+ remote,
215
+ });
216
+ }
217
+ return summaries;
218
+ }
219
+
220
+ async function exists(path: string): Promise<boolean> {
221
+ try {
222
+ await stat(path);
223
+ return true;
224
+ } catch {
225
+ return false;
226
+ }
227
+ }
228
+
229
+ /** Pin or unpin a snapshot, on disk and in the index. Returns false if unknown. */
230
+ export async function setSnapshotPinned(
231
+ id: string,
232
+ pinned: boolean,
233
+ home: string = dirs.root,
234
+ ): Promise<boolean> {
235
+ const manifest = await readManifest(id, home);
236
+ if (manifest) {
237
+ manifest.pinned = pinned;
238
+ await writeManifest(manifest, home);
239
+ if (getBackup(id)) reindexSnapshot(manifest);
240
+ else indexSnapshot(manifest);
241
+ return true;
242
+ }
243
+ // Remote-only snapshot: the index row is all we have.
244
+ return pinBackup(id, pinned);
245
+ }
246
+
247
+ // ── Retention ───────────────────────────────────────────────────────────────
248
+
249
+ /**
250
+ * Which snapshots to drop so that at most `keep` unpinned ones remain.
251
+ * Pure, and the order is the one the caller sees: newest first, pinned
252
+ * snapshots kept unconditionally and not counted against the budget —
253
+ * pinning is the user saying "this one outlives the policy".
254
+ */
255
+ export function selectPrunable<
256
+ T extends { id: string; createdAt: number; pinned: boolean },
257
+ >(snapshots: readonly T[], keep: number): T[] {
258
+ const ordered = [...snapshots].sort((a, b) => b.createdAt - a.createdAt);
259
+ const doomed: T[] = [];
260
+ let kept = 0;
261
+ for (const snapshot of ordered) {
262
+ if (snapshot.pinned) continue;
263
+ kept += 1;
264
+ if (kept > keep) doomed.push(snapshot);
265
+ }
266
+ return doomed;
267
+ }
268
+
269
+ /** Delete a snapshot directory and its index rows. */
270
+ async function removeSnapshot(
271
+ id: string,
272
+ home: string = dirs.root,
273
+ ): Promise<void> {
274
+ if (!isSnapshotId(id)) return;
275
+ await rm(snapshotDir(id, home), { recursive: true, force: true });
276
+ deleteBackup(id);
277
+ }
278
+
279
+ /** Apply the local retention policy. Returns the ids removed. */
280
+ export async function pruneLocal(
281
+ keep: number,
282
+ home: string = dirs.root,
283
+ ): Promise<string[]> {
284
+ const doomed = selectPrunable(await listLocalManifests(home), keep);
285
+ const removed: string[] = [];
286
+ for (const manifest of doomed) {
287
+ try {
288
+ await removeSnapshot(manifest.id, home);
289
+ removed.push(manifest.id);
290
+ } catch (err) {
291
+ logWarn("backup", `Could not prune ${manifest.id}: ${String(err)}`);
292
+ }
293
+ }
294
+ if (removed.length > 0) {
295
+ log(
296
+ "backup",
297
+ `Pruned ${removed.length} local snapshot(s) (keepLocal=${keep})`,
298
+ );
299
+ }
300
+ return removed;
301
+ }
302
+
303
+ /** Absolute path of one part inside a snapshot directory. */
304
+ export function partPath(
305
+ id: string,
306
+ name: string,
307
+ home: string = dirs.root,
308
+ ): string {
309
+ const dir = snapshotDir(id, home);
310
+ const abs = resolve(dir, name);
311
+ return abs.startsWith(dir) ? abs : join(dir, "invalid-part");
312
+ }
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Remote targets — the core half of the backup plugin protocol.
3
+ *
4
+ * A target is any loaded plugin that answers `backup.target.describe`.
5
+ * Core never learns what a target is made of: it hands over an absolute
6
+ * path and a manifest and gets an opaque remote id back, so Drive, an
7
+ * S3 bucket or an rclone remote are the same object here. The wire
8
+ * vocabulary (see docs/backups.md) is fixed and shared with the plugins
9
+ * that implement it:
10
+ *
11
+ * backup.target.describe → { id, name, ready, detail? }
12
+ * backup.target.upload → { remoteId, deduplicated? }
13
+ * backup.target.upload_manifest → { remoteId } (always last)
14
+ * backup.target.list → { snapshots: [...] }
15
+ * backup.target.delete → ok
16
+ * backup.target.download → ok
17
+ *
18
+ * `chatId` is the string `"system"`: these calls belong to the daemon,
19
+ * not to a conversation. The manifest is uploaded last on purpose — its
20
+ * presence on the remote is what marks a snapshot complete, so an upload
21
+ * interrupted halfway can never be mistaken for a restorable backup.
22
+ */
23
+
24
+ import { handlePluginActionIn, pluginsWithActions } from "../plugin/index.js";
25
+ import { logWarn } from "../../util/log.js";
26
+ import { TalonError } from "../errors.js";
27
+ import type { ActionResult } from "../types.js";
28
+ import type { Manifest, SnapshotPart } from "./types.js";
29
+
30
+ /** The daemon's chat id for plugin actions that belong to no conversation. */
31
+ const SYSTEM_CHAT = "system";
32
+
33
+ /** A part as the upload call describes it: metadata plus where to read it. */
34
+ type UploadPart = SnapshotPart & { path: string };
35
+
36
+ /** One snapshot as a target reports it. */
37
+ type RemoteSnapshot = {
38
+ snapshotId: string;
39
+ manifest: Manifest;
40
+ parts: Array<{ name: string; remoteId?: string; bytes: number }>;
41
+ };
42
+
43
+ export interface BackupTarget {
44
+ readonly id: string;
45
+ readonly name: string;
46
+ /** False when the target is configured but cannot accept uploads yet. */
47
+ readonly ready: boolean;
48
+ /** Why it is not ready, in the target's own words. */
49
+ readonly detail?: string;
50
+ upload(
51
+ snapshotId: string,
52
+ part: UploadPart,
53
+ manifest: Manifest,
54
+ ): Promise<{ remoteId: string; deduplicated?: boolean }>;
55
+ uploadManifest(
56
+ snapshotId: string,
57
+ manifest: Manifest,
58
+ ): Promise<{ remoteId: string }>;
59
+ list(): Promise<RemoteSnapshot[]>;
60
+ remove(snapshotId: string): Promise<void>;
61
+ download(
62
+ snapshotId: string,
63
+ partName: string,
64
+ destPath: string,
65
+ ): Promise<void>;
66
+ }
67
+
68
+ /** Sends one protocol body to one plugin. Replaced wholesale in tests. */
69
+ type TargetDispatch = (
70
+ plugin: string,
71
+ body: Record<string, unknown>,
72
+ ) => Promise<ActionResult | null>;
73
+
74
+ export type TargetDeps = {
75
+ /** Plugins to ask, in load order. */
76
+ plugins: () => string[];
77
+ dispatch: TargetDispatch;
78
+ };
79
+
80
+ /**
81
+ * Both members are wrapped rather than referenced directly, so the plugin
82
+ * module's exports are read when a target is actually used and not while
83
+ * this module is being evaluated. Backup reaches a lot of the tree — via
84
+ * the scheduler, the gateway imports it transitively — so a module-scope
85
+ * read here means every test that partially mocks `core/plugin` has to
86
+ * know to include these two exports or fail at import time, nowhere near
87
+ * anything it was testing.
88
+ */
89
+ const defaultDeps: TargetDeps = {
90
+ plugins: () => pluginsWithActions(),
91
+ dispatch: (plugin, body) => handlePluginActionIn(plugin, body, SYSTEM_CHAT),
92
+ };
93
+
94
+ function targetError(
95
+ target: string,
96
+ action: string,
97
+ detail: string,
98
+ ): TalonError {
99
+ return new TalonError(`${target}: ${action} failed — ${detail}`, {
100
+ reason: "unknown",
101
+ });
102
+ }
103
+
104
+ /** Unwrap `{ ok, data }`, turning every failure shape into one error. */
105
+ function dataOf(
106
+ result: ActionResult | null,
107
+ target: string,
108
+ action: string,
109
+ ): Record<string, unknown> {
110
+ if (!result) throw targetError(target, action, "plugin did not answer");
111
+ if (!result.ok) {
112
+ throw targetError(target, action, String(result.error ?? "unknown error"));
113
+ }
114
+ const data = result.data;
115
+ return typeof data === "object" && data !== null
116
+ ? (data as Record<string, unknown>)
117
+ : {};
118
+ }
119
+
120
+ /** A target backed by one plugin. */
121
+ class PluginTarget implements BackupTarget {
122
+ constructor(
123
+ private readonly plugin: string,
124
+ private readonly deps: TargetDeps,
125
+ readonly id: string,
126
+ readonly name: string,
127
+ readonly ready: boolean,
128
+ readonly detail?: string,
129
+ ) {}
130
+
131
+ private send(body: Record<string, unknown>): Promise<ActionResult | null> {
132
+ return this.deps.dispatch(this.plugin, body);
133
+ }
134
+
135
+ async upload(
136
+ snapshotId: string,
137
+ part: UploadPart,
138
+ manifest: Manifest,
139
+ ): Promise<{ remoteId: string; deduplicated?: boolean }> {
140
+ const data = dataOf(
141
+ await this.send({
142
+ action: "backup.target.upload",
143
+ snapshotId,
144
+ part,
145
+ manifest,
146
+ }),
147
+ this.id,
148
+ "upload",
149
+ );
150
+ const remoteId = typeof data.remoteId === "string" ? data.remoteId : "";
151
+ if (!remoteId) throw targetError(this.id, "upload", "no remoteId returned");
152
+ return { remoteId, deduplicated: data.deduplicated === true };
153
+ }
154
+
155
+ async uploadManifest(
156
+ snapshotId: string,
157
+ manifest: Manifest,
158
+ ): Promise<{ remoteId: string }> {
159
+ const data = dataOf(
160
+ await this.send({
161
+ action: "backup.target.upload_manifest",
162
+ snapshotId,
163
+ manifest,
164
+ }),
165
+ this.id,
166
+ "upload_manifest",
167
+ );
168
+ const remoteId = typeof data.remoteId === "string" ? data.remoteId : "";
169
+ if (!remoteId) {
170
+ throw targetError(this.id, "upload_manifest", "no remoteId returned");
171
+ }
172
+ return { remoteId };
173
+ }
174
+
175
+ async list(): Promise<RemoteSnapshot[]> {
176
+ const data = dataOf(
177
+ await this.send({ action: "backup.target.list" }),
178
+ this.id,
179
+ "list",
180
+ );
181
+ const snapshots = Array.isArray(data.snapshots) ? data.snapshots : [];
182
+ return snapshots.flatMap((entry) => {
183
+ const row = entry as Partial<RemoteSnapshot>;
184
+ if (typeof row.snapshotId !== "string" || !row.manifest) return [];
185
+ return [
186
+ {
187
+ snapshotId: row.snapshotId,
188
+ manifest: row.manifest,
189
+ parts: Array.isArray(row.parts) ? row.parts : [],
190
+ },
191
+ ];
192
+ });
193
+ }
194
+
195
+ async remove(snapshotId: string): Promise<void> {
196
+ dataOf(
197
+ await this.send({ action: "backup.target.delete", snapshotId }),
198
+ this.id,
199
+ "delete",
200
+ );
201
+ }
202
+
203
+ async download(
204
+ snapshotId: string,
205
+ partName: string,
206
+ destPath: string,
207
+ ): Promise<void> {
208
+ dataOf(
209
+ await this.send({
210
+ action: "backup.target.download",
211
+ snapshotId,
212
+ part: { name: partName },
213
+ destPath,
214
+ }),
215
+ this.id,
216
+ "download",
217
+ );
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Ask every loaded plugin whether it is a backup target. A plugin that is
223
+ * not one answers null and is skipped; one that answers badly is logged
224
+ * and skipped, because a misbehaving plugin must not stop the backup that
225
+ * the other targets (and the local store) can still complete.
226
+ */
227
+ export async function discoverTargets(
228
+ deps: TargetDeps = defaultDeps,
229
+ ): Promise<BackupTarget[]> {
230
+ const targets: BackupTarget[] = [];
231
+ for (const plugin of deps.plugins()) {
232
+ let result: ActionResult | null;
233
+ try {
234
+ result = await deps.dispatch(plugin, {
235
+ action: "backup.target.describe",
236
+ });
237
+ } catch (err) {
238
+ logWarn(
239
+ "backup",
240
+ `Plugin ${plugin} failed to describe itself: ${String(err)}`,
241
+ );
242
+ continue;
243
+ }
244
+ if (!result) continue; // not a backup target
245
+ if (!result.ok) {
246
+ logWarn(
247
+ "backup",
248
+ `Plugin ${plugin} describe error: ${String(result.error)}`,
249
+ );
250
+ continue;
251
+ }
252
+ const data = (result.data ?? {}) as Record<string, unknown>;
253
+ const id = typeof data.id === "string" ? data.id : "";
254
+ if (!id) {
255
+ logWarn("backup", `Plugin ${plugin} answered describe without an id`);
256
+ continue;
257
+ }
258
+ targets.push(
259
+ new PluginTarget(
260
+ plugin,
261
+ deps,
262
+ id,
263
+ typeof data.name === "string" ? data.name : id,
264
+ data.ready === true,
265
+ typeof data.detail === "string" ? data.detail : undefined,
266
+ ),
267
+ );
268
+ }
269
+ return targets;
270
+ }
271
+
272
+ /** The targets this run should use: config order wins, unknown ids are dropped. */
273
+ export function selectTargets(
274
+ available: readonly BackupTarget[],
275
+ configured: readonly string[] | undefined,
276
+ ): BackupTarget[] {
277
+ if (configured === undefined) return [...available];
278
+ return configured.flatMap((id) =>
279
+ available.filter((target) => target.id === id),
280
+ );
281
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The backup vocabulary — the shapes every other module in this subsystem
3
+ * and every surface above it speaks.
4
+ *
5
+ * `Manifest` is the contract with the future: it is written next to the
6
+ * parts on disk, uploaded last to every remote target (its presence is
7
+ * what marks a remote snapshot complete), and it is the source of truth
8
+ * for a restore. The SQLite index is a cache over these files, never the
9
+ * other way round — a snapshot whose row was lost is still restorable,
10
+ * a row whose directory is gone is not.
11
+ *
12
+ * `BackupSettings` is declared structurally rather than derived from the
13
+ * zod schema in core/config: the config layer imports this subsystem for
14
+ * its defaults, so the type may not travel back the other way.
15
+ */
16
+
17
+ /** Scheduled and pruned, or deliberate and kept. */
18
+ export type SnapshotKind = "backup" | "checkpoint";
19
+
20
+ /** One compressed file inside a snapshot directory. */
21
+ export type SnapshotPart = {
22
+ /** File name within the snapshot directory (also the remote object name). */
23
+ name: string;
24
+ bytes: number;
25
+ sha256: string;
26
+ /**
27
+ * The part's name encodes its content hash, so an identical part in an
28
+ * older snapshot is the same bytes: the local store hard-links it and
29
+ * targets may skip the upload entirely.
30
+ */
31
+ contentAddressed?: boolean;
32
+ };
33
+
34
+ /** Per-target upload state, mirrored into the `backup_remotes` table. */
35
+ export type RemoteState = {
36
+ status: "pending" | "uploaded" | "failed";
37
+ remoteId?: string;
38
+ uploadedAt?: number;
39
+ error?: string;
40
+ };
41
+
42
+ /** Where an `extra/<n>/…` subtree came from, so restore can put it back. */
43
+ type ExtraMapping = { n: number; source: string };
44
+
45
+ export type Manifest = {
46
+ schema: 1;
47
+ id: string;
48
+ kind: SnapshotKind;
49
+ label?: string;
50
+ pinned: boolean;
51
+ /** Epoch ms. */
52
+ createdAt: number;
53
+ host: string;
54
+ talonVersion: string;
55
+ gitHead?: string;
56
+ parts: SnapshotPart[];
57
+ /** Archive-relative roots this snapshot covers — what a restore replaces. */
58
+ includes: string[];
59
+ /** Human-readable exclusion rules, recorded so an old snapshot explains itself. */
60
+ excludes: string[];
61
+ /** `extra/<n>` → absolute source path. */
62
+ extras?: ExtraMapping[];
63
+ /** Tree fingerprint of the palace part, for content-addressed reuse. */
64
+ palaceHash?: string;
65
+ /** Total bytes of all parts. */
66
+ sizeBytes: number;
67
+ remote: Record<string, RemoteState>;
68
+ };
69
+
70
+ /** A snapshot as the listing surfaces show it. */
71
+ export type SnapshotSummary = {
72
+ id: string;
73
+ kind: SnapshotKind;
74
+ label?: string;
75
+ pinned: boolean;
76
+ createdAt: number;
77
+ sizeBytes: number;
78
+ /** False when the index has a row but the directory is gone (remote-only). */
79
+ local: boolean;
80
+ remote: Record<string, RemoteState>;
81
+ };
82
+
83
+ /** `config.backup`, with every default already applied. */
84
+ export type BackupSettings = {
85
+ enabled: boolean;
86
+ intervalHours: number;
87
+ keepLocal: number;
88
+ keepRemote: number;
89
+ includePalace: boolean;
90
+ workspaceInclude: readonly string[];
91
+ extraPaths: readonly string[];
92
+ /** Unset = every registered target; `[]` = local only. */
93
+ targets?: readonly string[];
94
+ checkpointBeforeUpdate: boolean;
95
+ notifyChatId?: string;
96
+ };