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.
Files changed (58) hide show
  1. package/package.json +2 -2
  2. package/src/app.ts +94 -1
  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/daemon/handoff.ts +192 -0
  25. package/src/core/daemon/respawn.ts +127 -52
  26. package/src/core/engine/gateway-actions/backup/index.ts +129 -0
  27. package/src/core/engine/gateway-actions/index.ts +4 -0
  28. package/src/core/mcp-hub/talon-server.ts +1 -1
  29. package/src/core/plugin/actions.ts +34 -0
  30. package/src/core/plugin/index.ts +5 -1
  31. package/src/core/tools/{ops/bridge.ts → bridge.ts} +7 -2
  32. package/src/core/tools/index.ts +2 -0
  33. package/src/core/tools/ops/backup.ts +67 -0
  34. package/src/core/tools/types.ts +2 -1
  35. package/src/core/update/self-update.ts +47 -0
  36. package/src/frontend/discord/callbacks/components/index.ts +3 -0
  37. package/src/frontend/discord/commands/backup.ts +203 -0
  38. package/src/frontend/discord/commands/definitions.ts +35 -0
  39. package/src/frontend/discord/commands/router.ts +3 -0
  40. package/src/frontend/telegram/callbacks/backup.ts +55 -0
  41. package/src/frontend/telegram/callbacks/index.ts +8 -0
  42. package/src/frontend/telegram/commands/backup.ts +209 -0
  43. package/src/frontend/telegram/commands/definitions.ts +4 -0
  44. package/src/frontend/telegram/commands/index.ts +2 -0
  45. package/src/index.ts +13 -5
  46. package/src/plugins/playwright/index.ts +39 -3
  47. package/src/plugins/playwright/provision.ts +21 -0
  48. package/src/plugins/playwright/version-coupling.ts +195 -0
  49. package/src/storage/backup/index.ts +82 -0
  50. package/src/storage/backup/repo.ts +164 -0
  51. package/src/storage/db.ts +20 -0
  52. package/src/storage/sql/backups.sql +46 -0
  53. package/src/storage/sql/db.sql +8 -0
  54. package/src/storage/sql/schema.sql +30 -0
  55. package/src/storage/sql/statements.generated.ts +60 -1
  56. package/src/util/log.ts +129 -81
  57. package/src/util/paths.ts +5 -0
  58. /package/src/core/tools/{ops/mcp-env.ts → mcp-env.ts} +0 -0
@@ -25,9 +25,9 @@
25
25
  * with "428 Precondition Required". @playwright/mcp is therefore pinned
26
26
  * exactly in package.json (0.0.56 → playwright 1.58.x, matching python
27
27
  * playwright 1.58 which hosts Camoufox — camoufox itself caps playwright at
28
- * <1.61, so the node client cannot chase latest). Bump BOTH sides together,
29
- * deliberately — do not let a routine dependency bump move one without the
30
- * other.
28
+ * <1.61, so the node client cannot chase latest). The pin is enforced, not
29
+ * just documented: see version-coupling.ts (unit test that fails CI on a
30
+ * bump, validateConfig refusal, and a live handshake probe at init).
31
31
  */
32
32
 
33
33
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -35,6 +35,12 @@ import { dirname, resolve } from "node:path";
35
35
  import type { TalonPlugin } from "../../core/plugin/types.js";
36
36
  import { files } from "../../util/paths.js";
37
37
  import { log } from "../../util/log.js";
38
+ import {
39
+ ENDPOINT_PLAYWRIGHT_MINOR,
40
+ bundledPlaywrightVersion,
41
+ couplingError,
42
+ probeEndpoint,
43
+ } from "./version-coupling.js";
38
44
 
