talon-agent 5.2.2 → 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.
- package/package.json +1 -1
- package/src/app.ts +78 -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/events.ts +14 -0
- package/src/cli/index.ts +64 -45
- 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 +52 -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,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The snapshot builder — one run, one directory, two parts.
|
|
3
|
+
*
|
|
4
|
+
* Everything here streams: entries are handed to the tar writer one at a
|
|
5
|
+
* time, the tar bytes go straight through zstd into the part file, and
|
|
6
|
+
* the sha256 is taken off the compressed stream on its way past. A
|
|
7
|
+
* multi-gigabyte workspace therefore costs one chunk of memory, not one
|
|
8
|
+
* workspace.
|
|
9
|
+
*
|
|
10
|
+
* Two rules worth keeping in mind while reading:
|
|
11
|
+
*
|
|
12
|
+
* - The database is never copied from disk. `VACUUM INTO` (see
|
|
13
|
+
* storage/db.ts) hands us a consistent single-file copy that goes in
|
|
14
|
+
* as `db/talon.db`; the live `data/talon.db*` files are excluded.
|
|
15
|
+
* - The memory palace is content-addressed. Its tree fingerprint is
|
|
16
|
+
* computed first, and if it matches the previous snapshot's the old
|
|
17
|
+
* part is hard-linked instead of recompressed — which is what makes
|
|
18
|
+
* a six-hourly backup of a large palace nearly free, locally and on
|
|
19
|
+
* every remote target.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createWriteStream } from "node:fs";
|
|
23
|
+
import { mkdir, readFile, rm, stat } from "node:fs/promises";
|
|
24
|
+
import { hostname } from "node:os";
|
|
25
|
+
import { dirname, join, resolve } from "node:path";
|
|
26
|
+
import { pipeline } from "node:stream/promises";
|
|
27
|
+
import { dirs } from "../../util/paths.js";
|
|
28
|
+
import { log, logWarn } from "../../util/log.js";
|
|
29
|
+
import { talonVersion } from "../../util/version.js";
|
|
30
|
+
import { snapshotDatabase } from "../../storage/backup/index.js";
|
|
31
|
+
import { TalonError } from "../errors.js";
|
|
32
|
+
import {
|
|
33
|
+
Sha256Tap,
|
|
34
|
+
sha256File,
|
|
35
|
+
treeHash,
|
|
36
|
+
type TreeFile,
|
|
37
|
+
} from "./archive/digest.js";
|
|
38
|
+
import { TarWriter } from "./archive/tar.js";
|
|
39
|
+
import { createCompressor } from "./archive/zstd.js";
|
|
40
|
+
import {
|
|
41
|
+
collectTree,
|
|
42
|
+
expandUserPath,
|
|
43
|
+
isExcluded,
|
|
44
|
+
isInside,
|
|
45
|
+
EXCLUDE_RULES,
|
|
46
|
+
HOME_INCLUDES,
|
|
47
|
+
workspaceRoots,
|
|
48
|
+
type SourceEntry,
|
|
49
|
+
} from "./plan.js";
|
|
50
|
+
import {
|
|
51
|
+
STATE_PART,
|
|
52
|
+
indexSnapshot,
|
|
53
|
+
linkOrCopy,
|
|
54
|
+
listLocalManifests,
|
|
55
|
+
newSnapshotId,
|
|
56
|
+
snapshotDir,
|
|
57
|
+
writeManifest,
|
|
58
|
+
} from "./store.js";
|
|
59
|
+
import type {
|
|
60
|
+
BackupSettings,
|
|
61
|
+
Manifest,
|
|
62
|
+
SnapshotKind,
|
|
63
|
+
SnapshotPart,
|
|
64
|
+
} from "./types.js";
|
|
65
|
+
|
|
66
|
+
/** Where the database copy lands inside the archive. */
|
|
67
|
+
const DB_MEMBER = "db/talon.db";
|
|
68
|
+
|
|
69
|
+
export type BuildOptions = {
|
|
70
|
+
kind: SnapshotKind;
|
|
71
|
+
label?: string;
|
|
72
|
+
pinned?: boolean;
|
|
73
|
+
settings: BackupSettings;
|
|
74
|
+
/** Talon home; tests point this at a scratch directory. */
|
|
75
|
+
home?: string;
|
|
76
|
+
/** Database copier — injected by tests that have no database. */
|
|
77
|
+
copyDatabase?: (destPath: string) => void;
|
|
78
|
+
/** Clock, for deterministic ids in tests. */
|
|
79
|
+
now?: Date;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// ── Archive writing ─────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
async function addEntries(
|
|
85
|
+
writer: TarWriter,
|
|
86
|
+
entries: readonly SourceEntry[],
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
if (entry.type === "dir") {
|
|
90
|
+
await writer.addDirectory(entry.archivePath, entry.mode, entry.mtime);
|
|
91
|
+
} else if (entry.type === "symlink") {
|
|
92
|
+
await writer.addSymlink(
|
|
93
|
+
entry.archivePath,
|
|
94
|
+
entry.linkTarget ?? "",
|
|
95
|
+
entry.mode,
|
|
96
|
+
entry.mtime,
|
|
97
|
+
);
|
|
98
|
+
} else {
|
|
99
|
+
await writer.addFile(
|
|
100
|
+
entry.archivePath,
|
|
101
|
+
entry.source,
|
|
102
|
+
entry.mode,
|
|
103
|
+
entry.mtime,
|
|
104
|
+
entry.size,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Write one compressed part and return its size and digest. The digest is
|
|
112
|
+
* taken off the compressed bytes, so verifying a part before extraction
|
|
113
|
+
* costs one pass over the file and no decompression.
|
|
114
|
+
*/
|
|
115
|
+
async function writePart(
|
|
116
|
+
destPath: string,
|
|
117
|
+
fill: (writer: TarWriter) => Promise<void>,
|
|
118
|
+
): Promise<{ bytes: number; sha256: string }> {
|
|
119
|
+
await mkdir(dirname(destPath), { recursive: true });
|
|
120
|
+
const compressor = createCompressor();
|
|
121
|
+
const tap = new Sha256Tap();
|
|
122
|
+
const out = createWriteStream(destPath);
|
|
123
|
+
const flushed = pipeline(compressor, tap, out);
|
|
124
|
+
try {
|
|
125
|
+
const writer = new TarWriter(compressor);
|
|
126
|
+
await fill(writer);
|
|
127
|
+
await writer.finalize();
|
|
128
|
+
compressor.end();
|
|
129
|
+
await flushed;
|
|
130
|
+
} catch (err) {
|
|
131
|
+
compressor.destroy();
|
|
132
|
+
out.destroy();
|
|
133
|
+
await rm(destPath, { force: true });
|
|
134
|
+
throw err;
|
|
135
|
+
}
|
|
136
|
+
return { bytes: tap.byteLength, sha256: tap.digest() };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── What goes in ────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
/** Everything under ~/.talon plus the configured workspace subset and extras. */
|
|
142
|
+
async function collectStateEntries(
|
|
143
|
+
home: string,
|
|
144
|
+
settings: BackupSettings,
|
|
145
|
+
): Promise<{
|
|
146
|
+
entries: SourceEntry[];
|
|
147
|
+
includes: string[];
|
|
148
|
+
extras: { n: number; source: string }[];
|
|
149
|
+
}> {
|
|
150
|
+
const skipped: string[] = [];
|
|
151
|
+
const onSkip = (path: string, err: unknown) => {
|
|
152
|
+
skipped.push(
|
|
153
|
+
`${path}: ${err instanceof Error ? err.message : String(err)}`,
|
|
154
|
+
);
|
|
155
|
+
};
|
|
156
|
+
const entries: SourceEntry[] = [];
|
|
157
|
+
const includes: string[] = [];
|
|
158
|
+
|
|
159
|
+
for (const root of HOME_INCLUDES) {
|
|
160
|
+
const found = await collectTree(join(home, root), root, { onSkip });
|
|
161
|
+
if (found.length > 0) {
|
|
162
|
+
entries.push(...found);
|
|
163
|
+
includes.push(root);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
for (const root of workspaceRoots(settings.workspaceInclude)) {
|
|
167
|
+
const archiveRoot = `workspace/${root}`;
|
|
168
|
+
const found = await collectTree(
|
|
169
|
+
join(home, "workspace", root),
|
|
170
|
+
archiveRoot,
|
|
171
|
+
{ onSkip },
|
|
172
|
+
);
|
|
173
|
+
if (found.length > 0) {
|
|
174
|
+
entries.push(...found);
|
|
175
|
+
includes.push(archiveRoot);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const extras: { n: number; source: string }[] = [];
|
|
179
|
+
for (const [index, raw] of settings.extraPaths.entries()) {
|
|
180
|
+
const source = expandUserPath(raw);
|
|
181
|
+
// Never reach into the FUSE namespace (it can be a dead mount) or back
|
|
182
|
+
// into the backup directory itself.
|
|
183
|
+
if (
|
|
184
|
+
isInside(join(home, "ns"), source) ||
|
|
185
|
+
isInside(join(home, "backups"), source)
|
|
186
|
+
) {
|
|
187
|
+
logWarn(
|
|
188
|
+
"backup",
|
|
189
|
+
`extraPaths entry skipped (inside ~/.talon/ns or backups/): ${raw}`,
|
|
190
|
+
);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const archiveRoot = `extra/${index}`;
|
|
194
|
+
const found = await collectTree(source, archiveRoot, { onSkip });
|
|
195
|
+
if (found.length === 0) {
|
|
196
|
+
logWarn("backup", `extraPaths entry is empty or unreadable: ${raw}`);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
entries.push(...found);
|
|
200
|
+
includes.push(archiveRoot);
|
|
201
|
+
extras.push({ n: index, source });
|
|
202
|
+
}
|
|
203
|
+
if (skipped.length > 0) {
|
|
204
|
+
logWarn(
|
|
205
|
+
"backup",
|
|
206
|
+
`Skipped ${skipped.length} unreadable path(s); first: ${skipped[0]}`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
return { entries, includes, extras };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── The memory palace part ──────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
/** Palace members, exempt from the rule that keeps them out of the state part. */
|
|
215
|
+
function palaceExclude(archivePath: string): boolean {
|
|
216
|
+
if (
|
|
217
|
+
archivePath === "workspace/palace" ||
|
|
218
|
+
archivePath.startsWith("workspace/palace/")
|
|
219
|
+
) {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
return isExcluded(archivePath);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Fingerprint the palace: path + size + mtime + content digest of every
|
|
227
|
+
* file. Content, not just mtime, because a restored or re-synced palace
|
|
228
|
+
* has new timestamps and identical bytes — and re-uploading gigabytes
|
|
229
|
+
* over a changed mtime is exactly what this is here to avoid.
|
|
230
|
+
*/
|
|
231
|
+
async function palaceFingerprint(
|
|
232
|
+
entries: readonly SourceEntry[],
|
|
233
|
+
): Promise<string> {
|
|
234
|
+
const files: TreeFile[] = [];
|
|
235
|
+
for (const entry of entries) {
|
|
236
|
+
if (entry.type !== "file") continue;
|
|
237
|
+
files.push({
|
|
238
|
+
path: entry.archivePath,
|
|
239
|
+
size: entry.size,
|
|
240
|
+
mtime: entry.mtime,
|
|
241
|
+
sha256: await sha256File(entry.source),
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
return treeHash(files);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Build (or reuse) the palace part. Reuse means an identical part already
|
|
249
|
+
* exists in an older snapshot: same name, same bytes, so it is hard-linked
|
|
250
|
+
* and marked `contentAddressed` — targets that already hold it skip the
|
|
251
|
+
* upload too.
|
|
252
|
+
*/
|
|
253
|
+
async function buildPalacePart(
|
|
254
|
+
id: string,
|
|
255
|
+
home: string,
|
|
256
|
+
): Promise<{ part: SnapshotPart; palaceHash: string } | null> {
|
|
257
|
+
const palaceDir = join(home, "workspace", "palace");
|
|
258
|
+
const entries = await collectTree(palaceDir, "workspace/palace", {
|
|
259
|
+
exclude: palaceExclude,
|
|
260
|
+
});
|
|
261
|
+
if (entries.length === 0) return null;
|
|
262
|
+
|
|
263
|
+
const palaceHash = await palaceFingerprint(entries);
|
|
264
|
+
const name = `palace-${palaceHash.slice(0, 12)}.tar.zst`;
|
|
265
|
+
const dest = join(snapshotDir(id, home), name);
|
|
266
|
+
|
|
267
|
+
for (const previous of await listLocalManifests(home)) {
|
|
268
|
+
if (previous.palaceHash !== palaceHash) continue;
|
|
269
|
+
const reusable = previous.parts.find((part) => part.name === name);
|
|
270
|
+
if (!reusable) continue;
|
|
271
|
+
try {
|
|
272
|
+
await linkOrCopy(join(snapshotDir(previous.id, home), name), dest);
|
|
273
|
+
log("backup", `Reused palace part from ${previous.id} (${name})`);
|
|
274
|
+
return { part: { ...reusable, contentAddressed: true }, palaceHash };
|
|
275
|
+
} catch (err) {
|
|
276
|
+
logWarn(
|
|
277
|
+
"backup",
|
|
278
|
+
`Could not reuse palace part from ${previous.id}: ${String(err)}`,
|
|
279
|
+
);
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const written = await writePart(dest, (writer) =>
|
|
285
|
+
addEntries(writer, entries),
|
|
286
|
+
);
|
|
287
|
+
return {
|
|
288
|
+
part: {
|
|
289
|
+
name,
|
|
290
|
+
bytes: written.bytes,
|
|
291
|
+
sha256: written.sha256,
|
|
292
|
+
contentAddressed: true,
|
|
293
|
+
},
|
|
294
|
+
palaceHash,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ── Provenance ──────────────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
/** Short git HEAD of the checkout Talon runs from, when there is one. */
|
|
301
|
+
async function readGitHead(startDir: string): Promise<string | undefined> {
|
|
302
|
+
let dir = resolve(startDir);
|
|
303
|
+
for (let depth = 0; depth < 12; depth++) {
|
|
304
|
+
try {
|
|
305
|
+
const head = (await readFile(join(dir, ".git", "HEAD"), "utf8")).trim();
|
|
306
|
+
if (head.startsWith("ref: ")) {
|
|
307
|
+
const ref = head.slice(5).trim();
|
|
308
|
+
const sha = await readFile(join(dir, ".git", ref), "utf8");
|
|
309
|
+
return sha.trim().slice(0, 12);
|
|
310
|
+
}
|
|
311
|
+
return head.slice(0, 12);
|
|
312
|
+
} catch {
|
|
313
|
+
const parent = dirname(dir);
|
|
314
|
+
if (parent === dir) break;
|
|
315
|
+
dir = parent;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return undefined;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ── The build ───────────────────────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Build one snapshot end to end: collect, archive, hash, write the
|
|
325
|
+
* manifest, index it. Leaves nothing behind on failure — a half-written
|
|
326
|
+
* directory would be indistinguishable from a good snapshot at restore
|
|
327
|
+
* time, which is the one thing a safety net may not be.
|
|
328
|
+
*/
|
|
329
|
+
export async function buildSnapshot(options: BuildOptions): Promise<Manifest> {
|
|
330
|
+
const home = options.home ?? dirs.root;
|
|
331
|
+
const id = newSnapshotId(options.now ?? new Date());
|
|
332
|
+
const dir = snapshotDir(id, home);
|
|
333
|
+
const started = Date.now();
|
|
334
|
+
await mkdir(dir, { recursive: true });
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
const { entries, includes, extras } = await collectStateEntries(
|
|
338
|
+
home,
|
|
339
|
+
options.settings,
|
|
340
|
+
);
|
|
341
|
+
const dbTemp = join(dir, "db-snapshot.tmp");
|
|
342
|
+
await rm(dbTemp, { force: true });
|
|
343
|
+
(options.copyDatabase ?? snapshotDatabase)(dbTemp);
|
|
344
|
+
const dbStat = await stat(dbTemp);
|
|
345
|
+
|
|
346
|
+
const state = await writePart(join(dir, STATE_PART), async (writer) => {
|
|
347
|
+
await addEntries(writer, entries);
|
|
348
|
+
await writer.addFile(
|
|
349
|
+
DB_MEMBER,
|
|
350
|
+
dbTemp,
|
|
351
|
+
0o600,
|
|
352
|
+
Math.floor(Date.now() / 1000),
|
|
353
|
+
dbStat.size,
|
|
354
|
+
);
|
|
355
|
+
});
|
|
356
|
+
await rm(dbTemp, { force: true });
|
|
357
|
+
|
|
358
|
+
const parts: SnapshotPart[] = [
|
|
359
|
+
{ name: STATE_PART, bytes: state.bytes, sha256: state.sha256 },
|
|
360
|
+
];
|
|
361
|
+
let palaceHash: string | undefined;
|
|
362
|
+
if (options.settings.includePalace) {
|
|
363
|
+
const palace = await buildPalacePart(id, home);
|
|
364
|
+
if (palace) {
|
|
365
|
+
parts.push(palace.part);
|
|
366
|
+
palaceHash = palace.palaceHash;
|
|
367
|
+
includes.push("workspace/palace");
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const gitHead = await readGitHead(process.cwd());
|
|
372
|
+
const manifest: Manifest = {
|
|
373
|
+
schema: 1,
|
|
374
|
+
id,
|
|
375
|
+
kind: options.kind,
|
|
376
|
+
...(options.label ? { label: options.label } : {}),
|
|
377
|
+
pinned: options.pinned ?? false,
|
|
378
|
+
createdAt: Date.now(),
|
|
379
|
+
host: hostname(),
|
|
380
|
+
talonVersion: talonVersion(),
|
|
381
|
+
...(gitHead ? { gitHead } : {}),
|
|
382
|
+
parts,
|
|
383
|
+
includes: [...includes, DB_MEMBER],
|
|
384
|
+
excludes: [...EXCLUDE_RULES],
|
|
385
|
+
...(extras.length > 0 ? { extras } : {}),
|
|
386
|
+
...(palaceHash ? { palaceHash } : {}),
|
|
387
|
+
sizeBytes: parts.reduce((sum, part) => sum + part.bytes, 0),
|
|
388
|
+
remote: {},
|
|
389
|
+
};
|
|
390
|
+
await writeManifest(manifest, home);
|
|
391
|
+
indexSnapshot(manifest);
|
|
392
|
+
log(
|
|
393
|
+
"backup",
|
|
394
|
+
`Snapshot ${id} (${options.kind}${options.label ? `: ${options.label}` : ""}) ` +
|
|
395
|
+
`— ${parts.length} part(s), ${(manifest.sizeBytes / 1024 / 1024).toFixed(1)} MB, ` +
|
|
396
|
+
`${Math.round((Date.now() - started) / 1000)}s`,
|
|
397
|
+
);
|
|
398
|
+
return manifest;
|
|
399
|
+
} catch (err) {
|
|
400
|
+
await rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
401
|
+
throw err instanceof TalonError
|
|
402
|
+
? err
|
|
403
|
+
: new TalonError(
|
|
404
|
+
`Snapshot ${id} failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
405
|
+
{ reason: "unknown", cause: err },
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The status report every surface renders.
|
|
3
|
+
*
|
|
4
|
+
* One collector and one formatter, shared by `talon backup`, `/backup`,
|
|
5
|
+
* the `backup_status` tool and the gateway action — so the CLI, the chat
|
|
6
|
+
* panel and the model all describe the same machine in the same words.
|
|
7
|
+
* Rendering is plain text (no HTML, no markdown): the frontends escape
|
|
8
|
+
* and wrap it as their platform needs.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { dirs } from "../../util/paths.js";
|
|
12
|
+
import { listSnapshots } from "./store.js";
|
|
13
|
+
import {
|
|
14
|
+
discoverTargets,
|
|
15
|
+
selectTargets,
|
|
16
|
+
type BackupTarget,
|
|
17
|
+
} from "./targets.js";
|
|
18
|
+
import { backupSettings, schedulerStatus } from "./scheduler.js";
|
|
19
|
+
import type { SnapshotSummary } from "./types.js";
|
|
20
|
+
|
|
21
|
+
type TargetStatus = {
|
|
22
|
+
id: string;
|
|
23
|
+
name: string;
|
|
24
|
+
ready: boolean;
|
|
25
|
+
detail?: string;
|
|
26
|
+
/** Snapshots this target holds, when it could be listed. */
|
|
27
|
+
snapshots?: number;
|
|
28
|
+
error?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type BackupStatus = {
|
|
32
|
+
schedule: ReturnType<typeof schedulerStatus>;
|
|
33
|
+
local: {
|
|
34
|
+
count: number;
|
|
35
|
+
sizeBytes: number;
|
|
36
|
+
pinned: number;
|
|
37
|
+
newest?: SnapshotSummary;
|
|
38
|
+
};
|
|
39
|
+
targets: TargetStatus[];
|
|
40
|
+
snapshots: SnapshotSummary[];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function formatBytes(bytes: number): string {
|
|
44
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
45
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
46
|
+
if (bytes < 1024 * 1024 * 1024)
|
|
47
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
48
|
+
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** "in 4h 10m" / "12m ago" — relative, so a stale panel is obviously stale. */
|
|
52
|
+
export function formatRelative(
|
|
53
|
+
at: number | undefined,
|
|
54
|
+
now: number = Date.now(),
|
|
55
|
+
): string {
|
|
56
|
+
if (!at) return "never";
|
|
57
|
+
const deltaMs = at - now;
|
|
58
|
+
const ahead = deltaMs >= 0;
|
|
59
|
+
const minutes = Math.round(Math.abs(deltaMs) / 60_000);
|
|
60
|
+
const text =
|
|
61
|
+
minutes < 60
|
|
62
|
+
? `${minutes}m`
|
|
63
|
+
: `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
|
64
|
+
return ahead ? `in ${text}` : `${text} ago`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function describeTargets(
|
|
68
|
+
targets: readonly BackupTarget[],
|
|
69
|
+
): Promise<TargetStatus[]> {
|
|
70
|
+
return Promise.all(
|
|
71
|
+
targets.map(async (target) => {
|
|
72
|
+
const base: TargetStatus = {
|
|
73
|
+
id: target.id,
|
|
74
|
+
name: target.name,
|
|
75
|
+
ready: target.ready,
|
|
76
|
+
detail: target.detail,
|
|
77
|
+
};
|
|
78
|
+
if (!target.ready) return base;
|
|
79
|
+
try {
|
|
80
|
+
return { ...base, snapshots: (await target.list()).length };
|
|
81
|
+
} catch (err) {
|
|
82
|
+
return {
|
|
83
|
+
...base,
|
|
84
|
+
error: err instanceof Error ? err.message : String(err),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Gather everything the status surfaces need. `withTargets: false` keeps
|
|
93
|
+
* it local-only — the agent tool and the chat panel use that when the
|
|
94
|
+
* answer must be instant, because listing a remote is a network call.
|
|
95
|
+
*/
|
|
96
|
+
export async function collectBackupStatus(
|
|
97
|
+
options: {
|
|
98
|
+
home?: string;
|
|
99
|
+
withTargets?: boolean;
|
|
100
|
+
} = {},
|
|
101
|
+
): Promise<BackupStatus> {
|
|
102
|
+
const home = options.home ?? dirs.root;
|
|
103
|
+
const snapshots = await listSnapshots(home);
|
|
104
|
+
const local = snapshots.filter((snapshot) => snapshot.local);
|
|
105
|
+
const settings = backupSettings();
|
|
106
|
+
const targets =
|
|
107
|
+
options.withTargets === false
|
|
108
|
+
? []
|
|
109
|
+
: await describeTargets(
|
|
110
|
+
selectTargets(await discoverTargets(), settings?.targets),
|
|
111
|
+
);
|
|
112
|
+
return {
|
|
113
|
+
schedule: schedulerStatus(),
|
|
114
|
+
local: {
|
|
115
|
+
count: local.length,
|
|
116
|
+
sizeBytes: local.reduce((sum, snapshot) => sum + snapshot.sizeBytes, 0),
|
|
117
|
+
pinned: snapshots.filter((snapshot) => snapshot.pinned).length,
|
|
118
|
+
newest: local[0],
|
|
119
|
+
},
|
|
120
|
+
targets,
|
|
121
|
+
snapshots,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** One line per snapshot: `<id> checkpoint 12.4 MB pinned "label" → drive`. */
|
|
126
|
+
function formatSnapshotLine(snapshot: SnapshotSummary): string {
|
|
127
|
+
const remote = Object.entries(snapshot.remote)
|
|
128
|
+
.map(([id, entry]) => `${id}:${entry.status}`)
|
|
129
|
+
.join(" ");
|
|
130
|
+
return [
|
|
131
|
+
snapshot.id,
|
|
132
|
+
snapshot.kind === "checkpoint" ? "checkpoint" : "backup",
|
|
133
|
+
formatBytes(snapshot.sizeBytes),
|
|
134
|
+
snapshot.pinned ? "pinned" : "",
|
|
135
|
+
snapshot.local ? "" : "(no local copy)",
|
|
136
|
+
snapshot.label ? `"${snapshot.label}"` : "",
|
|
137
|
+
remote ? `→ ${remote}` : "",
|
|
138
|
+
]
|
|
139
|
+
.filter(Boolean)
|
|
140
|
+
.join(" ");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function formatSnapshotList(
|
|
144
|
+
snapshots: readonly SnapshotSummary[],
|
|
145
|
+
limit = 20,
|
|
146
|
+
): string {
|
|
147
|
+
if (snapshots.length === 0) return "No snapshots yet.";
|
|
148
|
+
const lines = snapshots.slice(0, limit).map(formatSnapshotLine);
|
|
149
|
+
if (snapshots.length > limit) {
|
|
150
|
+
lines.push(`… and ${snapshots.length - limit} more`);
|
|
151
|
+
}
|
|
152
|
+
return lines.join("\n");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The status panel, as text. */
|
|
156
|
+
export function formatBackupStatus(
|
|
157
|
+
status: BackupStatus,
|
|
158
|
+
now: number = Date.now(),
|
|
159
|
+
): string {
|
|
160
|
+
const { schedule, local } = status;
|
|
161
|
+
const lines: string[] = [];
|
|
162
|
+
lines.push(
|
|
163
|
+
schedule.enabled
|
|
164
|
+
? `Schedule: every ${schedule.intervalHours}h — next ${formatRelative(schedule.nextRunAt, now)}` +
|
|
165
|
+
(schedule.running ? " (a snapshot is running now)" : "")
|
|
166
|
+
: "Schedule: disabled (manual checkpoints only)",
|
|
167
|
+
);
|
|
168
|
+
lines.push(
|
|
169
|
+
`Last run: ${formatRelative(schedule.lastRunAt, now)}` +
|
|
170
|
+
(schedule.lastSnapshotId ? ` — ${schedule.lastSnapshotId}` : ""),
|
|
171
|
+
);
|
|
172
|
+
if (schedule.consecutiveFailures > 0) {
|
|
173
|
+
lines.push(
|
|
174
|
+
`Failing: ${schedule.consecutiveFailures} consecutive — ${schedule.lastError ?? "unknown error"}`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
lines.push(
|
|
178
|
+
`Local: ${local.count} snapshot(s), ${formatBytes(local.sizeBytes)}` +
|
|
179
|
+
(local.pinned > 0 ? `, ${local.pinned} pinned` : ""),
|
|
180
|
+
);
|
|
181
|
+
if (status.targets.length === 0) {
|
|
182
|
+
lines.push("Targets: none registered (local only)");
|
|
183
|
+
} else {
|
|
184
|
+
for (const target of status.targets) {
|
|
185
|
+
const state = target.ready
|
|
186
|
+
? target.error
|
|
187
|
+
? `unreachable — ${target.error}`
|
|
188
|
+
: `ready, ${target.snapshots ?? 0} snapshot(s)`
|
|
189
|
+
: `not ready${target.detail ? ` — ${target.detail}` : ""}`;
|
|
190
|
+
lines.push(`Target ${target.name} (${target.id}): ${state}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return lines.join("\n");
|
|
194
|
+
}
|