toolroll 0.9.31 → 0.9.33

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 (68) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/assignment.d.ts +3 -0
  3. package/dist/assignment.js +8 -2
  4. package/dist/batch-checks.d.ts +15 -0
  5. package/dist/batch-checks.js +244 -0
  6. package/dist/batch-policy.d.ts +23 -0
  7. package/dist/batch-policy.js +25 -0
  8. package/dist/browser/workspace.js +13 -13
  9. package/dist/builder.js +14 -3
  10. package/dist/chat-actions.js +2 -2
  11. package/dist/check-levels-ui.d.ts +4 -0
  12. package/dist/check-levels-ui.js +21 -2
  13. package/dist/checkout-cleanup.d.ts +2 -1
  14. package/dist/checkout-cleanup.js +24 -8
  15. package/dist/coding-context.js +2 -2
  16. package/dist/coding-handoff.js +2 -2
  17. package/dist/desktop-update.d.ts +1 -1
  18. package/dist/exec.d.ts +2 -0
  19. package/dist/exec.js +11 -0
  20. package/dist/guides.js +3 -0
  21. package/dist/knowledge-cli.d.ts +31 -0
  22. package/dist/knowledge-cli.js +35 -2
  23. package/dist/knowledge-ui.d.ts +1 -1
  24. package/dist/knowledge-ui.js +7 -0
  25. package/dist/lead-capabilities.d.ts +0 -7
  26. package/dist/lead-capabilities.js +11 -1
  27. package/dist/mate-doors.js +2 -2
  28. package/dist/mate.d.ts +6 -2
  29. package/dist/mate.js +63 -38
  30. package/dist/never-index.d.ts +3 -0
  31. package/dist/never-index.js +21 -0
  32. package/dist/operate.js +99 -5
  33. package/dist/orphan-sweep.d.ts +63 -0
  34. package/dist/orphan-sweep.js +235 -0
  35. package/dist/project-delete.js +1 -1
  36. package/dist/project-knowledge.d.ts +28 -3
  37. package/dist/project-knowledge.js +64 -25
  38. package/dist/project-learning.d.ts +33 -0
  39. package/dist/project-learning.js +105 -15
  40. package/dist/project-skills.js +27 -21
  41. package/dist/result-follow-ups.d.ts +22 -2
  42. package/dist/result-follow-ups.js +44 -10
  43. package/dist/serve.js +33 -11
  44. package/dist/shared-deps.d.ts +18 -2
  45. package/dist/shared-deps.js +129 -15
  46. package/dist/staged-runtimes.d.ts +42 -0
  47. package/dist/staged-runtimes.js +191 -0
  48. package/dist/storage-sweep.d.ts +50 -0
  49. package/dist/storage-sweep.js +203 -0
  50. package/dist/storage-ui.d.ts +5 -0
  51. package/dist/storage-ui.js +30 -4
  52. package/dist/storage.d.ts +2 -0
  53. package/dist/storage.js +15 -3
  54. package/dist/store.d.ts +17 -2
  55. package/dist/store.js +37 -5
  56. package/dist/subscription-chat.d.ts +3 -1
  57. package/dist/subscription-chat.js +1 -0
  58. package/dist/surface.js +2 -1
  59. package/dist/task-status.d.ts +13 -0
  60. package/dist/task-status.js +37 -3
  61. package/dist/team-leads.js +6 -2
  62. package/dist/team-runtime.js +2 -1
  63. package/dist/test-temp.d.ts +20 -2
  64. package/dist/test-temp.js +53 -13
  65. package/dist/toolroll-update.js +2 -0
  66. package/dist/workspace-ui.js +3 -1
  67. package/dist/worktree.js +9 -0
  68. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.33 — 2026-10-05
