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
|
@@ -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).
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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 = ?
|
package/src/storage/sql/db.sql
CHANGED
|
@@ -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
|
+
);
|