toolroll 0.9.6 → 0.9.8

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 (102) hide show
  1. package/CHANGELOG.md +64 -1
  2. package/dist/actor.d.ts +21 -0
  3. package/dist/actor.js +48 -0
  4. package/dist/assignment-adapters.d.ts +2 -0
  5. package/dist/assignment-adapters.js +2 -0
  6. package/dist/assignment-ui.js +3 -1
  7. package/dist/assignment.d.ts +15 -0
  8. package/dist/assignment.js +80 -16
  9. package/dist/browser/workspace.css +2 -2
  10. package/dist/browser/workspace.js +28 -24
  11. package/dist/browser-workspace.d.ts +117 -2
  12. package/dist/build-review.d.ts +38 -0
  13. package/dist/build-review.js +341 -0
  14. package/dist/builder.d.ts +1 -0
  15. package/dist/builder.js +33 -13
  16. package/dist/chat-actions.js +2 -1
  17. package/dist/chat-delivery.js +2 -0
  18. package/dist/chat-quiet.js +11 -1
  19. package/dist/chat-task-actions.d.ts +13 -0
  20. package/dist/chat-task-actions.js +19 -1
  21. package/dist/check-levels-ui.d.ts +26 -0
  22. package/dist/check-levels-ui.js +84 -0
  23. package/dist/check-levels.d.ts +67 -0
  24. package/dist/check-levels.js +171 -0
  25. package/dist/claim.d.ts +22 -0
  26. package/dist/claim.js +43 -0
  27. package/dist/cli.d.ts +1 -1
  28. package/dist/cli.js +6 -0
  29. package/dist/demo-chat.js +4 -1
  30. package/dist/dispatch.js +9 -3
  31. package/dist/dispose.d.ts +12 -3
  32. package/dist/dispose.js +19 -5
  33. package/dist/exec.d.ts +5 -0
  34. package/dist/exec.js +30 -4
  35. package/dist/export.js +3 -2
  36. package/dist/first-run.d.ts +34 -0
  37. package/dist/first-run.js +37 -1
  38. package/dist/flow-gallery-ui.d.ts +30 -0
  39. package/dist/flow-gallery-ui.js +85 -0
  40. package/dist/flow-gallery.d.ts +111 -0
  41. package/dist/flow-gallery.js +445 -0
  42. package/dist/flow-share.d.ts +102 -0
  43. package/dist/flow-share.js +614 -0
  44. package/dist/flow-starters.d.ts +2 -0
  45. package/dist/flow-starters.js +1 -1
  46. package/dist/flow-steps.js +7 -0
  47. package/dist/flow-triggers.js +2 -0
  48. package/dist/flows-cli.d.ts +56 -0
  49. package/dist/flows-cli.js +109 -3
  50. package/dist/flows-ui.d.ts +15 -1
  51. package/dist/flows-ui.js +31 -6
  52. package/dist/guides.js +27 -2
  53. package/dist/invoke.js +5 -0
  54. package/dist/lead-context.js +6 -2
  55. package/dist/mate-doors.d.ts +0 -15
  56. package/dist/mate-doors.js +5 -1
  57. package/dist/mate-tools.js +9 -4
  58. package/dist/models-ui.js +1 -1
  59. package/dist/modes.d.ts +3 -1
  60. package/dist/modes.js +3 -1
  61. package/dist/operate.d.ts +4 -1
  62. package/dist/operate.js +711 -98
  63. package/dist/process-custody.js +8 -0
  64. package/dist/project-concurrency.d.ts +62 -0
  65. package/dist/project-concurrency.js +116 -0
  66. package/dist/project-delete-ui.d.ts +7 -0
  67. package/dist/project-delete-ui.js +19 -2
  68. package/dist/project-delete.js +4 -0
  69. package/dist/provider.js +16 -0
  70. package/dist/pull-request-flow.d.ts +9 -0
  71. package/dist/pull-request-flow.js +21 -3
  72. package/dist/result-follow-ups.d.ts +101 -0
  73. package/dist/result-follow-ups.js +333 -0
  74. package/dist/retention-ui.d.ts +3 -1
  75. package/dist/retention-ui.js +4 -4
  76. package/dist/retention.d.ts +13 -6
  77. package/dist/retention.js +50 -12
  78. package/dist/review-switch.d.ts +41 -0
  79. package/dist/review-switch.js +88 -0
  80. package/dist/reviewer.d.ts +24 -2
  81. package/dist/reviewer.js +49 -1
  82. package/dist/scope.js +11 -0
  83. package/dist/serve.d.ts +211 -2
  84. package/dist/serve.js +1038 -145
  85. package/dist/store.d.ts +112 -4
  86. package/dist/store.js +447 -27
  87. package/dist/surface.js +36 -9
  88. package/dist/tailnet.d.ts +11 -0
  89. package/dist/tailnet.js +47 -0
  90. package/dist/task-outcome-cli.js +2 -2
  91. package/dist/task-status.d.ts +13 -3
  92. package/dist/task-status.js +38 -16
  93. package/dist/team-cli.js +3 -0
  94. package/dist/telegram-status.js +2 -2
  95. package/dist/verification-evidence.js +21 -1
  96. package/dist/webhooks.js +5 -0
  97. package/dist/work-index.js +39 -6
  98. package/dist/work-summary.d.ts +0 -4
  99. package/dist/work-summary.js +4 -1
  100. package/dist/workspace-ui.d.ts +6 -1
  101. package/dist/workspace-ui.js +14 -11
  102. package/package.json +1 -1
