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.
- package/README.md +10 -6
- package/package.json +1 -1
- package/src/app.ts +78 -0
- package/src/backend/agy/auth.ts +128 -0
- package/src/backend/agy/constants.ts +88 -0
- package/src/backend/agy/doctor.ts +161 -0
- package/src/backend/agy/effort.ts +76 -0
- package/src/backend/agy/events.ts +402 -0
- package/src/backend/agy/factory.ts +137 -0
- package/src/backend/agy/handler/index.ts +9 -0
- package/src/backend/agy/handler/message.ts +444 -0
- package/src/backend/agy/init.ts +64 -0
- package/src/backend/agy/mcp/config.ts +336 -0
- package/src/backend/agy/mcp/register.ts +134 -0
- package/src/backend/agy/models.ts +335 -0
- package/src/backend/agy/one-shot.ts +301 -0
- package/src/backend/agy/process/child.ts +378 -0
- package/src/backend/agy/process/orphans.ts +106 -0
- package/src/backend/agy/sessions.ts +90 -0
- package/src/backend/agy/state.ts +77 -0
- package/src/backend/builtins.ts +1 -0
- package/src/backend/codex/mcp-config.ts +1 -1
- package/src/backend/openai-agents/mcp-pool.ts +1 -1
- package/src/backend/runtime/index.ts +1 -1
- package/src/cli/commands/backup.ts +396 -0
- package/src/cli/config-view.ts +5 -0
- package/src/cli/config.ts +4 -2
- package/src/cli/events.ts +14 -0
- package/src/cli/index.ts +64 -45
- package/src/cli/setup.ts +49 -0
- package/src/core/agent-runtime/model-ref.ts +1 -0
- package/src/core/backup/archive/digest.ts +77 -0
- package/src/core/backup/archive/tar.ts +567 -0
- package/src/core/backup/archive/zstd.ts +31 -0
- package/src/core/backup/index.ts +54 -0
- package/src/core/backup/plan.ts +273 -0
- package/src/core/backup/restore.ts +410 -0
- package/src/core/backup/scheduler.ts +357 -0
- package/src/core/backup/snapshot.ts +408 -0
- package/src/core/backup/status.ts +194 -0
- package/src/core/backup/store.ts +312 -0
- package/src/core/backup/targets.ts +281 -0
- package/src/core/backup/types.ts +96 -0
- package/src/core/backup/upload.ts +172 -0
- package/src/core/bus/events.ts +45 -1
- package/src/core/config/index.ts +53 -0
- package/src/core/engine/gateway-actions/backup/index.ts +129 -0
- package/src/core/engine/gateway-actions/index.ts +4 -0
- package/src/core/mcp-hub/talon-server.ts +1 -1
- package/src/core/plugin/actions.ts +34 -0
- package/src/core/plugin/index.ts +5 -1
- package/src/core/tools/{ops/bridge.ts → bridge.ts} +7 -2
- package/src/core/tools/index.ts +2 -0
- package/src/core/tools/ops/backup.ts +67 -0
- package/src/core/tools/types.ts +2 -1
- package/src/core/update/self-update.ts +3 -0
- package/src/frontend/discord/callbacks/components/index.ts +3 -0
- package/src/frontend/discord/commands/backup.ts +203 -0
- package/src/frontend/discord/commands/definitions.ts +35 -0
- package/src/frontend/discord/commands/router.ts +3 -0
- package/src/frontend/telegram/callbacks/backup.ts +55 -0
- package/src/frontend/telegram/callbacks/index.ts +8 -0
- package/src/frontend/telegram/commands/backup.ts +209 -0
- package/src/frontend/telegram/commands/definitions.ts +4 -0
- package/src/frontend/telegram/commands/index.ts +2 -0
- package/src/storage/backup/index.ts +82 -0
- package/src/storage/backup/repo.ts +164 -0
- package/src/storage/db.ts +20 -0
- package/src/storage/sql/backups.sql +46 -0
- package/src/storage/sql/db.sql +8 -0
- package/src/storage/sql/schema.sql +30 -0
- package/src/storage/sql/statements.generated.ts +60 -1
- package/src/util/log.ts +1 -0
- /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
|
+
};
|