toolroll 0.9.76 → 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.
Files changed (144) hide show
  1. package/CHANGELOG.md +273 -0
  2. package/README.md +11 -4
  3. package/THIRD_PARTY_NOTICES.md +5 -5
  4. package/dist/agent-fence.js +46 -10
  5. package/dist/assignment.js +3 -0
  6. package/dist/backup-ui.d.ts +9 -1
  7. package/dist/backup-ui.js +28 -3
  8. package/dist/backup.js +4 -3
  9. package/dist/browser/THIRD_PARTY_NOTICES.txt +5 -5
  10. package/dist/browser/workspace.css +2 -2
  11. package/dist/browser/workspace.js +31 -31
  12. package/dist/browser-workspace.d.ts +2 -0
  13. package/dist/builder.d.ts +18 -3
  14. package/dist/builder.js +152 -47
  15. package/dist/chat-decide.js +4 -0
  16. package/dist/check-failure.d.ts +92 -0
  17. package/dist/check-failure.js +295 -0
  18. package/dist/check-progress.d.ts +28 -1
  19. package/dist/check-progress.js +105 -6
  20. package/dist/check-report.d.ts +67 -6
  21. package/dist/check-report.js +254 -9
  22. package/dist/checkout-cleanup.js +2 -2
  23. package/dist/claim.d.ts +9 -0
  24. package/dist/claim.js +9 -1
  25. package/dist/cli.js +8 -5
  26. package/dist/coding-handoff.d.ts +1 -0
  27. package/dist/coding-handoff.js +20 -11
  28. package/dist/commit-extract.d.ts +6 -0
  29. package/dist/commit-extract.js +63 -8
  30. package/dist/console-chrome.d.ts +41 -0
  31. package/dist/console-chrome.js +166 -0
  32. package/dist/contracts/console-api.d.ts +1 -1
  33. package/dist/contracts/console-api.js +1 -1
  34. package/dist/contracts/release-check.d.ts +12 -0
  35. package/dist/contracts/release-check.js +17 -0
  36. package/dist/contracts/release-terms.d.ts +41 -0
  37. package/dist/contracts/release-terms.js +53 -0
  38. package/dist/contracts/verification-receipt.d.ts +20 -2
  39. package/dist/contracts/verification-receipt.js +15 -3
  40. package/dist/contracts/verification-view.d.ts +8 -0
  41. package/dist/controller-service.js +4 -1
  42. package/dist/controller-supervisor.d.ts +8 -0
  43. package/dist/controller-supervisor.js +33 -1
  44. package/dist/demo.js +3 -2
  45. package/dist/deploy-gate.js +5 -0
  46. package/dist/deploy-rescue.d.ts +41 -0
  47. package/dist/deploy-rescue.js +94 -0
  48. package/dist/desktop-update.d.ts +1 -1
  49. package/dist/dispose.d.ts +3 -0
  50. package/dist/dispose.js +9 -5
  51. package/dist/doctor.d.ts +7 -0
  52. package/dist/doctor.js +10 -2
  53. package/dist/envelope.js +2 -1
  54. package/dist/evidence.d.ts +9 -0
  55. package/dist/evidence.js +19 -4
  56. package/dist/exec.d.ts +9 -0
  57. package/dist/exec.js +22 -2
  58. package/dist/git-floor.d.ts +16 -0
  59. package/dist/git-floor.js +36 -0
  60. package/dist/grant.js +5 -1
  61. package/dist/guides.js +22 -11
  62. package/dist/latency-timeline.d.ts +46 -0
  63. package/dist/latency-timeline.js +120 -0
  64. package/dist/live-backup.d.ts +39 -0
  65. package/dist/live-backup.js +271 -0
  66. package/dist/operate.d.ts +13 -0
  67. package/dist/operate.js +969 -328
  68. package/dist/planner.js +2 -2
  69. package/dist/prepared-evidence.d.ts +2 -1
  70. package/dist/prepared-evidence.js +8 -3
  71. package/dist/project-knowledge.js +6 -2
  72. package/dist/project-learning.js +2 -2
  73. package/dist/proof.js +6 -0
  74. package/dist/read-executor-worker.js +8 -4
  75. package/dist/read-executor.d.ts +18 -1
  76. package/dist/read-executor.js +8 -0
  77. package/dist/release-adapters.d.ts +80 -2
  78. package/dist/release-adapters.js +441 -16
  79. package/dist/release-authority.js +6 -2
  80. package/dist/release-auto.d.ts +38 -7
  81. package/dist/release-auto.js +131 -15
  82. package/dist/release-card.d.ts +15 -3
  83. package/dist/release-card.js +25 -9
  84. package/dist/release-carry.d.ts +21 -1
  85. package/dist/release-carry.js +22 -0
  86. package/dist/release-ci-hold.d.ts +41 -0
  87. package/dist/release-ci-hold.js +53 -0
  88. package/dist/release-cli.d.ts +3 -0
  89. package/dist/release-cli.js +95 -17
  90. package/dist/release-coverage.js +5 -2
  91. package/dist/release-gate.d.ts +5 -0
  92. package/dist/release-gate.js +1 -1
  93. package/dist/release-recheck.d.ts +1 -1
  94. package/dist/release-recheck.js +8 -2
  95. package/dist/release.d.ts +144 -14
  96. package/dist/release.js +421 -81
  97. package/dist/review-context.js +8 -6
  98. package/dist/runner-process.d.ts +67 -0
  99. package/dist/runner-process.js +219 -0
  100. package/dist/runner.d.ts +48 -6
  101. package/dist/runner.js +73 -9
  102. package/dist/sandbox-runtime.d.ts +8 -0
  103. package/dist/sandbox-runtime.js +14 -2
  104. package/dist/scope.d.ts +19 -1
  105. package/dist/scope.js +26 -3
  106. package/dist/scout.js +2 -2
  107. package/dist/secret-mint.d.ts +1 -0
  108. package/dist/secret-mint.js +14 -0
  109. package/dist/serve.js +112 -51
  110. package/dist/server/chat.js +5 -5
  111. package/dist/server/http.d.ts +2 -0
  112. package/dist/server/pages.js +7 -3
  113. package/dist/server/remote-hooks.d.ts +1 -1
  114. package/dist/server/remote-hooks.js +5 -3
  115. package/dist/server/render-results.d.ts +2 -0
  116. package/dist/server/render-results.js +1 -0
  117. package/dist/server/render-tasks.d.ts +2 -0
  118. package/dist/server/render-tasks.js +2 -1
  119. package/dist/server/request-context.d.ts +8 -0
  120. package/dist/server/runtime.d.ts +2 -0
  121. package/dist/server/settings.js +22 -2
  122. package/dist/server/task-views.js +7 -2
  123. package/dist/server/tasks.js +4 -1
  124. package/dist/setup-guide.d.ts +2 -2
  125. package/dist/storage-sweep.js +1 -1
  126. package/dist/storage.d.ts +5 -0
  127. package/dist/storage.js +28 -1
  128. package/dist/store.d.ts +135 -2
  129. package/dist/store.js +275 -13
  130. package/dist/surface.js +3 -1
  131. package/dist/task-base.d.ts +4 -0
  132. package/dist/task-base.js +22 -2
  133. package/dist/teardown-git.d.ts +52 -3
  134. package/dist/teardown-git.js +145 -33
  135. package/dist/test-infra.js +1 -1
  136. package/dist/toolroll-update.js +2 -2
  137. package/dist/up-processes.d.ts +137 -0
  138. package/dist/up-processes.js +165 -0
  139. package/dist/up-role-child.d.ts +1 -0
  140. package/dist/up-role-child.js +81 -0
  141. package/dist/verification-evidence.d.ts +1 -0
  142. package/dist/verification-evidence.js +13 -1
  143. package/dist/workspace-revision.js +3 -1
  144. package/package.json +2 -1