@@ -0,0 +1,333 @@
1
+ /** Follow-ups on any result: "Run checks" and "Add tests".
2
+ *
3
+ * Run checks runs the project's quick or full check on the result's exact
4
+ * commit, in a fresh checkout, after the build has finished. A person (or a
5
+ * pull request opening) asks; the ask is a ledger entry; a worker runs it
6
+ * once and seals its log and a receipt beside the result. A pass upgrades
7
+ * what the result's Checks row says; a failure stays visible. Nothing here
8
+ * rewrites the build's own check or reruns crew work.
9
+ *
10
+ * Add tests files a small task, unapproved like any other filing, to write
11
+ * tests for that change. */
12
+ import { mkdtempSync, rmSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { run as execRun } from "./exec.js";
16
+ import { approvedCommandShell, SETUP_ENV_ALLOWLIST, SETUP_ENV_DENYLIST } from "./builder.js";
17
+ import { runWithIsolatedDatabase } from "./child-database.js";
18
+ import { boundStreamHeadTail, readVerifiedArtifact, redactSecretLines, scanForSecrets, storeEvidence } from "./evidence.js";
19
+ import { CHECK_LEVEL_WORDS, checkCommandFor, quickVerifyKey, repoClause, repoOfRun, runCheckLevel } from "./check-levels.js";
20
+ import { fileTaskProposal } from "./proposal.js";
21
+ export const CHECKS_REQUESTED = "checks requested";
22
+ export const CHECKS_STARTED = "checks started";
23
+ export const CHECKS_FINISHED = "checks finished";
24
+ export const FOLLOW_UP_LOG = "follow-up check log v1";
25
+ export const FOLLOW_UP_RECEIPT = "follow-up check receipt v1";
26
+ export const ADD_TESTS_ACTION = "tests task filed";
27
+ /** A started check with no result after its time limit and this grace didn't finish. */
28
+ const ABANDONED_GRACE_MS = 10 * 60_000;
29
+ const refuse = (reason, message) => ({ ok: false, reason, message });
30
+ function ledgerFor(store, runId) {
31
+ const [where, params] = repoClause(repoOfRun(store, runId));
32
+ return store.handle.prepare(`SELECT id, actor, at, outcome, detail, action FROM action_ledger WHERE ${where} AND run_id = ? AND action IN (?, ?, ?) ORDER BY id`)
33
+ .all(...params, runId, CHECKS_REQUESTED, CHECKS_STARTED, CHECKS_FINISHED)
34
+ .map(row => ({ id: Number(row["id"]), actor: String(row["actor"]), at: String(row["at"]), outcome: String(row["outcome"]),
35
+ detail: row["detail"] === null ? null : String(row["detail"]), action: String(row["action"]) }));
36
+ }
37
+ /** Every follow-up check a result has had, oldest first, as the ledger and its sealed receipts say. */
38
+ export function followUpChecksOf(store, runId, now = new Date(), root) {
39
+ const rows = ledgerFor(store, runId);
40
+ const receipts = new Map();
41
+ if (root !== undefined) {
42
+ for (const artifact of store.artifactsFor(runId).filter(one => one.kind === "structured-output" && one.capture === FOLLOW_UP_RECEIPT)) {
43
+ const read = readVerifiedArtifact(root, artifact);
44
+ if (!read.ok)
45
+ continue;
46
+ try {
47
+ const body = JSON.parse(read.content.toString("utf8"));
48
+ if (typeof body.request !== "number")
49
+ continue;
50
+ receipts.set(body.request, { ran: body.result?.ran === true, exitCode: typeof body.result?.exitCode === "number" ? body.result.exitCode : null,
51
+ log: typeof body.log?.artifactId === "number" ? body.log.artifactId : null });
52
+ }
53
+ catch { /* an unreadable receipt upgrades nothing */ }
54
+ }
55
+ }
56
+ return rows.filter(row => row.action === CHECKS_REQUESTED && (row.outcome === "quick" || row.outcome === "full")).map(request => {
57
+ const [head = "", why = "person", digest = ""] = (request.detail ?? "").split(" · ");
58
+ const started = rows.find(row => row.action === CHECKS_STARTED && row.outcome === String(request.id)) ?? null;
59
+ const finished = rows.find(row => row.action === CHECKS_FINISHED && row.outcome.startsWith(`${request.id}:`)) ?? null;
60
+ const base = { request: request.id, runId, level: request.outcome, head, digest, actor: request.actor, at: request.at,
61
+ why: why === "pull-request" ? "pull-request" : "person", exitCode: null, logArtifactId: null, note: null };
62
+ if (finished !== null) {
63
+ const status = finished.outcome.slice(String(request.id).length + 1);
64
+ const sealed = receipts.get(request.id) ?? null;
65
+ // With the evidence at hand, only a sealed receipt can say passed.
66
+ if (status === "passed" && root !== undefined && (sealed === null || !sealed.ran || sealed.exitCode !== 0)) {
67
+ return { ...base, state: "not-run", note: "The saved check result could not be verified." };
68
+ }
69
+ const exit = /exit (\d+)/.exec(finished.detail ?? "");
70
+ return { ...base, state: status === "passed" ? "passed" : status === "failed" ? "failed" : "not-run",
71
+ exitCode: exit === null ? sealed?.exitCode ?? null : Number(exit[1]), logArtifactId: sealed?.log ?? null, note: status === "not-run" ? finished.detail : null };
72
+ }
73
+ if (started !== null) {
74
+ const limit = Number(/limit (\d+)/.exec(started.detail ?? "")?.[1] ?? 3_600_000);
75
+ if (now.getTime() - Date.parse(started.at) > limit + ABANDONED_GRACE_MS)
76
+ return { ...base, state: "not-run", note: "The check didn't finish." };
77
+ return { ...base, state: "running" };
78
+ }
79
+ return { ...base, state: "waiting" };
80
+ });
81
+ }
82
+ /** Whether a result can have checks run on it, and with which command. */
83
+ function checkTarget(store, runId, level) {
84
+ const run = store.getRun(runId);
85
+ const ref = run === null ? null : store.refById(run.taskRef);
86
+ if (run === null || ref === null)
87
+ return refuse("unknown", "No such result.");
88
+ if (run.finishedAt === null || run.headRevision === null || !/^[a-f0-9]{40}$/.test(run.headRevision))
89
+ return refuse("no-commit", "This result has no finished commit to check.");
90
+ if (ref.repo === null)
91
+ return refuse("unplaced", "This result isn't in a project, so there is no check to run.");
92
+ const chosen = checkCommandFor(store, ref.repo, level);
93
+ if (chosen.command === null)
94
+ return refuse("no-check", `${ref.repo} has no approved ${level === "quick" ? "quick or full" : "full"} check. Set one in Settings → Projects → Checks.`);
95
+ return { ok: true, repo: ref.repo, taskId: ref.externalId, head: run.headRevision, level: chosen.level, command: chosen.command };
96
+ }
97
+ /** Ask for checks on a result's exact commit. Asking twice while one is waiting
98
+ * or running returns the same request; a worker runs it once. */
99
+ export function requestFollowUpChecks(store, input, now) {
100
+ return store.transact(() => {
101
+ const target = checkTarget(store, input.runId, input.level);
102
+ if (!target.ok)
103
+ return target;
104
+ const open = followUpChecksOf(store, input.runId, now).find(one => one.level === target.level && one.head === target.head && (one.state === "waiting" || one.state === "running"));
105
+ if (open !== undefined)
106
+ return { ok: true, request: open.request, level: target.level, existing: true };
107
+ const request = store.recordAction({ at: now.toISOString(), actor: input.actor, repo: target.repo, taskId: target.taskId, runId: input.runId,
108
+ action: CHECKS_REQUESTED, outcome: target.level, source: "work", detail: `${target.head} · ${input.why ?? "person"} · ${target.command.digest}` });
109
+ store.bumpWake();
110
+ return { ok: true, request, level: target.level, existing: false };
111
+ });
112
+ }
113
+ /** Requests nobody has started yet, oldest first. */
114
+ export function waitingCheckRequests(store, now) {
115
+ const runs = store.handle.prepare(`SELECT DISTINCT r.run_id AS run FROM action_ledger r WHERE r.action = ? AND r.run_id IS NOT NULL
116
+ AND NOT EXISTS (SELECT 1 FROM action_ledger s WHERE s.run_id = r.run_id AND s.action = ? AND s.outcome = CAST(r.id AS TEXT)) ORDER BY r.id`)
117
+ .all(CHECKS_REQUESTED, CHECKS_STARTED).map(row => Number(row["run"]));
118
+ return runs.flatMap(runId => followUpChecksOf(store, runId, now).filter(one => one.state === "waiting"));
119
+ }
120
+ /** Run one waiting request: claim it, check out its commit, run the approved
121
+ * setup (when the project has one) and the check, seal the log and receipt. */
122
+ export async function runFollowUpCheck(store, root, request, deps = {}) {
123
+ const now = deps.now ?? (() => new Date());
124
+ const runner = deps.runner ?? execRun;
125
+ const git = deps.git ?? execRun;
126
+ const asked = store.handle.prepare("SELECT run_id FROM action_ledger WHERE id = ? AND action = ?").get(request, CHECKS_REQUESTED);
127
+ if (asked === undefined)
128
+ return null;
129
+ const runId = Number(asked["run_id"]);
130
+ const wanted = followUpChecksOf(store, runId, now()).find(one => one.request === request);
131
+ if (wanted === undefined || wanted.state !== "waiting")
132
+ return wanted ?? null;
133
+ const target = checkTarget(store, runId, wanted.level);
134
+ const finish = (status, detail) => {
135
+ store.recordAction({ at: now().toISOString(), actor: "system", repo: target.ok ? target.repo : null, taskId: target.ok ? target.taskId : null, runId,
136
+ action: CHECKS_FINISHED, outcome: `${request}:${status}`, source: "work", detail });
137
+ store.bumpWake();
138
+ };
139
+ // The claim: one started entry per request, written only while none exists.
140
+ const claimed = store.transact(() => {
141
+ if (store.handle.prepare("SELECT 1 FROM action_ledger WHERE run_id = ? AND action = ? AND outcome = ?").get(runId, CHECKS_STARTED, String(request)) !== undefined)
142
+ return false;
143
+ store.recordAction({ at: now().toISOString(), actor: "system", repo: target.ok ? target.repo : null, taskId: target.ok ? target.taskId : null, runId,
144
+ action: CHECKS_STARTED, outcome: String(request), source: "work", detail: `limit ${target.ok ? target.command.timeoutMs : 0}` });
145
+ return true;
146
+ });
147
+ if (!claimed)
148
+ return followUpChecksOf(store, runId, now(), root).find(one => one.request === request) ?? null;
149
+ if (!target.ok) {
150
+ finish("not-run", target.message);
151
+ return followUpChecksOf(store, runId, now(), root).find(one => one.request === request) ?? null;
152
+ }
153
+ // The commit asked about is the one checked, even if the result changed since.
154
+ if (target.head !== wanted.head) {
155
+ finish("not-run", "The result's commit changed after checks were asked for.");
156
+ return followUpChecksOf(store, runId, now(), root).find(one => one.request === request) ?? null;
157
+ }
158
+ const folder = mkdtempSync(join(deps.scratch ?? tmpdir(), "toolroll-checks-"));
159
+ const checkout = join(folder, "checkout");
160
+ const log = [];
161
+ const bound = (text) => { const hits = scanForSecrets(text); return boundStreamHeadTail(hits.length > 0 ? redactSecretLines(text, hits) : text, 24 * 1024); };
162
+ const note = (label, command, result) => log.push(`=== ${label} ===\n$ ${command}\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)}`);
163
+ let result;
164
+ try {
165
+ const added = await git("git", ["-C", target.repo, "worktree", "add", "--detach", checkout, target.head], { timeoutMs: 120_000 });
166
+ if (added.code !== 0) {
167
+ note("Checkout", `git worktree add --detach <fresh folder> ${target.head}`, added);
168
+ result = { ran: false, failure: "The commit couldn't be checked out. It may have been removed from this computer." };
169
+ }
170
+ else {
171
+ const setup = store.liveWorktreeSetup(target.repo);
172
+ let ready = true;
173
+ if (setup !== null) {
174
+ const shell = approvedCommandShell(setup.command);
175
+ const prepared = await runWithIsolatedDatabase(runner, shell.file, shell.args, { cwd: checkout, timeoutMs: setup.timeoutMs, processGroup: true, envAllowlist: SETUP_ENV_ALLOWLIST, omitEnv: SETUP_ENV_DENYLIST });
176
+ note("Approved project setup", setup.command, prepared);
177
+ if (prepared.code !== 0 || prepared.timedOut || prepared.notFound) {
178
+ ready = false;
179
+ result = { ran: false, failure: "The project's setup failed, so the check didn't run." };
180
+ }
181
+ }
182
+ if (ready) {
183
+ // The approval is re-read right before the check starts: a revoked or changed command never runs.
184
+ const live = store.liveVerifyCommand(target.command.repo);
185
+ if (live === null || live.digest !== target.command.digest || live.digest !== wanted.digest) {
186
+ result = { ran: false, failure: "The approved check changed before it started." };
187
+ }
188
+ else {
189
+ const shell = approvedCommandShell(live.command);
190
+ const ran = await runWithIsolatedDatabase(runner, shell.file, shell.args, { cwd: checkout, timeoutMs: live.timeoutMs, processGroup: true, envAllowlist: SETUP_ENV_ALLOWLIST, omitEnv: SETUP_ENV_DENYLIST });
191
+ note(`${CHECK_LEVEL_WORDS[target.level]} check`, live.command, ran);
192
+ result = 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 };
193
+ }
194
+ }
195
+ else
196
+ result ??= { ran: false, failure: "The project's setup failed, so the check didn't run." };
197
+ }
198
+ }
199
+ finally {
200
+ await git("git", ["-C", target.repo, "worktree", "remove", "--force", checkout], { timeoutMs: 120_000 }).catch(() => null);
201
+ rmSync(folder, { recursive: true, force: true });
202
+ }
203
+ const at = now();
204
+ const text = log.length === 0 ? "No command started." : log.join("\n\n");
205
+ const logId = storeEvidence(store, root, runId, "structured-output", `follow-up-check-${request}.log`, Buffer.from(text, "utf8"), FOLLOW_UP_LOG, at, { captureStatus: "ok" });
206
+ const logArtifact = store.getArtifact(logId);
207
+ const receipt = JSON.stringify({ version: 1, request, run: runId, head: target.head, level: target.level,
208
+ command: { repo: target.command.repo, command: target.command.command, digest: target.command.digest, approvedBy: target.command.approvedBy },
209
+ result, log: { artifactId: logArtifact.id, sha256: logArtifact.sha256, bytesStored: logArtifact.bytesStored, truncated: logArtifact.truncated } }, null, 1);
210
+ storeEvidence(store, root, runId, "structured-output", `follow-up-check-${request}.json`, Buffer.from(receipt, "utf8"), FOLLOW_UP_RECEIPT, at, { captureStatus: "ok" });
211
+ if (result.ran)
212
+ finish(result.exitCode === 0 ? "passed" : "failed", `${CHECK_LEVEL_WORDS[target.level]} checks on ${target.head.slice(0, 7)} · exit ${result.exitCode}`);
213
+ else
214
+ finish("not-run", result.failure);
215
+ return followUpChecksOf(store, runId, now(), root).find(one => one.request === request) ?? null;
216
+ }
217
+ /** The worker's pass: run every waiting request, one at a time. */
218
+ export async function runWaitingChecks(store, root, deps = {}) {
219
+ let ran = 0;
220
+ for (const one of waitingCheckRequests(store, (deps.now ?? (() => new Date()))())) {
221
+ if (deps.shouldStop?.() === true)
222
+ break;
223
+ if ((await runFollowUpCheck(store, root, one.request, deps))?.state !== "waiting")
224
+ ran++;
225
+ }
226
+ return ran;
227
+ }
228
+ /** The build's own check, then each finished follow-up in order on the same
229
+ * commit: a pass upgrades (a quick pass never replaces a full one), a failure
230
+ * is shown as it is. A follow-up that couldn't run changes nothing. */
231
+ export function withFollowUps(original, followUps) {
232
+ let reading = original;
233
+ for (const one of followUps) {
234
+ if (original.head !== null && one.head !== "" && one.head !== original.head)
235
+ continue;
236
+ if (one.state === "passed") {
237
+ if (!(reading.status === "passed" && reading.level === "full" && one.level === "quick"))
238
+ reading = { status: "passed", level: one.level, exitCode: 0, head: one.head };
239
+ }
240
+ else if (one.state === "failed")
241
+ reading = { status: "failed", level: one.level, exitCode: one.exitCode, head: one.head };
242
+ }
243
+ const running = followUps.filter(one => one.state === "waiting" || one.state === "running").at(-1)?.level ?? null;
244
+ return { ...reading, running };
245
+ }
246
+ /** The level a result's checks stand at now: the build's own level, raised by
247
+ * a follow-up that passed on the same commit. null for results from before levels. */
248
+ export function resultCheckLevel(store, runId, now) {
249
+ const level = runCheckLevel(store, runId);
250
+ const run = store.getRun(runId);
251
+ const follows = followUpChecksOf(store, runId, now);
252
+ if (follows.length === 0)
253
+ return level;
254
+ const read = withFollowUps({ status: level === "off" ? "not-run" : "passed", level, exitCode: null, head: run?.headRevision ?? null }, follows);
255
+ return read.level;
256
+ }
257
+ /** Whether this result's commit has passed the project's full check, here or in a follow-up. */
258
+ export function fullCheckPassed(reading) {
259
+ return reading.status === "passed" && (reading.level === "full" || reading.level === null);
260
+ }
261
+ export function fullCheckGate(store, runId, now) {
262
+ const level = runCheckLevel(store, runId);
263
+ const repo = repoOfRun(store, runId);
264
+ const run = store.getRun(runId);
265
+ if (level === null || level === "full" || repo === null || run === null || store.liveVerifyCommand(repo) === null)
266
+ return { state: "clear" };
267
+ const last = followUpChecksOf(store, runId, now).filter(one => one.level === "full" && one.head === run.headRevision).at(-1);
268
+ if (last === undefined)
269
+ return { state: "not-requested", message: "The full check hasn't run on this commit yet. Merge once it passes, or merge anyway." };
270
+ if (last.state === "passed")
271
+ return { state: "clear" };
272
+ if (last.state === "failed")
273
+ return { state: "failed", message: `The full check failed on this commit${last.exitCode === null ? "" : ` (exit ${last.exitCode})`}. Fix it, or merge anyway.` };
274
+ if (last.state === "not-run")
275
+ return { state: "not-run", message: `The full check didn't finish on this commit${last.note === null ? "" : `: ${last.note}`} Run it again, or merge anyway.` };
276
+ return { state: "waiting", message: "The full check is still running on this commit. Merge once it passes, or merge anyway." };
277
+ }
278
+ export const isQuickCommand = (command, repo) => command !== null && command.repo === quickVerifyKey(repo);
279
+ // ---- Add tests --------------------------------------------------------------
280
+ /** File a small task to write tests for a result's change. It is filed like any
281
+ * other task: nothing runs until a person approves it. */
282
+ export function fileAddTestsTask(store, root, input, now) {
283
+ const run = store.getRun(input.runId);
284
+ const ref = run === null ? null : store.refById(run.taskRef);
285
+ if (run === null || ref === null)
286
+ return refuse("unknown", "No such result.");
287
+ if (ref.repo === null)
288
+ return refuse("unplaced", "This result isn't in a project.");
289
+ if (run.headRevision === null || !/^[a-f0-9]{40}$/.test(run.headRevision))
290
+ return refuse("no-commit", "This result has no commit to write tests for.");
291
+ const earlier = store.handle.prepare("SELECT outcome FROM action_ledger WHERE run_id = ? AND action = ? ORDER BY id DESC LIMIT 1").get(input.runId, ADD_TESTS_ACTION);
292
+ if (earlier !== undefined && store.getTask(String(earlier["outcome"])) !== null)
293
+ return { ok: true, id: String(earlier["outcome"]), existing: true };
294
+ const task = store.getTask(ref.externalId);
295
+ const files = root === null ? [] : changedFilesOf(store, root, input.runId);
296
+ const shown = files.slice(0, 12);
297
+ const title = `Add tests for ${task?.title ?? ref.externalId}`.slice(0, 200);
298
+ const goal = [
299
+ `Write tests for the change made by ${ref.externalId} (commit ${run.headRevision.slice(0, 7)}).`,
300
+ shown.length === 0 ? "" : `Changed files: ${shown.join(", ")}${files.length > shown.length ? `, and ${files.length - shown.length} more` : ""}.`,
301
+ "Cover the changed behaviour, including its edge cases. Change product code only if a test shows a real bug, and say so.",
302
+ ].filter(one => one !== "").join(" ");
303
+ const filed = store.transact(() => {
304
+ const made = fileTaskProposal(store, {
305
+ title, repo: ref.repo, goal, planning: "skip", filedVia: input.filedVia,
306
+ acceptance: [{ id: "c1", statement: "New tests cover the changed behaviour and pass", evidence: ["check"], how: null }],
307
+ ...(input.admittedRepos === undefined ? {} : { admittedRepos: input.admittedRepos }),
308
+ }, now);
309
+ if (!made.ok)
310
+ return made;
311
+ store.recordAction({ at: now.toISOString(), actor: input.actor, repo: ref.repo, taskId: ref.externalId, runId: input.runId, action: ADD_TESTS_ACTION, outcome: made.id, source: "request",
312
+ detail: `Filed ${made.id}` });
313
+ return made;
314
+ });
315
+ if (!filed.ok)
316
+ return refuse(filed.reason, filed.message);
317
+ return { ok: true, id: filed.id, existing: false };
318
+ }
319
+ function changedFilesOf(store, root, runId) {
320
+ const stat = store.artifactsFor(runId).find(one => one.kind === "diff-stat");
321
+ if (stat === undefined)
322
+ return [];
323
+ const read = readVerifiedArtifact(root, stat);
324
+ if (!read.ok)
325
+ return [];
326
+ try {
327
+ const parsed = JSON.parse(read.content.toString("utf8"));
328
+ return (parsed.files ?? []).map(one => one.path).filter((one) => typeof one === "string" && one.length < 300);
329
+ }
330
+ catch {
331
+ return [];
332
+ }
333
+ }
@@ -1,7 +1,9 @@
1
- import { type RetentionCount, type RetentionPeriods } from "./retention.js";
1
+ import { type RetentionCount, type RetentionKind, type RetentionPeriods } from "./retention.js";
2
2
  export declare const RETENTION_CSS: string;