39
45
  export function createPlaywrightPlugin(config: {
40
46
  browser?: string;
@@ -141,6 +147,13 @@ export function createPlaywrightPlugin(config: {
141
147
  );
142
148
  }
143
149
 
150
+ // Endpoint mode: refuse to start on a client/server minor mismatch
151
+ // rather than fail every tool call later (see version-coupling.ts).
152
+ if (endpoint) {
153
+ const coupling = couplingError(bundledPlaywrightVersion());
154
+ if (coupling) errors.push(coupling);
155
+ }
156
+
144
157
  return errors.length > 0 ? errors : undefined;
145
158
  },
146
159
 
@@ -149,6 +162,29 @@ export function createPlaywrightPlugin(config: {
149
162
  "playwright",
150
163
  `Ready (${endpoint ? `Camoufox @ ${endpoint}` : `${browser}, headless=${headless}`})`,
151
164
  );
165
+ if (!endpoint) return;
166
+ // Live handshake with the bundled client version: catches a drifted
167
+ // server (the static check only knows the expected minor).
168
+ const client =
169
+ bundledPlaywrightVersion() ?? `${ENDPOINT_PLAYWRIGHT_MINOR}.0`;
170
+ const probe = await probeEndpoint(endpoint, client);
171
+ switch (probe.state) {
172
+ case "match":
173
+ log("playwright", `Endpoint handshake OK (Playwright ${client})`);
174
+ break;
175
+ case "mismatch":
176
+ log(
177
+ "playwright",
178
+ `ERROR: endpoint ${endpoint} is on Playwright ${probe.server} but the bundled client is ${probe.client} — every browser tool call will fail with 428. Align the python playwright hosting Camoufox with ENDPOINT_PLAYWRIGHT_MINOR (${ENDPOINT_PLAYWRIGHT_MINOR}) or re-pin @playwright/mcp.`,
179
+ );
180
+ break;
181
+ case "unreachable":
182
+ log(
183
+ "playwright",
184
+ `Warning: endpoint ${endpoint} did not answer the handshake probe (${probe.reason}); browser tools will fail until it is up.`,
185
+ );
186
+ break;
187
+ }
152
188
  },
153
189
  };
154
190
  }
@@ -28,6 +28,11 @@ import {
28
28
  type ProvisionOutcome,
29
29
  } from "../../core/plugin/provision.js";
30
30
  import { dirs } from "../../util/paths.js";
31
+ import {
32
+ ENDPOINT_PLAYWRIGHT_MINOR,
33
+ bundledPlaywrightVersion,
34
+ couplingError,
35
+ } from "./version-coupling.js";
31
36
 
32
37
  /** Engines whose builds Playwright manages (vs system channels). */
33
38
  const MANAGED_ENGINES = new Set(["chromium", "firefox", "webkit"]);
@@ -54,6 +59,8 @@ export interface PlaywrightProvisionDeps {
54
59
  listDir?: (p: string) => string[];
55
60
  /** playwright-core's browser registry (default: browsers.json beside the CLI). */
56
61
  registry?: BrowserDescriptor[];
62
+ /** Bundled playwright-core version (default: read from node_modules). */
63
+ bundledPlaywrightVersion?: string;
57
64
  }
58
65
 
59
66
  /** One entry of playwright-core's browsers.json. */