package/dist/guides.js CHANGED
@@ -482,13 +482,17 @@ with a time limit.
482
482
  10. **tree**: main's merge commit must have exactly the gated tree, or
483
483
  nothing is tagged.
484
484
  11. **tag**: pushes \`v<version>\` on that merge commit. In publish.yml,
485
- macOS Node 22 and 24 must both pass before npm and GitHub publication.
485
+ macOS Node 22 and 24 must both pass before npm and GitHub publication
486
+ (the candidate's macOS checks already passed in ci.yml before the merge).
486
487
  macos.yml keeps the nightly matrix.
487
- 12. **publish**: waits for npm, the GitHub release and the tag's macOS
488
- and publish checks.
489
- 13. **homebrew**: updates the tap's formula, waits for checks to register
490
- and every registered check to pass, then merges. No checks means waiting,
491
- within the Homebrew time limit; a failed or skipped check stops the release.
488
+ 12. **publish**: waits for npm, the GitHub release and the tag's publish
489
+ job (which needs the macOS legs); not main's own CI on the merge commit.
490
+ 13. **homebrew**: the release is published once npm and the GitHub release
491
+ are verified. It proposes the tap's formula change (checksum from npm's
492
+ tarball) and looks at its checks once: \`Published; Homebrew pending\`
493
+ (or failed) ends the run, and the same command finishes it later, waiting
494
+ for checks to register and every one to pass, then merging exactly that
495
+ change. A tap already on a newer version is never moved back.
492
496
 
493
497
  ## When it stops
494
498
 
@@ -508,11 +512,14 @@ instead of making them twice.
508
512
  A stop at the gate comes before the task is filed, so nothing was filed
509
513
  either: a fix pushed as a new commit is released with \`--new\`; after
510
514
  rerunning the failed job on GitHub, run the same command again.
511
- - \`publish-failed\` / \`homebrew-failed\`: fix the failed workflow before
512
- resuming. A failed tag check cannot finish publication or update Homebrew.
513
- - \`base-moved\`: main gained a commit the branch doesn't have, so the
514
- merge wouldn't be the checked tree. Nothing was merged: update the
515
- branch and release it with \`--new\`.
515
+ - \`publish-failed\`: fix the failed workflow before resuming. A failed tag
516
+ check cannot finish publication or update Homebrew.
517
+ - Homebrew failed: the release is still published. Fix or rerun the tap's
518
+ check, then run the same command to finish Homebrew.
519
+ - \`base-moved\`: the branch is behind main (it doesn't contain main's
520
+ head), so the merge wouldn't be the tree CI tested. Checked at the gate
521
+ (nothing filed) and again right before merging (nothing merged): update
522
+ the branch, push, and release it with \`--new\`.
516
523
  - \`tree-mismatch\`: main changed what was checked. Nothing was tagged;
517
524
  the deployed build stays.
518
525
  - \`timeout\`: rerun to keep waiting; \`--limit <step>=<minutes>\`
@@ -520,6 +527,10 @@ instead of making them twice.
520
527
  - An interrupted deployment resumes with \`deploy-browser.mjs --stage <saved
521
528
  path> --phase recover\` before any new attempt, including \`--new\`.
522
529
  Failed recovery stops there; the original deployment journal remains available.
530
+ \`toolroll release --recover-deploy\` shows what that recovery will do for
531
+ the one unfinished deployment, and \`--yes\` runs it: when the old service
532
+ isn't proved stopped it stops only processes proved to be that service's
533
+ own, restarts the previous service and lifts the pause. Safe to rerun.
523
534
  - \`link\`: a CLI name didn't answer as the new build, so both were put
524
535
  back. The service already runs the new build; fix the name and rerun.
525
536
  - \`gate-held\`: the deployment stopped before its swap but left new work
@@ -0,0 +1,46 @@
1
+ import type { CheckReport } from "./contracts/release-check.js";
2
+ import { type Milestone, type Store } from "./store.js";
3
+ /** A console request slower than this keeps its timeline. */
4
+ export declare const SLOW_REQUEST_MS = 1000;
5
+ /** One operation's milestones as they happen, bounded (the store bounds them again when kept). */
6
+ export declare class Timeline {
7
+ readonly startedAt: Date;
8
+ private readonly t0;
9
+ readonly marks: Milestone[];
10
+ mark(name: string, detail?: string): void;
11
+ elapsed(): number;
12
+ }
13
+ /** Keep a finished console request's timeline when it was slow. Best effort: a failed write never touches the answer. */
14
+ export declare function keepSlowRequest(store: Store, timeline: Timeline, route: string, status: number, now: Date): void;
15
+ /** The check's own `took:` line (scripts/release-check.mjs): each part's duration, in ms, by label. */
16
+ export declare function partDurations(stdout: string): Map<string, number>;
17
+ /** A release check's parts as milestones: each part that started, and its end where its duration is known. */
18
+ export declare function checkMilestones(report: CheckReport, stdout: string): {
19
+ startedAt: Date;
20
+ totalMs: number;
21
+ milestones: Milestone[];
22
+ } | null;
23
+ /** Keep a release check's timeline beside its run. Best effort: the check's outcome never depends on it. */
24
+ export declare function keepCheckTimeline(store: Store, args: {
25
+ runId: number;
26
+ taskId: string;
27
+ report: CheckReport | null;
28
+ stdout: string;
29
+ now: Date;
30
+ }): void;
31
+ /** "Where the time went" on the task and run page: the run's check and the task's deploys, newest first. */
32
+ export type TimingView = {
33
+ title: string;
34
+ total: string;
35
+ rows: {
36
+ label: string;
37
+ at: string;
38
+ detail: string | null;
39
+ }[];
40
+ }[];
41
+ /** 65 s → "1:05"; 3 723 s → "1:02:03". */
42
+ export declare function clockWords(ms: number): string;
43
+ /** A run's check timelines and its task's deploy timelines, for the page (null: none kept). */
44
+ export declare function timingFor(store: Store, runId: number | null, taskId: string): TimingView | null;
45
+ /** The approver's debug view beside /healthz (/health): the slowest recent console requests, one line each. */
46
+ export declare function slowRequestLines(store: Store, limit?: number): string[];
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Latency timelines (docs/plans/actors-lessons.md, from durable-actors' latency timeline): for one operation, a short
3
+ * list of milestones, each an offset from its start, kept in the plane (latency_timeline, bounded and pruned) for:
4
+ *
5
+ * - console requests slower than SLOW_REQUEST_MS: route matched, signed in, chrome and worker reads, handler, render;
6
+ * - release checks: each part's start and end, from the sealed report's start times and the check's own `took:` line;
7
+ * - deploys: each phase, and each health wait with its reason (scripts/deploy-phases.mjs writes those).
8
+ *
9
+ * Milestones carry route ids, part and phase names and fixed reason words only: never a URL, a query, a body or a
10
+ * secret. A console request's offsets are monotonic (performance.now within one process); a check's and a deploy's
11
+ * are wall-clock offsets, since their parts run in other processes.
12
+ */
13
+ import { performance } from "node:perf_hooks";
14
+ import { latencyTimelines, MAX_MILESTONES, recordLatencyTimeline } from "./store.js";
15
+ /** A console request slower than this keeps its timeline. */
16
+ export const SLOW_REQUEST_MS = 1_000;
17
+ /** One operation's milestones as they happen, bounded (the store bounds them again when kept). */
18
+ export class Timeline {
19
+ startedAt = new Date();
20
+ t0 = performance.now();
21
+ marks = [];
22
+ mark(name, detail) {
23
+ if (this.marks.length >= MAX_MILESTONES * 2)
24
+ return;
25
+ this.marks.push({ name, ms: performance.now() - this.t0, ...(detail === undefined ? {} : { detail }) });
26
+ }
27
+ elapsed() { return performance.now() - this.t0; }
28
+ }
29
+ /** Keep a finished console request's timeline when it was slow. Best effort: a failed write never touches the answer. */
30
+ export function keepSlowRequest(store, timeline, route, status, now) {
31
+ const total = timeline.elapsed();
32
+ if (total < SLOW_REQUEST_MS)
33
+ return;
34
+ try {
35
+ recordLatencyTimeline(store, { kind: "console", subject: route, startedAt: timeline.startedAt, totalMs: total,
36
+ milestones: [...timeline.marks, { name: "answered", ms: total, detail: `status ${status}` }] }, now);
37
+ }
38
+ catch { /* display only */ }
39
+ }
40
+ const TOOK = /^(\d+(?:\.\d+)?) (s|min)$/;
41
+ /** The check's own `took:` line (scripts/release-check.mjs): each part's duration, in ms, by label. */
42
+ export function partDurations(stdout) {
43
+ const line = stdout.split("\n").reverse().find(one => one.startsWith("took: "));
44
+ const durations = new Map();
45
+ if (line === undefined)
46
+ return durations;
47
+ for (const one of line.slice("took: ".length).split(";")[0].split(", ")) {
48
+ const at = one.lastIndexOf(" ", one.lastIndexOf(" ") - 1);
49
+ if (at < 1)
50
+ continue;
51
+ const label = one.slice(0, at), took = TOOK.exec(one.slice(at + 1));
52
+ if (took !== null)
53
+ durations.set(label, Number(took[1]) * (took[2] === "min" ? 60_000 : 1_000));
54
+ }
55
+ return durations;
56
+ }
57
+ /** A release check's parts as milestones: each part that started, and its end where its duration is known. */
58
+ export function checkMilestones(report, stdout) {
59
+ const took = partDurations(stdout);
60
+ const started = report.parts.flatMap(part => {
61
+ const at = part.startedAt == null ? NaN : Date.parse(part.startedAt);
62
+ return Number.isFinite(at) ? [{ part: part.part, at, ms: took.get(part.part) ?? null }] : [];
63
+ });
64
+ if (started.length === 0)
65
+ return null;
66
+ const first = Math.min(...started.map(one => one.at));
67
+ const milestones = started.flatMap(one => [
68
+ { name: `${one.part} started`, ms: one.at - first },
69
+ ...(one.ms === null ? [] : [{ name: `${one.part} ended`, ms: one.at - first + one.ms }]),
70
+ ]).sort((a, b) => a.ms - b.ms);
71
+ return { startedAt: new Date(first), totalMs: Math.max(...milestones.map(one => one.ms)), milestones };
72
+ }
73
+ /** Keep a release check's timeline beside its run. Best effort: the check's outcome never depends on it. */
74
+ export function keepCheckTimeline(store, args) {
75
+ if (args.report === null)
76
+ return;
77
+ try {
78
+ const timeline = checkMilestones(args.report, args.stdout);
79
+ if (timeline === null)
80
+ return;
81
+ recordLatencyTimeline(store, { kind: "release-check", subject: `check ${args.report.mode}`, runId: args.runId, taskId: args.taskId, ...timeline }, args.now);
82
+ }
83
+ catch { /* display only */ }
84
+ }
85
+ /** 65 s → "1:05"; 3 723 s → "1:02:03". */
86
+ export function clockWords(ms) {
87
+ const seconds = Math.round(ms / 1000), h = Math.floor(seconds / 3600), m = Math.floor((seconds % 3600) / 60), s = seconds % 60;
88
+ return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` : `${m}:${String(s).padStart(2, "0")}`;
89
+ }
90
+ function viewOf(one) {
91
+ const title = one.kind === "deploy" ? `Deploy ${one.subject}` : one.kind === "release-check" ? "Release check" : `Request ${one.subject}`;
92
+ const started = new Map();
93
+ return { title, total: clockWords(one.totalMs), rows: one.milestones.map(mark => {
94
+ const part = / (started|ended)$/.exec(mark.name);
95
+ if (part?.[1] === "started")
96
+ started.set(mark.name.slice(0, -" started".length), mark.ms);
97
+ const began = part?.[1] === "ended" ? started.get(mark.name.slice(0, -" ended".length)) : undefined;
98
+ return { label: mark.name, at: `+${clockWords(mark.ms)}`, detail: began === undefined ? mark.detail ?? null : `took ${clockWords(mark.ms - began)}` };
99
+ }) };
100
+ }
101
+ /** A run's check timelines and its task's deploy timelines, for the page (null: none kept). */
102
+ export function timingFor(store, runId, taskId) {
103
+ try {
104
+ const kept = runId === null ? latencyTimelines(store, { taskId, limit: 5 }) : latencyTimelines(store, { runId, taskId, limit: 5 });
105
+ const shown = kept.filter(one => one.kind === "deploy" || one.runId === runId);
106
+ return shown.length === 0 ? null : shown.map(viewOf);
107
+ }
108
+ catch {
109
+ return null;
110
+ }
111
+ }
112
+ /** The approver's debug view beside /healthz (/health): the slowest recent console requests, one line each. */
113
+ export function slowRequestLines(store, limit = 20) {
114
+ try {
115
+ return latencyTimelines(store, { kind: "console", limit }).map(one => `${one.startedAt} ${one.subject} ${Math.round(one.totalMs)} ms: ${one.milestones.map(mark => `${mark.name} +${Math.round(mark.ms)}${mark.detail === undefined ? "" : ` (${mark.detail})`}`).join(", ")}`);
116
+ }
117
+ catch {
118
+ return [];
119
+ }
120
+ }
@@ -0,0 +1,39 @@
1
+ import { type LiveBackupCopy, type LiveBackupSettings, type Store } from "./store.js";
2
+ /** The choices Settings offers, in minutes (0: off is its own choice). */
3
+ export declare const LIVE_EVERY_MINUTES: readonly [1, 5, 15, 30];
4
+ /** Daily copies are kept this many days besides the newest `keep`. */
5
+ export declare const LIVE_DAILY_DAYS = 7;
6
+ /** `backups/live` beside the database, through its real path (the folder check refuses a path through a link). */
7
+ export declare function defaultLiveFolder(databaseFile: string): string;
8
+ export declare const liveFolderOf: (settings: LiveBackupSettings, databaseFile: string) => string;
9
+ /** Why a folder can't hold the copies, or null. Missing is fine (it is made on first use, then checked again). */
10
+ export declare function liveFolderProblem(folder: string, forbidden: readonly string[], protectedRoot?: string): string | null;
11
+ /** This database's copies in a folder, newest first (plain files named for it only). */
12
+ export declare function liveCopies(folder: string, tag: string): {
13
+ name: string;
14
+ path: string;
15
+ day: string;
16
+ }[];
17
+ /** Keep the newest `keep`, the newest of each of the last LIVE_DAILY_DAYS days, and `protect`; remove the rest (and
18
+ * this database's partial files older than an hour). Never anything not named for this database. */
19
+ export declare function pruneLiveCopies(folder: string, tag: string, keep: number, now: Date, protect: string | null): number;
20
+ /** One copy on a worker thread: copied, checked, hashed and renamed into place, or a problem (nothing left behind). */
21
+ export declare function copyOffThread(source: string, folder: string, tag: string, now: Date): Promise<{
22
+ ok: true;
23
+ copy: Omit<LiveBackupCopy, "madeAt">;
24
+ } | {
25
+ ok: false;
26
+ problem: string;
27
+ }>;
28
+ /** Whether a copy is due: on, and the latest (or the latest try) is at least the schedule old. */
29
+ export declare function liveCopyDue(store: Store, now: Date): boolean;
30
+ /** One pass: hold the lease, and when due check the folder, copy, record and prune. */
31
+ export declare function liveBackupPass(store: Store, databaseFile: string, holder: string, forbidden: () => readonly string[], clock?: () => Date): Promise<{
32
+ ok: true;
33
+ copy: LiveBackupCopy;
34
+ } | {
35
+ ok: false;
36
+ problem: string;
37
+ } | null>;
38
+ /** The loop in the worker: a pass a minute (the first after 30 s). The returned stop waits for a copy under way. */
39
+ export declare function startLiveBackups(store: Store, databaseFile: string, forbidden: () => readonly string[], everyMs?: number): () => Promise<void>;
@@ -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
+ }
package/dist/operate.d.ts CHANGED
@@ -6,6 +6,7 @@ import type { StepIo } from "./flow-steps.js";
6
6
  import type { FetchLike } from "./flow-share.js";