3
+ /** `chosen`: the kinds someone chose a period for; the rest keep their default. */
3
4
  export type RetentionView = {
4
5
  periods: RetentionPeriods;
6
+ chosen: readonly RetentionKind[];
5
7
  next: RetentionCount[];
6
8
  lastSweep: string | null;
7
9
  csrf: string;
@@ -4,7 +4,7 @@
4
4
  * take a step-up.
5
5
  */
6
6
  import { bytesWords } from "./storage.js";
7
- import { PERIOD_CHOICES, RETENTION_KINDS, countWords, periodWords } from "./retention.js";
7
+ import { RETENTION_KINDS, countWords, periodChoices, periodLabel } from "./retention.js";
8
8
  const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
9
9
  export const RETENTION_CSS = `.retention{max-width:720px;min-width:0}.retention-next{margin:0 0 16px}` +
10
10
  `.retention form{display:grid;gap:0}.retention .kind{display:grid;grid-template-columns:minmax(0,1fr) 11rem;gap:4px 16px;align-items:center;padding:12px 0;border-bottom:1px solid var(--border)}` +
@@ -26,8 +26,8 @@ export function retentionHtml(view, notice) {
26
26
  const note = notice.problem ? `<p class="problem" role="alert">${e(notice.problem)}</p>` : notice.said ? `<p role="status">${e(notice.said)}</p>` : "";
27
27
  const rows = RETENTION_KINDS.map(({ kind, label, detail }) => {
28
28
  const current = view.periods[kind];
29
- const choices = PERIOD_CHOICES.includes(current) ? PERIOD_CHOICES : [...PERIOD_CHOICES.slice(0, -1), current, null].sort((a, b) => (a ?? Infinity) - (b ?? Infinity));
30
- const options = choices.map(days => `<option value="${days === null ? "forever" : days}"${days === current ? " selected" : ""}>${days === null ? "Forever" : `${periodWords(days)[0].toUpperCase()}${periodWords(days).slice(1)}`}</option>`).join("");
29
+ const chosen = view.chosen.includes(kind);
30
+ const options = periodChoices(kind, current).map(days => `<option value="${days === null ? "forever" : days}"${days === current ? " selected" : ""}>${e(periodLabel(kind, days, chosen || days !== current))}</option>`).join("");
31
31
  return `<div class="kind" data-kind="${kind}"><label class="name" for="keep-${kind}">${e(label)}</label><p class="meta">${e(detail)}</p><select id="keep-${kind}" name="${kind}">${options}</select></div>`;
32
32
  }).join("");
33
33
  const last = view.lastSweep === null ? "" : ` Last sweep ${e(view.lastSweep.slice(0, 10))}.`;
@@ -35,5 +35,5 @@ export function retentionHtml(view, notice) {
35
35
  `<form method="post" action="/settings/retention"><input type="hidden" name="csrf" value="${e(view.csrf)}">${rows}` +
36
36
  `<label class="step-up">Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>` +
37
37
  `<button type="submit">Save</button></form>` +
38
- `<p class="meta">Never removed: the action ledger, and anything a task still needs (unfinished tasks, results not yet completed, anything on hold).</p></article>`;
38
+ `<p class="meta">Never removed: the action ledger, and anything a task still needs (unfinished tasks, results Ready for review, anything on hold).</p></article>`;
39
39
  }
@@ -1,5 +1,8 @@
1
- import type { Store } from "./store.js";
2
- export declare const RETENTION_SCHEMA = "\nCREATE TABLE IF NOT EXISTS retention_setting (\n kind TEXT PRIMARY KEY CHECK (kind IN ('evidence', 'checkouts', 'chat', 'notifications')),\n -- NULL is forever; a row only exists once someone chose.\n days INTEGER CHECK (days IS NULL OR (days >= 7 AND days <= 3650)),\n updated_by TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n";
1
+ import type { Database, Store } from "./store.js";
2
+ export declare const RETENTION_SCHEMA: string;
3
+ /** A file from before 1-day evidence keeps `days >= 7` in its CHECK: rebuild the table with the wider one, keeping
4
+ * every row, in one transaction. A file already widened is untouched. */
5
+ export declare function widenRetentionSchema(db: Database): void;
3
6
  export type RetentionKind = "evidence" | "checkouts" | "chat" | "notifications";
4
7
  export type RetentionPeriods = Record<RetentionKind, number | null>;
5
8
  export declare const RETENTION_KINDS: readonly {
@@ -7,19 +10,23 @@ export declare const RETENTION_KINDS: readonly {
7
10
  label: string;
8
11
  detail: string;
9
12
  }[];
10
- /** The periods the page offers; the command line takes any number of days from 7 to 3650. */
11
- export declare const PERIOD_CHOICES: readonly (number | null)[];
12
- export declare const MIN_DAYS = 7;
13
+ export declare const PERIOD_CHOICES: Readonly<Record<RetentionKind, readonly (number | null)[]>>;
14
+ export declare const MIN_DAYS = 1;
13
15
  export declare const MAX_DAYS = 3650;
14
16
  /** A sweep is due once a day. */
15
17
  export declare const SWEEP_EVERY_MS = 86400000;
16
18
  /** At most this many runs, messages, notifications or records go per kind per sweep; the next sweep takes the rest. */
17
19
  export declare const SWEEP_BATCH = 2000;
18
- export declare const FOREVER: RetentionPeriods;
20
+ /** What each kind keeps until someone chooses: evidence 28 days, everything else forever. */
21
+ export declare const DEFAULT_PERIODS: RetentionPeriods;
19
22
  export declare function isRetentionKind(value: string): value is RetentionKind;
20
23
  /** "forever", or a number of days ("90", "90d"), weeks ("12w") or years ("1y"); undefined when it's none of those or out of range. */
21
24
  export declare function parsePeriod(text: string): number | null | undefined;
22
25
  export declare function periodWords(days: number | null): string;
26
+ /** What the page offers for `kind`, with `current` among them (in order) when it was set some other way. */
27
+ export declare function periodChoices(kind: RetentionKind, current: number | null): readonly (number | null)[];
28
+ /** A period as the page and `retention show` say it: "28 days (default)" when nobody chose, "30 days (custom)" when it's not one the page offers. */
29
+ export declare function periodLabel(kind: RetentionKind, days: number | null, chosen: boolean): string;
23
30
  export type RetentionCount = {
24
31
  kind: RetentionKind;
25
32
  days: number | null;
package/dist/retention.js CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Retention (v105): how long Toolroll keeps run evidence and logs,
3
- * finished checkouts' records, chat messages and notifications. Each kind
4
- * keeps everything ("forever") until an instance operator chooses a period;
5
- * a daily sweep then deletes what is older than it and writes one action
6
- * ledger entry saying what went and about how much space it freed.
3
+ * finished checkouts' records, chat messages and notifications. Until an
4
+ * instance operator chooses a period, evidence is kept 28 days and every
5
+ * other kind forever; a daily sweep deletes what is older than its period
6
+ * and writes one action ledger entry saying what went and about how much
7
+ * space it freed.
7
8
  *
8
9
  * Never deleted, whatever the settings: the action ledger (it is append-only),
9
10
  * and anything a task still needs. A task still needs its things until it is
@@ -21,24 +22,47 @@ import { join } from "node:path";
21
22
  import { RETENTION_NOTE } from "./evidence.js";
22
23
  import { COMPLETION_ACTION } from "./result-completion.js";
23
24
  import { bytesWords, treeBytes } from "./storage.js";
24
- export const RETENTION_SCHEMA = `
25
- CREATE TABLE IF NOT EXISTS retention_setting (
25
+ const RETENTION_TABLE = (name) => `
26
+ CREATE TABLE IF NOT EXISTS ${name} (
26
27
  kind TEXT PRIMARY KEY CHECK (kind IN ('evidence', 'checkouts', 'chat', 'notifications')),
27
28
  -- NULL is forever; a row only exists once someone chose.
28
- days INTEGER CHECK (days IS NULL OR (days >= 7 AND days <= 3650)),
29
+ days INTEGER CHECK (days IS NULL OR (days >= 1 AND days <= 3650)),
29
30
  updated_by TEXT NOT NULL,
30
31
  updated_at TEXT NOT NULL
31
32
  );
32
33
  `;
34
+ export const RETENTION_SCHEMA = RETENTION_TABLE("retention_setting");
35
+ /** A file from before 1-day evidence keeps `days >= 7` in its CHECK: rebuild the table with the wider one, keeping
36
+ * every row, in one transaction. A file already widened is untouched. */
37
+ export function widenRetentionSchema(db) {
38
+ const row = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'retention_setting'").get();
39
+ if (row === undefined || !/days\s*>=\s*7\b/.test(String(row["sql"])))
40
+ return;
41
+ db.exec("BEGIN IMMEDIATE");
42
+ try {
43
+ db.exec(RETENTION_TABLE("retention_setting_next"));
44
+ db.exec("INSERT INTO retention_setting_next (kind, days, updated_by, updated_at) SELECT kind, days, updated_by, updated_at FROM retention_setting");
45
+ db.exec("DROP TABLE retention_setting");
46
+ db.exec("ALTER TABLE retention_setting_next RENAME TO retention_setting");
47
+ db.exec("COMMIT");
48
+ }
49
+ catch (error) {
50
+ db.exec("ROLLBACK");
51
+ throw error;
52
+ }
53
+ }
33
54
  export const RETENTION_KINDS = [
34
55
  { kind: "evidence", label: "Run evidence and logs", detail: "Diffs, check logs, screenshots and reports saved for each run" },
35
56
  { kind: "checkouts", label: "Finished checkout records", detail: "Records of build checkouts that were let go and are no longer on disk" },
36
57
  { kind: "chat", label: "Chat messages", detail: "Messages in lead and team chats" },
37
58
  { kind: "notifications", label: "Notifications", detail: "Delivered or dismissed notifications" },
38
59
  ];
39
- /** The periods the page offers; the command line takes any number of days from 7 to 3650. */
40
- export const PERIOD_CHOICES = [30, 90, 180, 365, 730, null];
41
- export const MIN_DAYS = 7;
60
+ /** The periods the page offers for each kind; the command line takes any number of days from 1 to 3650. */
61
+ const LONG_CHOICES = [30, 90, 180, 365, 730, null];
62
+ export const PERIOD_CHOICES = {
63
+ evidence: [1, 7, 14, 28, null], checkouts: LONG_CHOICES, chat: LONG_CHOICES, notifications: LONG_CHOICES,
64
+ };
65
+ export const MIN_DAYS = 1;
42
66
  export const MAX_DAYS = 3650;
43
67
  const DAY_MS = 86_400_000;
44
68
  /** A sweep is due once a day. */
@@ -46,7 +70,8 @@ export const SWEEP_EVERY_MS = DAY_MS;
46
70
  /** At most this many runs, messages, notifications or records go per kind per sweep; the next sweep takes the rest. */
47
71
  export const SWEEP_BATCH = 2000;
48
72
  const SWEEP_CURSOR = "retention:last-sweep";
49
- export const FOREVER = { evidence: null, checkouts: null, chat: null, notifications: null };
73
+ /** What each kind keeps until someone chooses: evidence 28 days, everything else forever. */
74
+ export const DEFAULT_PERIODS = { evidence: 28, checkouts: null, chat: null, notifications: null };
50
75
  export function isRetentionKind(value) {
51
76
  return RETENTION_KINDS.some(one => one.kind === value);
52
77
  }
@@ -67,7 +92,20 @@ export function periodWords(days) {
67
92
  return "forever";
68
93
  if (days % 365 === 0)
69
94
  return days === 365 ? "1 year" : `${days / 365} years`;
70
- return `${days} days`;
95
+ return days === 1 ? "1 day" : `${days} days`;
96
+ }
97
+ /** What the page offers for `kind`, with `current` among them (in order) when it was set some other way. */
98
+ export function periodChoices(kind, current) {
99
+ const offered = PERIOD_CHOICES[kind];
100
+ return offered.includes(current) ? offered : [...offered, current].sort((a, b) => (a ?? Infinity) - (b ?? Infinity));
101
+ }
102
+ /** A period as the page and `retention show` say it: "28 days (default)" when nobody chose, "30 days (custom)" when it's not one the page offers. */
103
+ export function periodLabel(kind, days, chosen) {
104
+ const words = periodWords(days);
105
+ const label = `${words[0].toUpperCase()}${words.slice(1)}`;
106
+ if (!chosen)
107
+ return `${label} (default)`;
108
+ return PERIOD_CHOICES[kind].includes(days) ? label : `${label} (custom)`;
71
109
  }
72
110
  /** Tasks that no longer need anything kept: cancelled, or done with their result marked complete; not on hold, not
73
111
  * running, not a release candidate, and no revision below it (at any depth) still unfinished — cancelled or completed,
@@ -0,0 +1,41 @@
1
+ /** The per-project automatic review switch and the one review each finished
2
+ * build gets (build-review.ts runs it): tables, and the read-only views every
3
+ * surface shows. No schema version bump. Kept free of heavy imports so the
4
+ * status readers can use it without an import cycle. */
5
+ import type { Store } from "./store.js";
6
+ export type FindingSeverity = "HIGH" | "MEDIUM" | "LOW";
7
+ /** One automatic-review finding: how bad, where, and how it fails. */
8
+ export type BuildFinding = {
9
+ severity: FindingSeverity;
10
+ file: string;
11
+ line: number;
12
+ scenario: string;
13
+ };
14
+ export declare const REVIEW_SCHEMA = "\n-- A project's explicit review switch. No row: on while the project is under a hands-off mode, off otherwise.\nCREATE TABLE IF NOT EXISTS review_switch (\n repo TEXT PRIMARY KEY,\n enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),\n changed_by TEXT NOT NULL,\n changed_at TEXT NOT NULL\n);\n-- One row per reviewed build run: pending until its one reviewer run settles it.\nCREATE TABLE IF NOT EXISTS build_review (\n run INTEGER PRIMARY KEY,\n task_id TEXT NOT NULL,\n repo TEXT NOT NULL,\n state TEXT NOT NULL CHECK (state IN ('pending', 'reviewed', 'not-reviewed')),\n request INTEGER,\n reviewer_run INTEGER,\n findings_json TEXT,\n reason TEXT,\n sent_back_as TEXT,\n queued_at TEXT NOT NULL,\n finished_at TEXT\n);\nCREATE INDEX IF NOT EXISTS build_review_pending ON build_review (repo) WHERE state = 'pending';\nCREATE INDEX IF NOT EXISTS build_review_sent_back ON build_review (sent_back_as) WHERE sent_back_as IS NOT NULL;\n";
15
+ export type ReviewSwitch = {
16
+ on: boolean;
17
+ source: "project" | "hands-off" | "default";
18
+ changedBy: string | null;
19
+ changedAt: string | null;
20
+ };
21
+ /** How the policy log words a switch state. */
22
+ export declare function reviewSwitchWords(state: ReviewSwitch): string;
23
+ export type BuildReviewView = {
24
+ run: number;
25
+ state: "pending" | "reviewed" | "not-reviewed";
26
+ reviewerRun: number | null;
27
+ /** Why it was not reviewed, in words. */
28
+ reason: string | null;
29
+ /** HIGH findings; they sent the task back when `sentBackAs` is set. */
30
+ high: BuildFinding[];
31
+ /** MEDIUM and LOW findings: suggested follow-ups, never blocking. */
32
+ followUps: BuildFinding[];
33
+ sentBackAs: string | null;
34
+ finishedAt: string | null;
35
+ };
36
+ export declare function buildReviewOf(store: Pick<Store, "handle">, runId: number): BuildReviewView | null;
37
+ export declare const findingWords: (finding: BuildFinding) => string;
38
+ /** The review's standing in one line, or null while nothing was asked. */
39
+ export declare function buildReviewHeadline(view: BuildReviewView | null): string | null;
40
+ /** Plain lines for `task show` and `assignment show`. */
41
+ export declare function buildReviewLines(view: BuildReviewView | null): string[];