4
+
5
+ - **A restart never breaks a project's builds.** A project's identity no
6
+ longer includes the disk's device number, which macOS can change at restart.
7
+ Saved project knowledge from before such a change no longer stops a build:
8
+ the build runs without it, and one notice offers to apply it again (Knowledge
9
+ page, or `toolroll knowledge apply`). When it is provably the same repository,
10
+ it carries over by itself.
11
+
12
+ ## 0.9.32 — 2026-10-04
13
+
14
+ - **Lighter on your computer.** A version bump no longer reinstalls every
15
+ dependency (about 22,000 files each time); it reuses the install. Tests and
16
+ checks remove their temp folders and stop what they started however they
17
+ end, and Toolroll sweeps what's left once a day by itself: old test folders,
18
+ processes a finished run left behind, finished release checkouts, extra
19
+ staged versions and unused installs. Spotlight skips Toolroll's folders.
20
+ - **The release check fits the machine.** Each part waits for memory and a
21
+ free model slot (4 real turns at once by default) instead of starting
22
+ everything at once, so a busy computer makes it slower, not failed. Most
23
+ browser journeys now run against a scripted model; real models run when
24
+ model-facing code changes and nightly. A UI-only check went from 9.3 to 5.8
25
+ minutes and from 95 real model turns to none.
26
+ - **The lead never goes silent.** A reply that runs past its time ends, says
27
+ it took too long, keeps any proposal it made, and frees the chat for your
28
+ next message.
29
+ - **Batch checks.** Projects that check every build can check results that
30
+ finish together once, on a temporary combined commit, and split the batch if
31
+ it fails. Off unless you turn it on for a project.
32
+
3
33
  ## 0.9.31 — 2026-10-04
4
34
 
5
35
  - **Flow templates for your integrations.** Nine new templates put connected
@@ -4,6 +4,7 @@ import { type VerifiedApprover } from "./principal.js";
4
4
  import { type CheckLevel } from "./check-levels.js";
5
5
  import { type BuildReviewView } from "./review-switch.js";
6
6
  import { type NeedKey, type WaitKey } from "./needs-you.js";
7
+ import { type ChecksBatch } from "./task-status.js";
7
8
  import { type LeadClaim } from "./lead-voice.js";
8
9
  export type AssignmentAccess = WorkSummaryAccess;