7
7
  import { type Store } from "./store.js";
8
8
  import { type DispatchAdapter } from "./sync.js";
9
+ import { type RolePort, type UpRoleStart } from "./up-processes.js";
9
10
  import { type TelegramTransport } from "./telegram.js";
10
11
  import { type LeadCliSeams } from "./lead-cli.js";
11
12
  import { type Principal, type RemoteSource } from "./operate-remote.js";
@@ -89,6 +90,8 @@ export type OperateOptions = {
89
90
  };
90
91
  /** Injected by tests: the classifier that sizes filed tasks (none by default under test). */
91
92
  filingSizer?: Sizer;
93
+ /** Set only by up-role-child.ts: this process is one role of a `toolroll up` (the console or the worker). */
94
+ upRole?: UpRoleHost;
92
95
  };
93
96
  export type OnboardSeams = Partial<Pick<OnboardIo, "home" | "env" | "cwd" | "interactive" | "confirm" | "findRepo" | "checkConnection" | "pullRequests">>;
94
97
  export declare const OPERATE_HELP: string;
@@ -117,6 +120,8 @@ export declare const KEYS_ACTIONS: readonly ["status", "set", "clear", "verify",
117
120
  * tests): every value-taking flag any verb reads, and every boolean.
118
121
  * A --flag in neither set is a typo, refused by name.
