talon-agent 5.2.1 → 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 +2 -2
- package/src/app.ts +94 -1
- 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/daemon/handoff.ts +192 -0
- package/src/core/daemon/respawn.ts +127 -52
- 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 +47 -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/index.ts +13 -5
- package/src/plugins/playwright/index.ts +39 -3
- package/src/plugins/playwright/provision.ts +21 -0
- package/src/plugins/playwright/version-coupling.ts +195 -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 +129 -81
- package/src/util/paths.ts +5 -0
- /package/src/core/tools/{ops/mcp-env.ts → mcp-env.ts} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talon-agent",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.3.0",
|
|
4
4
|
"description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
|
|
5
5
|
"author": "Dylan Neve",
|
|
6
6
|
"license": "MIT",
|
|
@@ -110,7 +110,7 @@
|
|
|
110
110
|
"@openai/agents": "^0.18.0",
|
|
111
111
|
"@openai/codex-sdk": "^0.154.0",
|
|
112
112
|
"@opencode-ai/sdk": "^1.17.4",
|
|
113
|
-
"@playwright/mcp": "0.0.
|
|
113
|
+
"@playwright/mcp": "0.0.56",
|
|
114
114
|
"@types/cross-spawn": "^6.0.6",
|
|
115
115
|
"@types/qrcode": "^1.5.6",
|
|
116
116
|
"baileys": "^7.0.0-rc14",
|
package/src/app.ts
CHANGED
|
@@ -27,9 +27,14 @@ 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
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
BOOT_SMOKE_FLAG,
|
|
35
|
+
BOOT_SMOKE_OK,
|
|
36
|
+
spawnSuccessor,
|
|
37
|
+
} from "./core/daemon/respawn.js";
|
|
33
38
|
import {
|
|
34
39
|
crashCleanup,
|
|
35
40
|
crashStep,
|
|
@@ -67,6 +72,54 @@ import {
|
|
|
67
72
|
stopResourceSampler,
|
|
68
73
|
} from "./core/daemon/resource-sampler.js";
|
|
69
74
|
|
|
75
|
+
// `/update` runs the freshly installed tree with --boot-smoke before it
|
|
76
|
+
// hands off. Every static import above has just been resolved against
|
|
77
|
+
// the new node_modules — reaching this line is the proof that the tree
|
|
78
|
+
// the successor will run is importable at all. Nothing has booted yet,
|
|
79
|
+
// so this is both the strongest and the last harmless place to say so.
|
|
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.
|
|
85
|
+
if (process.argv.includes(BOOT_SMOKE_FLAG)) {
|
|
86
|
+
console.log(BOOT_SMOKE_OK);
|
|
87
|
+
process.exit(0);
|
|
88
|
+
}
|
|
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
|
+
|
|
70
123
|
const { config } = await bootPhase("bootstrap", () => bootstrap());
|
|
71
124
|
|
|
72
125
|
// Record this process as the daemon. The gateway port is appended once
|
|
@@ -116,6 +169,36 @@ onBackendChange((holder, newBackend, info) => {
|
|
|
116
169
|
|
|
117
170
|
// ── Graceful shutdown ────────────────────────────────────────────────────────
|
|
118
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
|
+
|
|
119
202
|
let shuttingDown = false;
|
|
120
203
|
let triggerPruneTimer: ReturnType<typeof setInterval> | null = null;
|
|
121
204
|
|
|
@@ -215,6 +298,7 @@ async function gracefulShutdown(signal: string): Promise<void> {
|
|
|
215
298
|
});
|
|
216
299
|
}
|
|
217
300
|
await shutdownStep("fuse layer", unmountNamespaceFs);
|
|
301
|
+
await shutdownStep("backup scheduler", stopBackupScheduler);
|
|
218
302
|
await shutdownStep("pulse timer", stopPulseTimer);
|
|
219
303
|
await shutdownStep("heartbeat", async () => {
|
|
220
304
|
stopHeartbeatTimer();
|
|
@@ -283,6 +367,7 @@ async function main(): Promise<void> {
|
|
|
283
367
|
await Promise.all(frontends.map((frontend) => frontend.init()));
|
|
284
368
|
log("bot", "Starting Talon...");
|
|
285
369
|
|
|
370
|
+
await startBackups();
|
|
286
371
|
if (config.pulse) startPulseTimer(config.pulseIntervalMs);
|
|
287
372
|
if (config.heartbeat) startHeartbeatTimer(config.heartbeatIntervalMinutes);
|
|
288
373
|
startWatchdog(config.workspace);
|
|
@@ -330,6 +415,14 @@ async function main(): Promise<void> {
|
|
|
330
415
|
// Phase 0 accounting (docs/ts-migration-plan.md): the boot is over the
|
|
331
416
|
// moment the frontends are listening, so the totals are folded into the
|
|
332
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
|
+
|
|
333
426
|
const bootMs = Math.round(process.uptime() * 1000);
|
|
334
427
|
recordBootMetrics(bootMs);
|
|
335
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
|
|