9
10
  export type AssignmentOwner = {
@@ -21,6 +22,8 @@ export type AssignmentChecks = {
21
22
  level?: CheckLevel | null;
22
23
  /** A follow-up check waiting or running on this commit. */
23
24
  running?: "quick" | "full" | null;
25
+ /** A batch check (batch-checks.ts): waiting for its batch, or how it was checked. */
26
+ batch?: ChecksBatch | null;
24
27
  };
25
28
  export type AssignmentReceipt = {
26
29
  digest: string;
@@ -51,7 +51,7 @@ function olderStoreSafe(read, absent) {
51
51
  }
52
52
  }
53
53
  /** A check as the receipt digest seals it: presentation-only fields (check-levels.ts) left out. */
54
- const sealedChecks = ({ level: _level, running: _running, ...proof }) => proof;
54
+ const sealedChecks = ({ level: _level, running: _running, batch: _batch, ...proof }) => proof;
55
55
  const OWNER_ACTION = "assignment claimed";
56
56
  const CHECK_ACTION = COMPLETION_ACTION;
57
57
  const actorOf = (owner) => `${owner.kind}:${owner.id}`;
@@ -93,7 +93,13 @@ function nativeChecks(store, root, runId, now) {
93
93
  const latest = [...followUps].reverse().find(one => one.state === "passed" || one.state === "failed");
94
94
  const changed = read.status !== own.status || read.level !== (own.level ?? null) || read.exitCode !== own.exitCode;
95
95
  const words = read.level === "quick" ? "Quick checks" : "Checks";
96
- return { ...own, running: read.running,
96
+ // A batch check: waiting for its batch, or how the latest finished one was checked and on which commit.
97
+ const waitingBatch = followUps.some(one => one.why === "batch" && one.state === "waiting");
98
+ const sealedBatch = latest?.batch ?? null;
99
+ const batch = waitingBatch ? { state: "waiting", tested: null, peers: [] }
100
+ : sealedBatch !== null && latest !== undefined ? { state: sealedBatch.mode, tested: latest.tested ?? sealedBatch.tested,
101
+ peers: sealedBatch.members.filter(one => one.run !== runId).map(one => one.task) } : null;
102
+ return { ...own, running: read.running, ...(batch === null ? {} : { batch }),
97
103
  ...(changed ? { status: read.status, level: read.level, exitCode: read.exitCode, logArtifactId: latest?.logArtifactId ?? own.logArtifactId,
98
104
  detail: read.status === "passed" ? `${words} passed.` : `${words} failed (exit ${read.exitCode}).` } : {}) };
99
105
  }
@@ -0,0 +1,15 @@
1
+ import type { Store, VerifyCommand } from "./store.js";
2
+ import { type CheckRunDeps } from "./result-follow-ups.js";
3
+ /** Queue a finished build's commit for a batch check, bound to the exact approved command. Once per commit. */
4
+ export declare function queueBatchCheck(store: Store, input: {
5
+ runId: number;
6
+ taskId: string;
7
+ repo: string;
8
+ head: string;
9
+ base: string | null;
10
+ command: VerifyCommand;
11
+ }, now: Date): number;
12
+ /** The batch pass: settle what can't be checked, then check every cohort that is ready. Returns how many results it settled. */
13
+ export declare function runBatchChecks(store: Store, root: string, deps?: CheckRunDeps & {
14
+ shouldStop?: () => boolean;
15
+ }): Promise<number>;
@@ -0,0 +1,244 @@
1
+ /** Batch checks: like a merge queue, for projects that run the full check after every build.
2
+ *
3
+ * With batching on (a per-project setting, off by default), a Full build doesn't run its check
4
+ * inline. It asks for a batch check on its exact commit, and waits up to the window (10 minutes)
5
+ * for another result of the same project and the same approved command. The batch pass then
6
+ * merges the waiting results onto the recorded project base in a temporary, detached checkout
7
+ * and runs the approved check once:
8
+ *
9
+ * - it passes: every result gets its own sealed receipt, "checked together with" the others,
10
+ * naming the exact batch commit it tested;
11
+ * - it fails: the batch is split in halves and each half checked, down to single results, so the
12
+ * one that breaks it is found and the others still pass on their own;
13
+ * - the results conflict when merged: each is checked on its own.
14
+ *
15
+ * Nothing is merged into a real branch. The batch commit exists only in the temporary checkout,
16
+ * which is always removed; each result still lands on its own.
17
+ *
18
+ * The queue is the follow-up check ledger (result-follow-ups.ts), with `batch` requests: the same
19
+ * claim, receipts, abandoned-check reading, status projection and Merge gate. The policy is an
20
+ * append-only ledger entry, like the check level. */
21
+ import { mkdtempSync, rmSync } from "node:fs";
22
+ import { tmpdir } from "node:os";
23
+ import { join } from "node:path";
24
+ import { run as execRun } from "./exec.js";
25
+ import { approvedCommandShell, SETUP_ENV_ALLOWLIST, SETUP_ENV_DENYLIST } from "./builder.js";
26
+ import { runWithIsolatedDatabase } from "./child-database.js";
27
+ import { boundStreamHeadTail, redactSecretLines, scanForSecrets, storeEvidence } from "./evidence.js";
28
+ import { repoClause } from "./check-levels.js";
29
+ import { projectBatchChecks } from "./batch-policy.js";
30
+ import { CHECKS_FINISHED, CHECKS_REQUESTED, CHECKS_STARTED, FOLLOW_UP_LOG, FOLLOW_UP_RECEIPT, followUpChecksOf, waitingRequestsOfAnyKind, } from "./result-follow-ups.js";
31
+ /** Queue a finished build's commit for a batch check, bound to the exact approved command. Once per commit. */
32
+ export function queueBatchCheck(store, input, now) {
33
+ return store.transact(() => {
34
+ const [where, params] = repoClause(input.repo);
35
+ const existing = store.handle.prepare(`SELECT id FROM action_ledger WHERE ${where} AND run_id = ? AND action = ? AND detail LIKE ? ORDER BY id LIMIT 1`)
36
+ .get(...params, input.runId, CHECKS_REQUESTED, `${input.head} · batch · %`);
37
+ if (existing !== undefined)
38
+ return Number(existing["id"]);
39
+ const id = store.recordAction({ at: now.toISOString(), actor: "system", repo: input.repo, taskId: input.taskId, runId: input.runId, action: CHECKS_REQUESTED, outcome: "full",
40
+ source: "work", detail: `${input.head} · batch · ${input.command.digest} · ${input.base !== null && /^[a-f0-9]{40}$/.test(input.base) ? input.base : ""}` });
41
+ store.bumpWake();
42
+ return id;
43
+ });
44
+ }
45
+ /** The batch pass: settle what can't be checked, then check every cohort that is ready. Returns how many results it settled. */
46
+ export async function runBatchChecks(store, root, deps = {}) {
47
+ const now = deps.now ?? (() => new Date());
48
+ const waiting = waitingRequestsOfAnyKind(store, now()).filter(one => one.why === "batch");
49
+ const cohorts = new Map();
50
+ let settled = 0;
51
+ for (const one of waiting) {
52
+ const run = store.getRun(one.runId);
53
+ const ref = run === null ? null : store.refById(run.taskRef);
54
+ if (run === null || ref === null || ref.repo === null)
55
+ continue;
56
+ // Not finished yet: the build is still sealing its result.
57
+ if (run.finishedAt === null)
58
+ continue;
59
+ const finish = (detail) => { claim(store, one, ref.repo, ref.externalId, 0, now()) && finishOne(store, one, ref.repo, ref.externalId, "not-run", detail, now()); settled++; };
60
+ if (run.role !== "builder" || run.outcome !== "built" || run.headRevision !== one.head) {
61
+ finish("The result stopped or changed before its batch check.");
62
+ continue;
63
+ }
64
+ const live = store.liveVerifyCommand(ref.repo);
65
+ if (live === null || live.digest !== one.digest) {
66
+ finish("The approved check changed while it waited. Run checks to check it now.");
67
+ continue;
68
+ }
69
+ const key = `${ref.repo}\u0000${one.digest}`;
70
+ cohorts.set(key, [...(cohorts.get(key) ?? []), { request: one.request, runId: one.runId, task: ref.externalId, head: one.head, base: one.base ?? null, at: one.at }]);
71
+ }
72
+ for (const [key, members] of cohorts) {
73
+ if (deps.shouldStop?.() === true)
74
+ break;
75
+ const repo = key.split("\u0000")[0];
76
+ const policy = projectBatchChecks(store, repo);
77
+ const oldest = Math.min(...members.map(one => Date.parse(one.at)));
78
+ // A cohort closes when a second result joins it, or when its oldest has waited the window.
79
+ // Turned off while waiting: no point waiting any longer.
80
+ if (members.length < 2 && policy.on && now().getTime() - oldest < policy.windowMs)
81
+ continue;
82
+ if (policy.on)
83
+ settled += await checkCohort(store, root, repo, members, deps);
84
+ else
85
+ for (const one of members)
86
+ settled += await checkCohort(store, root, repo, [one], deps);
87
+ }
88
+ return settled;
89
+ }
90
+ function claim(store, one, repo, task, limitMs, at) {
91
+ return store.transact(() => {
92
+ if (store.handle.prepare("SELECT 1 FROM action_ledger WHERE run_id = ? AND action = ? AND outcome = ?").get(one.runId, CHECKS_STARTED, String(one.request)) !== undefined)
93
+ return false;
94
+ store.recordAction({ at: at.toISOString(), actor: "system", repo, taskId: task, runId: one.runId, action: CHECKS_STARTED, outcome: String(one.request), source: "work", detail: `limit ${limitMs} · batch` });
95
+ return true;
96
+ });
97
+ }
98
+ function finishOne(store, one, repo, task, status, detail, at) {
99
+ store.recordAction({ at: at.toISOString(), actor: "system", repo, taskId: task, runId: one.runId, action: CHECKS_FINISHED, outcome: `${one.request}:${status}`, source: "work", detail });
100
+ store.bumpWake();
101
+ return true;
102
+ }
103
+ /** Check one cohort. Every member is claimed first; every claimed member ends with a sealed receipt. */
104
+ async function checkCohort(store, root, repo, cohort, deps) {
105
+ const now = deps.now ?? (() => new Date());
106
+ const runner = deps.runner ?? execRun;
107
+ const git = deps.git ?? execRun;
108
+ const command = store.liveVerifyCommand(repo);
109
+ if (command === null)
110
+ return 0;
111
+ const setup = store.liveWorktreeSetup(repo);
112
+ // The longest a full split could take: every subset checked once, with its setup.
113
+ const limit = (command.timeoutMs + (setup?.timeoutMs ?? 0) + 240_000) * Math.max(1, 2 * cohort.length - 1);
114
+ const members = cohort.filter(one => claim(store, one, repo, one.task, limit, now())).sort((a, b) => a.request - b.request);
115
+ if (members.length === 0)
116
+ return 0;
117
+ // The project base: the oldest member's recorded base, else its own parent.
118
+ const base = members[0].base;
119
+ const logs = new Map(members.map(one => [one.request, []]));
120
+ const attempts = new Map(members.map(one => [one.request, []]));
121
+ const bound = (text) => { const hits = scanForSecrets(text); return boundStreamHeadTail(hits.length > 0 ? redactSecretLines(text, hits) : text, 12 * 1024); };
122
+ const section = (label, shown, result) => `=== ${label} ===\n$ ${shown}\n(exit ${result.code}${result.notFound ? ", could not start" : ""}${result.timedOut ? ", timed out" : ""})\n\n--- stdout ---\n${bound(result.stdout)}\n\n--- stderr ---\n${bound(result.stderr)}`;
123
+ const note = (set, text) => { for (const one of set)
124
+ logs.get(one.request).push(text); };
125
+ const attempt = (set, tested, outcome) => { for (const one of set)
126
+ attempts.get(one.request).push({ members: set.map(m => m.task), tested, outcome }); };
127
+ /** One approved check of `set` in a temporary detached checkout: the merge of every member onto the base,
128
+ * or a single member's own commit. Never a real ref: the checkout is detached and always removed. */
129
+ const checkOnce = async (set) => {
130
+ const folder = mkdtempSync(join(deps.scratch ?? tmpdir(), "toolroll-batch-"));
131
+ const checkout = join(folder, "checkout");
132
+ const label = set.length === 1 ? `${set[0].task} on its own` : `Batch of ${set.length}: ${set.map(one => one.task).join(", ")}`;
133
+ try {
134
+ const start = set.length === 1 ? set[0].head : base ?? `${set[0].head}^`;
135
+ const added = await git("git", ["-C", repo, "worktree", "add", "--detach", checkout, start], { timeoutMs: 120_000 });
136
+ if (added.code !== 0) {
137
+ note(set, section(`${label} · checkout`, `git worktree add --detach <temporary folder> ${start}`, added));
138
+ return { conflict: false, tested: null, outcome: { ran: false, failure: "The commit couldn't be checked out. It may have been removed from this computer." }, setupFailed: false };
139
+ }
140
+ if (set.length > 1) {
141
+ for (const one of set) {
142
+ // A local merge commit in the detached checkout only: no hooks, no signing, no branch.
143
+ const merged = await git("git", ["-C", checkout, "-c", "user.name=Toolroll batch check", "-c", "user.email=batch-check@toolroll.invalid", "-c", "commit.gpgsign=false",
144
+ "-c", "core.hooksPath=/dev/null", "merge", "--no-ff", "--no-edit", "--no-verify", "-m", `Batch check: ${one.task}`, one.head], { timeoutMs: 120_000 });
145
+ if (merged.code !== 0) {
146
+ note(set, section(`${label} · merge ${one.task}`, `git merge --no-ff ${one.head}`, merged));
147
+ await git("git", ["-C", checkout, "merge", "--abort"], { timeoutMs: 60_000 }).catch(() => null);
148
+ return { conflict: true };
149
+ }
150
+ }
151
+ }
152
+ const head = await git("git", ["-C", checkout, "rev-parse", "HEAD"], { timeoutMs: 60_000 });
153
+ const tested = head.code === 0 && /^[a-f0-9]{40}$/.test(head.stdout.trim()) ? head.stdout.trim() : null;
154
+ if (tested === null)
155
+ return { conflict: false, tested: null, outcome: { ran: false, failure: "The batch commit couldn't be read." }, setupFailed: false };
156
+ if (deps.shouldStop?.() === true)
157
+ return { conflict: false, tested, outcome: { ran: false, failure: "Stopped before the check started." }, setupFailed: false };
158
+ // The approvals are re-read before every spawn: a revoked or changed command never runs.
159
+ const liveSetup = store.liveWorktreeSetup(repo);
160
+ if ((liveSetup?.digest ?? null) !== (setup?.digest ?? null))
161
+ return { conflict: false, tested, outcome: { ran: false, failure: "The project's approved setup changed before the check started." }, setupFailed: false };
162
+ if (liveSetup !== null) {
163
+ const shell = approvedCommandShell(liveSetup.command);
164
+ const prepared = await runWithIsolatedDatabase(runner, shell.file, shell.args, { cwd: checkout, timeoutMs: liveSetup.timeoutMs, processGroup: true, envAllowlist: SETUP_ENV_ALLOWLIST, omitEnv: SETUP_ENV_DENYLIST });
165
+ note(set, section(`${label} · approved project setup on ${tested.slice(0, 7)}`, liveSetup.command, prepared));
166
+ if (prepared.code !== 0 || prepared.timedOut || prepared.notFound)
167
+ return { conflict: false, tested, outcome: { ran: false, failure: "The project's setup failed, so the check didn't run." }, setupFailed: true };
168
+ }
169
+ const live = store.liveVerifyCommand(repo);
170
+ if (live === null || live.digest !== command.digest)
171
+ return { conflict: false, tested, outcome: { ran: false, failure: "The approved check changed before it started." }, setupFailed: false };
172
+ const shell = approvedCommandShell(live.command);
173
+ const ran = await runWithIsolatedDatabase(runner, shell.file, shell.args, { cwd: checkout, timeoutMs: live.timeoutMs, processGroup: true, envAllowlist: SETUP_ENV_ALLOWLIST, omitEnv: SETUP_ENV_DENYLIST });
174
+ note(set, section(`${label} · full check on ${tested.slice(0, 7)}`, live.command, ran));
175
+ return { conflict: false, tested, setupFailed: false,
176
+ outcome: ran.timedOut ? { ran: false, failure: "The check ran out of time." } : ran.notFound ? { ran: false, failure: "The check couldn't start." } : { ran: true, exitCode: ran.code } };
177
+ }
178
+ finally {
179
+ await git("git", ["-C", repo, "worktree", "remove", "--force", checkout], { timeoutMs: 120_000 }).catch(() => null);
180
+ rmSync(folder, { recursive: true, force: true });
181
+ }
182
+ };
183
+ const seal = (one, mode, together, tested, outcome) => {
184
+ const at = now();
185
+ const text = logs.get(one.request).length === 0 ? "No command started." : logs.get(one.request).join("\n\n");
186
+ const logId = storeEvidence(store, root, one.runId, "structured-output", `batch-check-${one.request}.log`, Buffer.from(text, "utf8"), FOLLOW_UP_LOG, at, { captureStatus: "ok" });
187
+ const logArtifact = store.getArtifact(logId);
188
+ const batch = { mode, tested: tested ?? one.head, base, members: together.map(m => ({ task: m.task, run: m.runId, head: m.head })), attempts: attempts.get(one.request) };
189
+ const receipt = JSON.stringify({ version: 1, request: one.request, run: one.runId, head: one.head, tested, level: "full",
190
+ command: { repo: command.repo, command: command.command, digest: command.digest, approvedBy: command.approvedBy },
191
+ result: outcome, batch, log: { artifactId: logArtifact.id, sha256: logArtifact.sha256, bytesStored: logArtifact.bytesStored, truncated: logArtifact.truncated } }, null, 1);
192
+ storeEvidence(store, root, one.runId, "structured-output", `batch-check-${one.request}.json`, Buffer.from(receipt, "utf8"), FOLLOW_UP_RECEIPT, at, { captureStatus: "ok" });
193
+ const peers = together.filter(m => m.request !== one.request).map(m => m.task);
194
+ const how = mode === "together" ? `together with ${peers.join(", ")}` : mode === "split" ? "on its own after its batch failed" : mode === "conflict" ? "on its own: it conflicted with another result" : "on its own";
195
+ if (outcome.ran)
196
+ finishOne(store, one, repo, one.task, outcome.exitCode === 0 ? "passed" : "failed", `Full checks ${how} on ${(tested ?? one.head).slice(0, 7)} · exit ${outcome.exitCode}`.slice(0, 300), at);
197
+ else
198
+ finishOne(store, one, repo, one.task, "not-run", outcome.failure, at);
199
+ };
200
+ const checkSet = async (set, mode) => {
201
+ if (set.length === 1) {
202
+ const one = set[0];
203
+ const result = await checkOnce(set);
204
+ const tested = result.conflict ? null : result.tested;
205
+ const outcome = result.conflict ? { ran: false, failure: "The commit couldn't be checked out." } : result.outcome;
206
+ attempt(set, tested, outcome.ran ? outcome.exitCode === 0 ? "passed" : "failed" : "not-run");
207
+ seal(one, mode, set, outcome.ran ? tested : null, outcome);
208
+ return;
209
+ }
210
+ const result = await checkOnce(set);
211
+ if (result.conflict) {
212
+ attempt(set, null, "conflict");
213
+ for (const one of set)
214
+ await checkSet([one], "conflict");
215
+ return;
216
+ }
217
+ const { tested, outcome } = result;
218
+ if (outcome.ran && outcome.exitCode === 0) {
219
+ attempt(set, tested, "passed");
220
+ for (const one of set)
221
+ seal(one, "together", set, tested, outcome);
222
+ return;
223
+ }
224
+ if ((outcome.ran && outcome.exitCode !== 0) || result.setupFailed) {
225
+ // Something in this batch breaks the check: split it until the result that breaks it is found.
226
+ attempt(set, tested, "failed");
227
+ const half = Math.ceil(set.length / 2);
228
+ await checkSet(set.slice(0, half), "split");
229
+ await checkSet(set.slice(half), "split");
230
+ return;
231
+ }
232
+ // It couldn't run at all (time, approvals, a stop): nothing is known about any member.
233
+ attempt(set, tested, "not-run");
234
+ for (const one of set)
235
+ seal(one, mode, [one], null, outcome);
236
+ };
237
+ await checkSet(members, "alone").catch(error => {
238
+ // An unexpected failure still ends every claimed member, visibly.
239
+ for (const one of members)
240
+ if (followUpChecksOf(store, one.runId, now()).find(f => f.request === one.request)?.state === "running")
241
+ finishOne(store, one, repo, one.task, "not-run", `The batch check failed to run: ${String(error).slice(0, 200)}`, now());
242
+ });
243
+ return members.length;
244
+ }
@@ -0,0 +1,23 @@
1
+ /** Batch checks, the setting (batch-checks.ts runs them): off unless an approver turns it on for a project.
2
+ * Like the check level, it is an append-only ledger entry; the newest one wins. Kept apart from the
3
+ * runner so settings, status and the lead's facts can read it without loading the builder. */
4
+ import type { Store, VerifyCommand } from "./store.js";
5
+ /** Ledger action for the project setting. The newest entry wins; none means off. */
6
+ export declare const BATCH_POLICY_ACTION = "batch checks changed";
7
+ export declare const BATCH_WINDOW_MS: number;
8
+ export declare const BATCH_HINT = "Results that finish within 10 minutes of each other share one full check.";
9
+ export type ProjectBatchChecks = {
10
+ on: boolean;
11
+ windowMs: number;
12
+ setBy: string | null;
13
+ at: string | null;
14
+ };
15
+ /** A project's batch setting. Off unless an approver turned it on. */
16
+ export declare function projectBatchChecks(store: Store, repo: string): ProjectBatchChecks;
17
+ /** An approver's act: turn batching on or off for one project. The ledger keeps before → after. */
18
+ export declare function setProjectBatchChecks(store: Store, repo: string, on: boolean, by: string, now: Date): {
19
+ changed: boolean;
20
+ before: boolean;
21
+ };
22
+ /** The builder's question at its gate: does this Full check wait for a batch? */
23
+ export declare function batchesFullCheck(store: Store, repo: string | null, level: string, command: VerifyCommand | null): boolean;
@@ -0,0 +1,25 @@
1
+ /** Ledger action for the project setting. The newest entry wins; none means off. */
2
+ export const BATCH_POLICY_ACTION = "batch checks changed";
3
+ export const BATCH_WINDOW_MS = 10 * 60_000;
4
+ export const BATCH_HINT = "Results that finish within 10 minutes of each other share one full check.";
5
+ /** A project's batch setting. Off unless an approver turned it on. */
6
+ export function projectBatchChecks(store, repo) {
7
+ const row = store.handle.prepare("SELECT actor, at, outcome FROM action_ledger WHERE repo = ? AND task_id IS NULL AND action = ? ORDER BY id DESC LIMIT 1").get(repo, BATCH_POLICY_ACTION);
8
+ if (row === undefined || (row["outcome"] !== "on" && row["outcome"] !== "off"))
9
+ return { on: false, windowMs: BATCH_WINDOW_MS, setBy: null, at: null };
10
+ return { on: row["outcome"] === "on", windowMs: BATCH_WINDOW_MS, setBy: String(row["actor"]), at: String(row["at"]) };
11
+ }
12
+ /** An approver's act: turn batching on or off for one project. The ledger keeps before → after. */
13
+ export function setProjectBatchChecks(store, repo, on, by, now) {
14
+ const before = projectBatchChecks(store, repo);
15
+ if (before.on === on)
16
+ return { changed: false, before: before.on };
17
+ store.recordAction({ at: now.toISOString(), actor: by, repo, taskId: null, runId: null, action: BATCH_POLICY_ACTION, outcome: on ? "on" : "off", source: "policy",
18
+ detail: `Batch checks ${before.on ? "on" : "off"} → ${on ? "on" : "off"}` });
19
+ store.bumpWake();
20
+ return { changed: true, before: before.on };
21
+ }
22
+ /** The builder's question at its gate: does this Full check wait for a batch? */
23
+ export function batchesFullCheck(store, repo, level, command) {
24
+ return repo !== null && level === "full" && command !== null && command.repo === repo && projectBatchChecks(store, repo).on;
25
+ }