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.
@@ -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: stopped, switched and restarted with the service. */
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
- /** Fault injection for state-machine tests, never selectable by a flag. */
142
- checkpoint?: (phase: RuntimePhase) => void;
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
- export declare function requestRuntimeUpdateCancel(stateDir: string, now?: Date): string;
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;
@@ -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
- copyFileSync(backup, temp);
342
- chmodSync(temp, 0o600);
343
- for (const suffix of ["-wal", "-shm"])
344
- rmSync(live + suffix, { force: true });
345
- durableRename(temp, live);
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
- * A database that cannot be read (corrupt, unreadable) is not copied: it is moved aside whole, with its WAL, and the
356
- * restore goes ahead. */
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.keptAside = kept;
370
- delete j.keptAsideUnreadable;
445
+ recordKept(j, kept, false);
446
+ return [];
371
447
  }
372
- catch {
448
+ catch (error) {
373
449
  rmSync(kept, { force: true });
374
- // Already moved aside by an earlier attempt: that path stays the one named.
375
- if (!existsSync(j.databaseFile))
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
- /** Stop the service and every watch daemon, and wait until every process they ran has exited: only then may a file
391
- * they write be replaced. */
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
- watching.push({ unit, pids: await pidsOf(unit, j.watches?.find(w => w.unit === unit)?.pids ?? []) });
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
- export function requestRuntimeUpdateCancel(stateDir, now = new Date()) {
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
- if (!["scheduled", "verifying", "draining"].includes(j.phase))
661
- return `The update to ${j.to.version} is past the point it can be cancelled (${j.phase}); it will finish or restore on its own.`;
662
- durableJson(cancelFile(j), { id: j.id, action: "cancel" });
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(j);
782
+ ungate(held);
670
783
  }
671
784
  catch { /* no pause yet */ }
672
- if (j.kind === "update")
673
- rmSync(join(j.stageDir, "runtime"), { recursive: true, force: true });
674
- const noun = j.kind === "update" ? "update" : "rollback";
675
- save(j, "cancelled", `The ${noun} to ${j.to.version} was cancelled. Nothing changed; new work resumed.`, now);
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(j.databaseFile, now, j.actor, `toolroll ${noun} cancelled`, "cancelled", `${j.from.version} → ${j.to.version}`);
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 ${j.to.version}. Nothing was switched; new work resumes.`;
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
- restoreDatabase(j, { path: j.backupPath, hash: j.backupHash, ...(j.codingBackupPath && j.codingBackupHash ? { codingPath: j.codingBackupPath, codingHash: j.codingBackupHash } : {}) });
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 kept = !j.keptAside ? "" : j.keptAsideUnreadable ? ` The live database could not be read, so it was left as it was at ${j.keptAside}.` : ` Anything written since the backup is kept in ${j.keptAside}.`;
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
- return { journal: j, running, whatsNew };
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
- function npmPrefixBin() {
1219
- const answered = exec("npm", ["prefix", "--global", "--no-color"], { timeout: 30_000 });
1220
- const prefix = answered.status === 0 ? answered.stdout.trim() : "";
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);
@@ -233,23 +233,37 @@ export declare class WorktreePool {
233
233
  message: string;
234
234
  }>;
235
235
  /**
236
- * Keep storage in check: remove the working copies nobody needs any more.
237
- * A checkout goes when it was released `keepMs` ago or longer, nothing
238
- * holds it, it is clean (build output that .gitignore hides is not work),
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
- prune(repo: string, now: Date, keepMs: number, keep: () => readonly string[], max?: number): Promise<{
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.