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
package/package.json
CHANGED
package/src/app.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
} from "./core/background/cron/scheduler.js";
|
|
28
28
|
import { shutdownTriggers } from "./core/background/triggers/index.js";
|
|
29
29
|
import { shutdownAgents } from "./core/agents/index.js";
|
|
30
|
+
import { stopBackupScheduler } from "./core/backup/index.js";
|
|
30
31
|
import { pruneSettledTriggers } from "./storage/triggers.js";
|
|
31
32
|
import { startWatchdog, stopWatchdog } from "./util/watchdog.js";
|
|
32
33
|
import {
|
|
@@ -77,11 +78,48 @@ import {
|
|
|
77
78
|
// the successor will run is importable at all. Nothing has booted yet,
|
|
78
79
|
// so this is both the strongest and the last harmless place to say so.
|
|
79
80
|
// See core/update/self-update.ts for what a failure does instead.
|
|
81
|
+
//
|
|
82
|
+
// This stays ahead of the staged restore below: the smoke run must not
|
|
83
|
+
// touch state, and a restore is the single most destructive thing this
|
|
84
|
+
// file does.
|
|
80
85
|
if (process.argv.includes(BOOT_SMOKE_FLAG)) {
|
|
81
86
|
console.log(BOOT_SMOKE_OK);
|
|
82
87
|
process.exit(0);
|
|
83
88
|
}
|
|
84
89
|
|
|
90
|
+
/**
|
|
91
|
+
* A `/backup restore <id>` from chat writes ~/.talon/restore-pending.json
|
|
92
|
+
* and restarts. It is applied HERE, before anything else: every store
|
|
93
|
+
* below opens the database, and the database is one of the files this is
|
|
94
|
+
* about to replace. The pre-restore checkpoint inside needs the current
|
|
95
|
+
* database, so the handle is closed between the two — which is why the
|
|
96
|
+
* composition root, and not core/backup, owns that call.
|
|
97
|
+
*
|
|
98
|
+
* Never throws: a failed restore still boots the daemon (with the reason
|
|
99
|
+
* in the log and the request deleted, so the next boot is normal).
|
|
100
|
+
*/
|
|
101
|
+
async function applyStagedRestore(): Promise<string | null> {
|
|
102
|
+
const { applyPendingRestore, readRestorePending } =
|
|
103
|
+
await import("./core/backup/index.js");
|
|
104
|
+
if (!(await readRestorePending())) return null;
|
|
105
|
+
const { loadConfig } = await import("./core/config/index.js");
|
|
106
|
+
const { resolveBackupSettings } = await import("./core/backup/plan.js");
|
|
107
|
+
const { closeDatabase } = await import("./storage/db.js");
|
|
108
|
+
const report = await applyPendingRestore({
|
|
109
|
+
settings: resolveBackupSettings(loadConfig().backup),
|
|
110
|
+
beforeApply: closeDatabase,
|
|
111
|
+
});
|
|
112
|
+
if (!report) return null;
|
|
113
|
+
return (
|
|
114
|
+
`♻️ Restored snapshot ${report.id}` +
|
|
115
|
+
(report.checkpointId
|
|
116
|
+
? ` (previous state saved as checkpoint ${report.checkpointId})`
|
|
117
|
+
: "")
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const restoreReport = await bootPhase("staged restore", applyStagedRestore);
|
|
122
|
+
|
|
85
123
|
const { config } = await bootPhase("bootstrap", () => bootstrap());
|
|
86
124
|
|
|
87
125
|
// Record this process as the daemon. The gateway port is appended once
|
|
@@ -131,6 +169,36 @@ onBackendChange((holder, newBackend, info) => {
|
|
|
131
169
|
|
|
132
170
|
// ── Graceful shutdown ────────────────────────────────────────────────────────
|
|
133
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Arm the backup scheduler. Here rather than in bootstrap because this is
|
|
174
|
+
* where the process lifecycle lives — `stopBackupScheduler` is two screens
|
|
175
|
+
* down in `gracefulShutdown`, and the two belong together. Failure
|
|
176
|
+
* notifications go to `backup.notifyChatId` when set, otherwise to the
|
|
177
|
+
* admin, over the same route as the plan alerts.
|
|
178
|
+
*/
|
|
179
|
+
async function startBackups(): Promise<void> {
|
|
180
|
+
const { initBackup } = await import("./core/backup/index.js");
|
|
181
|
+
const { resolveBackupSettings } = await import("./core/backup/plan.js");
|
|
182
|
+
const notifyChatId = config.backup?.notifyChatId;
|
|
183
|
+
await initBackup({
|
|
184
|
+
settings: resolveBackupSettings(config.backup),
|
|
185
|
+
notify: notifyChatId
|
|
186
|
+
? async (text: string) => {
|
|
187
|
+
const { resolveFrontendIdAmong } =
|
|
188
|
+
await import("./core/frontend-runtime/routing.js");
|
|
189
|
+
const name = resolveFrontendIdAmong(
|
|
190
|
+
notifyChatId,
|
|
191
|
+
frontends.map((frontend) => frontend.name),
|
|
192
|
+
);
|
|
193
|
+
const target =
|
|
194
|
+
frontends.find((frontend) => frontend.name === name) ??
|
|
195
|
+
frontends[0];
|
|
196
|
+
if (target) await target.sendMessage(Number(notifyChatId), text);
|
|
197
|
+
}
|
|
198
|
+
: undefined,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
134
202
|
let shuttingDown = false;
|
|
135
203
|
let triggerPruneTimer: ReturnType<typeof setInterval> | null = null;
|
|
136
204
|
|
|
@@ -230,6 +298,7 @@ async function gracefulShutdown(signal: string): Promise<void> {
|
|
|
230
298
|
});
|
|
231
299
|
}
|
|
232
300
|
await shutdownStep("fuse layer", unmountNamespaceFs);
|
|
301
|
+
await shutdownStep("backup scheduler", stopBackupScheduler);
|
|
233
302
|
await shutdownStep("pulse timer", stopPulseTimer);
|
|
234
303
|
await shutdownStep("heartbeat", async () => {
|
|
235
304
|
stopHeartbeatTimer();
|
|
@@ -298,6 +367,7 @@ async function main(): Promise<void> {
|
|
|
298
367
|
await Promise.all(frontends.map((frontend) => frontend.init()));
|
|
299
368
|
log("bot", "Starting Talon...");
|
|
300
369
|
|
|
370
|
+
await startBackups();
|
|
301
371
|
if (config.pulse) startPulseTimer(config.pulseIntervalMs);
|
|
302
372
|
if (config.heartbeat) startHeartbeatTimer(config.heartbeatIntervalMinutes);
|
|
303
373
|
startWatchdog(config.workspace);
|
|
@@ -345,6 +415,14 @@ async function main(): Promise<void> {
|
|
|
345
415
|
// Phase 0 accounting (docs/ts-migration-plan.md): the boot is over the
|
|
346
416
|
// moment the frontends are listening, so the totals are folded into the
|
|
347
417
|
// metrics store here, from the same uptime figure the log line prints.
|
|
418
|
+
// A restore applied at boot happened before any frontend existed, so the
|
|
419
|
+
// operator hears about it here, on the first channel that can carry it.
|
|
420
|
+
if (restoreReport) {
|
|
421
|
+
const { notifyAdmin } =
|
|
422
|
+
await import("./core/frontend-runtime/admin-notify.js");
|
|
423
|
+
await notifyAdmin(restoreReport);
|
|
424
|
+
}
|
|
425
|
+
|
|
348
426
|
const bootMs = Math.round(process.uptime() * 1000);
|
|
349
427
|
recordBootMetrics(bootMs);
|
|
350
428
|
startResourceSampler();
|
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
pluginHubUrl,
|
|
30
30
|
hubPluginServerNames,
|
|
31
31
|
} from "../../core/mcp-hub/index.js";
|
|
32
|
-
import type { ToolExclusionConfig } from "../../core/tools/
|
|
32
|
+
import type { ToolExclusionConfig } from "../../core/tools/mcp-env.js";
|
|
33
33
|
import { frontendsForChat } from "../runtime/frontends.js";
|
|
34
34
|
|
|
35
35
|
/**
|
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
pluginHubUrl,
|
|
31
31
|
hubPluginServerNames,
|
|
32
32
|
} from "../../core/mcp-hub/index.js";
|
|
33
|
-
import type { ToolExclusionConfig } from "../../core/tools/
|
|
33
|
+
import type { ToolExclusionConfig } from "../../core/tools/mcp-env.js";
|
|
34
34
|
import { frontendsForChat } from "../runtime/frontends.js";
|
|
35
35
|
import { log, logWarn } from "../../util/log.js";
|
|
36
36
|
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
* - SDK-specific event types — those live in each backend.
|
|
38
38
|
* - Session storage — that's `src/storage/sessions.ts`.
|
|
39
39
|
* - MCP server registration — backend-specific transport details
|
|
40
|
-
* (the spawn/env contract they share is `core/tools/
|
|
40
|
+
* (the spawn/env contract they share is `core/tools/mcp-env.ts`).
|
|
41
41
|
*/
|
|
42
42
|
|
|
43
43
|
export { captureDeliveredText } from "./turn/delivered-text.js";
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `talon backup` — snapshots and checkpoints from the outside.
|
|
3
|
+
*
|
|
4
|
+
* Two execution modes, chosen per subcommand:
|
|
5
|
+
*
|
|
6
|
+
* - Through the daemon (`now`, `targets`, `status`) when one is
|
|
7
|
+
* running. The daemon has the plugins loaded, so it is the only
|
|
8
|
+
* process that can see the remote targets — and routing through it
|
|
9
|
+
* also means one writer, not two racing `VACUUM INTO`s.
|
|
10
|
+
* - In this process (`list`, `show`, `pin`, `unpin`, `prune`, and
|
|
11
|
+
* `now` with no daemon) — they read the manifests on disk and the
|
|
12
|
+
* index in talon.db, both of which are safe to touch concurrently.
|
|
13
|
+
*
|
|
14
|
+
* `restore` refuses while the daemon is running, full stop: it replaces
|
|
15
|
+
* the database and the memory under a live process. Stop Talon first, or
|
|
16
|
+
* use `/backup restore <id>` from chat, which stages the request and
|
|
17
|
+
* lets the next boot apply it before anything opens the database.
|
|
18
|
+
*
|
|
19
|
+
* Lives in `commands/` because that is where docs/structure.md's worklist
|
|
20
|
+
* item 8 puts every CLI command; `src/cli/` itself is at its file ceiling.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import pc from "picocolors";
|
|
24
|
+
import { createInterface } from "node:readline/promises";
|
|
25
|
+
import { getFrontends, loadConfig } from "../../core/config/index.js";
|
|
26
|
+
import { findRunningInstance } from "../../core/daemon/discovery.js";
|
|
27
|
+
import {
|
|
28
|
+
collectBackupStatus,
|
|
29
|
+
formatBackupStatus,
|
|
30
|
+
formatBytes,
|
|
31
|
+
formatRelative,
|
|
32
|
+
isSnapshotId,
|
|
33
|
+
listSnapshots,
|
|
34
|
+
discoverTargets,
|
|
35
|
+
readManifest,
|
|
36
|
+
restoreSnapshot,
|
|
37
|
+
setSnapshotPinned,
|
|
38
|
+
type BackupTarget,
|
|
39
|
+
type SnapshotSummary,
|
|
40
|
+
} from "../../core/backup/index.js";
|
|
41
|
+
import { resolveBackupSettings } from "../../core/backup/plan.js";
|
|
42
|
+
import { buildSnapshot } from "../../core/backup/snapshot.js";
|
|
43
|
+
import { pruneLocal } from "../../core/backup/store.js";
|
|
44
|
+
import { fetchGateway } from "../daemon-api.js";
|
|
45
|
+
|
|
46
|
+
const USAGE = `
|
|
47
|
+
${pc.bold("talon backup")} — snapshots and checkpoints
|
|
48
|
+
|
|
49
|
+
${pc.cyan("now")} [--checkpoint <label>] [--pin] take a snapshot now
|
|
50
|
+
${pc.cyan("list")} [--remote <targetId>] list snapshots
|
|
51
|
+
${pc.cyan("show")} <id> everything about one snapshot
|
|
52
|
+
${pc.cyan("pin")} <id> | ${pc.cyan("unpin")} <id> keep past retention, or release
|
|
53
|
+
${pc.cyan("restore")} <id> [--from <target>] [--yes] restore (daemon must be stopped)
|
|
54
|
+
${pc.cyan("prune")} apply the local retention policy
|
|
55
|
+
${pc.cyan("targets")} remote targets and their readiness
|
|
56
|
+
${pc.cyan("status")} schedule, sizes, targets
|
|
57
|
+
`;
|
|
58
|
+
|
|
59
|
+
type Flags = { values: string[]; flags: Map<string, string | true> };
|
|
60
|
+
|
|
61
|
+
/** `--from drive --yes x` → { values: ["x"], flags: { from: "drive", yes: true } } */
|
|
62
|
+
function parseArgs(argv: readonly string[]): Flags {
|
|
63
|
+
const values: string[] = [];
|
|
64
|
+
const flags = new Map<string, string | true>();
|
|
65
|
+
for (let i = 0; i < argv.length; i++) {
|
|
66
|
+
const arg = argv[i];
|
|
67
|
+
if (!arg.startsWith("--")) {
|
|
68
|
+
values.push(arg);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const name = arg.slice(2);
|
|
72
|
+
const next = argv[i + 1];
|
|
73
|
+
if (next && !next.startsWith("--")) {
|
|
74
|
+
flags.set(name, next);
|
|
75
|
+
i++;
|
|
76
|
+
} else {
|
|
77
|
+
flags.set(name, true);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { values, flags };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function daemonPort(): Promise<number | null> {
|
|
84
|
+
const instance = await findRunningInstance();
|
|
85
|
+
return instance?.port ?? null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Run one action inside the daemon. Returns null when there is no daemon. */
|
|
89
|
+
async function viaDaemon(
|
|
90
|
+
body: Record<string, unknown>,
|
|
91
|
+
): Promise<{ ok: boolean; text?: string; error?: string } | null> {
|
|
92
|
+
const port = await daemonPort();
|
|
93
|
+
if (port === null) return null;
|
|
94
|
+
// A snapshot of a large workspace can take minutes; the default CLI
|
|
95
|
+
// budget is three seconds.
|
|
96
|
+
return (await fetchGateway(
|
|
97
|
+
port,
|
|
98
|
+
"/action",
|
|
99
|
+
{
|
|
100
|
+
method: "POST",
|
|
101
|
+
headers: { "Content-Type": "application/json" },
|
|
102
|
+
body: JSON.stringify(body),
|
|
103
|
+
},
|
|
104
|
+
15 * 60_000,
|
|
105
|
+
)) as { ok: boolean; text?: string; error?: string };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function renderResult(result: {
|
|
109
|
+
ok: boolean;
|
|
110
|
+
text?: string;
|
|
111
|
+
error?: string;
|
|
112
|
+
}): void {
|
|
113
|
+
if (result.ok) console.log(` ${result.text ?? "Done."}\n`);
|
|
114
|
+
else console.log(` ${pc.red("●")} ${result.error ?? "Failed."}\n`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function renderRow(snapshot: SnapshotSummary): string {
|
|
118
|
+
const marks = [
|
|
119
|
+
snapshot.kind === "checkpoint"
|
|
120
|
+
? pc.magenta("checkpoint")
|
|
121
|
+
: pc.dim("backup"),
|
|
122
|
+
snapshot.pinned ? pc.yellow("pinned") : "",
|
|
123
|
+
snapshot.local ? "" : pc.dim("(remote only)"),
|
|
124
|
+
].filter(Boolean);
|
|
125
|
+
const remote = Object.entries(snapshot.remote)
|
|
126
|
+
.map(([id, entry]) =>
|
|
127
|
+
entry.status === "uploaded"
|
|
128
|
+
? pc.green(id)
|
|
129
|
+
: pc.yellow(`${id}:${entry.status}`),
|
|
130
|
+
)
|
|
131
|
+
.join(" ");
|
|
132
|
+
return (
|
|
133
|
+
` ${pc.bold(snapshot.id)} ${marks.join(" ")} ${formatBytes(snapshot.sizeBytes).padStart(9)}` +
|
|
134
|
+
` ${pc.dim(formatRelative(snapshot.createdAt))}` +
|
|
135
|
+
(snapshot.label ? ` ${snapshot.label}` : "") +
|
|
136
|
+
(remote ? ` → ${remote}` : "")
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── Subcommands ─────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
async function backupNow(flags: Flags): Promise<void> {
|
|
143
|
+
const label =
|
|
144
|
+
typeof flags.flags.get("checkpoint") === "string"
|
|
145
|
+
? String(flags.flags.get("checkpoint"))
|
|
146
|
+
: undefined;
|
|
147
|
+
const pin = flags.flags.get("pin") === true;
|
|
148
|
+
const remote = await viaDaemon({ action: "backup.now", label, pin });
|
|
149
|
+
if (remote) {
|
|
150
|
+
renderResult(remote);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
console.log(
|
|
154
|
+
` ${pc.dim("Talon is not running — taking a local snapshot (no remote upload).")}`,
|
|
155
|
+
);
|
|
156
|
+
const settings = resolveBackupSettings(loadConfig().backup);
|
|
157
|
+
const manifest = await buildSnapshot({
|
|
158
|
+
kind: label ? "checkpoint" : "backup",
|
|
159
|
+
label,
|
|
160
|
+
pinned: pin,
|
|
161
|
+
settings,
|
|
162
|
+
});
|
|
163
|
+
await pruneLocal(settings.keepLocal);
|
|
164
|
+
console.log(
|
|
165
|
+
` ${pc.green("●")} ${manifest.id} — ${manifest.parts.length} part(s), ` +
|
|
166
|
+
`${formatBytes(manifest.sizeBytes)}\n`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function backupList(flags: Flags): Promise<void> {
|
|
171
|
+
const target = flags.flags.get("remote");
|
|
172
|
+
const snapshots = await listSnapshots();
|
|
173
|
+
const filtered =
|
|
174
|
+
typeof target === "string"
|
|
175
|
+
? snapshots.filter(
|
|
176
|
+
(snapshot) => snapshot.remote[target]?.status === "uploaded",
|
|
177
|
+
)
|
|
178
|
+
: snapshots;
|
|
179
|
+
if (filtered.length === 0) {
|
|
180
|
+
console.log(` ${pc.dim("No snapshots.")}\n`);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
console.log("");
|
|
184
|
+
for (const snapshot of filtered) console.log(renderRow(snapshot));
|
|
185
|
+
const bytes = filtered.reduce((sum, snapshot) => sum + snapshot.sizeBytes, 0);
|
|
186
|
+
console.log(
|
|
187
|
+
`\n ${pc.dim(`${filtered.length} snapshot(s), ${formatBytes(bytes)}`)}\n`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function backupShow(id: string): Promise<void> {
|
|
192
|
+
const manifest = await readManifest(id);
|
|
193
|
+
if (!manifest) {
|
|
194
|
+
console.log(` ${pc.red("●")} No snapshot ${id} on this machine.\n`);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
console.log(
|
|
198
|
+
`\n ${pc.bold(manifest.id)} ${manifest.kind}${manifest.pinned ? pc.yellow(" pinned") : ""}`,
|
|
199
|
+
);
|
|
200
|
+
if (manifest.label) console.log(` ${manifest.label}`);
|
|
201
|
+
console.log(
|
|
202
|
+
` taken ${new Date(manifest.createdAt).toISOString()} (${formatRelative(manifest.createdAt)})`,
|
|
203
|
+
);
|
|
204
|
+
console.log(
|
|
205
|
+
` host ${manifest.host} talon ${manifest.talonVersion}${manifest.gitHead ? ` @ ${manifest.gitHead}` : ""}`,
|
|
206
|
+
);
|
|
207
|
+
console.log(` size ${formatBytes(manifest.sizeBytes)}`);
|
|
208
|
+
console.log(
|
|
209
|
+
` parts ${manifest.parts.map((part) => `${part.name} (${formatBytes(part.bytes)})`).join(", ")}`,
|
|
210
|
+
);
|
|
211
|
+
console.log(` includes ${manifest.includes.join(", ")}`);
|
|
212
|
+
if (manifest.extras?.length) {
|
|
213
|
+
console.log(
|
|
214
|
+
` extras ${manifest.extras.map((extra) => `extra/${extra.n} ← ${extra.source}`).join(", ")}`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
for (const [targetId, state] of Object.entries(manifest.remote)) {
|
|
218
|
+
console.log(
|
|
219
|
+
` remote ${targetId}: ${state.status}${state.error ? ` — ${state.error}` : ""}`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
console.log("");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function backupPin(id: string, pinned: boolean): Promise<void> {
|
|
226
|
+
const ok = await setSnapshotPinned(id, pinned);
|
|
227
|
+
console.log(
|
|
228
|
+
ok
|
|
229
|
+
? ` ${pc.green("●")} ${id} is ${pinned ? "pinned — retention will never prune it" : "unpinned"}\n`
|
|
230
|
+
: ` ${pc.red("●")} No snapshot ${id}\n`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function backupPrune(): Promise<void> {
|
|
235
|
+
const settings = resolveBackupSettings(loadConfig().backup);
|
|
236
|
+
const removed = await pruneLocal(settings.keepLocal);
|
|
237
|
+
console.log(
|
|
238
|
+
removed.length === 0
|
|
239
|
+
? ` ${pc.dim(`Nothing to prune (keepLocal=${settings.keepLocal}).`)}\n`
|
|
240
|
+
: ` ${pc.green("●")} Pruned ${removed.length}: ${removed.join(", ")}\n`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function backupTargets(): Promise<void> {
|
|
245
|
+
const result = await viaDaemon({ action: "backup.status" });
|
|
246
|
+
if (!result) {
|
|
247
|
+
console.log(
|
|
248
|
+
` ${pc.yellow("●")} Talon is not running — remote targets live in the daemon's plugins.\n`,
|
|
249
|
+
);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
renderResult(result);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function backupStatus(): Promise<void> {
|
|
256
|
+
const result = await viaDaemon({ action: "backup.status" });
|
|
257
|
+
if (result) {
|
|
258
|
+
renderResult(result);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const status = await collectBackupStatus({ withTargets: false });
|
|
262
|
+
console.log(
|
|
263
|
+
`\n${formatBackupStatus(status)
|
|
264
|
+
.split("\n")
|
|
265
|
+
.map((line) => ` ${line}`)
|
|
266
|
+
.join("\n")}\n`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function confirm(question: string): Promise<boolean> {
|
|
271
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
272
|
+
try {
|
|
273
|
+
return (await rl.question(question)).trim().toLowerCase() === "yes";
|
|
274
|
+
} finally {
|
|
275
|
+
rl.close();
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function backupRestore(id: string, flags: Flags): Promise<void> {
|
|
280
|
+
if (!isSnapshotId(id)) {
|
|
281
|
+
console.log(` ${pc.red("●")} "${id}" is not a snapshot id.\n`);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
const instance = await findRunningInstance();
|
|
285
|
+
if (instance) {
|
|
286
|
+
console.log(
|
|
287
|
+
` ${pc.red("●")} Talon is running (PID ${instance.pid}). A restore replaces the\n` +
|
|
288
|
+
` database and memory underneath it. Run ${pc.cyan("talon stop")} first, or use\n` +
|
|
289
|
+
` ${pc.cyan(`/backup restore ${id}`)} from chat — that stages the restore and applies\n` +
|
|
290
|
+
` it during the next boot.\n`,
|
|
291
|
+
);
|
|
292
|
+
process.exitCode = 1;
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const manifest = await readManifest(id);
|
|
296
|
+
const from = flags.flags.get("from");
|
|
297
|
+
if (!manifest && typeof from !== "string") {
|
|
298
|
+
console.log(
|
|
299
|
+
` ${pc.red("●")} No snapshot ${id} on this machine (use --from <target> to fetch it).\n`,
|
|
300
|
+
);
|
|
301
|
+
process.exitCode = 1;
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (flags.flags.get("yes") !== true) {
|
|
305
|
+
console.log(
|
|
306
|
+
`\n This replaces config, prompts, keys, sessions, the database and memory\n` +
|
|
307
|
+
` with the contents of ${pc.bold(id)}${manifest?.label ? ` ("${manifest.label}")` : ""}.\n` +
|
|
308
|
+
` A pinned checkpoint of the current state is taken first.\n`,
|
|
309
|
+
);
|
|
310
|
+
if (!(await confirm(` Type ${pc.bold("yes")} to continue: `))) {
|
|
311
|
+
console.log(` ${pc.dim("Cancelled.")}\n`);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const settings = resolveBackupSettings(loadConfig().backup);
|
|
316
|
+
const target =
|
|
317
|
+
typeof from === "string" ? await resolveTarget(from) : undefined;
|
|
318
|
+
const report = await restoreSnapshot({ id, settings, target });
|
|
319
|
+
const written = Object.entries(report.written)
|
|
320
|
+
.map(([root, count]) => `${root} (${count})`)
|
|
321
|
+
.join(", ");
|
|
322
|
+
console.log(`\n ${pc.green("●")} Restored ${report.id}`);
|
|
323
|
+
if (report.checkpointId) {
|
|
324
|
+
console.log(
|
|
325
|
+
` Previous state saved as ${pc.bold(report.checkpointId)} (pinned).`,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
console.log(` Written: ${written || "nothing"}`);
|
|
329
|
+
console.log(` Removed: ${report.removed} file(s)`);
|
|
330
|
+
console.log(
|
|
331
|
+
` Database: ${report.databaseReplaced ? "replaced" : "left in place"}\n`,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Find a target by id. Targets are plugins, and a restore runs with the
|
|
337
|
+
* daemon stopped, so this process loads them itself — the same call
|
|
338
|
+
* bootstrap makes, minus the frontends.
|
|
339
|
+
*/
|
|
340
|
+
async function resolveTarget(id: string): Promise<BackupTarget | undefined> {
|
|
341
|
+
const config = loadConfig();
|
|
342
|
+
const { loadPlugins } = await import("../../core/plugin/index.js");
|
|
343
|
+
await loadPlugins(config.plugins, getFrontends(config));
|
|
344
|
+
const targets = await discoverTargets();
|
|
345
|
+
const target = targets.find((candidate) => candidate.id === id);
|
|
346
|
+
if (!target) {
|
|
347
|
+
console.log(
|
|
348
|
+
` ${pc.red("●")} No backup target "${id}"` +
|
|
349
|
+
(targets.length > 0
|
|
350
|
+
? ` — loaded: ${targets.map((t) => t.id).join(", ")}\n`
|
|
351
|
+
: " — no target plugins are configured.\n"),
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
return target;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ── Entry ───────────────────────────────────────────────────────────────────
|
|
358
|
+
|
|
359
|
+
export async function runBackupCommand(argv: readonly string[]): Promise<void> {
|
|
360
|
+
const { values, flags } = parseArgs(argv);
|
|
361
|
+
const [subcommand, id] = values;
|
|
362
|
+
const args: Flags = { values, flags };
|
|
363
|
+
switch (subcommand) {
|
|
364
|
+
case "now":
|
|
365
|
+
await backupNow(args);
|
|
366
|
+
return;
|
|
367
|
+
case "list":
|
|
368
|
+
await backupList(args);
|
|
369
|
+
return;
|
|
370
|
+
case "show":
|
|
371
|
+
if (!id) return void console.log(USAGE);
|
|
372
|
+
await backupShow(id);
|
|
373
|
+
return;
|
|
374
|
+
case "pin":
|
|
375
|
+
case "unpin":
|
|
376
|
+
if (!id) return void console.log(USAGE);
|
|
377
|
+
await backupPin(id, subcommand === "pin");
|
|
378
|
+
return;
|
|
379
|
+
case "restore":
|
|
380
|
+
if (!id) return void console.log(USAGE);
|
|
381
|
+
await backupRestore(id, args);
|
|
382
|
+
return;
|
|
383
|
+
case "prune":
|
|
384
|
+
await backupPrune();
|
|
385
|
+
return;
|
|
386
|
+
case "targets":
|
|
387
|
+
await backupTargets();
|
|
388
|
+
return;
|
|
389
|
+
case "status":
|
|
390
|
+
case undefined:
|
|
391
|
+
await backupStatus();
|
|
392
|
+
return;
|
|
393
|
+
default:
|
|
394
|
+
console.log(USAGE);
|
|
395
|
+
}
|
|
396
|
+
}
|
package/src/cli/events.ts
CHANGED
|
@@ -44,6 +44,20 @@ function describe(event: TalonEvent): string {
|
|
|
44
44
|
return `${event.agentId} "${event.label}" → ${event.state} (${event.durationMs}ms)`;
|
|
45
45
|
case "agent.message":
|
|
46
46
|
return `${event.from} → ${event.to} (${event.kind})`;
|
|
47
|
+
case "backup.started":
|
|
48
|
+
return `${event.kind} (${event.trigger})`;
|
|
49
|
+
case "backup.completed":
|
|
50
|
+
return (
|
|
51
|
+
`${event.snapshotId} ${event.kind} ${event.parts} part(s) ` +
|
|
52
|
+
`${(event.sizeBytes / 1024 / 1024).toFixed(1)}MB (${event.durationMs}ms)`
|
|
53
|
+
);
|
|
54
|
+
case "backup.failed":
|
|
55
|
+
return `${event.trigger} failed (streak ${event.consecutiveFailures}): ${event.error}`;
|
|
56
|
+
case "backup.uploaded":
|
|
57
|
+
return (
|
|
58
|
+
`${event.snapshotId} → ${event.targetId} ` +
|
|
59
|
+
`${(event.bytes / 1024 / 1024).toFixed(1)}MB${event.deduplicated ? " (deduplicated)" : ""}`
|
|
60
|
+
);
|
|
47
61
|
}
|
|
48
62
|
}
|
|
49
63
|
|