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,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What goes into a snapshot — the include/exclude rules and the walker
|
|
3
|
+
* that turns them into a list of archive members.
|
|
4
|
+
*
|
|
5
|
+
* The rules answer one question: if this machine died, what would we need
|
|
6
|
+
* to rebuild the same agent? Identity (config, prompts, keys, sessions),
|
|
7
|
+
* everything the agent wrote about itself (memory, skills, scripts), and
|
|
8
|
+
* nothing that can be re-fetched or re-derived (node_modules, venvs,
|
|
9
|
+
* browser downloads, logs, traces). The database is deliberately absent
|
|
10
|
+
* here: a live SQLite file copied byte-wise is a corrupt SQLite file, so
|
|
11
|
+
* the builder adds it via `VACUUM INTO` instead (see snapshot.ts).
|
|
12
|
+
*
|
|
13
|
+
* `~/.talon/ns` is excluded by name and never stat()ed. It is a FUSE
|
|
14
|
+
* mount that can be dead ("Transport endpoint is not connected"), and on
|
|
15
|
+
* a dead mount a stat blocks or throws — a backup must not be the thing
|
|
16
|
+
* that hangs on it.
|
|
17
|
+
*
|
|
18
|
+
* Pure except for the walker: the rules are plain string predicates so
|
|
19
|
+
* they can be tested without a filesystem.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { lstat, readdir, readlink } from "node:fs/promises";
|
|
23
|
+
import { isAbsolute, join, resolve, sep } from "node:path";
|
|
24
|
+
import { homedir } from "node:os";
|
|
25
|
+
import type { BackupSettings } from "./types.js";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The workspace is mostly machine-generated bulk (uploads, media, build
|
|
29
|
+
* output, project checkouts). These are the parts that are the agent:
|
|
30
|
+
* what it knows, what it learned to do, and who it decided to be.
|
|
31
|
+
*/
|
|
32
|
+
export const DEFAULT_WORKSPACE_INCLUDE: readonly string[] = [
|
|
33
|
+
"identity.md",
|
|
34
|
+
"memory.md",
|
|
35
|
+
"state.md",
|
|
36
|
+
"heartbeat-instructions.md",
|
|
37
|
+
"memory/**",
|
|
38
|
+
"skills/**",
|
|
39
|
+
"scripts/**",
|
|
40
|
+
"secrets/**",
|
|
41
|
+
"stickers/**",
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The policy defaults — the single source of truth the zod schema in
|
|
46
|
+
* core/config defers to, so `config.backup` and an absent `config.backup`
|
|
47
|
+
* mean exactly the same thing.
|
|
48
|
+
*/
|
|
49
|
+
export const DEFAULT_BACKUP_SETTINGS = {
|
|
50
|
+
enabled: true,
|
|
51
|
+
intervalHours: 6,
|
|
52
|
+
keepLocal: 12,
|
|
53
|
+
keepRemote: 30,
|
|
54
|
+
includePalace: true,
|
|
55
|
+
workspaceInclude: DEFAULT_WORKSPACE_INCLUDE,
|
|
56
|
+
extraPaths: [] as readonly string[],
|
|
57
|
+
checkpointBeforeUpdate: true,
|
|
58
|
+
} as const;
|
|
59
|
+
|
|
60
|
+
/** Fill in whatever `config.backup` left out (or was entirely absent). */
|
|
61
|
+
export function resolveBackupSettings(
|
|
62
|
+
partial?: Partial<BackupSettings>,
|
|
63
|
+
): BackupSettings {
|
|
64
|
+
return { ...DEFAULT_BACKUP_SETTINGS, ...partial };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Roots under ~/.talon that a snapshot always carries, in archive order. */
|
|
68
|
+
export const HOME_INCLUDES: readonly string[] = [
|
|
69
|
+
"config.json",
|
|
70
|
+
"prompts",
|
|
71
|
+
"data",
|
|
72
|
+
"keys",
|
|
73
|
+
"whatsapp-auth",
|
|
74
|
+
".user-session",
|
|
75
|
+
"mesh-devices.json",
|
|
76
|
+
"mesh-history.json",
|
|
77
|
+
"teleport-state.json",
|
|
78
|
+
"agent-workspace",
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
/** The exclusion rules, in the words the manifest records them by. */
|
|
82
|
+
export const EXCLUDE_RULES: readonly string[] = [
|
|
83
|
+
"talon.log*",
|
|
84
|
+
"errors.log",
|
|
85
|
+
"node-bin/",
|
|
86
|
+
"*venv*/",
|
|
87
|
+
"ns/ (FUSE mount — never stat()ed)",
|
|
88
|
+
"backups/",
|
|
89
|
+
"data/traces/**",
|
|
90
|
+
"data/talon.db* (the database is added via VACUUM INTO)",
|
|
91
|
+
"*.tmp-*",
|
|
92
|
+
"workspace/palace/** (its own part)",
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* True when an archive path must not be captured. `path` is the path the
|
|
97
|
+
* member would have INSIDE the archive, which for the ~/.talon roots is
|
|
98
|
+
* also its path relative to the Talon home.
|
|
99
|
+
*/
|
|
100
|
+
export function isExcluded(path: string): boolean {
|
|
101
|
+
const segments = path.split("/").filter(Boolean);
|
|
102
|
+
if (segments.length === 0) return true;
|
|
103
|
+
const first = segments[0];
|
|
104
|
+
const last = segments[segments.length - 1];
|
|
105
|
+
// Anchored rules — only at the root of the archive.
|
|
106
|
+
if (first === "ns" || first === "backups") return true;
|
|
107
|
+
if (
|
|
108
|
+
first === "data" &&
|
|
109
|
+
(segments[1] === "traces" || segments[1]?.startsWith("talon.db"))
|
|
110
|
+
) {
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
if (
|
|
114
|
+
segments.length === 1 &&
|
|
115
|
+
(first.startsWith("talon.log") || first === "errors.log")
|
|
116
|
+
) {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
if (path === "workspace/palace" || path.startsWith("workspace/palace/"))
|
|
120
|
+
return true;
|
|
121
|
+
// Rules that hold at any depth: build output, virtualenvs, half-written
|
|
122
|
+
// files from an atomic write that never landed.
|
|
123
|
+
if (
|
|
124
|
+
segments.some(
|
|
125
|
+
(s) => s === "node-bin" || s === "node_modules" || s.includes("venv"),
|
|
126
|
+
)
|
|
127
|
+
) {
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
if (last.includes(".tmp-")) return true;
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Match one workspace-relative path against the `workspaceInclude` list.
|
|
136
|
+
* The pattern language is deliberately two rules wide — an exact relative
|
|
137
|
+
* path, or a `dir/**` prefix — because that is all the config needs and a
|
|
138
|
+
* glob engine is a dependency plus a surprise.
|
|
139
|
+
*/
|
|
140
|
+
export function matchesWorkspaceInclude(
|
|
141
|
+
relative: string,
|
|
142
|
+
patterns: readonly string[],
|
|
143
|
+
): boolean {
|
|
144
|
+
for (const pattern of patterns) {
|
|
145
|
+
if (pattern.endsWith("/**")) {
|
|
146
|
+
const prefix = pattern.slice(0, -3);
|
|
147
|
+
if (relative === prefix || relative.startsWith(`${prefix}/`)) return true;
|
|
148
|
+
} else if (pattern === relative) {
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The workspace subtrees to walk, derived from the include patterns. */
|
|
156
|
+
export function workspaceRoots(patterns: readonly string[]): string[] {
|
|
157
|
+
return [
|
|
158
|
+
...new Set(patterns.map((p) => (p.endsWith("/**") ? p.slice(0, -3) : p))),
|
|
159
|
+
];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Expand a leading `~` and resolve against the home directory. */
|
|
163
|
+
export function expandUserPath(path: string): string {
|
|
164
|
+
const trimmed = path.trim();
|
|
165
|
+
if (trimmed === "~") return homedir();
|
|
166
|
+
if (trimmed.startsWith("~/")) return resolve(homedir(), trimmed.slice(2));
|
|
167
|
+
return isAbsolute(trimmed) ? resolve(trimmed) : resolve(homedir(), trimmed);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** One member the builder will hand to the tar writer. */
|
|
171
|
+
export type SourceEntry = {
|
|
172
|
+
/** Path inside the archive (POSIX separators). */
|
|
173
|
+
archivePath: string;
|
|
174
|
+
/** Absolute path on disk. */
|
|
175
|
+
source: string;
|
|
176
|
+
type: "file" | "dir" | "symlink";
|
|
177
|
+
mode: number;
|
|
178
|
+
/** Epoch seconds. */
|
|
179
|
+
mtime: number;
|
|
180
|
+
size: number;
|
|
181
|
+
linkTarget?: string;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Walk one root into archive members. Missing roots are skipped silently —
|
|
186
|
+
* a fresh install has no whatsapp-auth/, and that is not an error. An
|
|
187
|
+
* unreadable entry is skipped too: a backup that aborts because one file
|
|
188
|
+
* lost its permissions is a backup that never runs.
|
|
189
|
+
*/
|
|
190
|
+
export async function collectTree(
|
|
191
|
+
absRoot: string,
|
|
192
|
+
archiveRoot: string,
|
|
193
|
+
opts: {
|
|
194
|
+
/** Defaults to {@link isExcluded}; the palace part overrides it. */
|
|
195
|
+
exclude?: (archivePath: string) => boolean;
|
|
196
|
+
onSkip?: (path: string, err: unknown) => void;
|
|
197
|
+
} = {},
|
|
198
|
+
): Promise<SourceEntry[]> {
|
|
199
|
+
const { exclude = isExcluded, onSkip } = opts;
|
|
200
|
+
const entries: SourceEntry[] = [];
|
|
201
|
+
const visit = async (abs: string, archivePath: string): Promise<void> => {
|
|
202
|
+
if (exclude(archivePath)) return;
|
|
203
|
+
let stats;
|
|
204
|
+
try {
|
|
205
|
+
stats = await lstat(abs);
|
|
206
|
+
} catch (err) {
|
|
207
|
+
// A root that does not exist is the normal case on a fresh install
|
|
208
|
+
// (no whatsapp-auth/, no agent-workspace/) — only real failures
|
|
209
|
+
// (permissions, I/O) are worth a line in the log.
|
|
210
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") onSkip?.(abs, err);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const mtime = Math.floor(stats.mtimeMs / 1000);
|
|
214
|
+
const mode = stats.mode & 0o7777;
|
|
215
|
+
if (stats.isSymbolicLink()) {
|
|
216
|
+
try {
|
|
217
|
+
entries.push({
|
|
218
|
+
archivePath,
|
|
219
|
+
source: abs,
|
|
220
|
+
type: "symlink",
|
|
221
|
+
mode,
|
|
222
|
+
mtime,
|
|
223
|
+
size: 0,
|
|
224
|
+
linkTarget: await readlink(abs),
|
|
225
|
+
});
|
|
226
|
+
} catch (err) {
|
|
227
|
+
onSkip?.(abs, err);
|
|
228
|
+
}
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (stats.isDirectory()) {
|
|
232
|
+
entries.push({
|
|
233
|
+
archivePath,
|
|
234
|
+
source: abs,
|
|
235
|
+
type: "dir",
|
|
236
|
+
mode,
|
|
237
|
+
mtime,
|
|
238
|
+
size: 0,
|
|
239
|
+
});
|
|
240
|
+
let children: string[] = [];
|
|
241
|
+
try {
|
|
242
|
+
children = (await readdir(abs)).sort();
|
|
243
|
+
} catch (err) {
|
|
244
|
+
onSkip?.(abs, err);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
for (const child of children) {
|
|
248
|
+
await visit(join(abs, child), `${archivePath}/${child}`);
|
|
249
|
+
}
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (!stats.isFile()) return; // sockets, fifos, devices: not agent state
|
|
253
|
+
entries.push({
|
|
254
|
+
archivePath,
|
|
255
|
+
source: abs,
|
|
256
|
+
type: "file",
|
|
257
|
+
mode,
|
|
258
|
+
mtime,
|
|
259
|
+
size: stats.size,
|
|
260
|
+
});
|
|
261
|
+
};
|
|
262
|
+
await visit(absRoot, archiveRoot);
|
|
263
|
+
return entries;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** True when `path` is inside `root` (or is `root`). */
|
|
267
|
+
export function isInside(root: string, path: string): boolean {
|
|
268
|
+
const normalizedRoot = resolve(root);
|
|
269
|
+
const normalized = resolve(path);
|
|
270
|
+
return (
|
|
271
|
+
normalized === normalizedRoot || normalized.startsWith(normalizedRoot + sep)
|
|
272
|
+
);
|
|
273
|
+
}
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Restore — turning a snapshot back into a running Talon.
|
|
3
|
+
*
|
|
4
|
+
* The rules that make this safe to run on a live home directory:
|
|
5
|
+
*
|
|
6
|
+
* 1. Verify before you touch anything. Every part's sha256 is checked
|
|
7
|
+
* against the manifest first; a part that fails is a stopped
|
|
8
|
+
* restore, not a half-applied one.
|
|
9
|
+
* 2. Stage, then swap. The archive is extracted into a staging
|
|
10
|
+
* directory beside the snapshot (same filesystem, so the swap is
|
|
11
|
+
* renames), and only then do the live paths change.
|
|
12
|
+
* 3. Take a checkpoint first. A pinned `pre-restore <id>` checkpoint
|
|
13
|
+
* of the current state is made before anything is replaced, so
|
|
14
|
+
* "restore the wrong snapshot" is itself undoable.
|
|
15
|
+
* 4. Replace only what the snapshot covers. Each include root is
|
|
16
|
+
* brought to exactly the snapshot's state — files the rules would
|
|
17
|
+
* have captured are removed, files the rules deliberately skip
|
|
18
|
+
* (traces, the live WAL, uploads) are left alone.
|
|
19
|
+
*
|
|
20
|
+
* The daemon must not be running: the CLI refuses while it is, and the
|
|
21
|
+
* chat path stages a request that the next boot applies BEFORE the
|
|
22
|
+
* database opens (see `applyPendingRestore`).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { createReadStream } from "node:fs";
|
|
26
|
+
import {
|
|
27
|
+
mkdir,
|
|
28
|
+
readFile,
|
|
29
|
+
rename,
|
|
30
|
+
rm,
|
|
31
|
+
rmdir,
|
|
32
|
+
stat,
|
|
33
|
+
unlink,
|
|
34
|
+
copyFile,
|
|
35
|
+
} from "node:fs/promises";
|
|
36
|
+
import { dirname, join } from "node:path";
|
|
37
|
+
import writeFileAtomic from "write-file-atomic";
|
|
38
|
+
import { dirs } from "../../util/paths.js";
|
|
39
|
+
import { log, logWarn } from "../../util/log.js";
|
|
40
|
+
import { TalonError } from "../errors.js";
|
|
41
|
+
import { sha256File } from "./archive/digest.js";
|
|
42
|
+
import { extractTar } from "./archive/tar.js";
|
|
43
|
+
import { createDecompressor } from "./archive/zstd.js";
|
|
44
|
+
import { collectTree } from "./plan.js";
|
|
45
|
+
import { buildSnapshot } from "./snapshot.js";
|
|
46
|
+
import { isSnapshotId, partPath, readManifest, snapshotDir } from "./store.js";
|
|
47
|
+
import type { BackupTarget } from "./targets.js";
|
|
48
|
+
import type { BackupSettings, Manifest } from "./types.js";
|
|
49
|
+
|
|
50
|
+
/** A staged request older than this is stale and ignored. */
|
|
51
|
+
export const RESTORE_PENDING_MAX_AGE_MS = 10 * 60_000;
|
|
52
|
+
const DB_MEMBER = "db/talon.db";
|
|
53
|
+
|
|
54
|
+
export type RestorePending = {
|
|
55
|
+
id: string;
|
|
56
|
+
targetId?: string;
|
|
57
|
+
/** Epoch ms. */
|
|
58
|
+
requestedAt: number;
|
|
59
|
+
/** Chat key that asked, so the boot can report back. */
|
|
60
|
+
requestedBy?: string;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type RestoreReport = {
|
|
64
|
+
id: string;
|
|
65
|
+
checkpointId?: string;
|
|
66
|
+
/** Include root → files written. */
|
|
67
|
+
written: Record<string, number>;
|
|
68
|
+
removed: number;
|
|
69
|
+
databaseReplaced: boolean;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export function restorePendingPath(home: string = dirs.root): string {
|
|
73
|
+
return join(home, "restore-pending.json");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── The staged request ──────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
export async function writeRestorePending(
|
|
79
|
+
request: RestorePending,
|
|
80
|
+
home: string = dirs.root,
|
|
81
|
+
): Promise<void> {
|
|
82
|
+
await mkdir(home, { recursive: true });
|
|
83
|
+
await writeFileAtomic(
|
|
84
|
+
restorePendingPath(home),
|
|
85
|
+
JSON.stringify(request) + "\n",
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function clearRestorePending(
|
|
90
|
+
home: string = dirs.root,
|
|
91
|
+
): Promise<void> {
|
|
92
|
+
await rm(restorePendingPath(home), { force: true });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Read the staged request, if there is a usable one. A malformed file, a
|
|
97
|
+
* bad id or a request older than ten minutes is deleted and ignored — a
|
|
98
|
+
* restore that fires days later because a file was left behind would be
|
|
99
|
+
* the most destructive bug this subsystem could have.
|
|
100
|
+
*/
|
|
101
|
+
export async function readRestorePending(
|
|
102
|
+
home: string = dirs.root,
|
|
103
|
+
now: number = Date.now(),
|
|
104
|
+
): Promise<RestorePending | null> {
|
|
105
|
+
let parsed: RestorePending;
|
|
106
|
+
try {
|
|
107
|
+
parsed = JSON.parse(
|
|
108
|
+
await readFile(restorePendingPath(home), "utf8"),
|
|
109
|
+
) as RestorePending;
|
|
110
|
+
} catch (err) {
|
|
111
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
112
|
+
logWarn(
|
|
113
|
+
"backup",
|
|
114
|
+
`Unreadable restore-pending.json, ignoring: ${String(err)}`,
|
|
115
|
+
);
|
|
116
|
+
await clearRestorePending(home);
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
const age = now - (parsed.requestedAt ?? 0);
|
|
121
|
+
if (!isSnapshotId(parsed.id ?? "")) {
|
|
122
|
+
logWarn(
|
|
123
|
+
"backup",
|
|
124
|
+
"restore-pending.json names no valid snapshot — discarded",
|
|
125
|
+
);
|
|
126
|
+
await clearRestorePending(home);
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
if (
|
|
130
|
+
!Number.isFinite(parsed.requestedAt) ||
|
|
131
|
+
age > RESTORE_PENDING_MAX_AGE_MS ||
|
|
132
|
+
age < 0
|
|
133
|
+
) {
|
|
134
|
+
logWarn(
|
|
135
|
+
"backup",
|
|
136
|
+
`restore-pending.json is stale (${Math.round(age / 1000)}s old) — discarded`,
|
|
137
|
+
);
|
|
138
|
+
await clearRestorePending(home);
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
return parsed;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── Parts ───────────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
/** Fetch any part that is not on local disk from the given target. */
|
|
147
|
+
async function ensureParts(
|
|
148
|
+
manifest: Manifest,
|
|
149
|
+
home: string,
|
|
150
|
+
target?: BackupTarget,
|
|
151
|
+
): Promise<void> {
|
|
152
|
+
for (const part of manifest.parts) {
|
|
153
|
+
const path = partPath(manifest.id, part.name, home);
|
|
154
|
+
try {
|
|
155
|
+
await stat(path);
|
|
156
|
+
continue;
|
|
157
|
+
} catch {
|
|
158
|
+
/* not here — fall through to the download */
|
|
159
|
+
}
|
|
160
|
+
if (!target) {
|
|
161
|
+
throw new TalonError(
|
|
162
|
+
`Part ${part.name} of ${manifest.id} is missing locally and no --from target was given`,
|
|
163
|
+
{ reason: "bad_request" },
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
log("backup", `Downloading ${part.name} from ${target.id}…`);
|
|
167
|
+
await mkdir(dirname(path), { recursive: true });
|
|
168
|
+
await target.download(manifest.id, part.name, path);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Check every part against the manifest. Throws on the first mismatch. */
|
|
173
|
+
export async function verifyParts(
|
|
174
|
+
manifest: Manifest,
|
|
175
|
+
home: string,
|
|
176
|
+
): Promise<void> {
|
|
177
|
+
for (const part of manifest.parts) {
|
|
178
|
+
const path = partPath(manifest.id, part.name, home);
|
|
179
|
+
const actual = await sha256File(path);
|
|
180
|
+
if (actual !== part.sha256) {
|
|
181
|
+
throw new TalonError(
|
|
182
|
+
`Part ${part.name} of ${manifest.id} is corrupt (sha256 mismatch) — restore aborted`,
|
|
183
|
+
{ reason: "bad_request" },
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Unpack every part into one staging tree. */
|
|
190
|
+
async function extractParts(
|
|
191
|
+
manifest: Manifest,
|
|
192
|
+
home: string,
|
|
193
|
+
staging: string,
|
|
194
|
+
): Promise<void> {
|
|
195
|
+
await rm(staging, { recursive: true, force: true });
|
|
196
|
+
await mkdir(staging, { recursive: true });
|
|
197
|
+
for (const part of manifest.parts) {
|
|
198
|
+
const source = createReadStream(partPath(manifest.id, part.name, home));
|
|
199
|
+
await extractTar(source.pipe(createDecompressor()), staging);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── Applying ────────────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
/** Where an archive path lands on this machine. */
|
|
206
|
+
export function destinationFor(
|
|
207
|
+
archivePath: string,
|
|
208
|
+
home: string,
|
|
209
|
+
extras: readonly { n: number; source: string }[],
|
|
210
|
+
): string | null {
|
|
211
|
+
const segments = archivePath.split("/");
|
|
212
|
+
if (segments[0] === "extra") {
|
|
213
|
+
const extra = extras.find((entry) => String(entry.n) === segments[1]);
|
|
214
|
+
if (!extra) return null; // an extra path this machine has no mapping for
|
|
215
|
+
return join(extra.source, ...segments.slice(2));
|
|
216
|
+
}
|
|
217
|
+
if (archivePath === DB_MEMBER) return join(home, "data", "talon.db");
|
|
218
|
+
return join(home, ...segments);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Bring one include root to exactly the snapshot's state: remove what the
|
|
223
|
+
* snapshot rules would have captured, keep what they deliberately skip.
|
|
224
|
+
* Returns how many live files were removed.
|
|
225
|
+
*/
|
|
226
|
+
async function clearCovered(
|
|
227
|
+
destRoot: string,
|
|
228
|
+
archiveRoot: string,
|
|
229
|
+
): Promise<number> {
|
|
230
|
+
const existing = await collectTree(destRoot, archiveRoot);
|
|
231
|
+
let removed = 0;
|
|
232
|
+
for (const entry of [...existing].reverse()) {
|
|
233
|
+
try {
|
|
234
|
+
if (entry.type === "dir") await rmdir(entry.source).catch(() => {});
|
|
235
|
+
else {
|
|
236
|
+
await unlink(entry.source);
|
|
237
|
+
removed += 1;
|
|
238
|
+
}
|
|
239
|
+
} catch (err) {
|
|
240
|
+
logWarn("backup", `Could not remove ${entry.source}: ${String(err)}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return removed;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Move one staged file into place, falling back to a copy across devices. */
|
|
247
|
+
async function placeFile(from: string, to: string): Promise<void> {
|
|
248
|
+
await mkdir(dirname(to), { recursive: true });
|
|
249
|
+
try {
|
|
250
|
+
await rename(from, to);
|
|
251
|
+
} catch {
|
|
252
|
+
await copyFile(from, to);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Swap the staged tree in. Include roots are handled one at a time so a
|
|
258
|
+
* report can say what changed, and the database is written last: it is
|
|
259
|
+
* the one file whose sidecars must go with it.
|
|
260
|
+
*/
|
|
261
|
+
async function applyStaged(
|
|
262
|
+
manifest: Manifest,
|
|
263
|
+
staging: string,
|
|
264
|
+
home: string,
|
|
265
|
+
): Promise<RestoreReport> {
|
|
266
|
+
const extras = manifest.extras ?? [];
|
|
267
|
+
const report: RestoreReport = {
|
|
268
|
+
id: manifest.id,
|
|
269
|
+
written: {},
|
|
270
|
+
removed: 0,
|
|
271
|
+
databaseReplaced: false,
|
|
272
|
+
};
|
|
273
|
+
for (const root of manifest.includes) {
|
|
274
|
+
if (root === DB_MEMBER) continue;
|
|
275
|
+
const stagedRoot = join(staging, ...root.split("/"));
|
|
276
|
+
const staged = await collectTree(stagedRoot, root);
|
|
277
|
+
if (staged.length === 0) continue;
|
|
278
|
+
const destRoot = destinationFor(root, home, extras);
|
|
279
|
+
if (!destRoot) {
|
|
280
|
+
logWarn("backup", `No destination for ${root} on this machine — skipped`);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
report.removed += await clearCovered(destRoot, root);
|
|
284
|
+
let written = 0;
|
|
285
|
+
for (const entry of staged) {
|
|
286
|
+
const dest = destinationFor(entry.archivePath, home, extras);
|
|
287
|
+
if (!dest) continue;
|
|
288
|
+
if (entry.type === "dir")
|
|
289
|
+
await mkdir(dest, { recursive: true, mode: entry.mode });
|
|
290
|
+
else {
|
|
291
|
+
await placeFile(entry.source, dest);
|
|
292
|
+
written += 1;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
report.written[root] = written;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const stagedDb = join(staging, "db", "talon.db");
|
|
299
|
+
try {
|
|
300
|
+
await stat(stagedDb);
|
|
301
|
+
const dbPath = join(home, "data", "talon.db");
|
|
302
|
+
// The sidecars describe the OLD database; leaving them beside the new
|
|
303
|
+
// file is how a restored database comes up as the one we replaced.
|
|
304
|
+
await rm(`${dbPath}-wal`, { force: true });
|
|
305
|
+
await rm(`${dbPath}-shm`, { force: true });
|
|
306
|
+
await placeFile(stagedDb, dbPath);
|
|
307
|
+
report.databaseReplaced = true;
|
|
308
|
+
} catch {
|
|
309
|
+
logWarn(
|
|
310
|
+
"backup",
|
|
311
|
+
"Snapshot carries no database copy — leaving the live one in place",
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
return report;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ── The operation ───────────────────────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
export type RestoreOptions = {
|
|
320
|
+
id: string;
|
|
321
|
+
settings: BackupSettings;
|
|
322
|
+
home?: string;
|
|
323
|
+
/** Where to fetch parts this machine does not have. */
|
|
324
|
+
target?: BackupTarget;
|
|
325
|
+
/**
|
|
326
|
+
* Called after the pre-restore checkpoint and before anything is
|
|
327
|
+
* replaced. The composition root passes `closeDatabase` here: the
|
|
328
|
+
* handle must be shut before its file is swapped underneath it.
|
|
329
|
+
*/
|
|
330
|
+
beforeApply?: () => void | Promise<void>;
|
|
331
|
+
/** Skip the automatic pre-restore checkpoint (it has already been taken). */
|
|
332
|
+
skipCheckpoint?: boolean;
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Restore a snapshot over this home directory. The daemon must already be
|
|
337
|
+
* stopped — this does not check, because the two callers check in their
|
|
338
|
+
* own way (the CLI refuses, the boot path runs before anything starts).
|
|
339
|
+
*/
|
|
340
|
+
export async function restoreSnapshot(
|
|
341
|
+
options: RestoreOptions,
|
|
342
|
+
): Promise<RestoreReport> {
|
|
343
|
+
const home = options.home ?? dirs.root;
|
|
344
|
+
const manifest = await readManifest(options.id, home);
|
|
345
|
+
if (!manifest) {
|
|
346
|
+
throw new TalonError(`No snapshot ${options.id} on this machine`, {
|
|
347
|
+
reason: "bad_request",
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
await ensureParts(manifest, home, options.target);
|
|
351
|
+
await verifyParts(manifest, home);
|
|
352
|
+
|
|
353
|
+
let checkpointId: string | undefined;
|
|
354
|
+
if (!options.skipCheckpoint) {
|
|
355
|
+
const checkpoint = await buildSnapshot({
|
|
356
|
+
kind: "checkpoint",
|
|
357
|
+
label: `pre-restore ${manifest.id}`,
|
|
358
|
+
pinned: true,
|
|
359
|
+
settings: options.settings,
|
|
360
|
+
home,
|
|
361
|
+
});
|
|
362
|
+
checkpointId = checkpoint.id;
|
|
363
|
+
log("backup", `Pre-restore checkpoint ${checkpointId} taken`);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const staging = join(snapshotDir(manifest.id, home), "restore-staging");
|
|
367
|
+
await extractParts(manifest, home, staging);
|
|
368
|
+
await options.beforeApply?.();
|
|
369
|
+
const report = await applyStaged(manifest, staging, home);
|
|
370
|
+
report.checkpointId = checkpointId;
|
|
371
|
+
await rm(staging, { recursive: true, force: true });
|
|
372
|
+
log(
|
|
373
|
+
"backup",
|
|
374
|
+
`Restored ${manifest.id}: ${Object.values(report.written).reduce((a, b) => a + b, 0)} ` +
|
|
375
|
+
`file(s) written, ${report.removed} removed` +
|
|
376
|
+
(report.databaseReplaced ? ", database replaced" : ""),
|
|
377
|
+
);
|
|
378
|
+
return report;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* The boot hook: apply a restore staged from chat, before the database is
|
|
383
|
+
* opened. Returns the report when one ran, null otherwise. Never throws —
|
|
384
|
+
* a failed restore must still let the daemon boot, with the failure loud
|
|
385
|
+
* in the log and the request deleted so the next boot is normal.
|
|
386
|
+
*/
|
|
387
|
+
export async function applyPendingRestore(options: {
|
|
388
|
+
settings: BackupSettings;
|
|
389
|
+
home?: string;
|
|
390
|
+
beforeApply?: () => void | Promise<void>;
|
|
391
|
+
}): Promise<(RestoreReport & { requestedBy?: string }) | null> {
|
|
392
|
+
const home = options.home ?? dirs.root;
|
|
393
|
+
const pending = await readRestorePending(home);
|
|
394
|
+
if (!pending) return null;
|
|
395
|
+
log("backup", `Applying staged restore of ${pending.id} requested at boot`);
|
|
396
|
+
try {
|
|
397
|
+
const report = await restoreSnapshot({
|
|
398
|
+
id: pending.id,
|
|
399
|
+
settings: options.settings,
|
|
400
|
+
home,
|
|
401
|
+
beforeApply: options.beforeApply,
|
|
402
|
+
});
|
|
403
|
+
await clearRestorePending(home);
|
|
404
|
+
return { ...report, requestedBy: pending.requestedBy };
|
|
405
|
+
} catch (err) {
|
|
406
|
+
logWarn("backup", `Staged restore of ${pending.id} failed: ${String(err)}`);
|
|
407
|
+
await clearRestorePending(home);
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
}
|