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.
Files changed (51) hide show
  1. package/package.json +1 -1
  2. package/src/app.ts +78 -0
  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/engine/gateway-actions/backup/index.ts +129 -0
  25. package/src/core/engine/gateway-actions/index.ts +4 -0
  26. package/src/core/mcp-hub/talon-server.ts +1 -1
  27. package/src/core/plugin/actions.ts +34 -0
  28. package/src/core/plugin/index.ts +5 -1
  29. package/src/core/tools/{ops/bridge.ts → bridge.ts} +7 -2
  30. package/src/core/tools/index.ts +2 -0
  31. package/src/core/tools/ops/backup.ts +67 -0
  32. package/src/core/tools/types.ts +2 -1
  33. package/src/core/update/self-update.ts +3 -0
  34. package/src/frontend/discord/callbacks/components/index.ts +3 -0
  35. package/src/frontend/discord/commands/backup.ts +203 -0
  36. package/src/frontend/discord/commands/definitions.ts +35 -0
  37. package/src/frontend/discord/commands/router.ts +3 -0
  38. package/src/frontend/telegram/callbacks/backup.ts +55 -0
  39. package/src/frontend/telegram/callbacks/index.ts +8 -0
  40. package/src/frontend/telegram/commands/backup.ts +209 -0
  41. package/src/frontend/telegram/commands/definitions.ts +4 -0
  42. package/src/frontend/telegram/commands/index.ts +2 -0
  43. package/src/storage/backup/index.ts +82 -0
  44. package/src/storage/backup/repo.ts +164 -0
  45. package/src/storage/db.ts +20 -0
  46. package/src/storage/sql/backups.sql +46 -0
  47. package/src/storage/sql/db.sql +8 -0
  48. package/src/storage/sql/schema.sql +30 -0
  49. package/src/storage/sql/statements.generated.ts +60 -1
  50. package/src/util/log.ts +1 -0
  51. /package/src/core/tools/{ops/mcp-env.ts → mcp-env.ts} +0 -0