@@ -287,12 +294,26 @@ export function inspectPlaywright(
287
294
  deps: PlaywrightProvisionDeps = {},
288
295
  ): DoctorCheck[] {
289
296
  if (section.endpoint ?? section.endpointFile) {
297
+ const bundled = deps.bundledPlaywrightVersion ?? bundledPlaywrightVersion();
298
+ const coupling = couplingError(bundled);
290
299
  return [
291
300
  {
292
301
  label: "Playwright: remote endpoint mode",
293
302
  status: "info",
294
303
  detail: "browser lives on the remote end",
295
304
  },
305
+ coupling
306
+ ? {
307
+ label: "Playwright: client/endpoint version mismatch",
308
+ status: "fail",
309
+ detail: coupling,
310
+ issue: true,
311
+ }
312
+ : {
313
+ label: `Playwright: client on endpoint minor ${ENDPOINT_PLAYWRIGHT_MINOR}`,
314
+ status: "ok",
315
+ detail: bundled ? `playwright-core ${bundled}` : undefined,
316
+ },
296
317
  ];
297
318
  }
298
319
  const browser = section.browser ?? "chromium";
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Endpoint-mode version coupling — the machinery behind the pin.
3
+ *
4
+ * In endpoint mode the MCP child connects to a remote Playwright server
5
+ * (the python-playwright process hosting Camoufox). Playwright's server
6
+ * refuses the WebSocket upgrade with "428 Precondition Required" when the
7
+ * client's playwright MINOR (sent in the User-Agent, `Playwright/x.y.z`)
8
+ * differs from its own — so a client bump that nobody noticed turns every
9
+ * browser tool call into an opaque error at the worst possible moment.
10
+ *
11
+ * Three guards, all keyed on ENDPOINT_PLAYWRIGHT_MINOR:
12
+ * - a unit test asserts the playwright-core that @playwright/mcp bundles is
13
+ * on that minor, so a dependency bump goes red in CI instead of green;
14
+ * - `validateConfig` refuses to start the plugin on a mismatch, with a
15
+ * message that names both versions and the fix;
16
+ * - `probeEndpoint` performs the real handshake at init and reports the
17
+ * server's verdict, so a drifted *server* is caught too.
18
+ *
19
+ * Bumping: change ENDPOINT_PLAYWRIGHT_MINOR and `@playwright/mcp` in
20
+ * package.json in the same commit, after upgrading the python side
21
+ * (camoufox caps python-playwright, so the node side cannot chase latest).
22
+ */
23
+
24
+ import { randomBytes } from "node:crypto";
25
+ import { readFileSync } from "node:fs";
26
+ import { request as httpRequest } from "node:http";
27
+ import { request as httpsRequest } from "node:https";
28
+ import { resolve } from "node:path";
29
+
30
+ /**
31
+ * Playwright minor of the remote endpoint (python playwright behind
32
+ * Camoufox). Must equal the minor of the playwright-core bundled by the
33
+ * pinned @playwright/mcp — see the header comment before changing it.
34
+ */
35
+ export const ENDPOINT_PLAYWRIGHT_MINOR = "1.58";
36
+
37
+ /** "1.58.0-alpha-2026-01-16" → "1.58". */
38
+ export function minorOf(version: string): string {
39
+ const m = version.match(/^(\d+)\.(\d+)/);
40
+ return m ? `${m[1]}.${m[2]}` : version;
41
+ }
42
+
43
+ function defaultModulesRoot(): string {
44
+ return resolve(import.meta.dirname ?? ".", "../../../node_modules");
45
+ }
46
+
47
+ /** Version of the playwright-core the MCP child will run with. */
48
+ export function bundledPlaywrightVersion(
49
+ modulesRoot: string = defaultModulesRoot(),
50
+ ): string | undefined {
51
+ try {
52
+ const pkg = JSON.parse(
53
+ readFileSync(
54
+ resolve(modulesRoot, "playwright-core/package.json"),
55
+ "utf-8",
56
+ ),
57
+ ) as { version?: string };
58
+ return pkg.version;
59
+ } catch {
60
+ return undefined;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Static check: does the bundled client sit on the endpoint's minor?
66
+ * Returns an error message, or undefined when coupled (or when the bundle
67
+ * cannot be read — that is reported separately as a missing install).
68
+ */
69
+ export function couplingError(
70
+ bundled: string | undefined,
71
+ expectedMinor: string = ENDPOINT_PLAYWRIGHT_MINOR,
72
+ ): string | undefined {
73
+ if (!bundled) return undefined;
74
+ const got = minorOf(bundled);
75
+ if (got === expectedMinor) return undefined;
76
+ return (
77
+ `@playwright/mcp bundles playwright-core ${bundled} (minor ${got}) but the ` +
78
+ `remote endpoint is on Playwright ${expectedMinor} — every browser tool call ` +
79
+ `would fail with "428 Precondition Required". Pin @playwright/mcp to the ` +
80
+ `release that bundles playwright-core ${expectedMinor}.x, or bump ` +
81
+ `ENDPOINT_PLAYWRIGHT_MINOR together with the python side ` +
82
+ `(src/plugins/playwright/version-coupling.ts).`
83
+ );
84
+ }
85
+
86
+ export type EndpointProbe =
87
+ | { state: "match"; client: string }
88
+ | { state: "mismatch"; client: string; server: string }
89
+ | { state: "unreachable"; client: string; reason: string };
90
+
91
+ /** Parse the body Playwright's server sends with its 428. */
92
+ export function parseMismatch(
93
+ body: string,
94
+ ): { server: string; client: string } | undefined {
95
+ const server = body.match(/server version:\s*v?([\d.]+)/);
96
+ const client = body.match(/client version:\s*v?([\d.]+)/);
97
+ return server && client
98
+ ? { server: server[1], client: client[1] }
99
+ : undefined;
100
+ }
101
+
102
+ /**
103
+ * Perform the WebSocket upgrade the MCP child performs, advertising
104
+ * `clientVersion`, and read the server's verdict. Never throws; never
105
+ * leaves a connection open (a completed upgrade is torn down at once, and
106
+ * Playwright's server treats that as an ordinary client disconnect).
107
+ */
108
+ export function probeEndpoint(
109
+ endpoint: string,
110
+ clientVersion: string,
111
+ timeoutMs = 3000,
112
+ ): Promise<EndpointProbe> {
113
+ return new Promise((settle) => {
114
+ let url: URL;
115
+ try {
116
+ url = new URL(endpoint);
117
+ } catch {
118
+ settle({
119
+ state: "unreachable",
120
+ client: clientVersion,
121
+ reason: `invalid endpoint URL: ${endpoint}`,
122
+ });
123
+ return;
124
+ }
125
+ const secure = url.protocol === "wss:" || url.protocol === "https:";
126
+ const request = secure ? httpsRequest : httpRequest;
127
+ const req = request({
128
+ host: url.hostname,
129
+ port: url.port || (secure ? 443 : 80),
130
+ path: `${url.pathname}${url.search}`,
131
+ method: "GET",
132
+ headers: {
133
+ Connection: "Upgrade",
134
+ Upgrade: "websocket",
135
+ "Sec-WebSocket-Version": "13",
136
+ "Sec-WebSocket-Key": randomBytes(16).toString("base64"),
137
+ "User-Agent": `Playwright/${clientVersion} (talon endpoint probe)`,
138
+ },
139
+ timeout: timeoutMs,
140
+ });
141
+ let settled = false;
142
+ const done = (result: EndpointProbe) => {
143
+ if (settled) return;
144
+ settled = true;
145
+ req.destroy();
146
+ settle(result);
147
+ };
148
+ req.on("upgrade", (_res, socket) => {
149
+ socket.destroy();
150
+ done({ state: "match", client: clientVersion });
151
+ });
152
+ req.on("response", (res) => {
153
+ let body = "";
154
+ res.setEncoding("utf-8");
155
+ res.on("data", (chunk: string) => {
156
+ body += chunk;
157
+ });
158
+ res.on("end", () => {
159
+ if (res.statusCode === 428) {
160
+ const parsed = parseMismatch(body);
161
+ done(
162
+ parsed
163
+ ? {
164
+ state: "mismatch",
165
+ client: clientVersion,
166
+ server: parsed.server,
167
+ }
168
+ : {
169
+ state: "unreachable",
170
+ client: clientVersion,
171
+ reason: "428 without a version box in the body",
172
+ },
173
+ );
174
+ return;
175
+ }
176
+ done({
177
+ state: "unreachable",
178
+ client: clientVersion,
179
+ reason: `HTTP ${res.statusCode ?? "?"} instead of an upgrade`,
180
+ });
181
+ });
182
+ });
183
+ req.on("timeout", () =>
184
+ done({ state: "unreachable", client: clientVersion, reason: "timeout" }),
185
+ );
186
+ req.on("error", (err: Error) =>
187
+ done({
188
+ state: "unreachable",
189
+ client: clientVersion,
190
+ reason: err.message,
191
+ }),
192
+ );
193
+ req.end();
194
+ });
195
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Snapshot index store — the listing/status view over the backups on
3
+ * disk and on remote targets.
4
+ *
5
+ * Zero SQL by contract (see repo.ts). The domain rule this layer adds is
6
+ * atomicity: a snapshot and its per-target rows move together, so a
7
+ * pruned snapshot never leaves orphan remote rows behind to be counted
8
+ * by the next retention pass.
9
+ *
10
+ * It lives in its own directory rather than as `storage/backups.ts`
11
+ * because the flat `storage/` surface is at its documented ceiling
12
+ * (docs/structure.md, tree baseline) — the store and its repository are
13
+ * one concern with one entry, which is what the contract asks for.
14
+ */
15
+
16
+ import { inTransaction } from "../db.js";
17
+ import * as repo from "./repo.js";
18
+
19
+ /**
20
+ * Re-exported so the backup subsystem can ask SQLite for a consistent
21
+ * copy of itself without importing the connection module: the database
22
+ * handle stays inside storage/ (`db-handle-stays-in-storage`), and a
23
+ * snapshot of the database is a storage concern with a storage API.
24
+ */
25
+ export { snapshotDatabase } from "../db.js";
26
+
27
+ export type { BackupRecord, BackupRemoteRecord } from "./repo.js";
28
+ import type { BackupRecord, BackupRemoteRecord } from "./repo.js";
29
+
30
+ /** Insert or replace one snapshot row. */
31
+ export function recordBackup(record: BackupRecord): void {
32
+ repo.upsert(record);
33
+ }
34
+
35
+ export function getBackup(id: string): BackupRecord | undefined {
36
+ return repo.get(id);
37
+ }
38
+
39
+ /** Every indexed snapshot, newest first. */
40
+ export function listBackups(): BackupRecord[] {
41
+ return repo.all();
42
+ }
43
+
44
+ /** Ids only — for reconciling the index against the directories on disk. */
45
+ export function backupIds(): Set<string> {
46
+ return new Set(repo.ids());
47
+ }
48
+
49
+ /** Returns false when no such snapshot is indexed. */
50
+ export function pinBackup(id: string, pinned: boolean): boolean {
51
+ return repo.setPinned(id, pinned);
52
+ }
53
+
54
+ /** Replace the stored manifest (after an upload, a pin, a remote prune). */
55
+ export function updateBackupManifest(
56
+ id: string,
57
+ manifestJson: string,
58
+ pinned: boolean,
59
+ sizeBytes: number,
60
+ ): void {
61
+ repo.setManifest(id, manifestJson, pinned, sizeBytes);
62
+ }
63
+
64
+ /** Drop a snapshot and every remote row that referenced it. */
65
+ export function deleteBackup(id: string): void {
66
+ inTransaction(() => {
67
+ repo.removeRemotes(id);
68
+ repo.remove(id);
69
+ });
70
+ }
71
+
72
+ export function recordBackupRemote(record: BackupRemoteRecord): void {
73
+ repo.upsertRemote(record);
74
+ }
75
+
76
+ export function listBackupRemotes(backupId?: string): BackupRemoteRecord[] {
77
+ return backupId === undefined ? repo.remotesAll() : repo.remotesFor(backupId);
78
+ }
79
+
80
+ export function deleteBackupRemote(backupId: string, targetId: string): void {
81
+ repo.removeRemote(backupId, targetId);
82
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Snapshot-index repository — executes the statements in
3
+ * sql/backups.sql against the `backups` and `backup_remotes` tables.
4
+ * No SQL text lives here and none lives above: `storage/backup/index.ts`
5
+ * holds the domain API, this module owns statement execution and the
6
+ * row↔domain mapping.
7
+ *
8
+ * The manifest travels as an opaque JSON string. Storage sits below the
9
+ * engine, so it must not know the shape core/backup gives it — it stores
10
+ * the bytes and the few columns the listing surfaces sort and filter on.
11
+ */
12
+
13
+ import { getDatabase } from "../db.js";
14
+ import { backupsSql } from "../sql/statements.generated.js";
15
+
16
+ /** One snapshot as the index holds it. */
17
+ export type BackupRecord = {
18
+ id: string;
19
+ kind: string;
20
+ label?: string;
21
+ pinned: boolean;
22
+ /** Epoch ms. */
23
+ createdAt: number;
24
+ sizeBytes: number;
25
+ /** The manifest.json body, verbatim. */
26
+ manifestJson: string;
27
+ };
28
+
29
+ /** One target's state for one snapshot. */
30
+ export type BackupRemoteRecord = {
31
+ backupId: string;
32
+ targetId: string;
33
+ status: string;
34
+ remoteId?: string;
35
+ uploadedAt?: number;
36
+ error?: string;
37
+ };
38
+
39
+ type Row = {
40
+ id: string;
41
+ kind: string;
42
+ label: string | null;
43
+ pinned: number;
44
+ created_at: number;
45
+ size_bytes: number;
46
+ manifest_json: string;
47
+ };
48
+
49
+ type RemoteRow = {
50
+ backup_id: string;
51
+ target_id: string;
52
+ status: string;
53
+ remote_id: string | null;
54
+ uploaded_at: number | null;
55
+ error: string | null;
56
+ };
57
+
58
+ function toRecord(row: Row): BackupRecord {
59
+ return {
60
+ id: row.id,
61
+ kind: row.kind,
62
+ label: row.label ?? undefined,
63
+ pinned: row.pinned === 1,
64
+ createdAt: row.created_at,
65
+ sizeBytes: row.size_bytes,
66
+ manifestJson: row.manifest_json,
67
+ };
68
+ }
69
+
70
+ function toRemote(row: RemoteRow): BackupRemoteRecord {
71
+ return {
72
+ backupId: row.backup_id,
73
+ targetId: row.target_id,
74
+ status: row.status,
75
+ remoteId: row.remote_id ?? undefined,
76
+ uploadedAt: row.uploaded_at ?? undefined,
77
+ error: row.error ?? undefined,
78
+ };
79
+ }
80
+
81
+ export function upsert(record: BackupRecord): void {
82
+ getDatabase()
83
+ .prepare(backupsSql.upsert)
84
+ .run(
85
+ record.id,
86
+ record.kind,
87
+ record.label ?? null,
88
+ record.pinned ? 1 : 0,
89
+ record.createdAt,
90
+ record.sizeBytes,
91
+ record.manifestJson,
92
+ );
93
+ }
94
+
95
+ export function get(id: string): BackupRecord | undefined {
96
+ const row = getDatabase().prepare(backupsSql.get).get(id) as Row | undefined;
97
+ return row ? toRecord(row) : undefined;
98
+ }
99
+
100
+ /** Newest first. */
101
+ export function all(): BackupRecord[] {
102
+ return (getDatabase().prepare(backupsSql.all).all() as Row[]).map(toRecord);
103
+ }
104
+
105
+ export function ids(): string[] {
106
+ return (getDatabase().prepare(backupsSql.ids).all() as { id: string }[]).map(
107
+ (row) => row.id,
108
+ );
109
+ }
110
+
111
+ export function setPinned(id: string, pinned: boolean): boolean {
112
+ const result = getDatabase()
113
+ .prepare(backupsSql.setPinned)
114
+ .run(pinned ? 1 : 0, id) as { changes?: number };
115
+ return (result.changes ?? 0) > 0;
116
+ }
117
+
118
+ export function setManifest(
119
+ id: string,
120
+ manifestJson: string,
121
+ pinned: boolean,
122
+ sizeBytes: number,
123
+ ): void {
124
+ getDatabase()
125
+ .prepare(backupsSql.setManifest)
126
+ .run(manifestJson, pinned ? 1 : 0, sizeBytes, id);
127
+ }
128
+
129
+ export function remove(id: string): void {
130
+ getDatabase().prepare(backupsSql.remove).run(id);
131
+ }
132
+
133
+ export function upsertRemote(record: BackupRemoteRecord): void {
134
+ getDatabase()
135
+ .prepare(backupsSql.upsertRemote)
136
+ .run(
137
+ record.backupId,
138
+ record.targetId,
139
+ record.status,
140
+ record.remoteId ?? null,
141
+ record.uploadedAt ?? null,
142
+ record.error ?? null,
143
+ );
144
+ }
145
+
146
+ export function remotesAll(): BackupRemoteRecord[] {
147
+ return (
148
+ getDatabase().prepare(backupsSql.remotesAll).all() as RemoteRow[]
149
+ ).map(toRemote);
150
+ }
151
+
152
+ export function remotesFor(backupId: string): BackupRemoteRecord[] {
153
+ return (
154
+ getDatabase().prepare(backupsSql.remotesFor).all(backupId) as RemoteRow[]
155
+ ).map(toRemote);
156
+ }
157
+
158
+ export function removeRemotes(backupId: string): void {
159
+ getDatabase().prepare(backupsSql.removeRemotes).run(backupId);
160
+ }
161
+
162
+ export function removeRemote(backupId: string, targetId: string): void {
163
+ getDatabase().prepare(backupsSql.removeRemote).run(backupId, targetId);
164
+ }
package/src/storage/db.ts CHANGED
@@ -216,6 +216,26 @@ export function flushDatabase(): void {
216
216
  }
217
217
  }
218
218
 
219
+ /**
220
+ * Write a transactionally consistent copy of the database to `destPath`.
221
+ *
222
+ * The backup subsystem's only way in: a snapshot may not copy
223
+ * `data/talon.db` from disk, because in WAL mode the committed pages
224
+ * live partly in the sidecar and a byte-wise copy of the main file is a
225
+ * corrupt (or silently stale) database. `VACUUM INTO` asks SQLite for
226
+ * the copy instead — one file, no WAL, checkpointed and compacted,
227
+ * consistent as of the moment it runs, with concurrent readers and
228
+ * writers untouched.
229
+ *
230
+ * `destPath` must not exist (SQLite refuses to overwrite); its parent
231
+ * directory is created if needed.
232
+ */
233
+ export function snapshotDatabase(destPath: string): void {
234
+ const database = getDatabase();
235
+ mkdirSync(dirname(destPath), { recursive: true });
236
+ database.prepare(dbSql.vacuumInto).run(destPath);
237
+ }
238
+
219
239
  export function closeDatabase(): void {
220
240
  if (!db) return;
221
241
  try {
@@ -0,0 +1,46 @@
1
+ -- Statements for the snapshot index (see repositories in
2
+ -- storage/backup/repo.ts for parameter order and row↔domain mapping).
3
+
4
+ -- name: upsert
5
+ INSERT OR REPLACE INTO backups
6
+ (id, kind, label, pinned, created_at, size_bytes, manifest_json)
7
+ VALUES (?, ?, ?, ?, ?, ?, ?)
8
+
9
+ -- name: get
10
+ SELECT id, kind, label, pinned, created_at, size_bytes, manifest_json
11
+ FROM backups WHERE id = ?
12
+
13
+ -- name: all
14
+ SELECT id, kind, label, pinned, created_at, size_bytes, manifest_json
15
+ FROM backups ORDER BY created_at DESC
16
+
17
+ -- name: ids
18
+ SELECT id FROM backups
19
+
20
+ -- name: setPinned
21
+ UPDATE backups SET pinned = ? WHERE id = ?
22
+
23
+ -- name: setManifest
24
+ UPDATE backups SET manifest_json = ?, pinned = ?, size_bytes = ? WHERE id = ?
25
+
26
+ -- name: remove
27
+ DELETE FROM backups WHERE id = ?
28
+
29
+ -- name: upsertRemote
30
+ INSERT OR REPLACE INTO backup_remotes
31
+ (backup_id, target_id, status, remote_id, uploaded_at, error)
32
+ VALUES (?, ?, ?, ?, ?, ?)
33
+
34
+ -- name: remotesAll
35
+ SELECT backup_id, target_id, status, remote_id, uploaded_at, error
36
+ FROM backup_remotes
37
+
38
+ -- name: remotesFor
39
+ SELECT backup_id, target_id, status, remote_id, uploaded_at, error
40
+ FROM backup_remotes WHERE backup_id = ?
41
+
42
+ -- name: removeRemotes
43
+ DELETE FROM backup_remotes WHERE backup_id = ?
44
+
45
+ -- name: removeRemote
46
+ DELETE FROM backup_remotes WHERE backup_id = ? AND target_id = ?
@@ -30,3 +30,11 @@ ALTER TABLE history_messages ADD COLUMN attachments TEXT
30
30
  -- Column reconciliation for databases that shipped before the cache-age
31
31
  -- signal existed. Fresh databases get the column via schema.sql.
32
32
  ALTER TABLE sessions ADD COLUMN last_turn_ended_at INTEGER
33
+
34
+ -- name: vacuumInto
35
+ -- Transactionally consistent copy of the whole database into a new file,
36
+ -- produced by SQLite itself (storage/db.ts snapshotDatabase). A backup
37
+ -- must never copy a live .db byte-wise: the WAL holds committed pages the
38
+ -- main file does not, so the copy would be a corrupt database or an old
39
+ -- one. Verified to accept a bound path on node:sqlite and bun:sqlite.
40
+ VACUUM INTO ?
@@ -362,3 +362,33 @@ CREATE TABLE IF NOT EXISTS whatsapp_messages (
362
362
  CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_msg ON whatsapp_messages(msg_id);
363
363
  CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_wa_id ON whatsapp_messages(wa_id);
364
364
  CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_time ON whatsapp_messages(timestamp);
365
+
366
+ -- Snapshot index: the listing/status view over ~/.talon/backups/. The
367
+ -- manifest.json next to the parts on disk stays the source of truth for
368
+ -- a restore (a database that needs restoring cannot also be the record
369
+ -- of how), so these rows are a cache — dropped rows are re-derived from
370
+ -- the directories on the next boot, and a row whose directory is gone is
371
+ -- kept because the snapshot may still exist on a remote target.
372
+ CREATE TABLE IF NOT EXISTS backups (
373
+ id TEXT PRIMARY KEY,
374
+ kind TEXT NOT NULL,
375
+ label TEXT,
376
+ pinned INTEGER NOT NULL DEFAULT 0,
377
+ created_at INTEGER NOT NULL,
378
+ size_bytes INTEGER NOT NULL DEFAULT 0,
379
+ manifest_json TEXT NOT NULL
380
+ );
381
+ CREATE INDEX IF NOT EXISTS idx_backups_created ON backups(created_at DESC);
382
+
383
+ -- Per-target upload state for one snapshot. Separate from the manifest
384
+ -- copy so retention can ask "what is on Drive?" without opening a file
385
+ -- per snapshot, and so a failed upload's error survives a restart.
386
+ CREATE TABLE IF NOT EXISTS backup_remotes (
387
+ backup_id TEXT NOT NULL,
388
+ target_id TEXT NOT NULL,
389
+ status TEXT NOT NULL,
390
+ remote_id TEXT,
391
+ uploaded_at INTEGER,
392
+ error TEXT,
393
+ PRIMARY KEY (backup_id, target_id)
394
+ );