toolroll 0.9.77 → 0.9.78
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/CHANGELOG.md +81 -0
- package/dist/agent-fence.js +18 -2
- package/dist/backup-ui.d.ts +9 -1
- package/dist/backup-ui.js +28 -3
- package/dist/backup.js +4 -3
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +18 -18
- package/dist/browser-workspace.d.ts +2 -0
- package/dist/builder.d.ts +4 -3
- package/dist/builder.js +14 -8
- package/dist/check-failure.js +31 -3
- package/dist/check-report.d.ts +18 -3
- package/dist/check-report.js +67 -7
- package/dist/claim.d.ts +9 -0
- package/dist/claim.js +9 -1
- package/dist/contracts/console-api.d.ts +1 -1
- package/dist/contracts/console-api.js +1 -1
- package/dist/contracts/release-terms.d.ts +41 -0
- package/dist/contracts/release-terms.js +53 -0
- package/dist/controller-service.js +4 -1
- package/dist/deploy-rescue.d.ts +41 -0
- package/dist/deploy-rescue.js +94 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/dispose.js +4 -3
- package/dist/evidence.d.ts +9 -0
- package/dist/evidence.js +15 -3
- package/dist/exec.d.ts +2 -0
- package/dist/exec.js +15 -1
- package/dist/guides.js +18 -8
- package/dist/latency-timeline.d.ts +46 -0
- package/dist/latency-timeline.js +120 -0
- package/dist/live-backup.d.ts +39 -0
- package/dist/live-backup.js +271 -0
- package/dist/operate.js +203 -16
- package/dist/planner.js +2 -2
- package/dist/release-adapters.d.ts +32 -1
- package/dist/release-adapters.js +99 -7
- package/dist/release-carry.d.ts +21 -1
- package/dist/release-carry.js +22 -0
- package/dist/release-cli.d.ts +3 -0
- package/dist/release-cli.js +74 -5
- package/dist/release-gate.d.ts +5 -0
- package/dist/release-gate.js +1 -1
- package/dist/release-recheck.js +3 -2
- package/dist/release.d.ts +37 -4
- package/dist/release.js +128 -43
- package/dist/runner-process.d.ts +67 -0
- package/dist/runner-process.js +219 -0
- package/dist/runner.d.ts +48 -6
- package/dist/runner.js +71 -8
- package/dist/scope.d.ts +19 -1
- package/dist/scope.js +19 -2
- package/dist/scout.js +2 -2
- package/dist/serve.js +11 -0
- package/dist/server/http.d.ts +2 -0
- package/dist/server/pages.js +7 -3
- package/dist/server/remote-hooks.d.ts +1 -1
- package/dist/server/remote-hooks.js +5 -3
- package/dist/server/render-results.d.ts +2 -0
- package/dist/server/render-results.js +1 -0
- package/dist/server/request-context.d.ts +2 -0
- package/dist/server/settings.js +22 -2
- package/dist/server/tasks.js +4 -1
- package/dist/storage-sweep.js +1 -1
- package/dist/store.d.ts +125 -2
- package/dist/store.js +250 -12
- package/dist/surface.js +3 -1
- package/dist/teardown-git.js +5 -5
- package/dist/toolroll-update.js +2 -2
- package/dist/up-role-child.js +6 -2
- package/dist/workspace-revision.js +3 -1
- package/package.json +1 -1
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The continuous copy (docs/plans/actors-lessons.md): every few minutes (default 5) the worker takes an online SQLite
|
|
3
|
+
* backup of the database into a folder (default `backups/live` beside it), checks it with `PRAGMA integrity_check`,
|
|
4
|
+
* hashes it and renames it into place, then records it as the latest verified copy (path, sha256, time, schema). It
|
|
5
|
+
* keeps the newest 24 and the newest of each of the last 7 days.
|
|
6
|
+
*
|
|
7
|
+
* Off the hot path: the copy, the check and the hash run on a worker thread, so neither the console nor the worker's
|
|
8
|
+
* loops wait for them. One process copies at a time (the live_backup lease).
|
|
9
|
+
*
|
|
10
|
+
* The folder is checked every time before use: a plain directory this user owns, not a link and reached through none,
|
|
11
|
+
* not writable by others, and inside the permanent fenced `backups` namespace beside the actual database, outside
|
|
12
|
+
* every project and build checkout (backup.ts checkBackupFolder). Restoring stays manual (`toolroll restore <file>` with Toolroll stopped). A deploy reuses the latest copy only
|
|
13
|
+
* when it is fresh, its schema matches and its content proves equal to the database it would otherwise copy
|
|
14
|
+
* (scripts/deploy-phases.mjs liveCopyBackup).
|
|
15
|
+
*/
|
|
16
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
17
|
+
import { lstatSync, mkdirSync, readdirSync, realpathSync, rmSync } from "node:fs";
|
|
18
|
+
import { hostname } from "node:os";
|
|
19
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
20
|
+
import { Worker } from "node:worker_threads";
|
|
21
|
+
import { checkBackupFolder, databaseTag } from "./backup.js";
|
|
22
|
+
import { holdLiveBackup, liveBackupState, recordLiveBackup, releaseLiveBackup } from "./store.js";
|
|
23
|
+
/** The choices Settings offers, in minutes (0: off is its own choice). */
|
|
24
|
+
export const LIVE_EVERY_MINUTES = [1, 5, 15, 30];
|
|
25
|
+
/** Daily copies are kept this many days besides the newest `keep`. */
|
|
26
|
+
export const LIVE_DAILY_DAYS = 7;
|
|
27
|
+
/** How long one copy may hold the lease. */
|
|
28
|
+
const LEASE_MS = 15 * 60_000;
|
|
29
|
+
/** `backups/live` beside the database, through its real path (the folder check refuses a path through a link). */
|
|
30
|
+
export function defaultLiveFolder(databaseFile) {
|
|
31
|
+
let database = resolve(databaseFile);
|
|
32
|
+
try {
|
|
33
|
+
database = realpathSync(database);
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (error.code !== "ENOENT")
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
let at = dirname(database);
|
|
40
|
+
try {
|
|
41
|
+
at = realpathSync(at);
|
|
42
|
+
}
|
|
43
|
+
catch { /* not there yet: as given */ }
|
|
44
|
+
return join(at, "backups", "live");
|
|
45
|
+
}
|
|
46
|
+
export const liveFolderOf = (settings, databaseFile) => settings.folder ?? defaultLiveFolder(databaseFile);
|
|
47
|
+
/** Why a folder can't hold the copies, or null. Missing is fine (it is made on first use, then checked again). */
|
|
48
|
+
export function liveFolderProblem(folder, forbidden, protectedRoot) {
|
|
49
|
+
const said = checkBackupFolder(folder, forbidden);
|
|
50
|
+
if (said !== null)
|
|
51
|
+
return said;
|
|
52
|
+
if (protectedRoot !== undefined) {
|
|
53
|
+
const rel = relative(resolve(protectedRoot), resolve(folder));
|
|
54
|
+
if (rel === ".." || rel.startsWith("../") || rel.startsWith("..\\") || isAbsolute(rel))
|
|
55
|
+
return `Choose a location inside the protected backup folder ${protectedRoot}.`;
|
|
56
|
+
}
|
|
57
|
+
let stat;
|
|
58
|
+
try {
|
|
59
|
+
stat = lstatSync(folder);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
if (stat.isSymbolicLink() || !stat.isDirectory())
|
|
65
|
+
return `${folder} isn't a plain folder.`;
|
|
66
|
+
if (typeof process.getuid === "function" && stat.uid !== process.getuid())
|
|
67
|
+
return `${folder} belongs to another user.`;
|
|
68
|
+
if ((stat.mode & 0o022) !== 0)
|
|
69
|
+
return `Other users can write to ${folder}.`;
|
|
70
|
+
let real;
|
|
71
|
+
try {
|
|
72
|
+
real = realpathSync(folder);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return `${folder} can't be read.`;
|
|
76
|
+
}
|
|
77
|
+
if (real !== resolve(folder))
|
|
78
|
+
return `${folder} goes through a link; choose its real path (${real}).`;
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
const NAME = (tag) => new RegExp(`^live-${tag}-(\\d{4}-\\d{2}-\\d{2})-(\\d{6})(?:-(\\d+))?\\.db$`);
|
|
82
|
+
const PARTIAL = (tag) => new RegExp(`^\\.live-${tag}-.*\\.partial$`);
|
|
83
|
+
const stampOf = (now) => now.toISOString().replace(/\.\d+Z$/, "").replace("T", "-").replace(/:/g, "");
|
|
84
|
+
/** This database's copies in a folder, newest first (plain files named for it only). */
|
|
85
|
+
export function liveCopies(folder, tag) {
|
|
86
|
+
let names;
|
|
87
|
+
try {
|
|
88
|
+
names = readdirSync(folder);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
const pattern = NAME(tag);
|
|
94
|
+
return names.flatMap(name => {
|
|
95
|
+
const m = pattern.exec(name);
|
|
96
|
+
if (m === null)
|
|
97
|
+
return [];
|
|
98
|
+
try {
|
|
99
|
+
if (!lstatSync(join(folder, name)).isFile())
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
return [{ name, path: join(folder, name), day: m[1], order: `${m[1]}-${m[2]}-${(m[3] ?? "1").padStart(6, "0")}` }];
|
|
106
|
+
}).sort((a, b) => a.order < b.order ? 1 : a.order > b.order ? -1 : 0).map(({ name, path, day }) => ({ name, path, day }));
|
|
107
|
+
}
|
|
108
|
+
/** Keep the newest `keep`, the newest of each of the last LIVE_DAILY_DAYS days, and `protect`; remove the rest (and
|
|
109
|
+
* this database's partial files older than an hour). Never anything not named for this database. */
|
|
110
|
+
export function pruneLiveCopies(folder, tag, keep, now, protect) {
|
|
111
|
+
const copies = liveCopies(folder, tag);
|
|
112
|
+
const kept = new Set(copies.slice(0, Math.max(1, keep)).map(one => one.path));
|
|
113
|
+
const oldest = new Date(now.getTime() - (LIVE_DAILY_DAYS - 1) * 86_400_000).toISOString().slice(0, 10);
|
|
114
|
+
const days = new Set();
|
|
115
|
+
for (const one of copies)
|
|
116
|
+
if (one.day >= oldest && !days.has(one.day)) {
|
|
117
|
+
days.add(one.day);
|
|
118
|
+
kept.add(one.path);
|
|
119
|
+
}
|
|
120
|
+
if (protect !== null)
|
|
121
|
+
kept.add(resolve(protect));
|
|
122
|
+
let removed = 0;
|
|
123
|
+
for (const one of copies)
|
|
124
|
+
if (!kept.has(one.path)) {
|
|
125
|
+
try {
|
|
126
|
+
rmSync(one.path);
|
|
127
|
+
removed++;
|
|
128
|
+
}
|
|
129
|
+
catch { /* the next pass tries again */ }
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
for (const name of readdirSync(folder).filter(one => PARTIAL(tag).test(one))) {
|
|
133
|
+
const path = join(folder, name), stat = lstatSync(path);
|
|
134
|
+
if (stat.isFile() && now.getTime() - stat.mtimeMs > 60 * 60_000)
|
|
135
|
+
rmSync(path, { force: true });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch { /* the next pass tries again */ }
|
|
139
|
+
return removed;
|
|
140
|
+
}
|
|
141
|
+
/** The thread's whole job, plain JavaScript (an eval worker: no loader, the same in a checkout and when installed). */
|
|
142
|
+
const COPY_THREAD = `
|
|
143
|
+
const { workerData, parentPort } = require("node:worker_threads");
|
|
144
|
+
const { DatabaseSync, backup } = require("node:sqlite");
|
|
145
|
+
const fs = require("node:fs"), crypto = require("node:crypto"), path = require("node:path");
|
|
146
|
+
(async () => {
|
|
147
|
+
const { source, partial, target } = workerData;
|
|
148
|
+
try {
|
|
149
|
+
const db = new DatabaseSync(source);
|
|
150
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
151
|
+
// SQLite inherits the private file: no world-readable partial exists even briefly.
|
|
152
|
+
fs.closeSync(fs.openSync(partial, "wx", 0o600));
|
|
153
|
+
try { await backup(db, partial, { rate: 1000000 }); } finally { db.close(); }
|
|
154
|
+
fs.chmodSync(partial, 0o600);
|
|
155
|
+
const copy = new DatabaseSync(partial);
|
|
156
|
+
let schemaVersion;
|
|
157
|
+
try {
|
|
158
|
+
copy.exec("PRAGMA journal_mode = DELETE");
|
|
159
|
+
const check = copy.prepare("PRAGMA integrity_check").all();
|
|
160
|
+
if (check.length !== 1 || check[0].integrity_check !== "ok") throw new Error("the copy failed SQLite's integrity check");
|
|
161
|
+
schemaVersion = Number(copy.prepare("SELECT version FROM schema_version").get()?.version);
|
|
162
|
+
if (!Number.isSafeInteger(schemaVersion) || schemaVersion < 1) throw new Error("the copy has no settled schema version");
|
|
163
|
+
} finally { copy.close(); }
|
|
164
|
+
const hash = crypto.createHash("sha256"), buffer = Buffer.alloc(1 << 20), fd = fs.openSync(partial, "r");
|
|
165
|
+
try { let n; while ((n = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) hash.update(buffer.subarray(0, n)); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
166
|
+
fs.renameSync(partial, target);
|
|
167
|
+
const dir = fs.openSync(path.dirname(target), "r");
|
|
168
|
+
try { fs.fsyncSync(dir); } finally { fs.closeSync(dir); }
|
|
169
|
+
parentPort.postMessage({ ok: true, sha256: hash.digest("hex"), bytes: fs.statSync(target).size, schemaVersion });
|
|
170
|
+
} catch (error) {
|
|
171
|
+
try { fs.rmSync(partial, { force: true }); } catch {}
|
|
172
|
+
parentPort.postMessage({ ok: false, problem: String((error && error.message) || error).slice(0, 300) });
|
|
173
|
+
}
|
|
174
|
+
})();
|
|
175
|
+
`;
|
|
176
|
+
/** One copy on a worker thread: copied, checked, hashed and renamed into place, or a problem (nothing left behind). */
|
|
177
|
+
export function copyOffThread(source, folder, tag, now) {
|
|
178
|
+
const stamp = stampOf(now);
|
|
179
|
+
let name = `live-${tag}-${stamp}.db`;
|
|
180
|
+
for (let n = 2; (() => { try {
|
|
181
|
+
lstatSync(join(folder, name));
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return false;
|
|
186
|
+
} })(); n++)
|
|
187
|
+
name = `live-${tag}-${stamp}-${n}.db`;
|
|
188
|
+
const target = join(folder, name), partial = join(folder, `.live-${tag}-${stamp}.${randomBytes(4).toString("hex")}.partial`);
|
|
189
|
+
return new Promise(done => {
|
|
190
|
+
const thread = new Worker(COPY_THREAD, { eval: true, workerData: { source, partial, target } });
|
|
191
|
+
let settled = false;
|
|
192
|
+
const finish = (answer) => { if (!settled) {
|
|
193
|
+
settled = true;
|
|
194
|
+
done(answer);
|
|
195
|
+
} };
|
|
196
|
+
thread.once("message", (message) => finish(message.ok
|
|
197
|
+
? { ok: true, copy: { file: target, sha256: String(message.sha256), bytes: Number(message.bytes), schemaVersion: Number(message.schemaVersion) } }
|
|
198
|
+
: { ok: false, problem: message.problem ?? "the copy failed" }));
|
|
199
|
+
thread.once("error", (error) => { rmSync(partial, { force: true }); finish({ ok: false, problem: String(error.message).slice(0, 300) }); });
|
|
200
|
+
thread.once("exit", () => finish({ ok: false, problem: "the copy stopped before it finished" }));
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
/** Whether a copy is due: on, and the latest (or the latest try) is at least the schedule old. */
|
|
204
|
+
export function liveCopyDue(store, now) {
|
|
205
|
+
const state = liveBackupState(store);
|
|
206
|
+
if (!state.settings.enabled)
|
|
207
|
+
return false;
|
|
208
|
+
const every = state.settings.everyMinutes * 60_000;
|
|
209
|
+
const last = Math.max(state.latest === null ? 0 : Date.parse(state.latest.madeAt), state.triedAt === null ? 0 : Date.parse(state.triedAt));
|
|
210
|
+
return !Number.isFinite(last) || now.getTime() - last >= every;
|
|
211
|
+
}
|
|
212
|
+
/** One pass: hold the lease, and when due check the folder, copy, record and prune. */
|
|
213
|
+
export async function liveBackupPass(store, databaseFile, holder, forbidden, clock = () => new Date()) {
|
|
214
|
+
const now = clock();
|
|
215
|
+
if (!liveCopyDue(store, now))
|
|
216
|
+
return null;
|
|
217
|
+
if (!holdLiveBackup(store, holder, now, new Date(now.getTime() + LEASE_MS)))
|
|
218
|
+
return null;
|
|
219
|
+
try {
|
|
220
|
+
const state = liveBackupState(store);
|
|
221
|
+
const folder = liveFolderOf(state.settings, databaseFile);
|
|
222
|
+
const fail = (problem) => { recordLiveBackup(store, { ok: false, problem }, clock()); return { ok: false, problem }; };
|
|
223
|
+
let problem = liveFolderProblem(folder, forbidden(), dirname(defaultLiveFolder(databaseFile)));
|
|
224
|
+
if (problem !== null)
|
|
225
|
+
return fail(problem);
|
|
226
|
+
try {
|
|
227
|
+
mkdirSync(folder, { recursive: true, mode: 0o700 });
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
return fail(`${folder} couldn't be made (${error.message})`);
|
|
231
|
+
}
|
|
232
|
+
// Checked again now that it exists: what was made (or found) is what is used.
|
|
233
|
+
problem = liveFolderProblem(folder, forbidden(), dirname(defaultLiveFolder(databaseFile)));
|
|
234
|
+
if (problem !== null)
|
|
235
|
+
return fail(problem);
|
|
236
|
+
const made = await copyOffThread(databaseFile, folder, databaseTag(databaseFile), now);
|
|
237
|
+
if (!made.ok)
|
|
238
|
+
return fail(made.problem);
|
|
239
|
+
const copy = { ...made.copy, madeAt: now.toISOString() };
|
|
240
|
+
recordLiveBackup(store, { ok: true, copy }, clock());
|
|
241
|
+
pruneLiveCopies(folder, databaseTag(databaseFile), state.settings.keep, clock(), copy.file);
|
|
242
|
+
return { ok: true, copy };
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
releaseLiveBackup(store, holder);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/** The loop in the worker: a pass a minute (the first after 30 s). The returned stop waits for a copy under way. */
|
|
249
|
+
export function startLiveBackups(store, databaseFile, forbidden, everyMs = 60_000) {
|
|
250
|
+
const holder = `${hostname()}:${process.pid}:${randomUUID().slice(0, 8)}`;
|
|
251
|
+
let stopped = false, timer = null, running = Promise.resolve();
|
|
252
|
+
const pass = () => {
|
|
253
|
+
if (stopped)
|
|
254
|
+
return;
|
|
255
|
+
running = liveBackupPass(store, databaseFile, holder, forbidden).catch(() => null).finally(() => {
|
|
256
|
+
if (!stopped) {
|
|
257
|
+
timer = setTimeout(pass, everyMs);
|
|
258
|
+
timer.unref?.();
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
};
|
|
262
|
+
timer = setTimeout(pass, 30_000);
|
|
263
|
+
timer.unref?.();
|
|
264
|
+
return async () => {
|
|
265
|
+
stopped = true;
|
|
266
|
+
if (timer !== null)
|
|
267
|
+
clearTimeout(timer);
|
|
268
|
+
// A copy under way gets a few seconds; past that the process may end under it (its partial file is removed later).
|
|
269
|
+
await Promise.race([running, new Promise(resolveWait => setTimeout(resolveWait, 10_000).unref?.())]);
|
|
270
|
+
};
|
|
271
|
+
}
|