@@ -0,0 +1,567 @@
1
+ /**
2
+ * tar — the container half of a snapshot part (write + streaming extract).
3
+ *
4
+ * Why hand-rolled: a backup is the one subsystem that must keep working
5
+ * when everything else is broken, so it earns zero dependencies. The
6
+ * format is ustar (POSIX 1003.1-1988) with PAX extended headers for the
7
+ * three things ustar cannot say — paths over 100 bytes, symlink targets
8
+ * over 100 bytes, and files over 8 GiB — which is exactly what GNU tar,
9
+ * bsdtar and every library reader already understand. A snapshot is
10
+ * therefore recoverable with `tar --zstd -xf` on any machine, with no
11
+ * Talon present at all.
12
+ *
13
+ * Everything streams. `addFile` pipes from disk one chunk at a time and
14
+ * honours the sink's backpressure; `extractTar` consumes an async
15
+ * iterable and writes as it parses. A 4 GiB workspace never lands in
16
+ * memory on either side.
17
+ *
18
+ * Extraction is hostile-input safe: a snapshot may have been round-tripped
19
+ * through a remote target, so every member path is resolved against the
20
+ * destination and rejected if it escapes (absolute paths, `..` segments,
21
+ * symlinks pointing outside the tree). A backup that can overwrite
22
+ * `~/.ssh/authorized_keys` is not a safety net.
23
+ */
24
+
25
+ import { createReadStream, createWriteStream } from "node:fs";
26
+ import { mkdir, symlink, utimes } from "node:fs/promises";
27
+ import { once } from "node:events";
28
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
29
+ import type { Writable } from "node:stream";
30
+ import { TalonError } from "../../errors.js";
31
+
32
+ const BLOCK = 512;
33
+ const ZERO_BLOCK = Buffer.alloc(BLOCK);
34
+ /** Largest size ustar's 11 octal digits can express (8 GiB - 1). */
35
+ const MAX_USTAR_SIZE = 0o77777777777;
36
+ /** Longest name/linkname ustar's fixed fields can hold. */
37
+ const MAX_USTAR_NAME = 100;
38
+
39
+ type TarEntryType = "file" | "dir" | "symlink";
40
+
41
+ /** One member of an archive, as both writer and reader see it. */
42
+ export type TarEntry = {
43
+ /** Archive path: POSIX separators, relative, no `.` or `..` segments. */
44
+ path: string;
45
+ type: TarEntryType;
46
+ /** Permission bits only (0o7777); the type bits come from `type`. */
47
+ mode: number;
48
+ /** Modification time, epoch SECONDS (tar's resolution). */
49
+ mtime: number;
50
+ /** Payload length; 0 for directories and symlinks. */
51
+ size: number;
52
+ /** Target of a symlink member. */
53
+ linkTarget?: string;
54
+ };
55
+
56
+ function tarError(message: string): TalonError {
57
+ return new TalonError(message, { reason: "bad_request" });
58
+ }
59
+
60
+ // ── Header encoding ─────────────────────────────────────────────────────────
61
+
62
+ /** `width - 1` octal digits, NUL-terminated — tar's numeric field form. */
63
+ function writeOctal(
64
+ header: Buffer,
65
+ value: number,
66
+ offset: number,
67
+ width: number,
68
+ ): void {
69
+ const digits = Math.max(0, Math.trunc(value))
70
+ .toString(8)
71
+ .padStart(width - 1, "0");
72
+ header.write(digits.slice(-(width - 1)), offset, "ascii");
73
+ header[offset + width - 1] = 0;
74
+ }
75
+
76
+ function writeText(
77
+ header: Buffer,
78
+ value: string,
79
+ offset: number,
80
+ width: number,
81
+ ): void {
82
+ header.write(value.slice(0, width), offset, width, "utf8");
83
+ }
84
+
85
+ const TYPE_FLAG: Record<TarEntryType, string> = {
86
+ file: "0",
87
+ dir: "5",
88
+ symlink: "2",
89
+ };
90
+
91
+ /**
92
+ * Build one 512-byte ustar header. `name` and `linkTarget` are assumed to
93
+ * fit already — the caller emits a PAX header first when they do not, and
94
+ * passes truncated values here so a PAX-blind reader still sees something
95
+ * recognisable rather than a blank entry.
96
+ */
97
+ function buildHeader(
98
+ entry: TarEntry,
99
+ name: string,
100
+ linkTarget: string,
101
+ size: number,
102
+ ): Buffer {
103
+ const header = Buffer.alloc(BLOCK);
104
+ writeText(header, name, 0, 100);
105
+ writeOctal(header, entry.mode & 0o7777, 100, 8);
106
+ writeOctal(header, 0, 108, 8); // uid — snapshots restore as the running user
107
+ writeOctal(header, 0, 116, 8); // gid
108
+ writeOctal(header, size, 124, 12);
109
+ writeOctal(header, entry.mtime, 136, 12);
110
+ header.write(TYPE_FLAG[entry.type], 156, 1, "ascii");
111
+ writeText(header, linkTarget, 157, 100);
112
+ header.write("ustar\0" + "00", 257, 8, "ascii");
113
+ writeText(header, "talon", 265, 32); // uname
114
+ writeText(header, "talon", 297, 32); // gname
115
+ // Checksum is computed with the field itself read as eight spaces.
116
+ header.fill(0x20, 148, 156);
117
+ let sum = 0;
118
+ for (const byte of header) sum += byte;
119
+ // Field layout: six octal digits, NUL, space — the NUL and the trailing
120
+ // space are already in place from the fill above.
121
+ writeOctal(header, sum, 148, 7);
122
+ return header;
123
+ }
124
+
125
+ /** `"<len> <key>=<value>\n"`, where `<len>` counts its own digits. */
126
+ function paxRecord(key: string, value: string): string {
127
+ const body = ` ${key}=${value}\n`;
128
+ // The length prefix counts itself, so the width is a fixpoint: start at
129
+ // one digit and re-measure until it stops moving (at most four passes for
130
+ // any record a filesystem can produce).
131
+ let len = body.length + 1;
132
+ for (let i = 0; i < 4; i++) {
133
+ const next = String(len).length + body.length;
134
+ if (next === len) break;
135
+ len = next;
136
+ }
137
+ return `${len}${body}`;
138
+ }
139
+
140
+ /** Which PAX records (if any) this entry needs, as one blob. */
141
+ function paxBody(entry: TarEntry): string {
142
+ let body = "";
143
+ if (Buffer.byteLength(entry.path) > MAX_USTAR_NAME) {
144
+ body += paxRecord("path", entry.path);
145
+ }
146
+ if (
147
+ entry.linkTarget &&
148
+ Buffer.byteLength(entry.linkTarget) > MAX_USTAR_NAME
149
+ ) {
150
+ body += paxRecord("linkpath", entry.linkTarget);
151
+ }
152
+ if (entry.size > MAX_USTAR_SIZE)
153
+ body += paxRecord("size", String(entry.size));
154
+ return body;
155
+ }
156
+
157
+ function padding(size: number): number {
158
+ const rem = size % BLOCK;
159
+ return rem === 0 ? 0 : BLOCK - rem;
160
+ }
161
+
162
+ // ── Writer ──────────────────────────────────────────────────────────────────
163
+
164
+ /**
165
+ * Streaming tar writer over any Writable (in practice a zstd compressor).
166
+ * The caller owns the sink: `finalize()` writes tar's two zero blocks but
167
+ * does not end the stream, so the same sink can be finished by a pipeline.
168
+ */
169
+ export class TarWriter {
170
+ constructor(private readonly out: Writable) {}
171
+
172
+ private async write(chunk: Buffer): Promise<void> {
173
+ if (!this.out.write(chunk)) await once(this.out, "drain");
174
+ }
175
+
176
+ /** Header (+ PAX header when needed) for one member. */
177
+ private async writeHeaders(entry: TarEntry): Promise<void> {
178
+ const body = paxBody(entry);
179
+ if (body) {
180
+ const payload = Buffer.from(body, "utf8");
181
+ const paxName = `PaxHeaders/${entry.path.split("/").pop() ?? "entry"}`;
182
+ const paxHeader = buildHeader(
183
+ {
184
+ path: paxName,
185
+ type: "file",
186
+ mode: 0o644,
187
+ mtime: entry.mtime,
188
+ size: payload.length,
189
+ },
190
+ paxName.slice(0, MAX_USTAR_NAME),
191
+ "",
192
+ payload.length,
193
+ );
194
+ paxHeader.write("x", 156, 1, "ascii");
195
+ // The type byte is part of the checksummed image — recompute it.
196
+ paxHeader.fill(0x20, 148, 156);
197
+ let sum = 0;
198
+ for (const byte of paxHeader) sum += byte;
199
+ writeOctal(paxHeader, sum, 148, 7);
200
+ await this.write(paxHeader);
201
+ await this.write(payload);
202
+ const pad = padding(payload.length);
203
+ if (pad) await this.write(Buffer.alloc(pad));
204
+ }
205
+ const size = entry.size > MAX_USTAR_SIZE ? 0 : entry.size;
206
+ await this.write(
207
+ buildHeader(
208
+ entry,
209
+ entry.path.slice(0, MAX_USTAR_NAME),
210
+ (entry.linkTarget ?? "").slice(0, MAX_USTAR_NAME),
211
+ size,
212
+ ),
213
+ );
214
+ }
215
+
216
+ async addDirectory(path: string, mode: number, mtime: number): Promise<void> {
217
+ await this.writeHeaders({
218
+ path: `${path}/`,
219
+ type: "dir",
220
+ mode,
221
+ mtime,
222
+ size: 0,
223
+ });
224
+ }
225
+
226
+ async addSymlink(
227
+ path: string,
228
+ linkTarget: string,
229
+ mode: number,
230
+ mtime: number,
231
+ ): Promise<void> {
232
+ await this.writeHeaders({
233
+ path,
234
+ type: "symlink",
235
+ mode,
236
+ mtime,
237
+ size: 0,
238
+ linkTarget,
239
+ });
240
+ }
241
+
242
+ /** In-memory member — for small synthesized files (manifests, markers). */
243
+ async addBuffer(
244
+ path: string,
245
+ content: Buffer,
246
+ mode = 0o644,
247
+ mtime = Math.floor(Date.now() / 1000),
248
+ ): Promise<void> {
249
+ await this.writeHeaders({
250
+ path,
251
+ type: "file",
252
+ mode,
253
+ mtime,
254
+ size: content.length,
255
+ });
256
+ await this.write(content);
257
+ const pad = padding(content.length);
258
+ if (pad) await this.write(Buffer.alloc(pad));
259
+ }
260
+
261
+ /**
262
+ * Stream a file from disk. `size` is the length recorded in the header:
263
+ * a file that changes under us is truncated or zero-padded to it, because
264
+ * a tar whose payload length disagrees with its header is unreadable.
265
+ */
266
+ async addFile(
267
+ path: string,
268
+ source: string,
269
+ mode: number,
270
+ mtime: number,
271
+ size: number,
272
+ ): Promise<void> {
273
+ await this.writeHeaders({ path, type: "file", mode, mtime, size });
274
+ let written = 0;
275
+ const stream = createReadStream(source);
276
+ for await (const chunk of stream) {
277
+ const buf = chunk as Buffer;
278
+ const room = size - written;
279
+ if (room <= 0) break;
280
+ const slice = buf.length > room ? buf.subarray(0, room) : buf;
281
+ await this.write(slice);
282
+ written += slice.length;
283
+ }
284
+ if (written < size) await this.write(Buffer.alloc(size - written));
285
+ const pad = padding(size);
286
+ if (pad) await this.write(Buffer.alloc(pad));
287
+ }
288
+
289
+ /** Tar's end-of-archive marker: two zero blocks. */
290
+ async finalize(): Promise<void> {
291
+ await this.write(ZERO_BLOCK);
292
+ await this.write(ZERO_BLOCK);
293
+ }
294
+ }
295
+
296
+ // ── Reader ──────────────────────────────────────────────────────────────────
297
+
298
+ /** Pull-based byte reader over an async chunk source. */
299
+ class BlockReader {
300
+ private readonly chunks: Buffer[] = [];
301
+ private length = 0;
302
+ private ended = false;
303
+
304
+ constructor(private readonly iter: AsyncIterator<Buffer | Uint8Array>) {}
305
+
306
+ private async fill(n: number): Promise<void> {
307
+ while (this.length < n && !this.ended) {
308
+ const next = await this.iter.next();
309
+ if (next.done) {
310
+ this.ended = true;
311
+ break;
312
+ }
313
+ const buf = Buffer.isBuffer(next.value)
314
+ ? next.value
315
+ : Buffer.from(next.value);
316
+ if (buf.length === 0) continue;
317
+ this.chunks.push(buf);
318
+ this.length += buf.length;
319
+ }
320
+ }
321
+
322
+ private consume(n: number): Buffer {
323
+ const out = Buffer.allocUnsafe(n);
324
+ let filled = 0;
325
+ while (filled < n) {
326
+ const head = this.chunks[0];
327
+ const take = Math.min(head.length, n - filled);
328
+ head.copy(out, filled, 0, take);
329
+ filled += take;
330
+ if (take === head.length) this.chunks.shift();
331
+ else this.chunks[0] = head.subarray(take);
332
+ }
333
+ this.length -= n;
334
+ return out;
335
+ }
336
+
337
+ /** Exactly `n` bytes; null at a clean end of stream. */
338
+ async take(n: number): Promise<Buffer | null> {
339
+ await this.fill(n);
340
+ if (this.length === 0) return null;
341
+ if (this.length < n) throw tarError("Truncated archive");
342
+ return this.consume(n);
343
+ }
344
+
345
+ /** Hand `n` bytes to `sink` in whatever chunks arrive; null sink discards. */
346
+ async drain(
347
+ n: number,
348
+ sink?: (chunk: Buffer) => Promise<void>,
349
+ ): Promise<void> {
350
+ let left = n;
351
+ while (left > 0) {
352
+ await this.fill(Math.min(left, BLOCK));
353
+ if (this.length === 0) throw tarError("Truncated archive body");
354
+ const chunk = this.consume(Math.min(left, this.length));
355
+ if (sink) await sink(chunk);
356
+ left -= chunk.length;
357
+ }
358
+ }
359
+ }
360
+
361
+ type RawHeader = {
362
+ name: string;
363
+ prefix: string;
364
+ mode: number;
365
+ size: number;
366
+ mtime: number;
367
+ typeflag: string;
368
+ linkname: string;
369
+ };
370
+
371
+ function readString(block: Buffer, offset: number, width: number): string {
372
+ const slice = block.subarray(offset, offset + width);
373
+ const end = slice.indexOf(0);
374
+ return slice.subarray(0, end === -1 ? slice.length : end).toString("utf8");
375
+ }
376
+
377
+ function readOctal(block: Buffer, offset: number, width: number): number {
378
+ const text = readString(block, offset, width).trim().replace(/\0+$/, "");
379
+ if (!text) return 0;
380
+ const value = parseInt(text, 8);
381
+ return Number.isFinite(value) ? value : 0;
382
+ }
383
+
384
+ /** Parse one header block; null means "end-of-archive marker". */
385
+ function parseHeader(block: Buffer): RawHeader | null {
386
+ if (block.every((byte) => byte === 0)) return null;
387
+ const stated = readOctal(block, 148, 8);
388
+ let sum = 0;
389
+ for (let i = 0; i < BLOCK; i++) {
390
+ sum += i >= 148 && i < 156 ? 0x20 : block[i];
391
+ }
392
+ if (sum !== stated)
393
+ throw tarError("Bad tar header checksum — archive corrupt");
394
+ return {
395
+ name: readString(block, 0, 100),
396
+ prefix: readString(block, 345, 155),
397
+ mode: readOctal(block, 100, 8),
398
+ size: readOctal(block, 124, 12),
399
+ mtime: readOctal(block, 136, 12),
400
+ typeflag: String.fromCharCode(block[156]) || "0",
401
+ linkname: readString(block, 157, 100),
402
+ };
403
+ }
404
+
405
+ /** `"<len> <key>=<value>\n"` records → a map. Unknown keys are ignored. */
406
+ function parsePax(body: string): Map<string, string> {
407
+ const records = new Map<string, string>();
408
+ let cursor = 0;
409
+ while (cursor < body.length) {
410
+ const space = body.indexOf(" ", cursor);
411
+ if (space === -1) break;
412
+ const length = parseInt(body.slice(cursor, space), 10);
413
+ if (!Number.isFinite(length) || length <= 0) break;
414
+ const record = body.slice(space + 1, cursor + length).replace(/\n$/, "");
415
+ const eq = record.indexOf("=");
416
+ if (eq > 0) records.set(record.slice(0, eq), record.slice(eq + 1));
417
+ cursor += length;
418
+ }
419
+ return records;
420
+ }
421
+
422
+ /**
423
+ * Resolve a member path inside `destDir`, refusing anything that escapes.
424
+ * Absolute paths, drive letters, `..` segments and backslash separators are
425
+ * all rejected rather than sanitized: a snapshot that needs sanitizing is
426
+ * not one we should be unpacking over a live home directory.
427
+ */
428
+ export function resolveMemberPath(destDir: string, memberPath: string): string {
429
+ const cleaned = memberPath.replace(/\/+$/, "");
430
+ if (!cleaned || cleaned === ".") throw tarError("Empty member path");
431
+ if (
432
+ isAbsolute(cleaned) ||
433
+ /^[A-Za-z]:/.test(cleaned) ||
434
+ cleaned.includes("\\")
435
+ ) {
436
+ throw tarError(`Unsafe member path in archive: ${memberPath}`);
437
+ }
438
+ if (cleaned.split("/").some((segment) => segment === "..")) {
439
+ throw tarError(`Path traversal in archive: ${memberPath}`);
440
+ }
441
+ const abs = resolve(destDir, cleaned);
442
+ const rel = relative(destDir, abs);
443
+ if (!rel || rel.startsWith("..") || isAbsolute(rel)) {
444
+ throw tarError(`Path escapes destination: ${memberPath}`);
445
+ }
446
+ return abs;
447
+ }
448
+
449
+ /** A symlink may point anywhere inside the extracted tree, and nowhere else. */
450
+ function checkLinkTarget(
451
+ destDir: string,
452
+ linkPath: string,
453
+ target: string,
454
+ ): void {
455
+ if (!target) throw tarError("Symlink with empty target");
456
+ if (isAbsolute(target) || /^[A-Za-z]:/.test(target)) {
457
+ throw tarError(`Absolute symlink target in archive: ${target}`);
458
+ }
459
+ const resolved = resolve(dirname(linkPath), target);
460
+ const rel = relative(destDir, resolved);
461
+ if (rel.startsWith("..") || isAbsolute(rel)) {
462
+ throw tarError(`Symlink escapes destination: ${target}`);
463
+ }
464
+ }
465
+
466
+ /** Write one regular member to disk, streaming `size` bytes from the reader. */
467
+ async function writeFileMember(
468
+ reader: BlockReader,
469
+ dest: string,
470
+ size: number,
471
+ entry: TarEntry,
472
+ ): Promise<void> {
473
+ await mkdir(dirname(dest), { recursive: true });
474
+ const out = createWriteStream(dest, { mode: entry.mode & 0o7777 });
475
+ try {
476
+ await reader.drain(size, async (chunk) => {
477
+ if (!out.write(chunk)) await once(out, "drain");
478
+ });
479
+ } finally {
480
+ out.end();
481
+ await once(out, "close");
482
+ }
483
+ await utimes(dest, entry.mtime, entry.mtime).catch(() => {
484
+ /* timestamps are cosmetic — a filesystem that refuses them is fine */
485
+ });
486
+ }
487
+
488
+ /** The member kinds a Talon snapshot can contain. */
489
+ function entryTypeOf(typeflag: string): TarEntryType | null {
490
+ if (typeflag === "0" || typeflag === "\0" || typeflag === "7") return "file";
491
+ if (typeflag === "5") return "dir";
492
+ if (typeflag === "2") return "symlink";
493
+ return null;
494
+ }
495
+
496
+ /**
497
+ * Extract an archive into `destDir`, creating it if needed. Returns the
498
+ * members written, in archive order. Streaming: memory use is one chunk,
499
+ * whatever the archive's size.
500
+ */
501
+ export async function extractTar(
502
+ source: AsyncIterable<Buffer | Uint8Array>,
503
+ destDir: string,
504
+ ): Promise<TarEntry[]> {
505
+ const reader = new BlockReader(source[Symbol.asyncIterator]());
506
+ await mkdir(destDir, { recursive: true });
507
+ const written: TarEntry[] = [];
508
+ let pax = new Map<string, string>();
509
+
510
+ for (;;) {
511
+ const block = await reader.take(BLOCK);
512
+ if (!block) break;
513
+ const header = parseHeader(block);
514
+ if (!header) break; // end-of-archive marker
515
+
516
+ // Metadata members carry the next member's long path / large size.
517
+ if (header.typeflag === "x" || header.typeflag === "g") {
518
+ const body: Buffer[] = [];
519
+ await reader.drain(header.size, async (chunk) => void body.push(chunk));
520
+ await reader.drain(padding(header.size));
521
+ const parsed = parsePax(Buffer.concat(body).toString("utf8"));
522
+ if (header.typeflag === "x") pax = parsed;
523
+ continue;
524
+ }
525
+
526
+ const rawPath =
527
+ pax.get("path") ??
528
+ (header.prefix ? `${header.prefix}/${header.name}` : header.name);
529
+ const size = Number(pax.get("size") ?? header.size);
530
+ const linkTarget = pax.get("linkpath") ?? header.linkname;
531
+ pax = new Map();
532
+
533
+ const type = entryTypeOf(header.typeflag);
534
+ if (!type) {
535
+ // Hardlinks, devices, fifos: Talon never writes them, and silently
536
+ // recreating one from an untrusted archive is not worth the surface.
537
+ await reader.drain(size);
538
+ await reader.drain(padding(size));
539
+ continue;
540
+ }
541
+
542
+ const dest = resolveMemberPath(destDir, rawPath);
543
+ const entry: TarEntry = {
544
+ path: rawPath.replace(/\/+$/, ""),
545
+ type,
546
+ mode: header.mode & 0o7777,
547
+ mtime: header.mtime,
548
+ size: type === "file" ? size : 0,
549
+ ...(type === "symlink" ? { linkTarget } : {}),
550
+ };
551
+
552
+ if (type === "dir") {
553
+ await mkdir(dest, { recursive: true, mode: entry.mode });
554
+ } else if (type === "symlink") {
555
+ checkLinkTarget(destDir, dest, linkTarget);
556
+ await mkdir(dirname(dest), { recursive: true });
557
+ await symlink(linkTarget, dest);
558
+ } else {
559
+ await writeFileMember(reader, dest, size, entry);
560
+ await reader.drain(padding(size));
561
+ written.push(entry);
562
+ continue;
563
+ }
564
+ written.push(entry);
565
+ }
566
+ return written;
567
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Zstandard streams — the compression half of a snapshot part.
3
+ *
4
+ * `node:zlib` has shipped zstd since Node 23 and bun 1.2, so a snapshot
5
+ * needs no dependency and no native addon: verified present on the two
6
+ * runtimes Talon supports (Node 24.19, bun 1.3.9). Level 19 is the
7
+ * archival end of the dial — snapshots are written once every few hours
8
+ * and read almost never, so CPU at write time is the cheap resource and
9
+ * bytes on someone else's disk is the expensive one.
10
+ *
11
+ * Both factories return duplex streams, so the caller pipes rather than
12
+ * buffers: a multi-GB workspace never lands in memory.
13
+ */
14
+
15
+ import { constants, createZstdCompress, createZstdDecompress } from "node:zlib";
16
+ import type { Transform } from "node:stream";
17
+
18
+ /** Archival compression level (1–22). */
19
+ const ZSTD_LEVEL = 19;
20
+
21
+ /** A zstd compressor for one part. */
22
+ export function createCompressor(level: number = ZSTD_LEVEL): Transform {
23
+ return createZstdCompress({
24
+ params: { [constants.ZSTD_c_compressionLevel]: level },
25
+ });
26
+ }
27
+
28
+ /** A zstd decompressor for one part. */
29
+ export function createDecompressor(): Transform {
30
+ return createZstdDecompress();
31
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Backups and checkpoints — the subsystem's public surface.
3
+ *
4
+ * A snapshot is everything that makes this deployment itself: config,
5
+ * prompts, keys, sessions, the database (via `VACUUM INTO`, never a
6
+ * byte-wise copy), the agent's memory and skills, and the memory palace
7
+ * as its own content-addressed part. Scheduled snapshots are pruned by
8
+ * retention; checkpoints are labelled, optionally pinned, and taken
9
+ * before anything risky (a self-update, a restore).
10
+ *
11
+ * This barrel is what the surfaces above use — the CLI, the `/backup`
12
+ * commands, the gateway actions, bootstrap and app. Inside the
13
+ * subsystem the modules import each other directly.
14
+ *
15
+ * Read them in this order: `plan` (what goes in), `archive/` (how it is
16
+ * written), `snapshot` (the build), `store` (the local store and its
17
+ * index), `targets` + `upload` (getting it off the machine), `scheduler`
18
+ * (when), `restore` (getting it back), `status` (what every surface
19
+ * renders). docs/backups.md has the operator's view and the plugin
20
+ * protocol.
21
+ */
22
+
23
+ export {
24
+ initBackup,
25
+ runBackup,
26
+ stopBackupScheduler,
27
+ checkpointBeforeUpdate,
28
+ } from "./scheduler.js";
29
+
30
+ export {
31
+ isSnapshotId,
32
+ listSnapshots,
33
+ readManifest,
34
+ setSnapshotPinned,
35
+ } from "./store.js";
36
+
37
+ export {
38
+ applyPendingRestore,
39
+ readRestorePending,
40
+ restoreSnapshot,
41
+ writeRestorePending,
42
+ } from "./restore.js";
43
+
44
+ export { discoverTargets, type BackupTarget } from "./targets.js";
45
+
46
+ export {
47
+ collectBackupStatus,
48
+ formatBackupStatus,
49
+ formatBytes,
50
+ formatRelative,
51
+ formatSnapshotList,
52
+ } from "./status.js";
53
+
54
+ export type { SnapshotSummary } from "./types.js";