119
122
  */
123
+ /** Flags whose value is a secret someone types or pastes: taken as the next argument even when it starts with "--". */
124
+ export declare const SECRET_VALUE_FLAGS: ReadonlySet<string>;
120
125
  export declare const OPERATE_VALUE_FLAGS: ReadonlySet<string>;
121
126
  export declare const OPERATE_BOOLEAN_FLAGS: ReadonlySet<string>;
122
127
  export declare function parseOperateArgs(argv: readonly string[], ownValues?: ReadonlySet<string>): Args | {
@@ -168,3 +173,11 @@ export declare function readUpConsoleUrl(dir: string): string | null;
168
173
  export declare const PROPOSALS_ACTIONS: readonly ["list", "confirm", "dismiss"];
169
174
  /** The value flags a verb reads under a global switch's name, for parseOperateArgs. */
170
175
  export declare function ownValueFlags(command: string, argv: readonly string[]): ReadonlySet<string> | undefined;
176
+ /** What up-role-child.ts hands runOperate: the role's start, and its channel to the `up` that started it. */
177
+ export type UpRoleHost = {
178
+ start: UpRoleStart;
179
+ port: RolePort;
180
+ };
181
+ /** A role child's whole run (up-role-child.ts): the ordinary command prelude (containment, secrets, limits), then the
182
+ * role itself. Only a process started by `up` gets here; no command line selects a role. */
183
+ export declare function runUpRole(start: UpRoleStart, port: RolePort): Promise<number>;