toolroll 0.8.1 → 0.8.2
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 +18 -0
- package/dist/agent-onboard.d.ts +4 -0
- package/dist/agent-onboard.js +20 -5
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +6 -6
- package/dist/checkout-cleanup.d.ts +84 -0
- package/dist/checkout-cleanup.js +245 -0
- package/dist/first-run.d.ts +4 -1
- package/dist/first-run.js +2 -1
- package/dist/mcp.js +26 -0
- package/dist/operate.d.ts +0 -4
- package/dist/operate.js +103 -21
- package/dist/prompt.d.ts +7 -2
- package/dist/prompt.js +36 -7
- package/dist/serve.js +60 -11
- package/dist/storage-ui.d.ts +10 -0
- package/dist/storage-ui.js +67 -0
- package/dist/storage.d.ts +15 -1
- package/dist/storage.js +62 -7
- package/dist/store.d.ts +5 -5
- package/dist/store.js +18 -16
- package/dist/toolroll-update-ui.d.ts +2 -0
- package/dist/toolroll-update-ui.js +1 -1
- package/dist/toolroll-update.d.ts +26 -6
- package/dist/toolroll-update.js +188 -50
- package/dist/worktree.d.ts +24 -10
- package/dist/worktree.js +84 -44
- package/package.json +1 -1
|
@@ -67,10 +67,12 @@ export type RuntimeUpdateJournal = {
|
|
|
67
67
|
unit: string;
|
|
68
68
|
pids: number[];
|
|
69
69
|
};
|
|
70
|
-
/** Each repo's watch daemon that runs this version, recorded the same way:
|
|
70
|
+
/** Each repo's watch daemon that runs this version, recorded the same way: switched with the service, and stopped and
|
|
71
|
+
* restarted with it only when launchd had it loaded before the update (`loaded` absent: 0.8.1 stopped every one). */
|
|
71
72
|
watches?: {
|
|
72
73
|
unit: string;
|
|
73
74
|
pids: number[];
|
|
75
|
+
loaded?: boolean;
|
|
74
76
|
}[];
|
|
75
77
|
/** Recorded before each change so a resumed or failed run knows what to put back. */
|
|
76
78
|
switched?: {
|
|
@@ -88,10 +90,15 @@ export type RuntimeUpdateJournal = {
|
|
|
88
90
|
}[];
|
|
89
91
|
databaseRestored?: boolean;
|
|
90
92
|
};
|
|
91
|
-
/** What the live database held when a failed run restored its backup: nothing written is lost. */
|
|
93
|
+
/** What the live database held when a failed run restored its backup: nothing written is lost. The newest of `kept`. */
|
|
92
94
|
keptAside?: string;
|
|
93
95
|
/** The live database could not be read, so it was moved aside whole rather than copied. */
|
|
94
96
|
keptAsideUnreadable?: boolean;
|
|
97
|
+
/** Every copy kept aside, oldest first: a retried restore adds one and never drops the pointer to an earlier one. */
|
|
98
|
+
kept?: {
|
|
99
|
+
path: string;
|
|
100
|
+
unreadable: boolean;
|
|
101
|
+
}[];
|
|
95
102
|
/** The restore put the backup back: a retried restore never puts it back again (what was written since belongs to
|
|
96
103
|
* the restored version and would be lost), and it keeps a fresh copy aside before every attempt until then. */
|
|
97
104
|
restoredDatabase?: boolean;
|
|
@@ -129,6 +136,8 @@ export type UpdateSystem = {
|
|
|
129
136
|
}) => Promise<void>;
|
|
130
137
|
/** The processes the service runs now. */
|
|
131
138
|
servicePids: (unit: string) => Promise<number[]>;
|
|
139
|
+
/** Whether launchd has the service loaded now. */
|
|
140
|
+
serviceLoaded: (unit: string) => Promise<boolean>;
|
|
132
141
|
/** Unload the service; resolves once launchd no longer has it. */
|
|
133
142
|
stopService: (unit: string) => Promise<void>;
|
|
134
143
|
/** Load and start the service from its definition on disk. */
|
|
@@ -138,8 +147,11 @@ export type UpdateSystem = {
|
|
|
138
147
|
healthTimeoutMs?: number;
|
|
139
148
|
/** How long stopped service processes may take to exit. */
|
|
140
149
|
exitTimeoutMs?: number;
|
|
141
|
-
/**
|
|
142
|
-
|
|
150
|
+
/** Bytes free for this user in the folder `dir` is on. */
|
|
151
|
+
freeBytes?: (dir: string) => number;
|
|
152
|
+
/** Fault injection for state-machine tests, never selectable by a flag. `kept-aside`: the live database was just
|
|
153
|
+
* kept aside, before the backup is put back. */
|
|
154
|
+
checkpoint?: (phase: RuntimePhase | "kept-aside") => void;
|
|
143
155
|
};
|
|
144
156
|
export type UpdateOutcome = {
|
|
145
157
|
ok: boolean;
|
|
@@ -211,7 +223,10 @@ export declare function startRuntimeRollback(o: {
|
|
|
211
223
|
export declare function resumeRuntimeUpdate(stateDir: string, system: UpdateSystem, id?: string): Promise<UpdateOutcome>;
|
|
212
224
|
/** A prepared update whose job could not start. */
|
|
213
225
|
export declare function abandonRuntimeUpdate(stateDir: string, id: string, why: string, now: Date): void;
|
|
214
|
-
|
|
226
|
+
/** `locked`: a test's seam, called once the updater's lock is held and before the journal is read again. */
|
|
227
|
+
export declare function requestRuntimeUpdateCancel(stateDir: string, now?: Date, seams?: {
|
|
228
|
+
locked?: () => void;
|
|
229
|
+
}): string;
|
|
215
230
|
/** Keep the newest release-* runtimes (and any in `keep`), at most KEEP_RUNTIMES; deploy-browser's browser-* and
|
|
216
231
|
* the rollback-* records are not this updater's to remove. */
|
|
217
232
|
export declare function pruneRuntimes(stateDir: string, keep: readonly string[]): string[];
|
|
@@ -223,6 +238,11 @@ export type RuntimeUpdateStatus = {
|
|
|
223
238
|
version: string;
|
|
224
239
|
notes: string[];
|
|
225
240
|
} | null;
|
|
241
|
+
/** The last completed update `--rollback` returns from, whatever was attempted since. */
|
|
242
|
+
lastUpdate: {
|
|
243
|
+
from: string;
|
|
244
|
+
to: string;
|
|
245
|
+
} | null;
|
|
226
246
|
};
|
|
227
247
|
export declare function runtimeUpdateStatus(stateDir: string): RuntimeUpdateStatus;
|
|
228
248
|
export declare function markWhatsNewSeen(stateDir: string): void;
|
|
@@ -261,7 +281,7 @@ export declare function launchRuntimeUpdate(args: {
|
|
|
261
281
|
home?: string;
|
|
262
282
|
run?: SupervisorRunner;
|
|
263
283
|
platform?: NodeJS.Platform;
|
|
264
|
-
npmBin?: () => string | null
|
|
284
|
+
npmBin?: () => Promise<string | null>;
|
|
265
285
|
}): Promise<void>;
|
|
266
286
|
/** The job's last act: its definition goes, so nothing can start it again. Only the definition for this id. */
|
|
267
287
|
export declare function retireUpdateJob(id: string, home?: string): void;
|
package/dist/toolroll-update.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
import { createHash, randomUUID, verify as signatureValid, X509Certificate } from "node:crypto";
|
|
26
26
|
import { spawnSync } from "node:child_process";
|
|
27
|
-
import { accessSync, chmodSync, closeSync, constants, copyFileSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { accessSync, chmodSync, closeSync, constants, copyFileSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statfsSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
28
28
|
import { homedir, userInfo } from "node:os";
|
|
29
29
|
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
30
30
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
@@ -115,6 +115,9 @@ function save(j, phase, detail, now) {
|
|
|
115
115
|
/** Every update, rollback, refusal and failure. Plain SQL any build can run,
|
|
116
116
|
* written after any database restore so the restore cannot erase it. */
|
|
117
117
|
function ledger(databaseFile, now, actor, action, outcome, detail) {
|
|
118
|
+
// Never an empty database where the live one should be.
|
|
119
|
+
if (!existsSync(databaseFile))
|
|
120
|
+
throw Error("The database is not in place.");
|
|
118
121
|
const db = new (sqlite().DatabaseSync)(databaseFile);
|
|
119
122
|
try {
|
|
120
123
|
db.exec("PRAGMA busy_timeout=5000");
|
|
@@ -333,17 +336,53 @@ async function rehearse(j, system, source) {
|
|
|
333
336
|
rmSync(copy + suffix, { force: true });
|
|
334
337
|
}
|
|
335
338
|
}
|
|
336
|
-
/** Put a verified backup in place of a live SQLite file. Only once the service's processes are gone.
|
|
339
|
+
/** Put a verified backup in place of a live SQLite file. Only once the service's processes are gone. The live file
|
|
340
|
+
* is replaced only by a complete copy: a copy that fails leaves it as it was. */
|
|
337
341
|
function restoreFile(live, backup, hash, what) {
|
|
338
342
|
if (fileHash(backup) !== hash)
|
|
339
343
|
throw Error(`The retained ${what} backup changed. It was not put back.`);
|
|
340
344
|
const temp = `${live}.${randomUUID()}.restore`;
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
345
|
+
try {
|
|
346
|
+
copyFileSync(backup, temp);
|
|
347
|
+
chmodSync(temp, 0o600);
|
|
348
|
+
for (const suffix of ["-wal", "-shm"])
|
|
349
|
+
rmSync(live + suffix, { force: true });
|
|
350
|
+
durableRename(temp, live);
|
|
351
|
+
}
|
|
352
|
+
finally {
|
|
353
|
+
rmSync(temp, { force: true });
|
|
354
|
+
}
|
|
346
355
|
}
|
|
356
|
+
/** Before anything is moved or replaced: every backup is still the one recorded, and there is room for the copy
|
|
357
|
+
* kept aside and for the backups put back. */
|
|
358
|
+
function assertRestorable(j, from, system) {
|
|
359
|
+
if (fileHash(from.path) !== from.hash)
|
|
360
|
+
throw Error("The retained database backup changed. Nothing was put back; the live database is as it was.");
|
|
361
|
+
if (from.codingPath && fileHash(from.codingPath) !== from.codingHash)
|
|
362
|
+
throw Error("The retained coding catalog backup changed. Nothing was put back; the live database is as it was.");
|
|
363
|
+
const size = (file) => { try {
|
|
364
|
+
return statSync(file).size;
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
return 0;
|
|
368
|
+
} };
|
|
369
|
+
const live = size(j.databaseFile) + size(`${j.databaseFile}-wal`);
|
|
370
|
+
const needs = new Map();
|
|
371
|
+
const need = (dir, bytes) => { const dev = statSync(dir).dev; const at = needs.get(dev) ?? { dir, bytes: 0 }; at.bytes += bytes; needs.set(dev, at); };
|
|
372
|
+
need(j.stageDir, live);
|
|
373
|
+
need(dirname(j.databaseFile), size(from.path) + (from.codingPath ? size(from.codingPath) : 0));
|
|
374
|
+
const free = system.freeBytes ?? freeBytes;
|
|
375
|
+
for (const { dir, bytes } of needs.values()) {
|
|
376
|
+
const available = free(dir);
|
|
377
|
+
if (available < bytes)
|
|
378
|
+
throw Error(`${dir} has ${megabytes(available)} free and the restore needs ${megabytes(bytes)}. Nothing was put back; the live database is as it was. Free some space, then run toolroll update --resume.`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
function freeBytes(dir) {
|
|
382
|
+
const fs = statfsSync(dir);
|
|
383
|
+
return Number(fs.bavail) * Number(fs.bsize);
|
|
384
|
+
}
|
|
385
|
+
const megabytes = (bytes) => `${Math.ceil(bytes / 1_048_576)} MB`;
|
|
347
386
|
/** The database and, when one was backed up, the coding catalog, then this run's coding gate lifted. */
|
|
348
387
|
function restoreDatabase(j, from) {
|
|
349
388
|
restoreFile(j.databaseFile, from.path, from.hash, "database");
|
|
@@ -351,10 +390,47 @@ function restoreDatabase(j, from) {
|
|
|
351
390
|
restoreFile(codingFile(j.databaseFile), from.codingPath, from.codingHash, "coding catalog");
|
|
352
391
|
ungateCoding(j);
|
|
353
392
|
}
|
|
393
|
+
/** SQLite's own answer for a file it cannot read as a database: corrupt, or not a database at all. Anything else
|
|
394
|
+
* (busy, a full disk, a permission) is not a reason to move the live database. */
|
|
395
|
+
function unreadableDatabase(error) {
|
|
396
|
+
const code = error.errcode;
|
|
397
|
+
return typeof code === "number" && [11, 26].includes(code & 0xff);
|
|
398
|
+
}
|
|
399
|
+
/** The kept-aside copies a journal names, oldest first; a journal written by 0.8.1 names only its newest. */
|
|
400
|
+
function keptOf(j) {
|
|
401
|
+
return j.kept ?? (j.keptAside ? [{ path: j.keptAside, unreadable: j.keptAsideUnreadable === true }] : []);
|
|
402
|
+
}
|
|
403
|
+
function recordKept(j, path, unreadable) {
|
|
404
|
+
j.kept = [...keptOf(j), { path, unreadable }];
|
|
405
|
+
j.keptAside = path;
|
|
406
|
+
if (unreadable)
|
|
407
|
+
j.keptAsideUnreadable = true;
|
|
408
|
+
else
|
|
409
|
+
delete j.keptAsideUnreadable;
|
|
410
|
+
}
|
|
411
|
+
function forgetKept(j, path) {
|
|
412
|
+
j.kept = keptOf(j).filter(one => one.path !== path);
|
|
413
|
+
const newest = j.kept[j.kept.length - 1];
|
|
414
|
+
if (newest) {
|
|
415
|
+
j.keptAside = newest.path;
|
|
416
|
+
if (newest.unreadable)
|
|
417
|
+
j.keptAsideUnreadable = true;
|
|
418
|
+
else
|
|
419
|
+
delete j.keptAsideUnreadable;
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
delete j.keptAside;
|
|
423
|
+
delete j.keptAsideUnreadable;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
354
426
|
/** A private copy of the live database before a restore replaces it: what the new version wrote is kept, and named.
|
|
355
|
-
*
|
|
356
|
-
* restore
|
|
427
|
+
* Only a database SQLite says is corrupt or not a database is moved aside whole, with its WAL; any other failure
|
|
428
|
+
* stops the restore with the live database where it was. Returns what was moved, so a failed restore moves it back. */
|
|
357
429
|
async function keepAside(j) {
|
|
430
|
+
// Nothing at the live path: an earlier attempt moved it aside and stopped before putting the backup back. That
|
|
431
|
+
// copy stays named in `kept`.
|
|
432
|
+
if (!existsSync(j.databaseFile))
|
|
433
|
+
return [];
|
|
358
434
|
const id = randomUUID().slice(0, 8), kept = join(j.stageDir, `orders.kept.${id}.db`);
|
|
359
435
|
try {
|
|
360
436
|
const db = new (sqlite().DatabaseSync)(j.databaseFile, { readOnly: true });
|
|
@@ -366,29 +442,47 @@ async function keepAside(j) {
|
|
|
366
442
|
}
|
|
367
443
|
chmodSync(kept, 0o600);
|
|
368
444
|
fsyncPath(kept);
|
|
369
|
-
j
|
|
370
|
-
|
|
445
|
+
recordKept(j, kept, false);
|
|
446
|
+
return [];
|
|
371
447
|
}
|
|
372
|
-
catch {
|
|
448
|
+
catch (error) {
|
|
373
449
|
rmSync(kept, { force: true });
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
return;
|
|
377
|
-
const unreadable = join(j.stageDir, `orders.unreadable.${id}.db`);
|
|
378
|
-
for (const suffix of ["-wal", "-shm", ""])
|
|
379
|
-
if (existsSync(j.databaseFile + suffix))
|
|
380
|
-
durableRename(j.databaseFile + suffix, unreadable + suffix);
|
|
381
|
-
j.keptAside = unreadable;
|
|
382
|
-
j.keptAsideUnreadable = true;
|
|
450
|
+
if (!unreadableDatabase(error))
|
|
451
|
+
throw Error(`The live database could not be copied aside before the restore (${error.message || "no reason given"}). It was left as it was, and nothing was put back.`);
|
|
383
452
|
}
|
|
453
|
+
const unreadable = join(j.stageDir, `orders.unreadable.${id}.db`);
|
|
454
|
+
const moved = [];
|
|
455
|
+
try {
|
|
456
|
+
for (const suffix of ["-wal", "-shm", ""]) {
|
|
457
|
+
if (!existsSync(j.databaseFile + suffix))
|
|
458
|
+
continue;
|
|
459
|
+
durableRename(j.databaseFile + suffix, unreadable + suffix);
|
|
460
|
+
moved.push({ from: j.databaseFile + suffix, to: unreadable + suffix });
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
catch (error) {
|
|
464
|
+
moveBack(moved);
|
|
465
|
+
throw Error(`The unreadable live database could not be moved aside (${error.message}). It was left as it was, and nothing was put back.`);
|
|
466
|
+
}
|
|
467
|
+
recordKept(j, unreadable, true);
|
|
468
|
+
return moved;
|
|
469
|
+
}
|
|
470
|
+
/** Undo keepAside's moves: the live database goes back where it was, never leaving the path empty. */
|
|
471
|
+
function moveBack(moved) {
|
|
472
|
+
for (const one of [...moved].reverse())
|
|
473
|
+
if (!existsSync(one.from) && existsSync(one.to))
|
|
474
|
+
durableRename(one.to, one.from);
|
|
384
475
|
}
|
|
385
476
|
// ---- the service: stop, and see it gone -------------------------------------
|
|
386
477
|
/** The service and watch daemons this run stops, switches and restarts: once switched, the ones it switched. */
|
|
387
478
|
function unitsOf(j) {
|
|
388
479
|
return { main: j.switched?.unit?.path ?? j.service?.unit ?? null, watches: j.switched?.watches?.map(w => w.path) ?? j.watches?.map(w => w.unit) ?? [] };
|
|
389
480
|
}
|
|
390
|
-
/**
|
|
391
|
-
* they
|
|
481
|
+
/** The watch daemons launchd had loaded before the update: the only ones stopped and started again. One a person
|
|
482
|
+
* unloaded stays unloaded (its definition is still switched, so it starts the new version when they load it). */
|
|
483
|
+
const loadedWatches = (j, watches) => watches.filter(unit => j.watches?.find(w => w.unit === unit)?.loaded !== false);
|
|
484
|
+
/** Stop the service and every loaded watch daemon, and wait until every process they ran has exited: only then may
|
|
485
|
+
* a file they write be replaced. */
|
|
392
486
|
async function stopServices(j, system, main, watches) {
|
|
393
487
|
if (!main && watches.length === 0)
|
|
394
488
|
return;
|
|
@@ -396,12 +490,16 @@ async function stopServices(j, system, main, watches) {
|
|
|
396
490
|
if (main)
|
|
397
491
|
j.service = { unit: main, pids: await pidsOf(main, j.service?.unit === main ? j.service.pids : []) };
|
|
398
492
|
const watching = [];
|
|
399
|
-
for (const unit of watches)
|
|
400
|
-
|
|
493
|
+
for (const unit of watches) {
|
|
494
|
+
const earlier = j.watches?.find(w => w.unit === unit);
|
|
495
|
+
// Recorded once, before the first stop: a resumed run must not read its own stop as the person's choice.
|
|
496
|
+
const loaded = earlier ? earlier.loaded !== false : await system.serviceLoaded(unit);
|
|
497
|
+
watching.push({ unit, pids: loaded ? await pidsOf(unit, earlier?.pids ?? []) : [], loaded });
|
|
498
|
+
}
|
|
401
499
|
if (watching.length > 0)
|
|
402
500
|
j.watches = watching;
|
|
403
501
|
save(j, j.phase, j.detail, system.now());
|
|
404
|
-
for (const unit of [main, ...watches])
|
|
502
|
+
for (const unit of [main, ...loadedWatches(j, watches)])
|
|
405
503
|
if (unit)
|
|
406
504
|
await system.stopService(unit);
|
|
407
505
|
const pids = [...(main ? j.service.pids : []), ...watching.flatMap(w => w.pids)];
|
|
@@ -475,6 +573,7 @@ async function switchRuntime(j, system) {
|
|
|
475
573
|
if (j.restoreFrom && !j.switched.databaseRestored) {
|
|
476
574
|
await stopServices(j, system, main, watches);
|
|
477
575
|
refuseWhileWatching(j, system, true);
|
|
576
|
+
assertRestorable(j, j.restoreFrom, system);
|
|
478
577
|
restoreDatabase(j, j.restoreFrom);
|
|
479
578
|
gate(j);
|
|
480
579
|
j.switched.databaseRestored = true;
|
|
@@ -653,31 +752,45 @@ export function abandonRuntimeUpdate(stateDir, id, why, now) {
|
|
|
653
752
|
if (j?.id === id && j.phase === "scheduled")
|
|
654
753
|
save(j, "refused", why, now);
|
|
655
754
|
}
|
|
656
|
-
|
|
755
|
+
const CANCELLABLE = ["scheduled", "verifying", "draining"];
|
|
756
|
+
/** `locked`: a test's seam, called once the updater's lock is held and before the journal is read again. */
|
|
757
|
+
export function requestRuntimeUpdateCancel(stateDir, now = new Date(), seams = {}) {
|
|
657
758
|
const j = readRuntimeUpdate(stateDir);
|
|
658
759
|
if (!j || runtimeUpdateTerminal(j.phase))
|
|
659
760
|
return "No update is in progress.";
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
761
|
+
const tooLate = (at) => `The update to ${at.to.version} is past the point it can be cancelled (${at.phase}); it will finish or restore on its own.`;
|
|
762
|
+
if (!CANCELLABLE.includes(j.phase))
|
|
763
|
+
return tooLate(j);
|
|
663
764
|
// No updater is running to see the request: cancel here, so the admission pause does not outlive it.
|
|
664
765
|
const lock = sqliteLock(join(j.stageDir, "worker.sqlite"));
|
|
665
|
-
if (!lock)
|
|
766
|
+
if (!lock) {
|
|
767
|
+
durableJson(cancelFile(j), { id: j.id, action: "cancel" });
|
|
666
768
|
return `Cancelling the update to ${j.to.version}. Nothing is switched; new work resumes.`;
|
|
769
|
+
}
|
|
667
770
|
try {
|
|
771
|
+
seams.locked?.();
|
|
772
|
+
// An updater may have moved on, finished or been replaced between the first read and the lock.
|
|
773
|
+
const held = readRuntimeUpdate(stateDir);
|
|
774
|
+
if (!held || runtimeUpdateTerminal(held.phase))
|
|
775
|
+
return "No update is in progress.";
|
|
776
|
+
if (held.id !== j.id)
|
|
777
|
+
return "A different update was saved while cancelling. Nothing was cancelled.";
|
|
778
|
+
if (!CANCELLABLE.includes(held.phase))
|
|
779
|
+
return tooLate(held);
|
|
780
|
+
durableJson(cancelFile(held), { id: held.id, action: "cancel" });
|
|
668
781
|
try {
|
|
669
|
-
ungate(
|
|
782
|
+
ungate(held);
|
|
670
783
|
}
|
|
671
784
|
catch { /* no pause yet */ }
|
|
672
|
-
if (
|
|
673
|
-
rmSync(join(
|
|
674
|
-
const noun =
|
|
675
|
-
save(
|
|
785
|
+
if (held.kind === "update")
|
|
786
|
+
rmSync(join(held.stageDir, "runtime"), { recursive: true, force: true });
|
|
787
|
+
const noun = held.kind === "update" ? "update" : "rollback";
|
|
788
|
+
save(held, "cancelled", `The ${noun} to ${held.to.version} was cancelled. Nothing changed; new work resumed.`, now);
|
|
676
789
|
try {
|
|
677
|
-
ledger(
|
|
790
|
+
ledger(held.databaseFile, now, held.actor, `toolroll ${noun} cancelled`, "cancelled", `${held.from.version} → ${held.to.version}`);
|
|
678
791
|
}
|
|
679
792
|
catch { /* the journal still says what happened */ }
|
|
680
|
-
return `Cancelled the ${noun} to ${
|
|
793
|
+
return `Cancelled the ${noun} to ${held.to.version}. Nothing was switched; new work resumes.`;
|
|
681
794
|
}
|
|
682
795
|
finally {
|
|
683
796
|
lock.close();
|
|
@@ -757,7 +870,7 @@ async function driveRuntimeUpdate(j, system) {
|
|
|
757
870
|
if (resumedAt <= at("restarting")) {
|
|
758
871
|
const { main, watches } = unitsOf(j);
|
|
759
872
|
step("restarting", main || watches.length > 0 ? "Restarting the background service." : "No background service runs here; the commands now start the new version.");
|
|
760
|
-
for (const unit of [main, ...watches])
|
|
873
|
+
for (const unit of [main, ...loadedWatches(j, watches)])
|
|
761
874
|
if (unit)
|
|
762
875
|
await system.restartService(unit);
|
|
763
876
|
}
|
|
@@ -805,7 +918,7 @@ async function driveRuntimeUpdate(j, system) {
|
|
|
805
918
|
rmSync(join(j.stageDir, "runtime"), { recursive: true, force: true });
|
|
806
919
|
// A service and watch daemons stopped for the backup start again, unchanged.
|
|
807
920
|
let restarted = "";
|
|
808
|
-
for (const unit of [j.service?.unit, ...(j.watches ?? []).map(w => w.unit)]) {
|
|
921
|
+
for (const unit of [j.service?.unit, ...(j.watches ?? []).filter(w => w.loaded !== false).map(w => w.unit)]) {
|
|
809
922
|
if (!unit)
|
|
810
923
|
continue;
|
|
811
924
|
try {
|
|
@@ -849,18 +962,34 @@ async function restore(j, system, finish, verb, why) {
|
|
|
849
962
|
throw Error("No verified backup was recorded.");
|
|
850
963
|
if (!j.restoredDatabase) {
|
|
851
964
|
refuseWhileWatching(j, system, true);
|
|
965
|
+
const from = { path: j.backupPath, hash: j.backupHash, ...(j.codingBackupPath && j.codingBackupHash ? { codingPath: j.codingBackupPath, codingHash: j.codingBackupHash } : {}) };
|
|
966
|
+
// Nothing moves until the backups are the ones recorded and there is room to put them back.
|
|
967
|
+
assertRestorable(j, from, system);
|
|
852
968
|
// A fresh copy before EVERY attempt that is about to replace the database, not only the first: a retry
|
|
853
969
|
// after a failure earlier in the restore must not overwrite writes made since without keeping them.
|
|
854
|
-
await keepAside(j);
|
|
970
|
+
const moved = await keepAside(j);
|
|
855
971
|
save(j, "rolling-back", j.detail, system.now());
|
|
856
|
-
|
|
972
|
+
try {
|
|
973
|
+
system.checkpoint?.("kept-aside");
|
|
974
|
+
restoreDatabase(j, from);
|
|
975
|
+
}
|
|
976
|
+
catch (error) {
|
|
977
|
+
// The backup did not go in: the live database comes back to its own path, and is no longer named as kept.
|
|
978
|
+
if (moved.length > 0 && !existsSync(j.databaseFile)) {
|
|
979
|
+
moveBack(moved);
|
|
980
|
+
forgetKept(j, j.keptAside);
|
|
981
|
+
save(j, "rolling-back", j.detail, system.now());
|
|
982
|
+
}
|
|
983
|
+
throw error;
|
|
984
|
+
}
|
|
857
985
|
j.restoredDatabase = true;
|
|
858
986
|
save(j, "rolling-back", j.detail, system.now());
|
|
859
987
|
}
|
|
860
|
-
for (const unit of [main, ...watches])
|
|
988
|
+
for (const unit of [main, ...loadedWatches(j, watches)])
|
|
861
989
|
if (unit)
|
|
862
990
|
await system.restartService(unit);
|
|
863
|
-
const
|
|
991
|
+
const copies = keptOf(j), readable = copies.filter(one => !one.unreadable).map(one => one.path), unreadable = copies.filter(one => one.unreadable).map(one => one.path);
|
|
992
|
+
const kept = (unreadable.length > 0 ? ` The live database could not be read, so it was left as it was at ${unreadable.join(" and ")}.` : "") + (readable.length > 0 ? ` Anything written since the backup is kept in ${readable.join(" and ")}.` : "");
|
|
864
993
|
const restored = `${why} Toolroll ${j.from.version} and its database were restored.${kept}`;
|
|
865
994
|
if (stuck.length > 0)
|
|
866
995
|
return finish(false, "needs-attention", `${restored} ${stuck.join("; ")} could not be pointed back at ${j.from.version}. Make ${stuck.length === 1 ? "its folder" : "those folders"} writable, then run toolroll update --resume.`, `toolroll ${noun} failed`, "needs attention", `${verb}: ${why} / ${stuck.join("; ")}`.slice(0, 300));
|
|
@@ -983,7 +1112,14 @@ export function runtimeUpdateStatus(stateDir) {
|
|
|
983
1112
|
lock?.close();
|
|
984
1113
|
}
|
|
985
1114
|
const whatsNew = j && j.kind === "update" && j.phase === "complete" && !j.seen ? { version: j.to.version, notes: j.notes ?? [] } : null;
|
|
986
|
-
|
|
1115
|
+
let last = null;
|
|
1116
|
+
try {
|
|
1117
|
+
last = lastCompletedUpdate(stateDir);
|
|
1118
|
+
}
|
|
1119
|
+
catch {
|
|
1120
|
+
last = null;
|
|
1121
|
+
}
|
|
1122
|
+
return { journal: j, running, whatsNew, lastUpdate: last ? { from: last.from.version, to: last.to.version } : null };
|
|
987
1123
|
}
|
|
988
1124
|
export function markWhatsNewSeen(stateDir) {
|
|
989
1125
|
const j = readRuntimeUpdate(stateDir);
|
|
@@ -1126,6 +1262,7 @@ export function machineSystem(home = homedir(), env = process.env, seams = {}) {
|
|
|
1126
1262
|
return false;
|
|
1127
1263
|
} });
|
|
1128
1264
|
},
|
|
1265
|
+
serviceLoaded: async (unit) => (await supervise("launchctl", ["print", `${domain}/${labelOf(unit)}`])).code === 0,
|
|
1129
1266
|
servicePids: async (unit) => {
|
|
1130
1267
|
const printed = await supervise("launchctl", ["print", `${domain}/${labelOf(unit)}`]);
|
|
1131
1268
|
const pid = Number(printed.stdout.match(/\n\s*pid = (\d+)/)?.[1]);
|
|
@@ -1214,10 +1351,11 @@ const jobUnit = (home) => join(home, "Library", "LaunchAgents", `${UPDATE_JOB_LA
|
|
|
1214
1351
|
* that id only; it does not run at login (no RunAtLoad: launchd starts it
|
|
1215
1352
|
* with a kickstart) and removes its definition when it finishes. Elsewhere it
|
|
1216
1353
|
* is a detached process. */
|
|
1217
|
-
/** Where npm puts global commands (`npm prefix --global`/bin), or null when npm does not say.
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
const
|
|
1354
|
+
/** Where npm puts global commands (`npm prefix --global`/bin), or null when npm does not say. Asked without blocking:
|
|
1355
|
+
* the console's request handler starts the job. */
|
|
1356
|
+
async function npmPrefixBin(run) {
|
|
1357
|
+
const answered = await run("npm", ["prefix", "--global", "--no-color"], { timeoutMs: 30_000 }).catch(() => null);
|
|
1358
|
+
const prefix = answered?.code === 0 ? answered.stdout.trim() : "";
|
|
1221
1359
|
return isAbsolute(prefix) ? join(prefix, "bin") : null;
|
|
1222
1360
|
}
|
|
1223
1361
|
export async function launchRuntimeUpdate(args, seams = {}) {
|
|
@@ -1229,7 +1367,7 @@ export async function launchRuntimeUpdate(args, seams = {}) {
|
|
|
1229
1367
|
const home = seams.home ?? homedir();
|
|
1230
1368
|
const run = seams.run ?? (await import("./exec.js")).run;
|
|
1231
1369
|
// Everywhere a toolroll command is usually linked: the job switches every one it finds on this PATH.
|
|
1232
|
-
const npmBin = (seams.npmBin ?? npmPrefixBin)();
|
|
1370
|
+
const npmBin = await (seams.npmBin ?? (() => npmPrefixBin(run)))();
|
|
1233
1371
|
const pathEnv = [...new Set([dirname(process.execPath), ...(npmBin ? [npmBin] : []), join(home, ".local", "bin"), join(home, "bin"), join(home, "Library", "pnpm"), "/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"])].join(":");
|
|
1234
1372
|
const unit = launchdPlist({ label: UPDATE_JOB_LABEL, command, workingDirectory: stateDir, logPath: log, pathEnv, keepAlive: false, runAtLoad: false });
|
|
1235
1373
|
const started = await installLaunchdService({ platform: "darwin", label: UPDATE_JOB_LABEL, bin: process.execPath, entry: join(dist, "bin.js"), logPath: log, unitPath: jobUnit(home), unitContent: unit }, run);
|
package/dist/worktree.d.ts
CHANGED
|
@@ -233,23 +233,37 @@ export declare class WorktreePool {
|
|
|
233
233
|
message: string;
|
|
234
234
|
}>;
|
|
235
235
|
/**
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
* everything its HEAD reaches is on a branch, tag or remote, and it isn't the
|
|
240
|
-
* checkout of one of `keep()`'s branches (those unfinished tasks work on or
|
|
241
|
-
* would come back to, read afresh before each removal), by its branch or by
|
|
242
|
-
* the name a lease of that branch would give it under any root. Only the
|
|
243
|
-
* working copy goes: its branch, and so every commit, stays, and a later
|
|
244
|
-
* lease of the same branch makes a new one. At most `max` go per pass.
|
|
236
|
+
* Whether a let-go checkout could go without losing anything: clean (build output that .gitignore hides is not
|
|
237
|
+
* work, and neither are the files Toolroll itself wrote in there), and everything its HEAD reaches on a branch, tag
|
|
238
|
+
* or remote. Reads only.
|
|
245
239
|
*/
|
|
246
|
-
|
|
240
|
+
inspect(path: string): Promise<"clean" | KeptWhy>;
|
|
241
|
+
/**
|
|
242
|
+
* Keep storage in check: remove the working copies nobody needs any more. Of `rows`, a checkout goes when it is
|
|
243
|
+
* let go, nothing holds it, `wanted` says it may go (asked again right before it goes, so a task coming back or a
|
|
244
|
+
* lease since keeps it) and `inspect` finds it clean. Only the working copy goes: its branch, and so every commit,
|
|
245
|
+
* stays, and a later lease of the same branch makes a new one. At most `max` go per pass.
|
|
246
|
+
*/
|
|
247
|
+
prune(rows: readonly WorktreeRow[], wanted: (row: WorktreeRow, fresh: boolean) => boolean, max?: number): Promise<{
|
|
247
248
|
removed: WorktreeRow[];
|
|
248
249
|
kept: {
|
|
249
250
|
path: string;
|
|
250
251
|
why: KeptWhy;
|
|
251
252
|
}[];
|
|
252
253
|
}>;
|
|
254
|
+
/**
|
|
255
|
+
* Throw away a let-go checkout kept for its changes, on purpose: the working copy and what is uncommitted in it
|
|
256
|
+
* go; its branch, and every commit on it, stays. Refused while anything holds it.
|
|
257
|
+
*/
|
|
258
|
+
discardChanges(path: string): Promise<{
|
|
259
|
+
ok: true;
|
|
260
|
+
row: WorktreeRow;
|
|
261
|
+
} | {
|
|
262
|
+
ok: false;
|
|
263
|
+
message: string;
|
|
264
|
+
}>;
|
|
265
|
+
/** Remove one idle checkout's working copy (the caller holds it in `busy`) and forget its row. */
|
|
266
|
+
private removeWorkingCopy;
|
|
253
267
|
/**
|
|
254
268
|
* Whether anybody's work is in there. null means we could not tell, which is
|
|
255
269
|
* neither clean nor dirty and must not be rounded to either.
|